Skip to content

Commit

Permalink
fix: buyUnlock reward calculation
Browse files Browse the repository at this point in the history
  • Loading branch information
kyriediculous committed Nov 30, 2023
1 parent 7541328 commit 660d781
Show file tree
Hide file tree
Showing 3 changed files with 214 additions and 29 deletions.
94 changes: 66 additions & 28 deletions src/Swap.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -296,23 +328,31 @@ 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
$.unlocking -= unlock.amount;
// - 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) {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
16 changes: 16 additions & 0 deletions test/Swap.harness.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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];
}
}
133 changes: 132 additions & 1 deletion test/Swap.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -34,6 +39,7 @@ contract TenderSwapTest is Test {

address registry;
address unlocks;
address adapter;

address addr1;
address addr2;
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit 660d781

Please sign in to comment.