From 660d78179f079dd68b2e1d367df7e729e485b485 Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Thu, 30 Nov 2023 20:11:30 +0100 Subject: [PATCH] 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);