diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35a9482..4479878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,22 +16,22 @@ jobs: runs-on: "ubuntu-latest" steps: - name: "Check out the repo" - uses: "actions/checkout@v3" + uses: "actions/checkout@v4" + + - name: "Install Bun.sh" + uses: oven-sh/setup-bun@v1 - name: "Install Foundry" uses: "foundry-rs/foundry-toolchain@v1" - name: "Install Node.js" uses: "actions/setup-node@v3" - with: - cache: "yarn" - node-version: "lts/*" - name: "Install the Node.js dependencies" - run: "yarn install --immutable" + run: "bun install --immutable" - name: "Lint the contracts" - run: "yarn lint" + run: "bun lint" - name: "Add lint summary" run: | @@ -76,4 +76,4 @@ jobs: - name: "Add test summary" run: | echo "## Tests result" >> $GITHUB_STEP_SUMMARY - echo "✅ Passed" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "✅ Passed" >> $GITHUB_STEP_SUMMARY diff --git a/.prettierrc.yml b/.prettierrc.yml index 4508a08..a1ecdbb 100644 --- a/.prettierrc.yml +++ b/.prettierrc.yml @@ -4,4 +4,4 @@ proseWrap: "always" singleQuote: false tabWidth: 2 trailingComma: "all" -useTabs: false \ No newline at end of file +useTabs: false diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..fc68b3f Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json index 8bb2c56..611460c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "name": "Tenderize", "url": "https://github.com/Tenderize" }, - "packageManager": "yarn@3.2.4", "devDependencies": { "prettier": "^3.0.0", "solhint-community": "^3.6.0" @@ -14,8 +13,8 @@ "private": true, "scripts": { "clean": "rm -rf cache out", - "lint": "yarn lint:sol && yarn prettier:check", - "lint:sol": "forge fmt --check && yarn solhint {script,src,test}/**/*.sol", + "lint": "yarn lint:sol && yarn prettier:write", + "lint:sol": "yarn solhint {src,test}/**/*.sol", "prettier:check": "prettier --check **/*.{json,md,yml} --ignore-path=.prettierignore", "prettier:write": "prettier --write **/*.{json,md,yml} --ignore-path=.prettierignore" } diff --git a/script/Add_Liquidity.s.sol b/script/Add_Liquidity.s.sol index b72d247..9030e19 100644 --- a/script/Add_Liquidity.s.sol +++ b/script/Add_Liquidity.s.sol @@ -19,7 +19,7 @@ contract Add_Liquidity is Script { vm.startBroadcast(deployerPrivateKey); TenderSwap swap = TenderSwap(0x2C7b29B0d07276bA2DF4abE02E9A38b5693af9c6); ERC20(underlying).approve(address(swap), 500_000 ether); - swap.deposit(500_000 ether); + swap.deposit(500_000 ether, 0); console2.log("liabilities", swap.liabilities()); console2.log("liquidity", swap.liquidity()); // ERC20(0x2eaC4210B90D13666f7E88635096BdC17C51FB70).approve(address(swap), 10 ether); diff --git a/script/Stats.s.sol b/script/Stats.s.sol index 036ef5e..fe6f0e4 100644 --- a/script/Stats.s.sol +++ b/script/Stats.s.sol @@ -4,7 +4,7 @@ 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"; -import { SD59x18 } from "@prb/math/SD59x18.sol"; +import { UD60x18 } from "@prb/math/UD60x18.sol"; import { Tenderizer } from "@tenderize/stake/tenderizer/Tenderizer.sol"; import { StakingXYZ } from "lib/staking/test/helpers/StakingXYZ.sol"; @@ -29,7 +29,7 @@ contract Stats is Script { swap.swap(address(0xE3350e66D3850B4f4C97b6737E9e8Ff78CFC1b00), 1 ether, 0); uint256 liabilities = swap.liabilities(); uint256 liquidity = swap.liquidity(); - SD59x18 utilisation = swap.utilisation(); + UD60x18 utilisation = swap.utilisation(); console2.log("liabilities %s", liabilities); console2.log("liquidity %s", liquidity); diff --git a/src/Swap.sol b/src/Swap.sol index f4b13a3..0e64266 100644 --- a/src/Swap.sol +++ b/src/Swap.sol @@ -9,11 +9,12 @@ // // 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 { SD59x18, ZERO as ZERO_SD59, UNIT, unwrap, sd } from "@prb/math/SD59x18.sol"; +import { UD60x18, ZERO as ZERO_UD60, 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 { FixedPointMathLib } from "solmate/utils/FixedPointMathLib.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"; @@ -30,15 +31,22 @@ import { ERC721Receiver } from "@tenderize/swap/util/ERC721Receiver.sol"; import { LPToken } from "@tenderize/swap/LPToken.sol"; import { UnlockQueue } from "@tenderize/swap/UnlockQueue.sol"; -pragma solidity >=0.8.19; +pragma solidity 0.8.19; -// TODO: UUPS upgradeable -// TODO: fix '_utilisation' to use UD60x18 +error ErrorNotMature(uint256 maturity, uint256 timestamp); +error ErrorAlreadyMature(uint256 maturity, uint256 timestamp); +error ErrorInvalidAsset(address asset); +error ErrorSlippage(uint256 out, uint256 minOut); +error ErrorInsufficientAssets(uint256 requested, uint256 available); +error ErrorRecoveryMode(); +error ErrorCalculateLPShares(); +error ErrorWithdrawCooldown(uint256 lpSharesRequested, uint256 lpSharesAvailable); 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); SD59x18 constant K = SD59x18.wrap(3e18); +uint64 constant COOLDOWN = 12 hours; struct Config { ERC20 underlying; @@ -53,6 +61,11 @@ struct SwapParams { SD59x18 S; } +struct LastDeposit { + uint192 amount; + uint64 timestamp; +} + abstract contract SwapStorage { uint256 private constant SSLOT = uint256(keccak256("xyz.tenderize.swap.storage.location")) - 1; @@ -73,6 +86,8 @@ abstract contract SwapStorage { mapping(address asset => SD59x18 lastSupply) lastSupplyForAsset; // relayer fees mapping(address relayer => uint256 reward) relayerRewards; + // last deposits (used to check cooldown) + mapping(address => LastDeposit) lastDeposit; } function _loadStorageSlot() internal pure returns (Data storage $) { @@ -90,13 +105,6 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS using SafeCastLib for uint256; using UnlockQueue for UnlockQueue.Data; - error UnlockNotMature(uint256 maturity, uint256 timestamp); - error UnlockAlreadyMature(uint256 maturity, uint256 timestamp); - error InvalidAsset(address asset); - error SlippageThresholdExceeded(uint256 out, uint256 minOut); - error InsufficientAssets(uint256 requested, uint256 available); - error RecoveryMode(); - event Deposit(address indexed from, uint256 amount, uint256 lpSharesMinted); event Withdraw(address indexed to, uint256 amount, uint256 lpSharesBurnt); event Swap(address indexed caller, address indexed asset, uint256 amountIn, uint256 amountOut); @@ -150,9 +158,9 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS * @notice Current general utilisation ratio of the pool's liquidity * @dev `utilisation = unlocking / liabilities` */ - function utilisation() public view returns (SD59x18 r) { + function utilisation() public view returns (UD60x18 r) { Data storage $ = _loadStorageSlot(); - if ($.liabilities == 0) return ZERO; + if ($.liabilities == 0) return ZERO_UD60; r = _utilisation($.unlocking, $.liabilities); } @@ -180,16 +188,33 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS * @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. * @param amount Amount of liquidity to deposit + * @param minLpShares Minimum amount of liquidity pool shares to receive * @return lpShares Amount of liquidity pool shares minted */ - function deposit(uint256 amount) external returns (uint256 lpShares) { + function deposit(uint256 amount, uint256 minLpShares) external returns (uint256 lpShares) { Data storage $ = _loadStorageSlot(); + // if there is an existing deposit cooldown we want to do a linear regression of the current amount and remaining time + LastDeposit storage ld = $.lastDeposit[msg.sender]; + if (ld.timestamp > 0) { + uint256 timePassed = block.timestamp - ld.timestamp; + if (timePassed < COOLDOWN) { + uint256 remaining = COOLDOWN - timePassed; + uint256 newAmount = FixedPointMathLib.mulDivUp(ld.amount, remaining, COOLDOWN); + ld.amount += SafeCastLib.safeCastTo192(newAmount); + ld.timestamp = uint64(block.timestamp); + } + } else { + ld.timestamp = uint64(block.timestamp); + ld.amount = SafeCastLib.safeCastTo192(amount); + } + // Transfer tokens to the pool underlying.safeTransferFrom(msg.sender, address(this), amount); // Calculate LP tokens to mint lpShares = _calculateLpShares(amount); + if (lpShares < minLpShares) revert ErrorSlippage(lpShares, minLpShares); // Update liabilities $.liabilities += amount; @@ -206,16 +231,34 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS * In this case the liquidity provider has to wait until pending unlocks are processed, * and the liquidity becomes available again to withdraw. * @param amount Amount of liquidity to withdraw + * @param maxLpSharesBurnt Maximum amount of liquidity pool shares to burn */ - function withdraw(uint256 amount) external { + function withdraw(uint256 amount, uint256 maxLpSharesBurnt) external { Data storage $ = _loadStorageSlot(); uint256 available = liquidity(); - if (amount > available) revert InsufficientAssets(amount, available); + if (amount > available) revert ErrorInsufficientAssets(amount, available); + + // If there is an existing cooldown since deposit want to check if the cooldown has passed + // If not we want to calculate the linear regrassion of the remaining amount and time + // and convert it into LP shares to subtract from the available LP shares for the user + uint256 availableLpShares = lpToken.balanceOf(msg.sender); + LastDeposit storage ld = $.lastDeposit[msg.sender]; + if (ld.timestamp > 0) { + uint256 timePassed = block.timestamp - ld.timestamp; + if (timePassed < COOLDOWN) { + uint256 remaining = COOLDOWN - timePassed; + uint256 cdAmount = FixedPointMathLib.mulDivUp(ld.amount, remaining, COOLDOWN); + uint256 cdLpShares = _calculateLpShares(cdAmount); + availableLpShares -= cdLpShares; + } + } // Calculate LP tokens to burn uint256 lpShares = _calculateLpShares(amount); + if (lpShares > availableLpShares) revert ErrorWithdrawCooldown(lpShares, availableLpShares); + if (lpShares > maxLpSharesBurnt) revert ErrorSlippage(lpShares, maxLpSharesBurnt); // Update liabilities $.liabilities -= amount; @@ -271,7 +314,7 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS (SD59x18 s, SD59x18 S) = _checkSupply(asset); SwapParams memory p = SwapParams({ U: U, u: u, S: S, s: s }); - return _quote(asset, amount, p); + return _quote(amount, p); } /** @@ -285,7 +328,7 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS * @return fee Amount of fees paid */ function swap(address asset, uint256 amount, uint256 minOut) external returns (uint256 out, uint256 fee) { - if (!_isValidAsset(asset)) revert InvalidAsset(asset); + if (!_isValidAsset(asset)) revert ErrorInvalidAsset(asset); Data storage $ = _loadStorageSlot(); @@ -296,10 +339,10 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS SwapParams memory p = SwapParams({ U: U, u: u, S: S, s: s }); - (out, fee) = _quote(asset, amount, p); + (out, fee) = _quote(amount, p); // Revert if slippage threshold is exceeded, i.e. if `out` is less than `minOut` - if (out < minOut) revert SlippageThresholdExceeded(out, minOut); + if (out < minOut) revert ErrorSlippage(out, minOut); // update pool state // - Update total amount unlocking @@ -337,7 +380,7 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS // Can not purchase unlocks in recovery mode // The fees need to flow back to paying off debt and relayers are cheaper - if ($.recovery > 0) revert RecoveryMode(); + if ($.recovery > 0) revert ErrorRecoveryMode(); // get newest item from unlock queue UnlockQueue.Item memory unlock = $.unlockQ.popTail().data; @@ -347,7 +390,7 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS (address tenderizer,) = _decodeTokenId(tokenId); Adapter adapter = Tenderizer(tenderizer).adapter(); uint256 time = adapter.currentTime(); - if (unlock.maturity <= time) revert UnlockAlreadyMature(unlock.maturity, block.timestamp); + if (unlock.maturity <= time) revert ErrorAlreadyMature(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 @@ -369,7 +412,7 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS // - Update S if unlockingForAsset is now zero if (ufa == 0) { $.S = $.S.sub($.lastSupplyForAsset[tenderizer]); - $.lastSupplyForAsset[tenderizer] = ZERO; + $.lastSupplyForAsset[tenderizer] = ZERO_SD59; } // - Update unlockingForAsset $.unlockingForAsset[tenderizer] = ufa; @@ -452,7 +495,7 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS // - Update S if unlockingForAsset is now zero if (ufa == 0) { $.S = $.S.sub($.lastSupplyForAsset[tenderizer]); - $.lastSupplyForAsset[tenderizer] = ZERO; + $.lastSupplyForAsset[tenderizer] = ZERO_SD59; } // - Update unlockingForAsset $.unlockingForAsset[tenderizer] = ufa; @@ -463,7 +506,7 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS emit UnlockRedeemed(msg.sender, unlock.id, amountReceived, relayerReward, fee); } - function _quote(address asset, uint256 amount, SwapParams memory p) internal view returns (uint256 out, uint256 fee) { + function _quote(uint256 amount, SwapParams memory p) internal view returns (uint256 out, uint256 fee) { Data storage $ = _loadStorageSlot(); SD59x18 x = sd(int256(amount)); @@ -499,8 +542,8 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS return Registry(registry).isTenderizer(asset) && Tenderizer(asset).asset() == address(underlying); } - function _utilisation(uint256 unlocking, uint256 liabilities) internal pure returns (SD59x18 r) { - r = sd(int256(unlocking)).div(sd(int256(liabilities))); + function _utilisation(uint256 unlocking, uint256 liabilities) internal pure returns (UD60x18 r) { + r = ud(unlocking).div(ud(liabilities)); } function _unlock(address asset, uint256 amount, uint256 fee) internal { @@ -546,16 +589,20 @@ contract TenderSwap is Initializable, UUPSUpgradeable, OwnableUpgradeable, SwapS /** * @notice Calculates the amount of LP tokens represented by a given amount of liabilities */ - function _calculateLpShares(uint256 amount) internal view returns (uint256) { + function _calculateLpShares(uint256 amount) internal view returns (uint256 shares) { Data storage $ = _loadStorageSlot(); uint256 supply = lpToken.totalSupply(); + uint256 liabilities = $.liabilities; - if (supply == 0) { - return amount; + if (liabilities == 0) { + return amount * 1e18; } - return amount * supply / $.liabilities; + shares = amount * (supply / liabilities); // calculate factor first since it's scaled up + if (shares == 0) { + revert ErrorCalculateLPShares(); + } } ///@dev required by the OZ UUPS module diff --git a/src/UnlockQueue.sol b/src/UnlockQueue.sol index b5279f9..ce5366b 100644 --- a/src/UnlockQueue.sol +++ b/src/UnlockQueue.sol @@ -19,6 +19,7 @@ pragma solidity >=0.8.19; library UnlockQueue { error QueueEmpty(); + error IdExists(); struct Item { uint256 id; @@ -110,6 +111,10 @@ library UnlockQueue { uint256 tail = q._tail; uint256 newTail = unlock.id; + if (tail != 0) { + if (q.nodes[newTail].data.id != 0) revert IdExists(); + } + q.nodes[newTail].data = unlock; q.nodes[newTail].prev = tail; diff --git a/test/Swap.t.sol b/test/Swap.t.sol index 6d4bf25..42078f1 100644 --- a/test/Swap.t.sol +++ b/test/Swap.t.sol @@ -19,11 +19,21 @@ 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, RELAYER_CUT, MIN_LP_CUT, _encodeTokenId, _decodeTokenId } from "@tenderize/swap/Swap.sol"; +import { + TenderSwap, + Config, + BASE_FEE, + RELAYER_CUT, + MIN_LP_CUT, + _encodeTokenId, + _decodeTokenId, + COOLDOWN, + ErrorWithdrawCooldown +} 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 { UD60x18, ud, UNIT as UNIT_60x18 } from "@prb/math/UD60x18.sol"; import { SwapHarness } from "./Swap.harness.sol"; import { UnlockQueue } from "@tenderize/swap/UnlockQueue.sol"; @@ -72,31 +82,76 @@ contract TenderSwapTest is Test { } 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); + uint256 deposit1 = bound(x, 100, type(uint128).max); + l = bound(l, deposit1, deposit1 * 1e18); + uint256 deposit2 = bound(y, 100, type(uint128).max); underlying.mint(addr1, deposit1); underlying.mint(addr2, deposit2); vm.startPrank(addr1); underlying.approve(address(swap), deposit1); - swap.deposit(deposit1); + swap.deposit(deposit1, 0); vm.stopPrank(); // Change liabilities ! swap.exposed_setLiabilities(l); + underlying.mint(address(swap), l - deposit1); vm.startPrank(addr2); underlying.approve(address(swap), deposit2); - swap.deposit(deposit2); + swap.deposit(deposit2, 0); vm.stopPrank(); - uint256 expBalY = deposit2 * deposit1 / l; + uint256 expBal2 = deposit2 * (deposit1 * 1e18 / l); - assertEq(swap.lpToken().totalSupply(), deposit1 + expBalY, "lpToken totalSupply"); - assertEq(swap.lpToken().balanceOf(addr1), deposit1, "addr1 lpToken balance"); - assertEq(swap.lpToken().balanceOf(addr2), expBalY, "addr2 lpToken balance"); - assertEq(underlying.balanceOf(address(swap)), deposit1 + deposit2, "TenderSwap underlying balance"); + assertEq(swap.lpToken().totalSupply(), (deposit1 * 1e18 + expBal2), "lpToken totalSupply"); + assertEq(swap.lpToken().balanceOf(addr1), deposit1 * 1e18, "addr1 lpToken balance"); + assertEq(swap.lpToken().balanceOf(addr2), expBal2, "addr2 lpToken balance"); + assertEq(underlying.balanceOf(address(swap)), l + deposit2, "TenderSwap underlying balance"); + } + + function test_withdrawCooldown(uint256 deposit) public { + uint256 start = 1; + vm.warp(1); + deposit = bound(deposit, 100, type(uint64).max); + underlying.mint(address(this), deposit); + + underlying.approve(address(swap), deposit); + swap.deposit(deposit, 0); + + vm.expectRevert(); + // even withdrawing '1' will revert, since no time has elapsed + swap.withdraw(1, type(uint256).max); + + vm.warp(block.timestamp + COOLDOWN / 2); + + // withdrawing half + 1 fails as it exceeds the available amount + // after only half the time has elapsed + vm.expectRevert(); + swap.withdraw(deposit / 2 + 1, type(uint256).max); + + uint256 balBefore = underlying.balanceOf(address(this)); + swap.withdraw(deposit / 2, type(uint256).max); + uint256 balAfter = underlying.balanceOf(address(this)); + assertEq(balAfter - balBefore, deposit / 2, "withdraw half"); + + // deposit again, the new cooldown amount will be half of the previous plus our new deposit + uint256 deposit2 = bound(deposit, 100, deposit); + underlying.mint(address(this), deposit2); + underlying.approve(address(swap), deposit2); + swap.deposit(deposit2, 0); + // withdrawing half the original amount should still fail + vm.expectRevert(); + swap.withdraw(deposit / 2 + 1, type(uint256).max); + + vm.warp(start + COOLDOWN); + // withdrawing half should work now, withdrawing deposit2 should fail + vm.expectRevert(); + swap.withdraw(deposit2, type(uint256).max); + + swap.withdraw(deposit - deposit / 2, type(uint256).max); + balAfter = underlying.balanceOf(address(this)); + assertEq(balAfter, deposit, "withdraw half"); } function test_claimRelayerRewards(uint256 amount) public { @@ -131,7 +186,7 @@ contract TenderSwapTest is Test { uint256 liquidity = 100 ether; underlying.mint(address(this), liquidity); underlying.approve(address(swap), liquidity); - swap.deposit(liquidity); + swap.deposit(liquidity, 0); vm.mockCall(address(tToken0), abi.encodeWithSelector(TenderizerImmutableArgs.adapter.selector), abi.encode(adapter)); @@ -241,7 +296,7 @@ contract TenderSwapTest is Test { uint256 liquidity = 100 ether; underlying.mint(address(this), liquidity); underlying.approve(address(swap), liquidity); - swap.deposit(liquidity); + swap.deposit(liquidity, 0); uint256 amount = 10 ether; uint256 tokenId = _encodeTokenId(address(tToken0), 0);