From 2825bc74b5d3e392c3405ef193b65f3ed7b6fec2 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Thu, 14 Mar 2024 12:11:54 +0100 Subject: [PATCH 1/8] Adding entry fees upon depositing tBTC to Acre Fee calculation logic was inspired by OpenZeppelin lib extending ERC4626 vault. This commit is handling only fees upon depositing tBTC. Basis points will be set in next commits. --- core/contracts/lib/ERC4626Fees.sol | 99 ++++++++++++++++++++++++ core/contracts/stBTC.sol | 65 ++++++++++++---- core/contracts/test/upgrades/stBTCV2.sol | 3 + 3 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 core/contracts/lib/ERC4626Fees.sol diff --git a/core/contracts/lib/ERC4626Fees.sol b/core/contracts/lib/ERC4626Fees.sol new file mode 100644 index 000000000..bcc5a2f62 --- /dev/null +++ b/core/contracts/lib/ERC4626Fees.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT + +// Inspired by https://docs.openzeppelin.com/contracts/5.x/erc4626#fees + +pragma solidity ^0.8.21; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +/// @dev ERC4626 vault with entry/exit fees expressed in https://en.wikipedia.org/wiki/Basis_point[basis point (bp)]. +abstract contract ERC4626Fees is ERC4626Upgradeable { + using Math for uint256; + + uint256 private constant _BASIS_POINT_SCALE = 1e4; + + // === Overrides === + + /// @dev Preview taking an entry fee on deposit. See {IERC4626-previewDeposit}. + function previewDeposit( + uint256 assets + ) public view virtual override returns (uint256) { + uint256 fee = _feeOnTotal(assets, _entryFeeBasisPoints()); + return super.previewDeposit(assets - fee); + } + + /// @dev Preview adding an entry fee on mint. See {IERC4626-previewMint}. + function previewMint( + uint256 shares + ) public view virtual override returns (uint256) { + uint256 assets = super.previewMint(shares); + return assets + _feeOnRaw(assets, _entryFeeBasisPoints()); + } + + // TODO: add previewWithraw + + // TODO: add previewRedeem + + /// @dev Send entry fee to {_feeRecipient}. See {IERC4626-_deposit}. + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal virtual override { + uint256 fee = _feeOnTotal(assets, _entryFeeBasisPoints()); + address recipient = _feeRecipient(); + + super._deposit(caller, receiver, assets, shares); + + if (fee > 0 && recipient != address(this)) { + SafeERC20.safeTransfer(IERC20(asset()), recipient, fee); + } + } + + // TODO: add withdraw + + // === Fee configuration === + + // slither-disable-next-line dead-code + function _entryFeeBasisPoints() internal view virtual returns (uint256); + + // TODO: add exitFeeBasisPoints + + // slither-disable-next-line dead-code + function _feeRecipient() internal view virtual returns (address); + + // === Fee operations === + + /// @dev Calculates the fees that should be added to an amount `assets` + /// that does not already include fees. + /// Used in {IERC4626-mint} and {IERC4626-withdraw} operations. + function _feeOnRaw( + uint256 assets, + uint256 feeBasisPoints + ) private pure returns (uint256) { + return + assets.mulDiv( + feeBasisPoints, + _BASIS_POINT_SCALE, + Math.Rounding.Ceil + ); + } + + /// @dev Calculates the fee part of an amount `assets` that already includes fees. + /// Used in {IERC4626-deposit} and {IERC4626-redeem} operations. + function _feeOnTotal( + uint256 assets, + uint256 feeBasisPoints + ) private pure returns (uint256) { + return + assets.mulDiv( + feeBasisPoints, + feeBasisPoints + _BASIS_POINT_SCALE, + Math.Rounding.Ceil + ); + } +} diff --git a/core/contracts/stBTC.sol b/core/contracts/stBTC.sol index ecb5769d6..ba0da4c46 100644 --- a/core/contracts/stBTC.sol +++ b/core/contracts/stBTC.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity ^0.8.21; -import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import "./Dispatcher.sol"; +import "./lib/ERC4626Fees.sol"; /// @title stBTC /// @notice This contract implements the ERC-4626 tokenized vault standard. By @@ -18,7 +18,7 @@ import "./Dispatcher.sol"; /// of yield-bearing vaults. This contract facilitates the minting and /// burning of shares (stBTC), which are represented as standard ERC20 /// tokens, providing a seamless exchange with tBTC tokens. -contract stBTC is ERC4626Upgradeable, Ownable2StepUpgradeable { +contract stBTC is ERC4626Fees, Ownable2StepUpgradeable { using SafeERC20 for IERC20; /// Dispatcher contract that routes tBTC from stBTC to a given vault and back. @@ -37,6 +37,9 @@ contract stBTC is ERC4626Upgradeable, Ownable2StepUpgradeable { /// Maximum total amount of tBTC token held by Acre protocol. uint256 public maximumTotalAssets; + /// Entry fee basis points applied to entry fee calculation. + uint256 public entryFeeBasisPoints; + /// Emitted when the treasury wallet address is updated. /// @param treasury New treasury wallet address. event TreasuryUpdated(address treasury); @@ -54,6 +57,10 @@ contract stBTC is ERC4626Upgradeable, Ownable2StepUpgradeable { /// @param newDispatcher Address of the new dispatcher contract. event DispatcherUpdated(address oldDispatcher, address newDispatcher); + /// Emitted when the entry fee basis points are updated. + /// @param entryFeeBasisPoints New value of the fee basis points. + event EntryFeeBasisPointsUpdated(uint256 entryFeeBasisPoints); + /// Reverts if the amount is less than the minimum deposit amount. /// @param amount Amount to check. /// @param min Minimum amount to check 'amount' against. @@ -84,6 +91,7 @@ contract stBTC is ERC4626Upgradeable, Ownable2StepUpgradeable { // TODO: Revisit the exact values closer to the launch. minimumDepositAmount = 0.001 * 1e18; // 0.001 tBTC maximumTotalAssets = 25 * 1e18; // 25 tBTC + entryFeeBasisPoints = 0; // TODO: tbd } /// @notice Updates treasury wallet address. @@ -151,15 +159,28 @@ contract stBTC is ERC4626Upgradeable, Ownable2StepUpgradeable { IERC20(asset()).forceApprove(address(dispatcher), type(uint256).max); } + // TODO: Implement a governed upgrade process that initiates an update and + // then finalizes it after a delay. + /// @notice Update the entry fee basis points. + /// @param newEntryFeeBasisPoints New value of the fee basis points. + function updateEntryFeeBasisPoints( + uint256 newEntryFeeBasisPoints + ) external onlyOwner { + entryFeeBasisPoints = newEntryFeeBasisPoints; + + emit EntryFeeBasisPointsUpdated(newEntryFeeBasisPoints); + } + /// @notice Mints shares to receiver by depositing exactly amount of /// tBTC tokens. /// @dev Takes into account a deposit parameter, minimum deposit amount, /// which determines the minimum amount for a single deposit operation. /// The amount of the assets has to be pre-approved in the tBTC /// contract. - /// @param assets Approved amount of tBTC tokens to deposit. + /// @param assets Approved amount of tBTC tokens to deposit. This includes + /// treasury fees for staking tBTC. /// @param receiver The address to which the shares will be minted. - /// @return Minted shares. + /// @return Minted shares adjusted for the fees taken by the treasury. function deposit( uint256 assets, address receiver @@ -176,11 +197,15 @@ contract stBTC is ERC4626Upgradeable, Ownable2StepUpgradeable { /// which determines the minimum amount for a single deposit operation. /// The amount of the assets has to be pre-approved in the tBTC /// contract. - /// The msg.sender is required to grant approval for tBTC transfer. + /// The msg.sender is required to grant approval for the transfer of a + /// certain amount of tBTC, and in addition, approval for the associated + /// fee. Specifically, the total amount to be approved (amountToApprove) + /// should be equal to the sum of the deposited amount and the fee. /// To determine the total assets amount necessary for approval /// corresponding to a given share amount, use the `previewMint` function. /// @param shares Amount of shares to mint. /// @param receiver The address to which the shares will be minted. + /// @return assets Used assets to mint shares. function mint( uint256 shares, address receiver @@ -202,9 +227,10 @@ contract stBTC is ERC4626Upgradeable, Ownable2StepUpgradeable { /// deposited into the vault for the receiver through a deposit /// call. It takes into account the deposit parameter, maximum total /// assets, which determines the total amount of tBTC token held by - /// Acre protocol. - /// @dev When the remaining amount of unused limit is less than the minimum - /// deposit amount, this function returns 0. + /// Acre. This function always returns available limit for deposits, + /// but the fee is not taken into account. As a result of this, there + /// always will be some dust left. If the dust is lower than the + /// minimum deposit amount, this function will return 0. /// @return The maximum amount of tBTC token that can be deposited into /// Acre protocol for the receiver. function maxDeposit(address) public view override returns (uint256) { @@ -212,12 +238,14 @@ contract stBTC is ERC4626Upgradeable, Ownable2StepUpgradeable { return type(uint256).max; } - uint256 _totalAssets = totalAssets(); + uint256 currentTotalAssets = totalAssets(); + if (currentTotalAssets >= maximumTotalAssets) return 0; - return - _totalAssets >= maximumTotalAssets - ? 0 - : maximumTotalAssets - _totalAssets; + // Max amount left for next deposits. If it is lower than the minimum + // deposit amount, return 0. + uint256 unusedLimit = maximumTotalAssets - currentTotalAssets; + + return minimumDepositAmount > unusedLimit ? 0 : unusedLimit; } /// @notice Returns the maximum amount of the vault shares that can be @@ -239,4 +267,15 @@ contract stBTC is ERC4626Upgradeable, Ownable2StepUpgradeable { function depositParameters() public view returns (uint256, uint256) { return (minimumDepositAmount, maximumTotalAssets); } + + /// @notice Redeems shares for tBTC tokens. + function _entryFeeBasisPoints() internal view override returns (uint256) { + return entryFeeBasisPoints; + } + + /// @notice Returns the address of the treasury wallet, where fees should be + /// transferred to. + function _feeRecipient() internal view override returns (address) { + return treasury; + } } diff --git a/core/contracts/test/upgrades/stBTCV2.sol b/core/contracts/test/upgrades/stBTCV2.sol index dcdce7f6d..c4194610e 100644 --- a/core/contracts/test/upgrades/stBTCV2.sol +++ b/core/contracts/test/upgrades/stBTCV2.sol @@ -30,6 +30,9 @@ contract stBTCV2 is ERC4626Upgradeable, Ownable2StepUpgradeable { /// Maximum total amount of tBTC token held by Acre protocol. uint256 public maximumTotalAssets; + /// Entry fee basis points applied to entry fee calculation. + uint256 public entryFeeBasisPoints; + // TEST: New variable. uint256 public newVariable; From 67d24e00cb151a846a7dee0e06232d9b33edb061 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Thu, 14 Mar 2024 12:43:03 +0100 Subject: [PATCH 2/8] Adding withdrawal fees upon withdraw/redeem Calculation is inspired by the OZ lib extending ERC4626 with collecting fees upon withdrawal. Recepient for deposit and withdrawal is the same address pointing to treasury. --- core/contracts/lib/ERC4626Fees.sol | 37 +++++++++++++++++++++--- core/contracts/stBTC.sol | 27 ++++++++++++++++- core/contracts/test/upgrades/stBTCV2.sol | 3 ++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/core/contracts/lib/ERC4626Fees.sol b/core/contracts/lib/ERC4626Fees.sol index bcc5a2f62..235d69835 100644 --- a/core/contracts/lib/ERC4626Fees.sol +++ b/core/contracts/lib/ERC4626Fees.sol @@ -33,9 +33,21 @@ abstract contract ERC4626Fees is ERC4626Upgradeable { return assets + _feeOnRaw(assets, _entryFeeBasisPoints()); } - // TODO: add previewWithraw + /// @dev Preview adding an exit fee on withdraw. See {IERC4626-previewWithdraw}. + function previewWithdraw( + uint256 assets + ) public view virtual override returns (uint256) { + uint256 fee = _feeOnRaw(assets, _exitFeeBasisPoints()); + return super.previewWithdraw(assets + fee); + } - // TODO: add previewRedeem + /// @dev Preview taking an exit fee on redeem. See {IERC4626-previewRedeem}. + function previewRedeem( + uint256 shares + ) public view virtual override returns (uint256) { + uint256 assets = super.previewRedeem(shares); + return assets - _feeOnTotal(assets, _exitFeeBasisPoints()); + } /// @dev Send entry fee to {_feeRecipient}. See {IERC4626-_deposit}. function _deposit( @@ -54,14 +66,31 @@ abstract contract ERC4626Fees is ERC4626Upgradeable { } } - // TODO: add withdraw + /// @dev Send exit fee to {_exitFeeRecipient}. See {IERC4626-_deposit}. + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override { + uint256 fee = _feeOnRaw(assets, _exitFeeBasisPoints()); + address recipient = _feeRecipient(); + + super._withdraw(caller, receiver, owner, assets, shares); + + if (fee > 0 && recipient != address(this)) { + SafeERC20.safeTransfer(IERC20(asset()), recipient, fee); + } + } // === Fee configuration === // slither-disable-next-line dead-code function _entryFeeBasisPoints() internal view virtual returns (uint256); - // TODO: add exitFeeBasisPoints + // slither-disable-next-line dead-code + function _exitFeeBasisPoints() internal view virtual returns (uint256); // slither-disable-next-line dead-code function _feeRecipient() internal view virtual returns (address); diff --git a/core/contracts/stBTC.sol b/core/contracts/stBTC.sol index ba0da4c46..c287b322b 100644 --- a/core/contracts/stBTC.sol +++ b/core/contracts/stBTC.sol @@ -40,6 +40,9 @@ contract stBTC is ERC4626Fees, Ownable2StepUpgradeable { /// Entry fee basis points applied to entry fee calculation. uint256 public entryFeeBasisPoints; + /// Exit fee basis points applied to exit fee calculation. + uint256 public exitFeeBasisPoints; + /// Emitted when the treasury wallet address is updated. /// @param treasury New treasury wallet address. event TreasuryUpdated(address treasury); @@ -61,6 +64,10 @@ contract stBTC is ERC4626Fees, Ownable2StepUpgradeable { /// @param entryFeeBasisPoints New value of the fee basis points. event EntryFeeBasisPointsUpdated(uint256 entryFeeBasisPoints); + /// Emitted when the exit fee basis points are updated. + /// @param exitFeeBasisPoints New value of the fee basis points. + event ExitFeeBasisPointsUpdated(uint256 exitFeeBasisPoints); + /// Reverts if the amount is less than the minimum deposit amount. /// @param amount Amount to check. /// @param min Minimum amount to check 'amount' against. @@ -92,6 +99,7 @@ contract stBTC is ERC4626Fees, Ownable2StepUpgradeable { minimumDepositAmount = 0.001 * 1e18; // 0.001 tBTC maximumTotalAssets = 25 * 1e18; // 25 tBTC entryFeeBasisPoints = 0; // TODO: tbd + exitFeeBasisPoints = 0; // TODO: tbd } /// @notice Updates treasury wallet address. @@ -171,6 +179,18 @@ contract stBTC is ERC4626Fees, Ownable2StepUpgradeable { emit EntryFeeBasisPointsUpdated(newEntryFeeBasisPoints); } + // TODO: Implement a governed upgrade process that initiates an update and + // then finalizes it after a delay. + /// @notice Update the exit fee basis points. + /// @param newExitFeeBasisPoints New value of the fee basis points. + function updateExitFeeBasisPoints( + uint256 newExitFeeBasisPoints + ) external onlyOwner { + exitFeeBasisPoints = newExitFeeBasisPoints; + + emit ExitFeeBasisPointsUpdated(newExitFeeBasisPoints); + } + /// @notice Mints shares to receiver by depositing exactly amount of /// tBTC tokens. /// @dev Takes into account a deposit parameter, minimum deposit amount, @@ -268,11 +288,16 @@ contract stBTC is ERC4626Fees, Ownable2StepUpgradeable { return (minimumDepositAmount, maximumTotalAssets); } - /// @notice Redeems shares for tBTC tokens. + /// @return Returns entry fee basis point used in deposits. function _entryFeeBasisPoints() internal view override returns (uint256) { return entryFeeBasisPoints; } + /// @return Returns exit fee basis point used in withdrawals. + function _exitFeeBasisPoints() internal view override returns (uint256) { + return exitFeeBasisPoints; + } + /// @notice Returns the address of the treasury wallet, where fees should be /// transferred to. function _feeRecipient() internal view override returns (address) { diff --git a/core/contracts/test/upgrades/stBTCV2.sol b/core/contracts/test/upgrades/stBTCV2.sol index c4194610e..586c1e246 100644 --- a/core/contracts/test/upgrades/stBTCV2.sol +++ b/core/contracts/test/upgrades/stBTCV2.sol @@ -33,6 +33,9 @@ contract stBTCV2 is ERC4626Upgradeable, Ownable2StepUpgradeable { /// Entry fee basis points applied to entry fee calculation. uint256 public entryFeeBasisPoints; + /// Exit fee basis points applied to exit fee calculation. + uint256 public exitFeeBasisPoints; + // TEST: New variable. uint256 public newVariable; From 40b2cbd2efce2f7801d58f4b164a9e5cbc376b36 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Thu, 14 Mar 2024 15:31:24 +0100 Subject: [PATCH 3/8] First batch of fixes after fees were added --- core/test/stBTC.test.ts | 172 +++++++++++++++++++++++++++++++++------- 1 file changed, 145 insertions(+), 27 deletions(-) diff --git a/core/test/stBTC.test.ts b/core/test/stBTC.test.ts index 6b6d33261..87b8e0edb 100644 --- a/core/test/stBTC.test.ts +++ b/core/test/stBTC.test.ts @@ -39,6 +39,10 @@ async function fixture() { } describe("stBTC", () => { + const entryFeeBasisPoints = 5n // Used only for the tests. + // const exitFeeBasisPoints = 10n + const basisPointScale = 10000n // Matches the contract. + let stbtc: stBTC let tbtc: TestERC20 let dispatcher: Dispatcher @@ -58,6 +62,10 @@ describe("stBTC", () => { governance, thirdParty, } = await loadFixture(fixture)) + + await stbtc + .connect(governance) + .updateEntryFeeBasisPoints(entryFeeBasisPoints) }) describe("assetsBalanceOf", () => { @@ -86,8 +94,10 @@ describe("stBTC", () => { }) it("should return the correct amount of assets", async () => { + const depositFee = feeOnTotal(amountToDeposit) + expect(await stbtc.assetsBalanceOf(depositor1.address)).to.be.equal( - amountToDeposit, + amountToDeposit - depositFee, ) }) }) @@ -120,15 +130,18 @@ describe("stBTC", () => { beforeAfterSnapshotWrapper() it("should return the correct amount of assets", async () => { + const deposit1Fee = feeOnTotal(depositor1AmountToDeposit) + const deposit2Fee = feeOnTotal(depositor2AmountToDeposit) + expect( await stbtc.assetsBalanceOf(depositor1.address), "invalid assets balance of depositor 1", - ).to.be.equal(depositor1AmountToDeposit) + ).to.be.equal(depositor1AmountToDeposit - deposit1Fee) expect( await stbtc.assetsBalanceOf(depositor2.address), "invalid assets balance of depositor 2", - ).to.be.equal(depositor2AmountToDeposit) + ).to.be.equal(depositor2AmountToDeposit - deposit2Fee) }) }) @@ -138,23 +151,31 @@ describe("stBTC", () => { const earnedYield = to1e18(6) // Values are floor rounded as per the `convertToAssets` function. - const expectedAssets1 = 2999999999999999999n // 1 + (1/3 * 6) - const expectedAssets2 = 5999999999999999998n // 2 + (2/3 * 6) + // 1 - fee + (1/3 * 6) = ~3 + const expectedAssets1 = + depositor1AmountToDeposit - + feeOnTotal(depositor1AmountToDeposit) + + to1e18(2) + // 2 - fee + (2/3 * 6) = ~6 + const expectedAssets2 = + depositor2AmountToDeposit - + feeOnTotal(depositor2AmountToDeposit) + + to1e18(4) before(async () => { await tbtc.mint(await stbtc.getAddress(), earnedYield) }) it("should return the correct amount of assets", async () => { - expect( + expectCloseTo( await stbtc.assetsBalanceOf(depositor1.address), - "invalid assets balance of depositor 1", - ).to.be.equal(expectedAssets1) + expectedAssets1, + ) - expect( + expectCloseTo( await stbtc.assetsBalanceOf(depositor2.address), - "invalid assets balance of depositor 2", - ).to.be.equal(expectedAssets2) + expectedAssets2, + ) }) }) }) @@ -206,7 +227,7 @@ describe("stBTC", () => { const minimumDepositAmount = await stbtc.minimumDepositAmount() amountToDeposit = minimumDepositAmount - expectedReceivedShares = amountToDeposit + expectedReceivedShares = amountToDeposit - feeOnTotal(amountToDeposit) await tbtc.approve(await stbtc.getAddress(), amountToDeposit) tx = await stbtc @@ -236,7 +257,8 @@ describe("stBTC", () => { }) it("should transfer tBTC tokens to Acre", async () => { - const actualDepositdAmount = amountToDeposit + const actualDepositdAmount = + amountToDeposit - feeOnTotal(amountToDeposit) await expect(tx).to.changeTokenBalances( tbtc, @@ -333,8 +355,10 @@ describe("stBTC", () => { }) it("the total assets amount should be equal to all deposited tokens", async () => { - const actualDepositAmount1 = depositor1AmountToDeposit - const actualDepositAmount2 = depositor2AmountToDeposit + const actualDepositAmount1 = + depositor1AmountToDeposit - feeOnTotal(depositor1AmountToDeposit) + const actualDepositAmount2 = + depositor2AmountToDeposit - feeOnTotal(depositor2AmountToDeposit) expect(await stbtc.totalAssets()).to.eq( actualDepositAmount1 + actualDepositAmount2, @@ -369,8 +393,10 @@ describe("stBTC", () => { }) it("the vault should hold more assets minus fees", async () => { - const actualDepositAmount1 = depositor1AmountToDeposit - const actualDepositAmount2 = depositor2AmountToDeposit + const actualDepositAmount1 = + depositor1AmountToDeposit - feeOnTotal(depositor1AmountToDeposit) + const actualDepositAmount2 = + depositor2AmountToDeposit - feeOnTotal(depositor2AmountToDeposit) expect(await stbtc.totalAssets()).to.be.eq( actualDepositAmount1 + actualDepositAmount2 + earnedYield, @@ -390,9 +416,9 @@ describe("stBTC", () => { const shares = await stbtc.balanceOf(depositor1.address) const availableAssetsToRedeem = await stbtc.previewRedeem(shares) - // 7 * 15 / 10 = 10.5 + // (7 - fee) * 15 / 10 = 10.5 // Due to Solidity's mulDiv functions the result is floor rounded. - const expectedAssetsToRedeem = 10499999999999999999n + const expectedAssetsToRedeem = 10496501749125437280n expect(availableAssetsToRedeem).to.be.eq(expectedAssetsToRedeem) }) @@ -401,9 +427,9 @@ describe("stBTC", () => { const shares = await stbtc.balanceOf(depositor2.address) const availableAssetsToRedeem = await stbtc.previewRedeem(shares) - // 3 * 15 / 10 = 4.5 + // (3 - fee) * 15 / 10 = 4.5 // Due to Solidity's mulDiv functions the result is floor rounded. - const expectedAssetsToRedeem = 4499999999999999999n + const expectedAssetsToRedeem = 4498500749625187405n expect(availableAssetsToRedeem).to.be.eq(expectedAssetsToRedeem) }) @@ -454,15 +480,21 @@ describe("stBTC", () => { // Expected amount to redeem by depositor 1: // (7 + ~1.3) * 17 / ~11.3 = ~12.49 - const expectedTotalAssetsAvailableToRedeem = - 12499999999999999999n + const amount1 = + depositor1AmountToDeposit - + feeOnTotal(depositor1AmountToDeposit) + const amount2 = await stbtc.previewDeposit(newAmountToDeposit) + const totalAssets = await tbtc.balanceOf( + await stbtc.getAddress(), + ) + const totalShares = await stbtc.totalSupply() + const expectedAssetsToRedeem = + ((amount1 + amount2) * totalAssets) / totalShares expect(availableToRedeem).to.be.greaterThan( availableToRedeemBefore, ) - expect(availableToRedeem).to.be.eq( - expectedTotalAssetsAvailableToRedeem, - ) + expect(availableToRedeem).to.be.eq(expectedAssetsToRedeem) }) }, ) @@ -532,7 +564,11 @@ describe("stBTC", () => { stbtc, "ERC4626ExceededMaxDeposit", ) - .withArgs(depositor1.address, amountToDeposit, 0n) + .withArgs( + depositor1.address, + amountToDeposit, + feeOnTotal(amountToDeposit), + ) }) }, ) @@ -1065,4 +1101,86 @@ describe("stBTC", () => { }) }) }) + + describe("feeOnTotal - internal test helper", () => { + context("when the fee's modulo remainder is greater than 0", () => { + it("should add 1 to the result", () => { + // feeOnTotal - test's internal function simulating the OZ mulDiv + // function. + const fee = feeOnTotal(to1e18(1)) + // fee = (1e18 * 5) / (10000 + 5) = 499750124937531 + 1 + const expectedFee = 499750124937532 + expect(fee).to.be.eq(expectedFee) + }) + }) + + context("when the fee's modulo remainder is equal to 0", () => { + it("should return the actual result", () => { + // feeOnTotal - test's internal function simulating the OZ mulDiv + // function. + const fee = feeOnTotal(2001n) + // fee = (2001 * 5) / (10000 + 5) = 1 + const expectedFee = 1n + expect(fee).to.be.eq(expectedFee) + }) + }) + }) + + describe("feeOnRaw - internal test helper", () => { + context("when the fee's modulo remainder is greater than 0", () => { + it("should return the correct amount of fees", () => { + // feeOnRaw - this is a test internal function + const fee = feeOnRaw(to1e18(1)) + // fee = (1e18 * 5) / (10000) = 500000000000000 + const expectedFee = 500000000000000 + expect(fee).to.be.eq(expectedFee) + }) + }) + + context("when the fee's modulo remainder is equal to 0", () => { + it("should return the actual result", () => { + // feeOnTotal - test's internal function simulating the OZ mulDiv + // function. + const fee = feeOnTotal(2000n) + // fee = (2000 * 5) / 10000 = 1 + const expectedFee = 1n + expect(fee).to.be.eq(expectedFee) + }) + }) + }) + + // Calculates the fee when it's included in the amount. + // One is added to the result if there is a remainder to match the Solidity + // mulDiv() math which rounds up towards infinity (Ceil) when fees are + // calculated. + function feeOnTotal(amount: bigint) { + const result = + (amount * entryFeeBasisPoints) / (entryFeeBasisPoints + basisPointScale) + if ( + (amount * entryFeeBasisPoints) % (entryFeeBasisPoints + basisPointScale) > + 0 + ) { + return result + 1n + } + return result + } + + // Calculates the fee when it's not included in the amount. + // One is added to the result if there is a remainder to match the Solidity + // mulDiv() math which rounds up towards infinity (Ceil) when fees are + // calculated. + function feeOnRaw(amount: bigint) { + const result = (amount * entryFeeBasisPoints) / basisPointScale + if ((amount * entryFeeBasisPoints) % basisPointScale > 0) { + return result + 1n + } + return result + } + + // 2n is added or subtracted to the expected value to match the Solidity + // math which rounds up or down depending on the remainder. It is a very small + // number. + function expectCloseTo(actual: bigint, expected: bigint) { + return expect(actual, "invalid asset balance").to.be.closeTo(expected, 2n) + } }) From 4112eac77ea0d79782d1911233d874c8d0810bc9 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 15 Mar 2024 10:58:07 +0100 Subject: [PATCH 4/8] Fixing the rest of the tests after adding deposit fees --- core/test/stBTC.test.ts | 49 +++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/core/test/stBTC.test.ts b/core/test/stBTC.test.ts index 87b8e0edb..2364dc19d 100644 --- a/core/test/stBTC.test.ts +++ b/core/test/stBTC.test.ts @@ -51,6 +51,7 @@ describe("stBTC", () => { let depositor1: HardhatEthersSigner let depositor2: HardhatEthersSigner let thirdParty: HardhatEthersSigner + let treasury: HardhatEthersSigner before(async () => { ;({ @@ -61,6 +62,7 @@ describe("stBTC", () => { dispatcher, governance, thirdParty, + treasury, } = await loadFixture(fixture)) await stbtc @@ -592,17 +594,21 @@ describe("stBTC", () => { const sharesToMint = to1e18(1) let tx: ContractTransactionResponse let amountToDeposit: bigint + let amountToSpend: bigint before(async () => { amountToDeposit = sharesToMint + amountToSpend = amountToDeposit + feeOnRaw(amountToDeposit) await tbtc .connect(depositor1) - .approve(await stbtc.getAddress(), amountToDeposit) + .approve(await stbtc.getAddress(), amountToSpend) tx = await stbtc .connect(depositor1) .mint(sharesToMint, receiver.address) + const balanceOfTreasury = await tbtc.balanceOf(treasury.address) + console.log("balanceOfTreasury", balanceOfTreasury.toString()) }) it("should emit Deposit event", async () => { @@ -611,8 +617,8 @@ describe("stBTC", () => { depositor1.address, // Receiver. receiver.address, - // Depositd tokens. - amountToDeposit, + // Depositd tokens including deposit fees. + amountToSpend, // Received shares. sharesToMint, ) @@ -630,7 +636,15 @@ describe("stBTC", () => { await expect(tx).to.changeTokenBalances( tbtc, [depositor1.address, stbtc], - [-amountToDeposit, amountToDeposit], + [-amountToSpend, amountToDeposit], + ) + }) + + it("should transfer tBTC tokens to Treasury", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [treasury.address], + [feeOnRaw(amountToDeposit)], ) }) }) @@ -829,7 +843,7 @@ describe("stBTC", () => { await tbtc.connect(depositor1).approve(await stbtc.getAddress(), toMint) await stbtc.connect(depositor1).deposit(toMint, depositor1.address) - expectedValue = maximumTotalAssets - toMint + expectedValue = maximumTotalAssets - toMint + feeOnTotal(toMint) }) it("should return correct value", async () => { @@ -1031,7 +1045,7 @@ describe("stBTC", () => { context("when the maximum total amount has not yet been reached", () => { beforeAfterSnapshotWrapper() - let expectedValue: bigint + let expectedSharesToReceive: bigint before(async () => { const toMint = to1e18(4) @@ -1049,17 +1063,20 @@ describe("stBTC", () => { await tbtc.mint(await stbtc.getAddress(), toMint) // The current state is: - // Total assets: 4 + 2 = 6 - // Total supply: 2 + // Fee on deposit: 0.000999500249875063 + // Total assets: 1.999000499750124937 + 4 = 5.999000499750124937 + // Total supply of stBTC shares: 1.999000499750124937 // Maximum total assets: 25 - // Current max deposit: 25 - 6 = 19 - // Max stBTC shares: (mulDiv added 1 to totalSupply and totalAssets to help with floor rounding) - // 19 * 6 / 2 = 22 - expectedValue = 6333333333333333335n + // Current max deposit: 25 - 5.999000499750124937 = 19.000999500249875063 + // Expected shares: 19.000999500249875063 * 1.999000499750124937 / 5.999000499750124937 = 6.331555981422817414 + expectedSharesToReceive = 6331555981422817414n }) it("should return correct value", async () => { - expect(await stbtc.maxMint(depositor1.address)).to.be.eq(expectedValue) + expectCloseTo( + await stbtc.maxMint(depositor1.address), + expectedSharesToReceive, + ) }) }) @@ -1177,9 +1194,9 @@ describe("stBTC", () => { return result } - // 2n is added or subtracted to the expected value to match the Solidity - // math which rounds up or down depending on the remainder. It is a very small - // number. + // 2n is added or subtracted to/from the expected value to match the Solidity + // math which rounds up or down depending on the modulo remainder. It is a very + // small number. function expectCloseTo(actual: bigint, expected: bigint) { return expect(actual, "invalid asset balance").to.be.closeTo(expected, 2n) } From f6cdd4cc20806427d9f0d182fa1256b92c43f0b7 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Tue, 19 Mar 2024 10:21:29 +0100 Subject: [PATCH 5/8] Adding tests for withdraw and redeem functions including fees --- core/test/stBTC.test.ts | 675 +++++++++++++++++++++++++++++++++++----- 1 file changed, 594 insertions(+), 81 deletions(-) diff --git a/core/test/stBTC.test.ts b/core/test/stBTC.test.ts index 2364dc19d..c5ce7d207 100644 --- a/core/test/stBTC.test.ts +++ b/core/test/stBTC.test.ts @@ -40,7 +40,7 @@ async function fixture() { describe("stBTC", () => { const entryFeeBasisPoints = 5n // Used only for the tests. - // const exitFeeBasisPoints = 10n + const exitFeeBasisPoints = 10n // Used only for the tests. const basisPointScale = 10000n // Matches the contract. let stbtc: stBTC @@ -68,6 +68,188 @@ describe("stBTC", () => { await stbtc .connect(governance) .updateEntryFeeBasisPoints(entryFeeBasisPoints) + + await stbtc.connect(governance).updateExitFeeBasisPoints(exitFeeBasisPoints) + }) + + describe("previewDeposit", () => { + beforeAfterSnapshotWrapper() + + context("when the vault is empty", () => { + const amountToDeposit = to1e18(1) + + before(async () => { + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), amountToDeposit) + }) + + context("when validating preview deposit against hardcoded value", () => { + it("should return the correct amount of shares", async () => { + const shares = await stbtc.previewDeposit(amountToDeposit) + // amount to deposit = 1 tBTC + // fee = (1e18 * 5) / (10000 + 5) = 499750124937532 + // shares = 1e18 - 499750124937532 = 999500249875062468 + const expectedShares = 999500249875062468n + expect(shares).to.be.eq(expectedShares) + }) + }) + + context( + "when previewing shares against programatically calculated values", + () => { + it("should return the correct amount of shares", async () => { + const shares = await stbtc.previewDeposit(amountToDeposit) + const expectedShares = + amountToDeposit - feeOnTotal(amountToDeposit, entryFeeBasisPoints) + expect(shares).to.be.eq(expectedShares) + }) + }, + ) + }) + + context("when the vault is not empty", () => { + beforeAfterSnapshotWrapper() + + const amountToDeposit1 = to1e18(1) + const amountToDeposit2 = to1e18(2) + + before(async () => { + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), amountToDeposit1) + + await stbtc + .connect(depositor1) + .deposit(amountToDeposit1, depositor1.address) + }) + + it("should return the correct amount of shares", async () => { + const expectedShares = + amountToDeposit2 - feeOnTotal(amountToDeposit2, entryFeeBasisPoints) + const shares = await stbtc.previewDeposit(amountToDeposit2) + expect(shares).to.be.eq(expectedShares) + }) + }) + }) + + describe("previewRedeem", () => { + beforeAfterSnapshotWrapper() + + context("when the vault is empty", () => { + it("should return correct value", async () => { + const toRedeem = to1e18(1) + const expectedShares = + toRedeem - feeOnTotal(toRedeem, exitFeeBasisPoints) + expect(await stbtc.previewRedeem(toRedeem)).to.be.equal(expectedShares) + }) + }) + + context("when the vault is not empty", () => { + beforeAfterSnapshotWrapper() + + const amountToDeposit = to1e18(1) + + before(async () => { + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), amountToDeposit) + + await stbtc + .connect(depositor1) + .deposit(amountToDeposit, depositor1.address) + }) + + context("when there is no yield generated", () => { + it("should return the correct amount of assets", async () => { + const shares = await stbtc.balanceOf(depositor1.address) + // Preview redeem on already deposited amount for which entry fee was + // taken. + const availableAssetsToRedeem = await stbtc.previewRedeem(shares) + const actualAssets = shares + + const expectedAssetsToRedeem = + actualAssets - feeOnTotal(actualAssets, exitFeeBasisPoints) + expect(availableAssetsToRedeem).to.be.eq(expectedAssetsToRedeem) + }) + }) + + context("when there is yield generated", () => { + beforeAfterSnapshotWrapper() + + const earnedYield = to1e18(6) + + before(async () => { + await tbtc.mint(await stbtc.getAddress(), earnedYield) + }) + + it("should return the correct amount of assets", async () => { + const shares = await stbtc.balanceOf(depositor1.address) + const availableAssetsToRedeem = await stbtc.previewRedeem(shares) + const actualAssets = shares + + // expected assets = (1 - depositFee(1) + earnedYield) - (exitFee(1 + earnedYield)) + const expectedAssetsToRedeem = + actualAssets + + earnedYield - + feeOnTotal(actualAssets + earnedYield, exitFeeBasisPoints) + expectCloseTo(availableAssetsToRedeem, expectedAssetsToRedeem) + }) + }) + }) + }) + + describe("previewMint", () => { + let amountToDeposit: bigint + + beforeAfterSnapshotWrapper() + + context("when validating preview mint against hardcoded value", () => { + it("should return the correct amount of assets", async () => { + // 1e18 + 500000000000000 + amountToDeposit = 1000500000000000000n + + const assetsToDeposit = await stbtc.previewMint(to1e18(1)) + expect(assetsToDeposit).to.be.eq(amountToDeposit) + }) + }) + + context( + "when validating preview mint against programatically calculated value", + () => { + context("when the vault is not empty", () => { + const sharesToMint1 = to1e18(1) + const sharesToMint2 = to1e18(2) + + // To receive 1 stBTC, a user must deposit 1.0005 tBTC where 0.0005 tBTC + // is a fee. + const amountToDeposit1 = + sharesToMint1 + feeOnRaw(sharesToMint1, entryFeeBasisPoints) + + // To receive 2 stBTC, a user must deposit 2.001 tBTC where 0.001 tBTC + // is a fee. + const amountToDeposit2 = + sharesToMint2 + feeOnRaw(sharesToMint2, entryFeeBasisPoints) + + it("should preview the correct amount of assets for deposit 2", async () => { + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), amountToDeposit1) + + await tbtc + .connect(depositor2) + .approve(await stbtc.getAddress(), amountToDeposit2) + + await stbtc + .connect(depositor1) + .mint(sharesToMint1, depositor1.address) + + const assets = await stbtc.previewMint(sharesToMint2) + expect(assets).to.be.eq(amountToDeposit2) + }) + }) + }, + ) }) describe("assetsBalanceOf", () => { @@ -96,7 +278,7 @@ describe("stBTC", () => { }) it("should return the correct amount of assets", async () => { - const depositFee = feeOnTotal(amountToDeposit) + const depositFee = feeOnTotal(amountToDeposit, entryFeeBasisPoints) expect(await stbtc.assetsBalanceOf(depositor1.address)).to.be.equal( amountToDeposit - depositFee, @@ -132,8 +314,14 @@ describe("stBTC", () => { beforeAfterSnapshotWrapper() it("should return the correct amount of assets", async () => { - const deposit1Fee = feeOnTotal(depositor1AmountToDeposit) - const deposit2Fee = feeOnTotal(depositor2AmountToDeposit) + const deposit1Fee = feeOnTotal( + depositor1AmountToDeposit, + entryFeeBasisPoints, + ) + const deposit2Fee = feeOnTotal( + depositor2AmountToDeposit, + entryFeeBasisPoints, + ) expect( await stbtc.assetsBalanceOf(depositor1.address), @@ -156,12 +344,12 @@ describe("stBTC", () => { // 1 - fee + (1/3 * 6) = ~3 const expectedAssets1 = depositor1AmountToDeposit - - feeOnTotal(depositor1AmountToDeposit) + + feeOnTotal(depositor1AmountToDeposit, entryFeeBasisPoints) + to1e18(2) // 2 - fee + (2/3 * 6) = ~6 const expectedAssets2 = depositor2AmountToDeposit - - feeOnTotal(depositor2AmountToDeposit) + + feeOnTotal(depositor2AmountToDeposit, entryFeeBasisPoints) + to1e18(4) before(async () => { @@ -229,7 +417,8 @@ describe("stBTC", () => { const minimumDepositAmount = await stbtc.minimumDepositAmount() amountToDeposit = minimumDepositAmount - expectedReceivedShares = amountToDeposit - feeOnTotal(amountToDeposit) + expectedReceivedShares = + amountToDeposit - feeOnTotal(amountToDeposit, entryFeeBasisPoints) await tbtc.approve(await stbtc.getAddress(), amountToDeposit) tx = await stbtc @@ -260,7 +449,7 @@ describe("stBTC", () => { it("should transfer tBTC tokens to Acre", async () => { const actualDepositdAmount = - amountToDeposit - feeOnTotal(amountToDeposit) + amountToDeposit - feeOnTotal(amountToDeposit, entryFeeBasisPoints) await expect(tx).to.changeTokenBalances( tbtc, @@ -358,9 +547,11 @@ describe("stBTC", () => { it("the total assets amount should be equal to all deposited tokens", async () => { const actualDepositAmount1 = - depositor1AmountToDeposit - feeOnTotal(depositor1AmountToDeposit) + depositor1AmountToDeposit - + feeOnTotal(depositor1AmountToDeposit, entryFeeBasisPoints) const actualDepositAmount2 = - depositor2AmountToDeposit - feeOnTotal(depositor2AmountToDeposit) + depositor2AmountToDeposit - + feeOnTotal(depositor2AmountToDeposit, entryFeeBasisPoints) expect(await stbtc.totalAssets()).to.eq( actualDepositAmount1 + actualDepositAmount2, @@ -396,9 +587,11 @@ describe("stBTC", () => { it("the vault should hold more assets minus fees", async () => { const actualDepositAmount1 = - depositor1AmountToDeposit - feeOnTotal(depositor1AmountToDeposit) + depositor1AmountToDeposit - + feeOnTotal(depositor1AmountToDeposit, entryFeeBasisPoints) const actualDepositAmount2 = - depositor2AmountToDeposit - feeOnTotal(depositor2AmountToDeposit) + depositor2AmountToDeposit - + feeOnTotal(depositor2AmountToDeposit, entryFeeBasisPoints) expect(await stbtc.totalAssets()).to.be.eq( actualDepositAmount1 + actualDepositAmount2 + earnedYield, @@ -413,28 +606,6 @@ describe("stBTC", () => { depositor2SharesBefore, ) }) - - it("the depositor 1 should be able to redeem more tokens than before", async () => { - const shares = await stbtc.balanceOf(depositor1.address) - const availableAssetsToRedeem = await stbtc.previewRedeem(shares) - - // (7 - fee) * 15 / 10 = 10.5 - // Due to Solidity's mulDiv functions the result is floor rounded. - const expectedAssetsToRedeem = 10496501749125437280n - - expect(availableAssetsToRedeem).to.be.eq(expectedAssetsToRedeem) - }) - - it("the depositor 2 should be able to redeem more tokens than before", async () => { - const shares = await stbtc.balanceOf(depositor2.address) - const availableAssetsToRedeem = await stbtc.previewRedeem(shares) - - // (3 - fee) * 15 / 10 = 4.5 - // Due to Solidity's mulDiv functions the result is floor rounded. - const expectedAssetsToRedeem = 4498500749625187405n - - expect(availableAssetsToRedeem).to.be.eq(expectedAssetsToRedeem) - }) }) context("when depositor 1 deposits more tokens", () => { @@ -443,14 +614,11 @@ describe("stBTC", () => { () => { const newAmountToDeposit = to1e18(2) let sharesBefore: bigint - let availableToRedeemBefore: bigint before(async () => { await afterSimulatingYieldSnapshot.restore() sharesBefore = await stbtc.balanceOf(depositor1.address) - availableToRedeemBefore = - await stbtc.previewRedeem(sharesBefore) await tbtc.mint(depositor1.address, newAmountToDeposit) @@ -475,29 +643,6 @@ describe("stBTC", () => { expect(shares).to.be.eq(sharesBefore + expectedSharesToMint) }) - - it("should be able to redeem more tokens than before", async () => { - const shares = await stbtc.balanceOf(depositor1.address) - const availableToRedeem = await stbtc.previewRedeem(shares) - - // Expected amount to redeem by depositor 1: - // (7 + ~1.3) * 17 / ~11.3 = ~12.49 - const amount1 = - depositor1AmountToDeposit - - feeOnTotal(depositor1AmountToDeposit) - const amount2 = await stbtc.previewDeposit(newAmountToDeposit) - const totalAssets = await tbtc.balanceOf( - await stbtc.getAddress(), - ) - const totalShares = await stbtc.totalSupply() - const expectedAssetsToRedeem = - ((amount1 + amount2) * totalAssets) / totalShares - - expect(availableToRedeem).to.be.greaterThan( - availableToRedeemBefore, - ) - expect(availableToRedeem).to.be.eq(expectedAssetsToRedeem) - }) }, ) @@ -569,7 +714,7 @@ describe("stBTC", () => { .withArgs( depositor1.address, amountToDeposit, - feeOnTotal(amountToDeposit), + feeOnTotal(amountToDeposit, entryFeeBasisPoints), ) }) }, @@ -579,6 +724,125 @@ describe("stBTC", () => { }) }) + describe("redeem", () => { + beforeAfterSnapshotWrapper() + + context("when redeeming from a single deposit", () => { + beforeAfterSnapshotWrapper() + + const amountToDeposit = to1e18(1) + let tx: ContractTransactionResponse + let amountToRedeem: bigint + let amountStaked: bigint + let shares: bigint + + before(async () => { + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), amountToDeposit) + shares = await stbtc.previewDeposit(amountToDeposit) + await stbtc + .connect(depositor1) + .deposit(amountToDeposit, depositor1.address) + amountStaked = + amountToDeposit - feeOnTotal(amountToDeposit, entryFeeBasisPoints) + amountToRedeem = + amountStaked - feeOnTotal(amountStaked, exitFeeBasisPoints) + tx = await stbtc + .connect(depositor1) + .redeem(shares, depositor1, depositor1) + }) + + it("should emit Redeem event", async () => { + await expect(tx).to.emit(stbtc, "Withdraw").withArgs( + // Caller. + depositor1.address, + // Receiver + depositor1.address, + // Owner + depositor1.address, + // Redeemed tokens. + amountToRedeem, + // Burned shares. + shares, + ) + }) + + it("should burn stBTC tokens", async () => { + await expect(tx).to.changeTokenBalances( + stbtc, + [depositor1.address], + [-shares], + ) + }) + + it("should transfer tBTC tokens to a Staker", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [depositor1.address], + [amountToRedeem], + ) + }) + + it("should transfer tBTC tokens to Treasury", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [treasury.address], + [feeOnTotal(amountStaked, exitFeeBasisPoints)], + ) + }) + }) + + context("when redeeming all shares from two deposits", () => { + const firstDeposit = to1e18(1) + const secondDeposit = to1e18(2) + + before(async () => { + const totalDeposit = firstDeposit + secondDeposit + await tbtc.mint(depositor1.address, totalDeposit) + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), totalDeposit) + await stbtc + .connect(depositor1) + .deposit(firstDeposit, depositor1.address) + + await stbtc + .connect(depositor1) + .deposit(secondDeposit, depositor1.address) + }) + + it("should be able to redeem tokens from the first and second deposit", async () => { + const shares = await stbtc.balanceOf(depositor1.address) + const redeemTx = await stbtc.redeem( + shares, + depositor1.address, + depositor1.address, + ) + + const shares1 = + firstDeposit - feeOnTotal(firstDeposit, entryFeeBasisPoints) + const shares2 = + secondDeposit - feeOnTotal(secondDeposit, entryFeeBasisPoints) + const expectedAssetsToReceive = + shares1 + shares2 - feeOnTotal(shares1 + shares2, exitFeeBasisPoints) + + await expect(redeemTx).to.emit(stbtc, "Withdraw").withArgs( + // Caller. + depositor1.address, + // Receiver + depositor1.address, + // Owner + depositor1.address, + // Redeemed tokens. + expectedAssetsToReceive, + // Burned shares. + shares, + ) + }) + }) + }) + describe("mint", () => { beforeAfterSnapshotWrapper() @@ -598,7 +862,8 @@ describe("stBTC", () => { before(async () => { amountToDeposit = sharesToMint - amountToSpend = amountToDeposit + feeOnRaw(amountToDeposit) + amountToSpend = + amountToDeposit + feeOnRaw(amountToDeposit, entryFeeBasisPoints) await tbtc .connect(depositor1) @@ -607,8 +872,6 @@ describe("stBTC", () => { tx = await stbtc .connect(depositor1) .mint(sharesToMint, receiver.address) - const balanceOfTreasury = await tbtc.balanceOf(treasury.address) - console.log("balanceOfTreasury", balanceOfTreasury.toString()) }) it("should emit Deposit event", async () => { @@ -644,7 +907,7 @@ describe("stBTC", () => { await expect(tx).to.changeTokenBalances( tbtc, [treasury.address], - [feeOnRaw(amountToDeposit)], + [feeOnRaw(amountToDeposit, entryFeeBasisPoints)], ) }) }) @@ -706,6 +969,142 @@ describe("stBTC", () => { ) }) + describe("withdraw", () => { + beforeAfterSnapshotWrapper() + + context("when withdrawing from a single deposit", () => { + beforeAfterSnapshotWrapper() + + const amountToDeposit = to1e18(1) + let tx: ContractTransactionResponse + let availableToWithdraw: bigint + let shares: bigint + + before(async () => { + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), amountToDeposit) + shares = await stbtc.previewDeposit(amountToDeposit) + availableToWithdraw = await stbtc.previewRedeem(shares) + await stbtc + .connect(depositor1) + .deposit(amountToDeposit, depositor1.address) + tx = await stbtc + .connect(depositor1) + .withdraw(availableToWithdraw, depositor1, depositor1) + }) + + it("should emit Withdraw event", async () => { + await expect(tx).to.emit(stbtc, "Withdraw").withArgs( + // Caller. + depositor1.address, + // Receiver + depositor1.address, + // Owner + depositor1.address, + // Available assets to withdraw. + availableToWithdraw, + // Burned shares. + shares, + ) + }) + + it("should burn stBTC tokens", async () => { + await expect(tx).to.changeTokenBalances( + stbtc, + [depositor1.address], + [-shares], + ) + }) + + it("should transfer tBTC tokens to a Staker", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [depositor1.address], + [availableToWithdraw], + ) + }) + + it("should transfer tBTC tokens to Treasury", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [treasury.address], + [feeOnRaw(availableToWithdraw, exitFeeBasisPoints)], + ) + }) + }) + + context("when withdrawing all shares from two deposits", () => { + const firstDeposit = to1e18(1) + const secondDeposit = to1e18(2) + let withdrawTx: ContractTransactionResponse + let availableToWithdraw: bigint + let shares: bigint + + before(async () => { + await tbtc.mint(depositor1.address, firstDeposit + secondDeposit) + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), firstDeposit + secondDeposit) + await stbtc + .connect(depositor1) + .deposit(firstDeposit, depositor1.address) + + await stbtc + .connect(depositor1) + .deposit(secondDeposit, depositor1.address) + + shares = await stbtc.balanceOf(depositor1.address) + availableToWithdraw = await stbtc.previewRedeem(shares) + withdrawTx = await stbtc.withdraw( + availableToWithdraw, + depositor1.address, + depositor1.address, + ) + }) + + it("should emit Withdraw event", async () => { + await expect(withdrawTx).to.emit(stbtc, "Withdraw").withArgs( + // Caller. + depositor1.address, + // Receiver + depositor1.address, + // Owner + depositor1.address, + // Available assets to withdraw including fees. Actual assets sent to + // a user will be less because of the exit fee. + availableToWithdraw, + // Burned shares. + shares, + ) + }) + + it("should burn stBTC tokens", async () => { + await expect(withdrawTx).to.changeTokenBalances( + stbtc, + [depositor1.address], + [-shares], + ) + }) + + it("should transfer tBTC tokens to a Staker", async () => { + await expect(withdrawTx).to.changeTokenBalances( + tbtc, + [depositor1.address], + [availableToWithdraw], + ) + }) + + it("should transfer tBTC tokens to Treasury", async () => { + await expect(withdrawTx).to.changeTokenBalances( + tbtc, + [treasury.address], + [feeOnRaw(availableToWithdraw, exitFeeBasisPoints)], + ) + }) + }) + }) + describe("updateDepositParameters", () => { beforeAfterSnapshotWrapper() @@ -843,7 +1242,8 @@ describe("stBTC", () => { await tbtc.connect(depositor1).approve(await stbtc.getAddress(), toMint) await stbtc.connect(depositor1).deposit(toMint, depositor1.address) - expectedValue = maximumTotalAssets - toMint + feeOnTotal(toMint) + expectedValue = + maximumTotalAssets - toMint + feeOnTotal(toMint, entryFeeBasisPoints) }) it("should return correct value", async () => { @@ -1006,6 +1406,122 @@ describe("stBTC", () => { }) }) + describe("updateEntryFeeBasisPoints", () => { + beforeAfterSnapshotWrapper() + + const validEntryFeeBasisPoints = 100n // 1% + + context("when is called by governance", () => { + context("when entry fee basis points are valid", () => { + beforeAfterSnapshotWrapper() + + let tx: ContractTransactionResponse + + before(async () => { + tx = await stbtc + .connect(governance) + .updateEntryFeeBasisPoints(validEntryFeeBasisPoints) + }) + + it("should emit EntryFeeBasisPointsUpdated event", async () => { + await expect(tx) + .to.emit(stbtc, "EntryFeeBasisPointsUpdated") + .withArgs(validEntryFeeBasisPoints) + }) + + it("should update entry fee basis points correctly", async () => { + expect(await stbtc.entryFeeBasisPoints()).to.be.eq( + validEntryFeeBasisPoints, + ) + }) + }) + + context("when entry fee basis points are 0", () => { + beforeAfterSnapshotWrapper() + + const newEntryFeeBasisPoints = 0 + + before(async () => { + await stbtc + .connect(governance) + .updateEntryFeeBasisPoints(newEntryFeeBasisPoints) + }) + + it("should update entry fee basis points correctly", async () => { + expect(await stbtc.entryFeeBasisPoints()).to.be.eq( + newEntryFeeBasisPoints, + ) + }) + }) + }) + + context("when is called by non-governance", () => { + it("should revert", async () => { + await expect( + stbtc.connect(depositor1).updateEntryFeeBasisPoints(100n), + ).to.be.revertedWithCustomError(stbtc, "OwnableUnauthorizedAccount") + }) + }) + }) + + describe("updateExitFeeBasisPoints", () => { + beforeAfterSnapshotWrapper() + + const validExitFeeBasisPoints = 100n // 1% + + context("when is called by governance", () => { + context("when exit fee basis points are valid", () => { + beforeAfterSnapshotWrapper() + + let tx: ContractTransactionResponse + + before(async () => { + tx = await stbtc + .connect(governance) + .updateExitFeeBasisPoints(validExitFeeBasisPoints) + }) + + it("should emit ExitFeeBasisPointsUpdated event", async () => { + await expect(tx) + .to.emit(stbtc, "ExitFeeBasisPointsUpdated") + .withArgs(validExitFeeBasisPoints) + }) + + it("should update exit fee basis points correctly", async () => { + expect(await stbtc.exitFeeBasisPoints()).to.be.eq( + validExitFeeBasisPoints, + ) + }) + }) + + context("when exit fee basis points are 0", () => { + beforeAfterSnapshotWrapper() + + const newExitFeeBasisPoints = 0 + + before(async () => { + await stbtc + .connect(governance) + .updateExitFeeBasisPoints(newExitFeeBasisPoints) + }) + + it("should update exit fee basis points correctly", async () => { + expect(await stbtc.exitFeeBasisPoints()).to.be.eq( + newExitFeeBasisPoints, + ) + }) + }) + }) + + context("when is called by non-governance", () => { + it("should revert", async () => { + await expect( + stbtc.connect(depositor1).updateExitFeeBasisPoints(100n), + ).to.be.revertedWithCustomError(stbtc, "OwnableUnauthorizedAccount") + }) + }) + }) + describe("maxMint", () => { beforeAfterSnapshotWrapper() @@ -1124,7 +1640,7 @@ describe("stBTC", () => { it("should add 1 to the result", () => { // feeOnTotal - test's internal function simulating the OZ mulDiv // function. - const fee = feeOnTotal(to1e18(1)) + const fee = feeOnTotal(to1e18(1), entryFeeBasisPoints) // fee = (1e18 * 5) / (10000 + 5) = 499750124937531 + 1 const expectedFee = 499750124937532 expect(fee).to.be.eq(expectedFee) @@ -1135,7 +1651,7 @@ describe("stBTC", () => { it("should return the actual result", () => { // feeOnTotal - test's internal function simulating the OZ mulDiv // function. - const fee = feeOnTotal(2001n) + const fee = feeOnTotal(2001n, entryFeeBasisPoints) // fee = (2001 * 5) / (10000 + 5) = 1 const expectedFee = 1n expect(fee).to.be.eq(expectedFee) @@ -1147,7 +1663,7 @@ describe("stBTC", () => { context("when the fee's modulo remainder is greater than 0", () => { it("should return the correct amount of fees", () => { // feeOnRaw - this is a test internal function - const fee = feeOnRaw(to1e18(1)) + const fee = feeOnRaw(to1e18(1), entryFeeBasisPoints) // fee = (1e18 * 5) / (10000) = 500000000000000 const expectedFee = 500000000000000 expect(fee).to.be.eq(expectedFee) @@ -1158,7 +1674,7 @@ describe("stBTC", () => { it("should return the actual result", () => { // feeOnTotal - test's internal function simulating the OZ mulDiv // function. - const fee = feeOnTotal(2000n) + const fee = feeOnTotal(2000n, entryFeeBasisPoints) // fee = (2000 * 5) / 10000 = 1 const expectedFee = 1n expect(fee).to.be.eq(expectedFee) @@ -1170,13 +1686,10 @@ describe("stBTC", () => { // One is added to the result if there is a remainder to match the Solidity // mulDiv() math which rounds up towards infinity (Ceil) when fees are // calculated. - function feeOnTotal(amount: bigint) { + function feeOnTotal(amount: bigint, feeBasisPoints: bigint) { const result = - (amount * entryFeeBasisPoints) / (entryFeeBasisPoints + basisPointScale) - if ( - (amount * entryFeeBasisPoints) % (entryFeeBasisPoints + basisPointScale) > - 0 - ) { + (amount * feeBasisPoints) / (feeBasisPoints + basisPointScale) + if ((amount * feeBasisPoints) % (feeBasisPoints + basisPointScale) > 0) { return result + 1n } return result @@ -1186,18 +1699,18 @@ describe("stBTC", () => { // One is added to the result if there is a remainder to match the Solidity // mulDiv() math which rounds up towards infinity (Ceil) when fees are // calculated. - function feeOnRaw(amount: bigint) { - const result = (amount * entryFeeBasisPoints) / basisPointScale - if ((amount * entryFeeBasisPoints) % basisPointScale > 0) { + function feeOnRaw(amount: bigint, feeBasisPoints: bigint) { + const result = (amount * feeBasisPoints) / basisPointScale + if ((amount * feeBasisPoints) % basisPointScale > 0) { return result + 1n } return result } - // 2n is added or subtracted to/from the expected value to match the Solidity + // 10 is added or subtracted to/from the expected value to match the Solidity // math which rounds up or down depending on the modulo remainder. It is a very // small number. function expectCloseTo(actual: bigint, expected: bigint) { - return expect(actual, "invalid asset balance").to.be.closeTo(expected, 2n) + return expect(actual, "invalid asset balance").to.be.closeTo(expected, 10n) } }) From 30f78a3f0b230d242b66f744187f84fc68a709ef Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 3 Apr 2024 11:44:00 +0200 Subject: [PATCH 6/8] Using third party account as a receiver upon redeeming shares --- core/test/stBTC.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/core/test/stBTC.test.ts b/core/test/stBTC.test.ts index c5ce7d207..a8f3feee8 100644 --- a/core/test/stBTC.test.ts +++ b/core/test/stBTC.test.ts @@ -750,7 +750,7 @@ describe("stBTC", () => { amountStaked - feeOnTotal(amountStaked, exitFeeBasisPoints) tx = await stbtc .connect(depositor1) - .redeem(shares, depositor1, depositor1) + .redeem(shares, thirdParty, depositor1) }) it("should emit Redeem event", async () => { @@ -758,7 +758,7 @@ describe("stBTC", () => { // Caller. depositor1.address, // Receiver - depositor1.address, + thirdParty.address, // Owner depositor1.address, // Redeemed tokens. @@ -776,10 +776,10 @@ describe("stBTC", () => { ) }) - it("should transfer tBTC tokens to a Staker", async () => { + it("should transfer tBTC tokens to receiver", async () => { await expect(tx).to.changeTokenBalances( tbtc, - [depositor1.address], + [thirdParty.address], [amountToRedeem], ) }) @@ -880,7 +880,7 @@ describe("stBTC", () => { depositor1.address, // Receiver. receiver.address, - // Depositd tokens including deposit fees. + // Deposited tokens including deposit fees. amountToSpend, // Received shares. sharesToMint, @@ -991,7 +991,7 @@ describe("stBTC", () => { .deposit(amountToDeposit, depositor1.address) tx = await stbtc .connect(depositor1) - .withdraw(availableToWithdraw, depositor1, depositor1) + .withdraw(availableToWithdraw, thirdParty, depositor1) }) it("should emit Withdraw event", async () => { @@ -999,7 +999,7 @@ describe("stBTC", () => { // Caller. depositor1.address, // Receiver - depositor1.address, + thirdParty.address, // Owner depositor1.address, // Available assets to withdraw. @@ -1017,10 +1017,10 @@ describe("stBTC", () => { ) }) - it("should transfer tBTC tokens to a Staker", async () => { + it("should transfer tBTC tokens to a Receiver", async () => { await expect(tx).to.changeTokenBalances( tbtc, - [depositor1.address], + [thirdParty.address], [availableToWithdraw], ) }) From c9e2a9cb2b04f44064debcb323fdaaea213fe761 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 3 Apr 2024 12:32:51 +0200 Subject: [PATCH 7/8] Hardcoding values for previewRedeem --- core/test/stBTC.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/test/stBTC.test.ts b/core/test/stBTC.test.ts index a8f3feee8..a8908e669 100644 --- a/core/test/stBTC.test.ts +++ b/core/test/stBTC.test.ts @@ -139,8 +139,10 @@ describe("stBTC", () => { context("when the vault is empty", () => { it("should return correct value", async () => { const toRedeem = to1e18(1) - const expectedShares = - toRedeem - feeOnTotal(toRedeem, exitFeeBasisPoints) + // fee = (1e18 * 10) / (10000 + 10) = 999000999000999 + // expectedShares = toReedem - fee + // expectedShares = to1e18(1) - 999000999000999 = 999000999000999001 + const expectedShares = 999000999000999000n // -1 to match the contract's math expect(await stbtc.previewRedeem(toRedeem)).to.be.equal(expectedShares) }) }) From 61757b087f852a73b0e6c0df3a247a99448c09df Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 3 Apr 2024 12:43:03 +0200 Subject: [PATCH 8/8] Hardcoding more values for testing purposes --- core/test/stBTC.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/test/stBTC.test.ts b/core/test/stBTC.test.ts index a8908e669..192adfcef 100644 --- a/core/test/stBTC.test.ts +++ b/core/test/stBTC.test.ts @@ -986,8 +986,8 @@ describe("stBTC", () => { await tbtc .connect(depositor1) .approve(await stbtc.getAddress(), amountToDeposit) - shares = await stbtc.previewDeposit(amountToDeposit) - availableToWithdraw = await stbtc.previewRedeem(shares) + shares = 999500249875062468n + availableToWithdraw = 998501748126935532n await stbtc .connect(depositor1) .deposit(amountToDeposit, depositor1.address) @@ -1056,8 +1056,8 @@ describe("stBTC", () => { .connect(depositor1) .deposit(secondDeposit, depositor1.address) - shares = await stbtc.balanceOf(depositor1.address) - availableToWithdraw = await stbtc.previewRedeem(shares) + shares = 2998500749625187405n + availableToWithdraw = 2995505244380806598n withdrawTx = await stbtc.withdraw( availableToWithdraw, depositor1.address,