From be836bd07ade28ec1bf297bff151631604f4c455 Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Tue, 3 Oct 2023 16:15:06 +0200 Subject: [PATCH 1/6] =?UTF-8?q?wip=20curve=20update=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- foundry.toml | 44 +++++----- script/Add_Liquidity.s.sol | 32 +++++++ script/Base.s.sol | 41 --------- script/Swap_Deploy.s.sol | 27 ++++++ src/Swap.sol | 158 +++++++++++++++++------------------ src/UnlockQueue.sol | 19 +---- src/util/ERC721Receiver.sol | 11 +++ test/Swap.harness.sol | 25 ++++++ test/Swap.t.sol | 45 ++++++---- test/UnlockQueue.harness.sol | 42 ++++++++++ test/UnlockQueue.t.sol | 57 +++++++++++++ test/helpers/Utils.sol | 9 ++ 12 files changed, 330 insertions(+), 180 deletions(-) create mode 100644 script/Add_Liquidity.s.sol delete mode 100644 script/Base.s.sol create mode 100644 script/Swap_Deploy.s.sol create mode 100644 src/util/ERC721Receiver.sol create mode 100644 test/Swap.harness.sol create mode 100644 test/UnlockQueue.harness.sol create mode 100644 test/UnlockQueue.t.sol create mode 100644 test/helpers/Utils.sol diff --git a/foundry.toml b/foundry.toml index bc4683d..224002f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,7 +1,5 @@ # Full reference https://github.com/foundry-rs/foundry/tree/master/config -# Full reference https://github.com/foundry-rs/foundry/tree/master/config - [profile.default] bytecode_hash = "none" fuzz = { runs = 1_000 } @@ -25,25 +23,25 @@ quote_style = "double" tab_width = 4 wrap_comments = true -[etherscan] -arbitrum_one = { 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}" } +# [etherscan] +# arbitrum_one = { 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}" } -[rpc_endpoints] -arbitrum_one = "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}" +# [rpc_endpoints] +# arbitrum_one = "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}" diff --git a/script/Add_Liquidity.s.sol b/script/Add_Liquidity.s.sol new file mode 100644 index 0000000..975f068 --- /dev/null +++ b/script/Add_Liquidity.s.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import { Script, console2 } from "forge-std/Script.sol"; +import { ERC20 } from "solmate/tokens/ERC20.sol"; +import { TenderSwap, Config } from "@tenderize/swap/Swap.sol"; + +contract Add_Liquidity is Script { + // Contracts are deployed deterministically. + // e.g. `foo = new Foo{salt: salt}(constructorArgs)` + // The presence of the salt argument tells forge to use https://github.com/Arachnid/deterministic-deployment-proxy + bytes32 private constant salt = 0x0; + + // Start broadcasting with private key from `.env` file + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address underlying = vm.envAddress("UNDERLYING"); + + function run() public { + vm.startBroadcast(deployerPrivateKey); + TenderSwap swap = TenderSwap(0x3ADdb29D60fE684f1B773527cB7eCF17223C3b82); + // ERC20(underlying).approve(address(swap), 200_000 ether); + // swap.deposit(200_000 ether); + console2.log("liabilities", swap.liabilities()); + console2.log("liquidity", swap.liquidity()); + // ERC20(0x2eaC4210B90D13666f7E88635096BdC17C51FB70).approve(address(swap), 10 ether); + (uint256 out, uint256 fee) = swap.quote(0x2eaC4210B90D13666f7E88635096BdC17C51FB70, 10 ether); + console2.log("quote", out); + + // swap.swap(0x2eaC4210B90D13666f7E88635096BdC17C51FB70, 10 ether, 0); + vm.stopBroadcast(); + } +} diff --git a/script/Base.s.sol b/script/Base.s.sol deleted file mode 100644 index b9d6487..0000000 --- a/script/Base.s.sol +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.17; - -import { Script } from "forge-std/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/Swap_Deploy.s.sol b/script/Swap_Deploy.s.sol new file mode 100644 index 0000000..fc6f81f --- /dev/null +++ b/script/Swap_Deploy.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import { Script, console2 } from "forge-std/Script.sol"; +import { ERC20 } from "solmate/tokens/ERC20.sol"; +import { TenderSwap, Config } from "@tenderize/swap/Swap.sol"; + +contract Swap_Deploy is Script { + // Contracts are deployed deterministically. + // e.g. `foo = new Foo{salt: salt}(constructorArgs)` + // The presence of the salt argument tells forge to use https://github.com/Arachnid/deterministic-deployment-proxy + bytes32 private constant salt = 0x0; + + // Start broadcasting with private key from `.env` file + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address underlying = vm.envAddress("UNDERLYING"); + address registry = vm.envAddress("REGISTRY"); + address unlocks = vm.envAddress("UNLOCKS"); + Config cfg = Config({ underlying: ERC20(underlying), registry: registry, unlocks: unlocks }); + + function run() public { + vm.startBroadcast(deployerPrivateKey); + TenderSwap swap = new TenderSwap{salt: salt}(cfg); + console2.log("TenderSwap deployed at: ", address(swap)); + vm.stopBroadcast(); + } +} diff --git a/src/Swap.sol b/src/Swap.sol index 25dc249..fae755c 100644 --- a/src/Swap.sol +++ b/src/Swap.sol @@ -10,7 +10,6 @@ // Copyright (c) Tenderize Labs Ltd import { UD60x18, ZERO, UNIT, unwrap, ud } from "@prb/math/UD60x18.sol"; -import { ClonesWithImmutableArgs } from "clones/ClonesWithImmutableArgs.sol"; import { ERC20 } from "solmate/tokens/ERC20.sol"; import { ERC721 } from "solmate/tokens/ERC721.sol"; import { SafeTransferLib } from "solmate/utils/SafeTransferLib.sol"; @@ -21,6 +20,7 @@ import { SafeCastLib } from "solmate/utils/SafeCastLib.sol"; import { Multicall } from "@tenderize/swap/util/Multicall.sol"; import { SelfPermit } from "@tenderize/swap/util/SelfPermit.sol"; +import { ERC721Receiver } from "@tenderize/swap/util/ERC721Receiver.sol"; import { LPToken } from "@tenderize/swap/LPToken.sol"; import { UnlockQueue } from "@tenderize/swap/UnlockQueue.sol"; @@ -31,7 +31,7 @@ pragma solidity >=0.8.19; UD60x18 constant BASE_FEE = UD60x18.wrap(0.0005e18); UD60x18 constant RELAYER_CUT = UD60x18.wrap(0.1e18); UD60x18 constant MIN_LP_CUT = UD60x18.wrap(0.1e18); -UD60x18 constant POW = UD60x18.wrap(3e18); +UD60x18 constant K = UD60x18.wrap(3e18); struct Config { ERC20 underlying; @@ -39,7 +39,14 @@ struct Config { address unlocks; } -contract TenderSwapStorage { +struct SwapParams { + UD60x18 u; + UD60x18 U; + UD60x18 s; + UD60x18 S; +} + +abstract contract SwapStorage { uint256 private constant SSLOT = uint256(keccak256("xyz.tenderize.swap.storage.location")) - 1; struct Data { @@ -48,7 +55,7 @@ contract TenderSwapStorage { // total amount of liabilities owed to LPs uint256 liabilities; // sum of token supplies that have outstanding unlocks - uint256 S; + UD60x18 S; // Unlock queue to hold unlocks UnlockQueue.Data unlockQ; // Recovery amount, if `recovery` > 0 enable recovery mode @@ -56,7 +63,7 @@ contract TenderSwapStorage { // 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 => uint256 lastSupply) lastSupplyForAsset; + mapping(address asset => UD60x18 lastSupply) lastSupplyForAsset; // relayer fees mapping(address relayer => uint256 fee) relayerFees; } @@ -71,7 +78,7 @@ contract TenderSwapStorage { } } -contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { +contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { using SafeTransferLib for ERC20; using SafeCastLib for uint256; using UnlockQueue for UnlockQueue.Data; @@ -103,7 +110,8 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { } modifier supplyUpdateHook(address asset) { - _supplyUpdateHook(asset); + Data storage $ = _loadStorageSlot(); + // _supplyUpdateHook(asset); _; } @@ -134,22 +142,6 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { r = _utilisation($.unlocking, $.liabilities); } - /** - * @notice Current general utilisation fee given the current utilisation ratio - * @dev `utilisationFee = utilisation^n` - */ - function utilisationFee() public view returns (UD60x18 f) { - f = _utilisationFee(utilisation()); - } - - /** - * @notice Current spread multiplier for an asset that can be exchanged. - * The spread is based on the individual utilisation ratio of the asset and its supply vs other assets - */ - function spread(address asset) public view returns (UD60x18 s) { - return _spread(asset, 0); - } - /** * @notice Deposit liquidity into the pool, receive liquidity pool shares in return. * The liquidity pool shares represent an amount of liabilities owed to the liquidity provider. @@ -228,11 +220,14 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { * @return fee Amount of fees paid */ function quote(address asset, uint256 amount) public view returns (uint256 out, uint256 fee) { - (bool success, bytes memory returnData) = - address(this).staticcall(abi.encodeWithSelector(this.swap.selector, asset, amount)); - if (success) { - (out, fee) = abi.decode(returnData, (uint256, uint256)); - } + Data storage $ = _loadStorageSlot(); + + UD60x18 U = ud($.unlocking); + UD60x18 u = ud($.unlockingForAsset[asset]); + (UD60x18 s, UD60x18 S) = _checkSupply(asset); + + SwapParams memory p = SwapParams({ U: U, u: u, S: S, s: s }); + return _quote(asset, amount, p); } /** @@ -245,20 +240,19 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { * @return out Amount of output tokens * @return fee Amount of fees paid */ - function swap( - address asset, - uint256 amount, - uint256 minOut - ) - external - supplyUpdateHook(asset) - returns (uint256 out, uint256 fee) - { + function swap(address asset, uint256 amount, uint256 minOut) external returns (uint256 out, uint256 fee) { if (!_isValidAsset(asset)) revert InvalidAsset(asset); Data storage $ = _loadStorageSlot(); - (out, fee) = _quote(asset, amount); + UD60x18 U = ud($.unlocking); + UD60x18 u = ud($.unlockingForAsset[asset]); + UD60x18 x = ud(amount); + (UD60x18 s, UD60x18 S) = _checkSupply(asset); + + SwapParams memory p = SwapParams({ U: U, u: u, S: S, s: s }); + + (out, fee) = _quote(asset, amount, p); // Revert if slippage threshold is exceeded, i.e. if `out` is less than `minOut` if (out < minOut) revert SlippageThresholdExceeded(out, minOut); @@ -267,9 +261,9 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { // - Update total amount unlocking $.unlocking += amount; // - update supplyForAsset - $.lastSupplyForAsset[asset] -= amount; + $.lastSupplyForAsset[asset] = s.sub(x); // - update S - $.S -= amount; + $.S = S.sub(x); // - update unlockingForAsset $.unlockingForAsset[asset] += amount; @@ -321,8 +315,8 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { uint256 ufa = $.unlockingForAsset[tenderizer] - unlock.amount; // - Update S if unlockingForAsset is now zero if (ufa == 0) { - $.S -= $.lastSupplyForAsset[tenderizer]; - delete $.lastSupplyForAsset[tenderizer]; + $.S = $.S.sub($.lastSupplyForAsset[tenderizer]); + $.lastSupplyForAsset[tenderizer] = ZERO; } // - Update unlockingForAsset $.unlockingForAsset[tenderizer] = ufa; @@ -355,8 +349,6 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { (address tenderizer, uint96 id) = _decodeTokenId(unlock.id); uint256 amountReceived = Tenderizer(tenderizer).withdraw(address(this), id); - // TODO: Handle amount received > 0 ? - //calculate the relayer reward uint256 relayerReward = unwrap(ud(unlock.fee).mul(RELAYER_CUT)); // update relayer rewards @@ -364,14 +356,28 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { uint256 fee = unlock.fee - relayerReward; - // Handle potential recovery mode { uint256 recovery = $.recovery; + // Handle deficit if (amountReceived < unlock.amount) { recovery += unlock.amount - amountReceived; } + // Handle surplus + if (amountReceived > unlock.amount) { + uint256 excess = amountReceived - unlock.amount; + amountReceived = unlock.amount; + if (excess > recovery) { + excess -= recovery; + recovery = 0; + $.liabilities += excess; + } else { + recovery -= excess; + excess = 0; + } + } + if (recovery > 0) { if (fee >= recovery) { unchecked { @@ -394,8 +400,8 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { uint256 ufa = $.unlockingForAsset[tenderizer] - amountReceived; // - Update S if unlockingForAsset is now zero if (ufa == 0) { - $.S -= $.lastSupplyForAsset[tenderizer]; - delete $.lastSupplyForAsset[tenderizer]; + $.S = $.S.sub($.lastSupplyForAsset[tenderizer]); + $.lastSupplyForAsset[tenderizer] = ZERO; } // - Update unlockingForAsset $.unlockingForAsset[tenderizer] = ufa; @@ -406,28 +412,32 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { emit UnlockRedeemed(msg.sender, unlock.id, amountReceived, relayerReward, fee); } - function _quote(address asset, uint256 amount) internal view returns (uint256 out, uint256 fee) { + function _quote(address asset, uint256 amount, SwapParams memory p) internal view returns (uint256 out, uint256 fee) { Data storage $ = _loadStorageSlot(); - // calculate utilisation rate - UD60x18 r = _utilisation($.unlocking + amount, $.liabilities); + UD60x18 x = ud(amount); + UD60x18 L = ud($.liabilities); - // calculate spread multiplier - UD60x18 w = _spread(asset, amount); + { + UD60x18 nom = p.u.add(x).mul(K).add(p.u).sub(p.U).mul(p.U.add(x).div(L).pow(K)); + + K.mul(p.u).gt(p.U.sub(p.u)) + ? nom = nom.sub(K.mul(p.u).add(p.u).sub(p.U).mul(p.U.div(L).pow(K))) + : nom = nom.add(p.U.sub(p.u).sub(K.mul(p.u)).mul(p.U.div(L).pow(K))); - // calculate fee by multiplying the base fee by the spread - UD60x18 f = _utilisationFee(r).mul(w); + nom = nom.mul(p.S.add(p.U)); - f = f.gt(UNIT) ? UNIT : f; // max 100% fee + UD60x18 denom = K.mul(UNIT.add(K)).mul(p.s.add(p.u)); - // get the marginal fee - fee = f.mul(ud(amount)).unwrap(); + fee = BASE_FEE.mul(x).add(nom.div(denom)).unwrap(); - // get the output amount + fee = fee >= amount ? amount : fee; + } unchecked { out = amount - fee; } } + // (((u + x)*k - U + u)*((U + x)/L)**k + (-k*u + U - u)*(U/L)**k)*(S + U)/(k*(1 + k)*(s + u)) /** * @notice checks if an asset is a valid tenderizer for `underlying` @@ -440,19 +450,6 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { r = ud(unlocking).div(ud(liabilities)); } - function _utilisationFee(UD60x18 r) internal pure returns (UD60x18 f) { - f = BASE_FEE.add(r.pow(POW)); - f > UNIT ? UNIT : f; - } - - function _spread(address asset, uint256 x) internal view returns (UD60x18 w) { - Data storage $ = _loadStorageSlot(); - - ud($.unlockingForAsset[asset] + x).div(ud($.unlocking + x)).div(ud($.lastSupplyForAsset[asset] + x).div(ud($.S + x))); - - w = ud(($.unlockingForAsset[asset] + x) * $.lastSupplyForAsset[asset]).div(ud(($.unlocking + x) * $.S)); - } - function _unlock(address asset, uint256 amount, uint256 fee) internal { Data storage $ = _loadStorageSlot(); @@ -465,7 +462,6 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { uint256 maturity = t.unlockMaturity(id); $.unlockQ.push( - key, UnlockQueue.Item({ id: key, amount: SafeCastLib.safeCastTo128(amount), @@ -479,19 +475,19 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { * @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 _supplyUpdateHook(address tenderizer) internal { + function _checkSupply(address tenderizer) internal view returns (UD60x18 s, UD60x18 S) { Data storage $ = _loadStorageSlot(); - uint256 newSupply = Tenderizer(tenderizer).totalSupply(); - uint256 oldSupply = $.lastSupplyForAsset[tenderizer]; + S = $.S; - if (oldSupply < newSupply) { - $.S += newSupply - oldSupply; - } else if (oldSupply > newSupply) { - $.S -= oldSupply - newSupply; - } + s = ud(Tenderizer(tenderizer).totalSupply()); + UD60x18 oldSupply = $.lastSupplyForAsset[tenderizer]; - $.lastSupplyForAsset[tenderizer] = newSupply; + if (oldSupply.lt(s)) { + S = S.add(s.sub(oldSupply)); + } else if (oldSupply.gt(s)) { + S = S.sub(oldSupply.sub(s)); + } } /** diff --git a/src/UnlockQueue.sol b/src/UnlockQueue.sol index 477b174..2add175 100644 --- a/src/UnlockQueue.sol +++ b/src/UnlockQueue.sol @@ -39,20 +39,6 @@ library UnlockQueue { mapping(uint256 index => Node) nodes; // elements as a map } - /** - * @notice returns the oldest element in the queue - */ - function head(UnlockQueue.Data storage q) internal view returns (Item memory) { - return q.nodes[q.head].data; - } - - /** - * @notice returns the newest element in the queue - */ - function tail(UnlockQueue.Data storage q) internal view returns (Item memory) { - return q.nodes[q.tail].data; - } - /** * @notice Pop the oldest element from the queue * @param q The queue to pop from @@ -100,12 +86,11 @@ library UnlockQueue { /** * @notice Push a new element to the back of the queue * @param q The queue to push to - * @param id The id of the unlock * @param unlock The unlock data to push */ - function push(UnlockQueue.Data storage q, uint256 id, Item memory unlock) internal { + function push(UnlockQueue.Data storage q, Item memory unlock) internal { uint256 tail = q.tail; - uint256 newTail = id; + uint256 newTail = unlock.id; q.nodes[newTail].data = unlock; q.nodes[newTail].prev = tail; diff --git a/src/util/ERC721Receiver.sol b/src/util/ERC721Receiver.sol new file mode 100644 index 0000000..9d09d50 --- /dev/null +++ b/src/util/ERC721Receiver.sol @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2021 Tenderize + +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.19; + +abstract contract ERC721Receiver { + function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) { + return ERC721Receiver.onERC721Received.selector; + } +} diff --git a/test/Swap.harness.sol b/test/Swap.harness.sol new file mode 100644 index 0000000..0c74b54 --- /dev/null +++ b/test/Swap.harness.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +import { TenderSwap, Config } from "@tenderize/swap/Swap.sol"; + +// solhint-disable func-name-mixedcase + +contract SwapHarness is TenderSwap { + constructor(Config memory config) TenderSwap(config) { } + + function exposed_setLiabilities(uint256 _liabilities) public { + Data storage $ = _loadStorageSlot(); + $.liabilities = _liabilities; + } +} diff --git a/test/Swap.t.sol b/test/Swap.t.sol index 86c8223..fc3c12c 100644 --- a/test/Swap.t.sol +++ b/test/Swap.t.sol @@ -11,7 +11,7 @@ pragma solidity >=0.8.19; -import { Test } from "forge-std/Test.sol"; +import { Test, console } from "forge-std/Test.sol"; import { MockERC20 } from "test/helpers/MockERC20.sol"; import { Registry } from "@tenderize/stake/registry/Registry.sol"; import { Tenderizer, TenderizerImmutableArgs } from "@tenderize/stake/tenderizer/Tenderizer.sol"; @@ -21,12 +21,16 @@ import { LPToken } from "@tenderize/swap/LPToken.sol"; import { UD60x18, ud, unwrap, ZERO, UNIT } from "@prb/math/UD60x18.sol"; +import { SwapHarness } from "./Swap.harness.sol"; + +import { acceptableDelta } from "./helpers/Utils.sol"; + contract TenderSwapTest is Test { MockERC20 underlying; MockERC20 tToken0; MockERC20 tToken1; - TenderSwap swap; + SwapHarness swap; address registry; address unlocks; @@ -55,12 +59,13 @@ contract TenderSwapTest is Test { ); Config memory cfg = Config({ underlying: underlying, registry: registry, unlocks: unlocks }); - swap = new TenderSwap(cfg); + swap = new SwapHarness(cfg); } - function test_deposits() public { - uint256 deposit1 = 100 ether; - uint256 deposit2 = 250 ether; + function testFuzz_deposits(uint256 x, uint256 y, uint256 l) public { + uint256 deposit1 = bound(x, 1, type(uint128).max); + uint256 deposit2 = bound(y, 1, type(uint128).max); + l = bound(l, 1, type(uint128).max); underlying.mint(addr1, deposit1); underlying.mint(addr2, deposit2); @@ -69,19 +74,20 @@ contract TenderSwapTest is Test { swap.deposit(deposit1); vm.stopPrank(); + // Change liabilities ! + swap.exposed_setLiabilities(l); + vm.startPrank(addr2); underlying.approve(address(swap), deposit2); swap.deposit(deposit2); vm.stopPrank(); - assertEq(swap.lpToken().totalSupply(), deposit1 + deposit2, "lpToken totalSupply"); + uint256 expBalY = deposit2 * deposit1 / l; + + assertEq(swap.lpToken().totalSupply(), deposit1 + expBalY, "lpToken totalSupply"); assertEq(swap.lpToken().balanceOf(addr1), deposit1, "addr1 lpToken balance"); - assertEq(swap.lpToken().balanceOf(addr2), deposit2, "addr2 lpToken balance"); + assertEq(swap.lpToken().balanceOf(addr2), expBalY, "addr2 lpToken balance"); assertEq(underlying.balanceOf(address(swap)), deposit1 + deposit2, "TenderSwap underlying balance"); - assertEq(swap.liabilities(), deposit1 + deposit2, "TenderSwap liquidity"); - assertEq(swap.liquidity(), deposit1 + deposit2, "TenderSwap available liquidity"); - assertTrue(swap.utilisation().eq(ZERO), "TenderSwap utilisation"); - assertTrue(swap.utilisationFee().eq(BASE_FEE), "TenderSwap utilisation fee"); } function test_swap() public { @@ -111,9 +117,9 @@ contract TenderSwapTest is Test { assertEq(swap.liquidity(), 90 ether, "TenderSwap available liquidity"); } - function testFuzz_swap(uint256 liquidity, uint256 amount) public { - liquidity = bound(liquidity, 1e18, type(uint64).max); - amount = bound(amount, 1e9, liquidity); + function testFuzz_swap_basic(uint256 liquidity, uint256 amount) public { + liquidity = bound(liquidity, 1e18, type(uint128).max); + amount = bound(amount, 1e3, liquidity); underlying.mint(address(this), liquidity); underlying.approve(address(swap), liquidity); @@ -126,11 +132,14 @@ contract TenderSwapTest is Test { tToken0.approve(address(swap), amount); (uint256 out, uint256 fee) = swap.swap(address(tToken0), amount, 0); - uint256 expFee = unwrap(ud(amount).mul((BASE_FEE + ud(amount).div(ud(liquidity)).pow(ud(3e18))))); + uint256 expFee = ud(amount).mul(BASE_FEE).add(ud(amount).mul((ud(amount).div(ud(liquidity)).pow(ud(3e18))))).unwrap(); expFee = expFee >= amount ? amount : expFee; - assertEq(fee, expFee, "swap fee"); - assertEq(out, amount - expFee, "swap out"); + console.log("expFee", expFee); + console.log("fee", fee); + + assertTrue(acceptableDelta(fee, expFee, 2), "fee amount"); + assertTrue(acceptableDelta(out, amount - expFee, 2), "swap out"); assertEq(swap.liquidity(), liquidity - amount, "TenderSwap available liquidity"); } } diff --git a/test/UnlockQueue.harness.sol b/test/UnlockQueue.harness.sol new file mode 100644 index 0000000..3da87da --- /dev/null +++ b/test/UnlockQueue.harness.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +import { UnlockQueue } from "@tenderize/swap/UnlockQueue.sol"; + +// solhint-disable func-name-mixedcase + +contract UnlockQueueHarness { + using UnlockQueue for UnlockQueue.Data; + + UnlockQueue.Data queue; + + function exposed_push(UnlockQueue.Item memory item) public { + queue.push(item); + } + + function exposed_popFront() public returns (UnlockQueue.Item memory item) { + item = queue.popFront(); + } + + function exposed_popBack() public returns (UnlockQueue.Item memory item) { + item = queue.popBack(); + } + + function exposed_head() public view returns (UnlockQueue.Item memory item) { + item = queue.nodes[queue.head].data; + } + + function exposed_tail() public view returns (UnlockQueue.Item memory item) { + item = queue.nodes[queue.tail].data; + } +} diff --git a/test/UnlockQueue.t.sol b/test/UnlockQueue.t.sol new file mode 100644 index 0000000..7d36108 --- /dev/null +++ b/test/UnlockQueue.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +import { Test } from "forge-std/Test.sol"; +import { UnlockQueue } from "@tenderize/swap/UnlockQueue.sol"; +import { UnlockQueueHarness } from "./UnlockQueue.harness.sol"; + +contract UnlockQueueTest is Test { + UnlockQueueHarness queue; + + function setUp() public { + queue = new UnlockQueueHarness(); + } + + function test_queue() public { + UnlockQueue.Item memory item1 = UnlockQueue.Item({ id: 1, amount: 2, fee: 3, maturity: 4 }); + UnlockQueue.Item memory item2 = UnlockQueue.Item({ id: 2, amount: 5, fee: 9, maturity: 20 }); + UnlockQueue.Item memory item3 = UnlockQueue.Item({ id: 3, amount: 9, fee: 12, maturity: 31 }); + + queue.exposed_push(item1); + + // assert + assertEq(queue.exposed_head().id, 1); + assertEq(queue.exposed_tail().id, 1); + + queue.exposed_push(item2); + // assert + assertEq(queue.exposed_head().id, 1); + assertEq(queue.exposed_tail().id, 2); + + queue.exposed_push(item3); + // assert + assertEq(queue.exposed_head().id, 1); + assertEq(queue.exposed_tail().id, 3); + + // pop front + UnlockQueue.Item memory popped = queue.exposed_popFront(); + assertEq(popped.id, 1); + assertEq(queue.exposed_head().id, 2); + + // pop back + popped = queue.exposed_popBack(); + assertEq(popped.id, 3); + assertEq(queue.exposed_head().id, 2); + assertEq(queue.exposed_tail().id, 2); + } +} diff --git a/test/helpers/Utils.sol b/test/helpers/Utils.sol new file mode 100644 index 0000000..d02d782 --- /dev/null +++ b/test/helpers/Utils.sol @@ -0,0 +1,9 @@ +pragma solidity >=0.8.19; + +function acceptableDelta(uint256 x, uint256 y, uint256 d) pure returns (bool) { + if (x > y) { + return x - y <= d; + } else { + return y - x <= d; + } +} From 7d4196b526207ffcb0b8f76506431cee4c1eb1e3 Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Tue, 17 Oct 2023 13:56:52 +0200 Subject: [PATCH 2/6] fix: use signed integers for math instead of unsigned --- src/Swap.sol | 77 ++++++++++++++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/src/Swap.sol b/src/Swap.sol index fae755c..2d923db 100644 --- a/src/Swap.sol +++ b/src/Swap.sol @@ -9,7 +9,7 @@ // // Copyright (c) Tenderize Labs Ltd -import { UD60x18, ZERO, UNIT, unwrap, ud } from "@prb/math/UD60x18.sol"; +import { SD59x18, ZERO, UNIT, unwrap, sd } from "@prb/math/SD59x18.sol"; import { ERC20 } from "solmate/tokens/ERC20.sol"; import { ERC721 } from "solmate/tokens/ERC721.sol"; import { SafeTransferLib } from "solmate/utils/SafeTransferLib.sol"; @@ -28,10 +28,10 @@ pragma solidity >=0.8.19; // TODO: UUPS upgradeable -UD60x18 constant BASE_FEE = UD60x18.wrap(0.0005e18); -UD60x18 constant RELAYER_CUT = UD60x18.wrap(0.1e18); -UD60x18 constant MIN_LP_CUT = UD60x18.wrap(0.1e18); -UD60x18 constant K = UD60x18.wrap(3e18); +SD59x18 constant BASE_FEE = SD59x18.wrap(0.0005e18); +SD59x18 constant RELAYER_CUT = SD59x18.wrap(0.1e18); +SD59x18 constant MIN_LP_CUT = SD59x18.wrap(0.1e18); +SD59x18 constant K = SD59x18.wrap(3e18); struct Config { ERC20 underlying; @@ -40,10 +40,10 @@ struct Config { } struct SwapParams { - UD60x18 u; - UD60x18 U; - UD60x18 s; - UD60x18 S; + SD59x18 u; + SD59x18 U; + SD59x18 s; + SD59x18 S; } abstract contract SwapStorage { @@ -55,7 +55,7 @@ abstract contract SwapStorage { // total amount of liabilities owed to LPs uint256 liabilities; // sum of token supplies that have outstanding unlocks - UD60x18 S; + SD59x18 S; // Unlock queue to hold unlocks UnlockQueue.Data unlockQ; // Recovery amount, if `recovery` > 0 enable recovery mode @@ -63,7 +63,7 @@ abstract contract SwapStorage { // 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; + mapping(address asset => SD59x18 lastSupply) lastSupplyForAsset; // relayer fees mapping(address relayer => uint256 fee) relayerFees; } @@ -136,7 +136,7 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { * @notice Current general utilisation ratio of the pool's liquidity * @dev `utilisation = unlocking / liabilities` */ - function utilisation() public view returns (UD60x18 r) { + function utilisation() public view returns (SD59x18 r) { Data storage $ = _loadStorageSlot(); if ($.liabilities == 0) return ZERO; r = _utilisation($.unlocking, $.liabilities); @@ -222,9 +222,9 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { function quote(address asset, uint256 amount) public view returns (uint256 out, uint256 fee) { Data storage $ = _loadStorageSlot(); - UD60x18 U = ud($.unlocking); - UD60x18 u = ud($.unlockingForAsset[asset]); - (UD60x18 s, UD60x18 S) = _checkSupply(asset); + SD59x18 U = sd(int256($.unlocking)); + SD59x18 u = sd(int256($.unlockingForAsset[asset])); + (SD59x18 s, SD59x18 S) = _checkSupply(asset); SwapParams memory p = SwapParams({ U: U, u: u, S: S, s: s }); return _quote(asset, amount, p); @@ -245,10 +245,10 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { Data storage $ = _loadStorageSlot(); - UD60x18 U = ud($.unlocking); - UD60x18 u = ud($.unlockingForAsset[asset]); - UD60x18 x = ud(amount); - (UD60x18 s, UD60x18 S) = _checkSupply(asset); + SD59x18 U = sd(int256($.unlocking)); + SD59x18 u = sd(int256($.unlockingForAsset[asset])); + SD59x18 x = sd(int256(amount)); + (SD59x18 s, SD59x18 S) = _checkSupply(asset); SwapParams memory p = SwapParams({ U: U, u: u, S: S, s: s }); @@ -302,7 +302,8 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { if (unlock.maturity <= block.timestamp) revert UnlockNotMature(unlock.maturity, block.timestamp); // calculate reward after decay, take base fee cut for LPs - uint256 reward = (unlock.fee - unwrap(ud(unlock.fee).mul(MIN_LP_CUT))) * unlock.maturity / block.timestamp; + uint256 reward = + (unlock.fee - uint256(unwrap(sd(int256(uint256(unlock.fee))).mul(MIN_LP_CUT)))) * unlock.maturity / block.timestamp; // Update pool state // - update unlocking @@ -350,7 +351,7 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { uint256 amountReceived = Tenderizer(tenderizer).withdraw(address(this), id); //calculate the relayer reward - uint256 relayerReward = unwrap(ud(unlock.fee).mul(RELAYER_CUT)); + uint256 relayerReward = uint256(unwrap(sd(int256(uint256(unlock.fee))).mul(RELAYER_CUT))); // update relayer rewards $.relayerFees[msg.sender] += relayerReward; @@ -415,21 +416,25 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { function _quote(address asset, uint256 amount, SwapParams memory p) internal view returns (uint256 out, uint256 fee) { Data storage $ = _loadStorageSlot(); - UD60x18 x = ud(amount); - UD60x18 L = ud($.liabilities); + SD59x18 x = sd(int256(amount)); + SD59x18 L = sd(int256($.liabilities)); { - UD60x18 nom = p.u.add(x).mul(K).add(p.u).sub(p.U).mul(p.U.add(x).div(L).pow(K)); - - K.mul(p.u).gt(p.U.sub(p.u)) - ? nom = nom.sub(K.mul(p.u).add(p.u).sub(p.U).mul(p.U.div(L).pow(K))) - : nom = nom.add(p.U.sub(p.u).sub(K.mul(p.u)).mul(p.U.div(L).pow(K))); - + SD59x18 nom = p.u.add(x); + nom = nom.mul(K); + + nom = nom.add(p.u).sub(p.U); + nom = nom.mul(p.U.add(x).div(L).pow(K)); + { + K.mul(p.u).gt(p.U.sub(p.u)) + ? nom = nom.sub(K.mul(p.u).add(p.u).sub(p.U).mul(p.U.div(L).pow(K))) + : nom = nom.add(p.U.sub(p.u).sub(K.mul(p.u)).mul(p.U.div(L).pow(K))); + } nom = nom.mul(p.S.add(p.U)); - UD60x18 denom = K.mul(UNIT.add(K)).mul(p.s.add(p.u)); + SD59x18 denom = K.mul(UNIT.add(K)).mul(p.s.add(p.u)); - fee = BASE_FEE.mul(x).add(nom.div(denom)).unwrap(); + fee = uint256(BASE_FEE.mul(x).add(nom.div(denom)).unwrap()); fee = fee >= amount ? amount : fee; } @@ -446,8 +451,8 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { return Registry(registry).isTenderizer(asset) && Tenderizer(asset).asset() == address(underlying); } - function _utilisation(uint256 unlocking, uint256 liabilities) internal pure returns (UD60x18 r) { - r = ud(unlocking).div(ud(liabilities)); + function _utilisation(uint256 unlocking, uint256 liabilities) internal pure returns (SD59x18 r) { + r = sd(int256(unlocking)).div(sd(int256(liabilities))); } function _unlock(address asset, uint256 amount, uint256 fee) internal { @@ -475,13 +480,13 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { * @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 _checkSupply(address tenderizer) internal view returns (UD60x18 s, UD60x18 S) { + function _checkSupply(address tenderizer) internal view returns (SD59x18 s, SD59x18 S) { Data storage $ = _loadStorageSlot(); S = $.S; - s = ud(Tenderizer(tenderizer).totalSupply()); - UD60x18 oldSupply = $.lastSupplyForAsset[tenderizer]; + s = sd(int256(Tenderizer(tenderizer).totalSupply())); + SD59x18 oldSupply = $.lastSupplyForAsset[tenderizer]; if (oldSupply.lt(s)) { S = S.add(s.sub(oldSupply)); From 1077b1c88db953776a68013d9afa5f7d36e734db Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Tue, 17 Oct 2023 14:53:13 +0200 Subject: [PATCH 3/6] wip: script changes --- script/Add_Liquidity.s.sol | 20 +++++-- src/Swap.sol | 4 +- test/Swap.t.sol | 112 +++++++++++++++++++++++++++++++------ 3 files changed, 110 insertions(+), 26 deletions(-) diff --git a/script/Add_Liquidity.s.sol b/script/Add_Liquidity.s.sol index 975f068..de02177 100644 --- a/script/Add_Liquidity.s.sol +++ b/script/Add_Liquidity.s.sol @@ -17,16 +17,24 @@ contract Add_Liquidity is Script { function run() public { vm.startBroadcast(deployerPrivateKey); - TenderSwap swap = TenderSwap(0x3ADdb29D60fE684f1B773527cB7eCF17223C3b82); - // ERC20(underlying).approve(address(swap), 200_000 ether); - // swap.deposit(200_000 ether); + TenderSwap swap = TenderSwap(0x4ec6faD51A1957cAb7E8a62e43f0A0a0c2143d3f); + ERC20(underlying).approve(address(swap), 500_000 ether); + swap.deposit(500_000 ether); console2.log("liabilities", swap.liabilities()); console2.log("liquidity", swap.liquidity()); // ERC20(0x2eaC4210B90D13666f7E88635096BdC17C51FB70).approve(address(swap), 10 ether); - (uint256 out, uint256 fee) = swap.quote(0x2eaC4210B90D13666f7E88635096BdC17C51FB70, 10 ether); - console2.log("quote", out); - // swap.swap(0x2eaC4210B90D13666f7E88635096BdC17C51FB70, 10 ether, 0); + // (uint256 out, uint256 fee) = swap.quote(0x2eaC4210B90D13666f7E88635096BdC17C51FB70, 10 ether); + // console2.log("quote", out); + // ERC20(0x2eaC4210B90D13666f7E88635096BdC17C51FB70).approve(address(swap), 10 ether); + // // (out, fee) = swap.swap(0x2eaC4210B90D13666f7E88635096BdC17C51FB70, 10 ether, 0); + // console2.log("out", out); + // console2.log("fee", fee); + + // // Other Tenderizer: 0xD58Fed21106A046093086903909478AD96D310a8 + // (out, fee) = swap.quote(0xD58Fed21106A046093086903909478AD96D310a8, 10 ether); + // console2.log("quote", out); + vm.stopBroadcast(); } } diff --git a/src/Swap.sol b/src/Swap.sol index 2d923db..7d322c8 100644 --- a/src/Swap.sol +++ b/src/Swap.sol @@ -421,9 +421,7 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { { SD59x18 nom = p.u.add(x); - nom = nom.mul(K); - - nom = nom.add(p.u).sub(p.U); + nom = nom.mul(K).sub(p.U).add(p.u); nom = nom.mul(p.U.add(x).div(L).pow(K)); { K.mul(p.u).gt(p.U.sub(p.u)) diff --git a/test/Swap.t.sol b/test/Swap.t.sol index fc3c12c..ea4254c 100644 --- a/test/Swap.t.sol +++ b/test/Swap.t.sol @@ -19,7 +19,7 @@ import { Tenderizer, TenderizerImmutableArgs } from "@tenderize/stake/tenderizer import { TenderSwap, Config, BASE_FEE, _encodeTokenId, _decodeTokenId } from "@tenderize/swap/Swap.sol"; import { LPToken } from "@tenderize/swap/LPToken.sol"; -import { UD60x18, ud, unwrap, ZERO, UNIT } from "@prb/math/UD60x18.sol"; +import { SD59x18, ZERO, UNIT, unwrap, sd } from "@prb/math/SD59x18.sol"; import { SwapHarness } from "./Swap.harness.sol"; @@ -102,8 +102,8 @@ contract TenderSwapTest is Test { vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlock.selector, amount), abi.encode(0)); vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlockMaturity.selector, 0), abi.encode(block.number + 100)); - tToken0.mint(address(this), 100 ether); - tToken0.approve(address(swap), 10 ether); + tToken0.mint(address(this), 10_000 ether); + tToken0.approve(address(swap), 150 ether); (uint256 out, uint256 fee) = swap.swap(address(tToken0), 10 ether, 5 ether); // Fee should be 0.15% or 0.0015 @@ -117,29 +117,107 @@ contract TenderSwapTest is Test { assertEq(swap.liquidity(), 90 ether, "TenderSwap available liquidity"); } - function testFuzz_swap_basic(uint256 liquidity, uint256 amount) public { - liquidity = bound(liquidity, 1e18, type(uint128).max); - amount = bound(amount, 1e3, liquidity); + function testFuzz_swap_other( + uint256 liquidity, + uint256 t0Supply, + uint256 t1Supply, + uint256 t0Amount, + uint256 t1Amount + ) + public + { + vm.assume(liquidity >= 10 ether && liquidity <= type(uint128).max); + t0Supply = bound(t0Supply, 1 ether, liquidity); + t1Supply = bound(t1Supply, 1 ether, liquidity); + t0Amount = bound(t0Amount, 1 ether / 5, t0Supply / 5); + t1Amount = bound(t1Amount, 1 ether / 5, t1Supply / 5); underlying.mint(address(this), liquidity); underlying.approve(address(swap), liquidity); swap.deposit(liquidity); - vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlock.selector, amount), abi.encode(0)); + uint256 tokenId = _encodeTokenId(address(tToken0), 0); + + vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlock.selector, t0Amount), abi.encode(0)); vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlockMaturity.selector, 0), abi.encode(block.number + 100)); - tToken0.mint(address(this), liquidity); - tToken0.approve(address(swap), amount); - (uint256 out, uint256 fee) = swap.swap(address(tToken0), amount, 0); + tToken0.mint(address(this), t0Amount); + tToken1.mint(address(this), t1Amount); + tToken0.approve(address(swap), t0Amount); + (uint256 out, uint256 fee) = swap.swap(address(tToken0), t0Amount, 0 ether); + + (out, fee) = swap.quote(address(tToken1), t1Amount); + console.log("swap quote 1", out, fee); + // Fee should be 0.15% or 0.0015 + // As utilisation after is 0.1 and 0.1^3 = 0.001 + // Base fee is 0.005 so that makes 0.0015 + // Since there is only 1 token drawing liquidity, its weight is 1 + + // uint256 expFee = amount * 15 / 10_000; + + // assertEq(fee, expFee, "swap fee"); + // assertEq(out, amount - expFee, "swap out"); + // assertEq(swap.liquidity(), 90 ether, "TenderSwap available liquidity"); + } + + function test_swap_other() public { + uint256 liquidity = 2_000_000 ether; + underlying.mint(address(this), liquidity); + underlying.approve(address(swap), liquidity); + swap.deposit(liquidity); + + uint256 amount = 1 ether; + uint256 tokenId = _encodeTokenId(address(tToken0), 0); + + vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlock.selector, amount), abi.encode(0)); + vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlockMaturity.selector, 0), abi.encode(block.number + 100)); - uint256 expFee = ud(amount).mul(BASE_FEE).add(ud(amount).mul((ud(amount).div(ud(liquidity)).pow(ud(3e18))))).unwrap(); - expFee = expFee >= amount ? amount : expFee; + tToken0.mint(address(this), 34_000 ether); + tToken1.mint(address(this), 14_000 ether); + tToken0.approve(address(swap), 1500 ether); + (uint256 out, uint256 fee) = swap.swap(address(tToken0), amount, 0 ether); - console.log("expFee", expFee); - console.log("fee", fee); + (out, fee) = swap.quote(address(tToken1), 50 ether); + console.log("swap quote 1", out, fee); + // Fee should be 0.15% or 0.0015 + // As utilisation after is 0.1 and 0.1^3 = 0.001 + // Base fee is 0.005 so that makes 0.0015 + // Since there is only 1 token drawing liquidity, its weight is 1 + uint256 expFee = amount * 15 / 10_000; - assertTrue(acceptableDelta(fee, expFee, 2), "fee amount"); - assertTrue(acceptableDelta(out, amount - expFee, 2), "swap out"); - assertEq(swap.liquidity(), liquidity - amount, "TenderSwap available liquidity"); + // assertEq(fee, expFee, "swap fee"); + // assertEq(out, amount - expFee, "swap out"); + // assertEq(swap.liquidity(), 90 ether, "TenderSwap available liquidity"); } + + // function testFuzz_swap_basic(uint256 liquidity, uint256 amount) public { + // liquidity = bound(liquidity, 1e18, type(uint128).max); + // amount = bound(amount, 1e3, liquidity); + + // underlying.mint(address(this), liquidity); + // underlying.approve(address(swap), liquidity); + // swap.deposit(liquidity); + + // vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlock.selector, amount), abi.encode(0)); + // vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlockMaturity.selector, 0), abi.encode(block.number + + // 100)); + + // tToken0.mint(address(this), liquidity); + // tToken0.approve(address(swap), amount); + // (uint256 out, uint256 fee) = swap.swap(address(tToken0), amount, 0); + + // uint256 expFee = uint256( + // sd(int256(amount)).mul(BASE_FEE).add( + // sd(int256(amount)).mul((sd(int256(amount)).div(sd(int256(liquidity))).pow(sd(3e18)))) + // ).unwrap() + // ); + // expFee = expFee >= amount ? amount : expFee; + + // console.log("expFee", expFee); + // console.log("fee", fee); + + // assertTrue(acceptableDelta(fee, expFee, 2), "fee amount"); + // assertTrue(acceptableDelta(out, amount - expFee, 2), "swap out"); + // assertEq(swap.liquidity(), liquidity - amount, "TenderSwap available liquidity"); + // } } From 078bcb976f285bf92f44b05dd0a213228dd584aa Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Thu, 30 Nov 2023 20:10:13 +0100 Subject: [PATCH 4/6] upgrade: upgrade tenderize/staking dependency --- .gitmodules | 6 +++--- lib/staking | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitmodules b/.gitmodules index 119fa16..b72a377 100644 --- a/.gitmodules +++ b/.gitmodules @@ -12,9 +12,9 @@ [submodule "lib/clones-with-immutable-args"] path = lib/clones-with-immutable-args url = https://github.com/wighawag/clones-with-immutable-args -[submodule "lib/staking"] - path = lib/staking - url = https://github.com/Tenderize/staking [submodule "lib/solmate"] path = lib/solmate url = https://github.com/transmissions11/solmate +[submodule "lib/staking"] + path = lib/staking + url = https://github.com/tenderize/staking diff --git a/lib/staking b/lib/staking index c5c9b1b..78b890b 160000 --- a/lib/staking +++ b/lib/staking @@ -1 +1 @@ -Subproject commit c5c9b1b3007f2b29153624bbf794fbd695dd8bb0 +Subproject commit 78b890b5e5b61b3b5adc9feb44d81783a3aad8ed From 75413280a611d04a53ca6984278edcc47e000943 Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Thu, 30 Nov 2023 20:11:00 +0100 Subject: [PATCH 5/6] feat: improve UnlockQueue API --- src/UnlockQueue.sol | 52 ++++++++++++++++++++++++------------ test/UnlockQueue.harness.sol | 16 +++++------ test/UnlockQueue.t.sol | 32 +++++++++++++--------- 3 files changed, 62 insertions(+), 38 deletions(-) diff --git a/src/UnlockQueue.sol b/src/UnlockQueue.sol index 2add175..b5279f9 100644 --- a/src/UnlockQueue.sol +++ b/src/UnlockQueue.sol @@ -34,27 +34,45 @@ library UnlockQueue { } struct Data { - uint256 head; // oldest element - uint256 tail; // newest element + 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(UnlockQueue.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(UnlockQueue.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 popFront(UnlockQueue.Data storage q) internal returns (Item memory unlock) { - uint256 head = q.head; + function popHead(UnlockQueue.Data storage q) internal returns (Node memory node) { + uint256 head = q._head; if (head == 0) revert QueueEmpty(); - unlock = q.nodes[head].data; + node = q.nodes[head]; uint256 next = q.nodes[head].next; if (next == 0) { - q.head = 0; - q.tail = 0; + q._head = 0; + q._tail = 0; } else { - q.head = next; + q._head = next; q.nodes[next].prev = 0; } @@ -65,18 +83,18 @@ library UnlockQueue { * @notice Pop the newest element from the queue * @param q The queue to pop from */ - function popBack(UnlockQueue.Data storage q) internal returns (Item memory unlock) { - uint256 tail = q.tail; + function popTail(UnlockQueue.Data storage q) internal returns (Node memory node) { + uint256 tail = q._tail; if (tail == 0) revert QueueEmpty(); - unlock = q.nodes[tail].data; + node = q.nodes[tail]; uint256 prev = q.nodes[tail].prev; if (prev == 0) { - q.head = 0; - q.tail = 0; + q._head = 0; + q._tail = 0; } else { - q.tail = prev; + q._tail = prev; q.nodes[prev].next = 0; } @@ -89,18 +107,18 @@ library UnlockQueue { * @param unlock The unlock data to push */ function push(UnlockQueue.Data storage q, Item memory unlock) internal { - uint256 tail = q.tail; + uint256 tail = q._tail; uint256 newTail = unlock.id; q.nodes[newTail].data = unlock; q.nodes[newTail].prev = tail; if (tail == 0) { - q.head = newTail; + q._head = newTail; } else { q.nodes[tail].next = newTail; } - q.tail = newTail; + q._tail = newTail; } } diff --git a/test/UnlockQueue.harness.sol b/test/UnlockQueue.harness.sol index 3da87da..430c4be 100644 --- a/test/UnlockQueue.harness.sol +++ b/test/UnlockQueue.harness.sol @@ -24,19 +24,19 @@ contract UnlockQueueHarness { queue.push(item); } - function exposed_popFront() public returns (UnlockQueue.Item memory item) { - item = queue.popFront(); + function exposed_popHead() public returns (UnlockQueue.Node memory node) { + node = queue.popHead(); } - function exposed_popBack() public returns (UnlockQueue.Item memory item) { - item = queue.popBack(); + function exposed_popTail() public returns (UnlockQueue.Node memory node) { + node = queue.popTail(); } - function exposed_head() public view returns (UnlockQueue.Item memory item) { - item = queue.nodes[queue.head].data; + function exposed_head() public view returns (UnlockQueue.Node memory node) { + node = queue.nodes[queue._head]; } - function exposed_tail() public view returns (UnlockQueue.Item memory item) { - item = queue.nodes[queue.tail].data; + function exposed_tail() public view returns (UnlockQueue.Node memory node) { + node = queue.nodes[queue._tail]; } } diff --git a/test/UnlockQueue.t.sol b/test/UnlockQueue.t.sol index 7d36108..f6093e1 100644 --- a/test/UnlockQueue.t.sol +++ b/test/UnlockQueue.t.sol @@ -30,28 +30,34 @@ contract UnlockQueueTest is Test { queue.exposed_push(item1); // assert - assertEq(queue.exposed_head().id, 1); - assertEq(queue.exposed_tail().id, 1); + assertEq(queue.exposed_head().data.id, 1); + assertEq(queue.exposed_tail().data.id, 1); queue.exposed_push(item2); // assert - assertEq(queue.exposed_head().id, 1); - assertEq(queue.exposed_tail().id, 2); + assertEq(queue.exposed_head().data.id, 1); + assertEq(queue.exposed_head().prev, 0); + assertEq(queue.exposed_head().next, 2); + assertEq(queue.exposed_tail().data.id, 2); queue.exposed_push(item3); // assert - assertEq(queue.exposed_head().id, 1); - assertEq(queue.exposed_tail().id, 3); + assertEq(queue.exposed_head().data.id, 1); + assertEq(queue.exposed_head().prev, 0); + assertEq(queue.exposed_head().next, 2); + assertEq(queue.exposed_tail().data.id, 3); + assertEq(queue.exposed_tail().prev, 2); + assertEq(queue.exposed_tail().next, 0); // pop front - UnlockQueue.Item memory popped = queue.exposed_popFront(); - assertEq(popped.id, 1); - assertEq(queue.exposed_head().id, 2); + UnlockQueue.Node memory popped = queue.exposed_popHead(); + assertEq(popped.data.id, 1); + assertEq(queue.exposed_head().data.id, 2); // pop back - popped = queue.exposed_popBack(); - assertEq(popped.id, 3); - assertEq(queue.exposed_head().id, 2); - assertEq(queue.exposed_tail().id, 2); + popped = queue.exposed_popTail(); + assertEq(popped.data.id, 3); + assertEq(queue.exposed_head().data.id, 2); + assertEq(queue.exposed_tail().data.id, 2); } } From 660d78179f079dd68b2e1d367df7e729e485b485 Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Thu, 30 Nov 2023 20:11:30 +0100 Subject: [PATCH 6/6] fix: buyUnlock reward calculation --- src/Swap.sol | 94 ++++++++++++++++++++--------- test/Swap.harness.sol | 16 +++++ test/Swap.t.sol | 133 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 214 insertions(+), 29 deletions(-) diff --git a/src/Swap.sol b/src/Swap.sol index 7d322c8..9c59806 100644 --- a/src/Swap.sol +++ b/src/Swap.sol @@ -10,11 +10,13 @@ // Copyright (c) Tenderize Labs Ltd import { SD59x18, ZERO, UNIT, unwrap, sd } from "@prb/math/SD59x18.sol"; +import { UD60x18, UNIT as UNIT_60x18, ud } from "@prb/math/UD60x18.sol"; import { ERC20 } from "solmate/tokens/ERC20.sol"; import { ERC721 } from "solmate/tokens/ERC721.sol"; import { SafeTransferLib } from "solmate/utils/SafeTransferLib.sol"; +import { Adapter } from "@tenderize/stake/adapters/Adapter.sol"; import { Registry } from "@tenderize/stake/registry/Registry.sol"; -import { Tenderizer } from "@tenderize/stake/tenderizer/Tenderizer.sol"; +import { Tenderizer, TenderizerImmutableArgs } from "@tenderize/stake/tenderizer/Tenderizer.sol"; import { Unlocks } from "@tenderize/stake/unlocks/Unlocks.sol"; import { SafeCastLib } from "solmate/utils/SafeCastLib.sol"; @@ -29,8 +31,8 @@ pragma solidity >=0.8.19; // TODO: UUPS upgradeable SD59x18 constant BASE_FEE = SD59x18.wrap(0.0005e18); -SD59x18 constant RELAYER_CUT = SD59x18.wrap(0.1e18); -SD59x18 constant MIN_LP_CUT = SD59x18.wrap(0.1e18); +UD60x18 constant RELAYER_CUT = UD60x18.wrap(0.1e18); +UD60x18 constant MIN_LP_CUT = UD60x18.wrap(0.1e18); SD59x18 constant K = SD59x18.wrap(3e18); struct Config { @@ -142,6 +144,26 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { r = _utilisation($.unlocking, $.liabilities); } + /** + * @notice Current oldest unlock in the queue + * @dev returns a struct with zero values if queue is empty + * @return unlock UnlockQueue.Item struct + */ + function oldestUnlock() public view returns (UnlockQueue.Item memory) { + Data storage $ = _loadStorageSlot(); + return $.unlockQ.head().data; + } + + /** + * @notice Current newest unlock in the queue + * @dev returns a struct with zero values if queue is empty + * @return unlock UnlockQueue.Item struct + */ + function newestUnlock() public view returns (UnlockQueue.Item memory) { + Data storage $ = _loadStorageSlot(); + return $.unlockQ.tail().data; + } + /** * @notice Deposit liquidity into the pool, receive liquidity pool shares in return. * The liquidity pool shares represent an amount of liabilities owed to the liquidity provider. @@ -211,6 +233,16 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { emit RelayerRewardsClaimed(msg.sender, relayerReward); } + /** + * @notice Check outstanding rewards for a relayer. + * @param relayer Address of the relayer + * @return relayerReward Amount of tokens that can be claimed + */ + function getPendingRelayerRewards(address relayer) external view returns (uint256) { + Data storage $ = _loadStorageSlot(); + return $.relayerFees[relayer]; + } + /** * @notice Quote the amount of tokens that would be received for a given amount of input tokens. * @dev This function wraps `swap` in `staticcall` and is therefore not very gas efficient to be used on-chain. @@ -296,14 +328,24 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { if ($.recovery > 0) revert RecoveryMode(); // get newest item from unlock queue - UnlockQueue.Item memory unlock = $.unlockQ.popBack(); + UnlockQueue.Item memory unlock = $.unlockQ.popTail().data; // revert if unlock at maturity - if (unlock.maturity <= block.timestamp) revert UnlockNotMature(unlock.maturity, block.timestamp); - - // calculate reward after decay, take base fee cut for LPs - uint256 reward = - (unlock.fee - uint256(unwrap(sd(int256(uint256(unlock.fee))).mul(MIN_LP_CUT)))) * unlock.maturity / block.timestamp; + tokenId = unlock.id; + (address tenderizer,) = _decodeTokenId(tokenId); + Adapter adapter = Tenderizer(tenderizer).adapter(); + uint256 time = adapter.currentTime(); + if (unlock.maturity <= time) revert UnlockAlreadyMature(unlock.maturity, block.timestamp); + + // Calculate the reward for purchasing the unlock + // The base reward is the fee minus the MIN_LP_CUT going to liquidity providers + // The base reward then further decays as time to maturity decreases + uint256 reward; + { + UD60x18 progress = ud(unlock.maturity - time).div(ud(adapter.unlockTime())); + UD60x18 fee60x18 = ud(unlock.fee); + reward = fee60x18.sub(fee60x18.mul(MIN_LP_CUT)).mul(UNIT_60x18.sub(progress)).unwrap(); + } // Update pool state // - update unlocking @@ -311,8 +353,6 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { // - Update liabilities to distribute LP rewards $.liabilities += unlock.fee - reward; - tokenId = unlock.id; - (address tenderizer,) = _decodeTokenId(tokenId); uint256 ufa = $.unlockingForAsset[tenderizer] - unlock.amount; // - Update S if unlockingForAsset is now zero if (ufa == 0) { @@ -341,17 +381,15 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { Data storage $ = _loadStorageSlot(); // get oldest item from unlock queue - UnlockQueue.Item memory unlock = $.unlockQ.popFront(); - - // revert if unlock *not* at maturity - if (unlock.maturity > block.timestamp) revert UnlockNotMature(unlock.maturity, block.timestamp); + UnlockQueue.Item memory unlock = $.unlockQ.popHead().data; // withdraw the unlock (returns amount withdrawn) (address tenderizer, uint96 id) = _decodeTokenId(unlock.id); + // this will revert if unlock is not at maturity uint256 amountReceived = Tenderizer(tenderizer).withdraw(address(this), id); //calculate the relayer reward - uint256 relayerReward = uint256(unwrap(sd(int256(uint256(unlock.fee))).mul(RELAYER_CUT))); + uint256 relayerReward = ud(unlock.fee).mul(RELAYER_CUT).unwrap(); // update relayer rewards $.relayerFees[msg.sender] += relayerReward; @@ -418,24 +456,24 @@ contract TenderSwap is SwapStorage, Multicall, SelfPermit, ERC721Receiver { SD59x18 x = sd(int256(amount)); SD59x18 L = sd(int256($.liabilities)); + SD59x18 nom; + SD59x18 denom; { - SD59x18 nom = p.u.add(x); - nom = nom.mul(K).sub(p.U).add(p.u); - nom = nom.mul(p.U.add(x).div(L).pow(K)); - { - K.mul(p.u).gt(p.U.sub(p.u)) - ? nom = nom.sub(K.mul(p.u).add(p.u).sub(p.U).mul(p.U.div(L).pow(K))) - : nom = nom.add(p.U.sub(p.u).sub(K.mul(p.u)).mul(p.U.div(L).pow(K))); - } - nom = nom.mul(p.S.add(p.U)); + SD59x18 sumA = p.u.add(x); + sumA = sumA.mul(K).sub(p.U).add(p.u); + sumA = sumA.mul(p.U.add(x).div(L).pow(K)); - SD59x18 denom = K.mul(UNIT.add(K)).mul(p.s.add(p.u)); + SD59x18 sumB = p.U.sub(p.u).sub(K.mul(p.u)).mul(p.U.div(L).pow(K)); - fee = uint256(BASE_FEE.mul(x).add(nom.div(denom)).unwrap()); + nom = sumA.add(sumB).mul(p.S.add(p.U)); - fee = fee >= amount ? amount : fee; + denom = K.mul(UNIT.add(K)).mul(p.s.add(p.u)); } + SD59x18 baseFee = BASE_FEE.mul(x); + fee = uint256(baseFee.add(nom.div(denom)).unwrap()); + + fee = fee >= amount ? amount : fee; unchecked { out = amount - fee; } diff --git a/test/Swap.harness.sol b/test/Swap.harness.sol index 0c74b54..8270bb0 100644 --- a/test/Swap.harness.sol +++ b/test/Swap.harness.sol @@ -12,6 +12,7 @@ pragma solidity >=0.8.19; import { TenderSwap, Config } from "@tenderize/swap/Swap.sol"; +import { UnlockQueue } from "@tenderize/swap/UnlockQueue.sol"; // solhint-disable func-name-mixedcase @@ -22,4 +23,19 @@ contract SwapHarness is TenderSwap { Data storage $ = _loadStorageSlot(); $.liabilities = _liabilities; } + + function exposed_queueQuery(uint256 index) public view returns (UnlockQueue.Node memory) { + Data storage $ = _loadStorageSlot(); + return $.unlockQ.nodes[index]; + } + + function exposed_unlocking() public view returns (uint256) { + Data storage $ = _loadStorageSlot(); + return $.unlocking; + } + + function exposed_unlockingForAsset(address asset) public view returns (uint256) { + Data storage $ = _loadStorageSlot(); + return $.unlockingForAsset[asset]; + } } diff --git a/test/Swap.t.sol b/test/Swap.t.sol index ea4254c..681816e 100644 --- a/test/Swap.t.sol +++ b/test/Swap.t.sol @@ -12,16 +12,21 @@ pragma solidity >=0.8.19; import { Test, console } from "forge-std/Test.sol"; +import { ERC721 } from "solmate/tokens/ERC721.sol"; import { MockERC20 } from "test/helpers/MockERC20.sol"; + +import { Adapter } from "@tenderize/stake/adapters/Adapter.sol"; import { Registry } from "@tenderize/stake/registry/Registry.sol"; import { Tenderizer, TenderizerImmutableArgs } from "@tenderize/stake/tenderizer/Tenderizer.sol"; -import { TenderSwap, Config, BASE_FEE, _encodeTokenId, _decodeTokenId } from "@tenderize/swap/Swap.sol"; +import { TenderSwap, Config, BASE_FEE, RELAYER_CUT, MIN_LP_CUT, _encodeTokenId, _decodeTokenId } from "@tenderize/swap/Swap.sol"; import { LPToken } from "@tenderize/swap/LPToken.sol"; import { SD59x18, ZERO, UNIT, unwrap, sd } from "@prb/math/SD59x18.sol"; +import { UD60x18, ud, UNIT as UNIT_60x18 } from "@prb/math/ud60x18.sol"; import { SwapHarness } from "./Swap.harness.sol"; +import { UnlockQueue } from "@tenderize/swap/UnlockQueue.sol"; import { acceptableDelta } from "./helpers/Utils.sol"; @@ -34,6 +39,7 @@ contract TenderSwapTest is Test { address registry; address unlocks; + address adapter; address addr1; address addr2; @@ -45,6 +51,7 @@ contract TenderSwapTest is Test { registry = vm.addr(123); unlocks = vm.addr(567); + adapter = vm.addr(789); addr1 = vm.addr(111); addr2 = vm.addr(222); @@ -90,6 +97,130 @@ contract TenderSwapTest is Test { assertEq(underlying.balanceOf(address(swap)), deposit1 + deposit2, "TenderSwap underlying balance"); } + // write end to end swap test with checking the queue + // make three swaps, check the queue state (check head and tail) + // buy up the last unlock and check all code paths + // * mock unlocks as ERC721 mock transfer + // process blocks and redeem the first unlock and check all code paths + // * mock Tenderizer.withdraw() + // check that queue is now only containing the second unlock + // * Mock Tenderizer.unlock() and Tenderizer.unlockMaturity() + + function test_scenario_full() public { + uint256 unlockTime = 100; + tToken0.mint(address(this), 10_000 ether); + tToken0.approve(address(swap), 10_000 ether); + + // 1. Deposit Liquidity + uint256 liquidity = 100 ether; + underlying.mint(address(this), liquidity); + underlying.approve(address(swap), liquidity); + swap.deposit(liquidity); + + vm.mockCall(address(tToken0), abi.encodeWithSelector(TenderizerImmutableArgs.adapter.selector), abi.encode(adapter)); + + // 2. Make 3 swaps + uint256 amount = 10 ether; + vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlock.selector, amount), abi.encode(1)); + vm.mockCall( + address(tToken0), abi.encodeWithSelector(Tenderizer.unlockMaturity.selector, 1), abi.encode(block.number + unlockTime) + ); + swap.swap(address(tToken0), 10 ether, 0 ether); + + uint256 unlockBlockOne = block.number; + uint256 unlockBlockTwo = block.number + 1; + uint256 unlockBlockThree = block.number + 2; + + vm.roll(unlockBlockTwo); + amount = 20 ether; + vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlock.selector, amount), abi.encode(2)); + vm.mockCall( + address(tToken0), abi.encodeWithSelector(Tenderizer.unlockMaturity.selector, 2), abi.encode(block.number + unlockTime) + ); + swap.swap(address(tToken0), 20 ether, 0 ether); + + vm.roll(unlockBlockThree); + amount = 30 ether; + vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.unlock.selector, amount), abi.encode(3)); + vm.mockCall( + address(tToken0), abi.encodeWithSelector(Tenderizer.unlockMaturity.selector, 3), abi.encode(block.number + unlockTime) + ); + swap.swap(address(tToken0), 30 ether, 0 ether); + + // 3. Check queue state + UnlockQueue.Item memory head = swap.oldestUnlock(); + assertEq(head.id, _encodeTokenId(address(tToken0), 1), "head id"); + assertEq(head.amount, 10 ether, "head amount"); + assertEq(head.maturity, unlockBlockOne + unlockTime, "head maturity"); + + UnlockQueue.Node memory middleUnlock = swap.exposed_queueQuery(_encodeTokenId(address(tToken0), 2)); + assertEq(middleUnlock.prev, _encodeTokenId(address(tToken0), 1), "middleUnlock prev"); + assertEq(middleUnlock.next, _encodeTokenId(address(tToken0), 3), "middleUnlock next"); + assertEq(middleUnlock.data.id, _encodeTokenId(address(tToken0), 2), "middleUnlock id"); + assertEq(middleUnlock.data.amount, 20 ether, "middleUnlock amount"); + assertEq(middleUnlock.data.maturity, unlockBlockTwo + unlockTime, "middleUnlock maturity"); + + UnlockQueue.Item memory tail = swap.newestUnlock(); + assertEq(tail.id, _encodeTokenId(address(tToken0), 3), "tail id"); + assertEq(tail.amount, 30 ether, "tail amount"); + assertEq(tail.maturity, unlockBlockThree + unlockTime, "tail maturity"); + + // 4. Buy up the last unlock + uint256 currentTime = unlockBlockThree + 50; + vm.mockCall(adapter, abi.encodeWithSelector(Adapter.currentTime.selector), abi.encode(currentTime)); + vm.mockCall(adapter, abi.encodeWithSelector(Adapter.unlockTime.selector), abi.encode(unlockTime)); + + vm.mockCall( + unlocks, + abi.encodeWithSignature( + "safeTransferFrom(address,address,uint256)", address(swap), address(this), _encodeTokenId(address(tToken0), 3) + ), + abi.encode(true) + ); + underlying.mint(address(this), 30 ether); + underlying.approve(address(swap), 30 ether); + // console.log("fee %s", tail.fee); + // console.log("lp cut %s", uint256(unwrap(sd(int256(uint256(tail.fee))).mul(sd(0.1e18))))); + // console.log("maturity %s", tail.maturity); + // console.log("block num %s", block.number); + + uint256 liabilitiesBefore = swap.liabilities(); + { + // buy unlock 3 + assertEq(swap.buyUnlock(), _encodeTokenId(address(tToken0), 3), "bought id"); + UD60x18 tailFee = ud(tail.fee); + UD60x18 baseReward = tailFee.sub(tailFee.mul(MIN_LP_CUT)); + UD60x18 timeLeft = ud(tail.maturity - currentTime); + UD60x18 unlockTimex18 = ud(unlockTime); + UD60x18 progress = timeLeft.div(unlockTimex18); + assertEq(swap.liabilities(), liabilitiesBefore + tailFee.sub(baseReward.mul(progress)).unwrap(), "liabilities"); + // sanity check that the LP cut is half of the baseReward plus the LP cut + assertEq( + swap.liabilities(), liabilitiesBefore + tailFee.sub(baseReward.div(ud(2e18))).unwrap(), "liabilities sanity check" + ); + } + assertEq(swap.exposed_unlocking(), 20 ether + 10 ether, "unlocking"); + assertEq(swap.exposed_unlockingForAsset(address(tToken0)), 20 ether + 10 ether, "unlocking for asset"); + head = swap.oldestUnlock(); + assertEq(head.id, _encodeTokenId(address(tToken0), 1), "head id"); + tail = swap.newestUnlock(); + assertEq(tail.id, _encodeTokenId(address(tToken0), 2), "tail id"); + + // 5. Redeem the first unlock + vm.roll(unlockBlockOne + unlockTime); + vm.mockCall(address(tToken0), abi.encodeWithSelector(Tenderizer.withdraw.selector, address(swap), 1), abi.encode(10 ether)); + liabilitiesBefore = swap.liabilities(); + swap.redeemUnlock(); + assertEq(swap.liabilities(), liabilitiesBefore + ud(head.fee).sub(ud(head.fee).mul(RELAYER_CUT)).unwrap(), "liabilities"); + assertEq(swap.getPendingRelayerRewards(address(this)), ud(head.fee).mul(RELAYER_CUT).unwrap(), "relayer rewards"); + assertEq(swap.exposed_unlocking(), 20 ether, "unlocking"); // unlock 2 remains + assertEq(swap.exposed_unlockingForAsset(address(tToken0)), 20 ether, "unlocking for asset"); // unlock 2 remains + head = swap.oldestUnlock(); + assertEq(head.id, _encodeTokenId(address(tToken0), 2), "head id"); + tail = swap.newestUnlock(); + assertEq(tail.id, _encodeTokenId(address(tToken0), 2), "tail id"); + } + function test_swap() public { uint256 liquidity = 100 ether; underlying.mint(address(this), liquidity);