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/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/lib/staking b/lib/staking index c5c9b1b..78b890b 160000 --- a/lib/staking +++ b/lib/staking @@ -1 +1 @@ -Subproject commit c5c9b1b3007f2b29153624bbf794fbd695dd8bb0 +Subproject commit 78b890b5e5b61b3b5adc9feb44d81783a3aad8ed diff --git a/script/Add_Liquidity.s.sol b/script/Add_Liquidity.s.sol new file mode 100644 index 0000000..de02177 --- /dev/null +++ b/script/Add_Liquidity.s.sol @@ -0,0 +1,40 @@ +// 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(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); + // 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/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..9c59806 100644 --- a/src/Swap.sol +++ b/src/Swap.sol @@ -9,18 +9,20 @@ // // Copyright (c) Tenderize Labs Ltd -import { UD60x18, ZERO, UNIT, unwrap, ud } from "@prb/math/UD60x18.sol"; -import { ClonesWithImmutableArgs } from "clones/ClonesWithImmutableArgs.sol"; +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"; 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"; @@ -28,10 +30,10 @@ pragma solidity >=0.8.19; // TODO: UUPS upgradeable -UD60x18 constant BASE_FEE = UD60x18.wrap(0.0005e18); +SD59x18 constant BASE_FEE = SD59x18.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); +SD59x18 constant K = SD59x18.wrap(3e18); struct Config { ERC20 underlying; @@ -39,7 +41,14 @@ struct Config { address unlocks; } -contract TenderSwapStorage { +struct SwapParams { + SD59x18 u; + SD59x18 U; + SD59x18 s; + SD59x18 S; +} + +abstract contract SwapStorage { uint256 private constant SSLOT = uint256(keccak256("xyz.tenderize.swap.storage.location")) - 1; struct Data { @@ -48,7 +57,7 @@ contract TenderSwapStorage { // total amount of liabilities owed to LPs uint256 liabilities; // sum of token supplies that have outstanding unlocks - uint256 S; + SD59x18 S; // Unlock queue to hold unlocks UnlockQueue.Data unlockQ; // Recovery amount, if `recovery` > 0 enable recovery mode @@ -56,7 +65,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 => SD59x18 lastSupply) lastSupplyForAsset; // relayer fees mapping(address relayer => uint256 fee) relayerFees; } @@ -71,7 +80,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 +112,8 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { } modifier supplyUpdateHook(address asset) { - _supplyUpdateHook(asset); + Data storage $ = _loadStorageSlot(); + // _supplyUpdateHook(asset); _; } @@ -128,26 +138,30 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { * @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); } /** - * @notice Current general utilisation fee given the current utilisation ratio - * @dev `utilisationFee = utilisation^n` + * @notice Current oldest unlock in the queue + * @dev returns a struct with zero values if queue is empty + * @return unlock UnlockQueue.Item struct */ - function utilisationFee() public view returns (UD60x18 f) { - f = _utilisationFee(utilisation()); + function oldestUnlock() public view returns (UnlockQueue.Item memory) { + Data storage $ = _loadStorageSlot(); + return $.unlockQ.head().data; } /** - * @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 + * @notice Current newest unlock in the queue + * @dev returns a struct with zero values if queue is empty + * @return unlock UnlockQueue.Item struct */ - function spread(address asset) public view returns (UD60x18 s) { - return _spread(asset, 0); + function newestUnlock() public view returns (UnlockQueue.Item memory) { + Data storage $ = _loadStorageSlot(); + return $.unlockQ.tail().data; } /** @@ -219,6 +233,16 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { 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. @@ -228,11 +252,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(); + + 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,20 +272,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); + 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 }); + + (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 +293,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; @@ -302,13 +328,24 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { 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 - unwrap(ud(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 @@ -316,13 +353,11 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { // - 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) { - $.S -= $.lastSupplyForAsset[tenderizer]; - delete $.lastSupplyForAsset[tenderizer]; + $.S = $.S.sub($.lastSupplyForAsset[tenderizer]); + $.lastSupplyForAsset[tenderizer] = ZERO; } // - Update unlockingForAsset $.unlockingForAsset[tenderizer] = ufa; @@ -346,32 +381,42 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { 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); - // TODO: Handle amount received > 0 ? - //calculate the relayer reward - uint256 relayerReward = unwrap(ud(unlock.fee).mul(RELAYER_CUT)); + uint256 relayerReward = ud(unlock.fee).mul(RELAYER_CUT).unwrap(); // update relayer rewards $.relayerFees[msg.sender] += relayerReward; 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 +439,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 +451,34 @@ 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); + SD59x18 x = sd(int256(amount)); + SD59x18 L = sd(int256($.liabilities)); + SD59x18 nom; + SD59x18 denom; - // calculate spread multiplier - UD60x18 w = _spread(asset, amount); + { + 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)); - // calculate fee by multiplying the base fee by the spread - UD60x18 f = _utilisationFee(r).mul(w); + SD59x18 sumB = p.U.sub(p.u).sub(K.mul(p.u)).mul(p.U.div(L).pow(K)); - f = f.gt(UNIT) ? UNIT : f; // max 100% fee + nom = sumA.add(sumB).mul(p.S.add(p.U)); - // get the marginal fee - fee = f.mul(ud(amount)).unwrap(); + 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()); - // 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` @@ -436,21 +487,8 @@ contract TenderSwap is TenderSwapStorage, Multicall, SelfPermit { 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 _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 _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 { @@ -465,7 +503,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 +516,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 (SD59x18 s, SD59x18 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 = sd(int256(Tenderizer(tenderizer).totalSupply())); + SD59x18 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..b5279f9 100644 --- a/src/UnlockQueue.sol +++ b/src/UnlockQueue.sol @@ -34,41 +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 returns the oldest element in the queue + * @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 (Item memory) { - return q.nodes[q.head].data; + function head(UnlockQueue.Data storage q) internal view returns (Node memory) { + return q.nodes[q._head]; } /** - * @notice returns the newest element in the queue + * @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 (Item memory) { - return q.nodes[q.tail].data; + 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; } @@ -79,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; } @@ -100,22 +104,21 @@ 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 { - uint256 tail = q.tail; - uint256 newTail = id; + function push(UnlockQueue.Data storage q, Item memory unlock) internal { + 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/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..8270bb0 --- /dev/null +++ b/test/Swap.harness.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +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 + +contract SwapHarness is TenderSwap { + constructor(Config memory config) TenderSwap(config) { } + + function exposed_setLiabilities(uint256 _liabilities) public { + 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 86c8223..681816e 100644 --- a/test/Swap.t.sol +++ b/test/Swap.t.sol @@ -11,25 +11,35 @@ pragma solidity >=0.8.19; -import { Test } from "forge-std/Test.sol"; +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 { UD60x18, ud, unwrap, ZERO, UNIT } from "@prb/math/UD60x18.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"; contract TenderSwapTest is Test { MockERC20 underlying; MockERC20 tToken0; MockERC20 tToken1; - TenderSwap swap; + SwapHarness swap; address registry; address unlocks; + address adapter; address addr1; address addr2; @@ -41,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); @@ -55,12 +66,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 +81,144 @@ 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"); + } + + // 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 { @@ -96,8 +233,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 @@ -111,26 +248,107 @@ 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_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); + 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), 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)); - tToken0.mint(address(this), liquidity); - tToken0.approve(address(swap), amount); - (uint256 out, uint256 fee) = swap.swap(address(tToken0), amount, 0); + 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); - uint256 expFee = unwrap(ud(amount).mul((BASE_FEE + ud(amount).div(ud(liquidity)).pow(ud(3e18))))); - expFee = expFee >= amount ? amount : expFee; + (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; - assertEq(fee, expFee, "swap fee"); - assertEq(out, amount - expFee, "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"); + // } } diff --git a/test/UnlockQueue.harness.sol b/test/UnlockQueue.harness.sol new file mode 100644 index 0000000..430c4be --- /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_popHead() public returns (UnlockQueue.Node memory node) { + node = queue.popHead(); + } + + function exposed_popTail() public returns (UnlockQueue.Node memory node) { + node = queue.popTail(); + } + + function exposed_head() public view returns (UnlockQueue.Node memory node) { + node = queue.nodes[queue._head]; + } + + 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 new file mode 100644 index 0000000..f6093e1 --- /dev/null +++ b/test/UnlockQueue.t.sol @@ -0,0 +1,63 @@ +// 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().data.id, 1); + assertEq(queue.exposed_tail().data.id, 1); + + queue.exposed_push(item2); + // assert + 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().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.Node memory popped = queue.exposed_popHead(); + assertEq(popped.data.id, 1); + assertEq(queue.exposed_head().data.id, 2); + + // pop back + popped = queue.exposed_popTail(); + assertEq(popped.data.id, 3); + assertEq(queue.exposed_head().data.id, 2); + assertEq(queue.exposed_tail().data.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; + } +}