From 49b4be9e06c2cadbabbf500388dc130f1b9e9b68 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 12 Apr 2024 13:10:58 +0200 Subject: [PATCH 1/6] Fixing withdrawal functionality in stBTC Upon withdrawal we need to take into account fees that will be collected by the treasury. A user will get the desired assets, but on top we need to pull the funds from Mezo covering not only what a user wants but also pull a bit more to cover the withdrawal fees. Added tests to cover scenarios with allocated funds to Mezo Portal and then withdrawing and redeeming from Acre. --- solidity/contracts/stBTC.sol | 7 +- solidity/test/stBTC.test.ts | 572 +++++++++++++++++++++++------------ 2 files changed, 378 insertions(+), 201 deletions(-) diff --git a/solidity/contracts/stBTC.sol b/solidity/contracts/stBTC.sol index 613c8c301..34c719dfe 100644 --- a/solidity/contracts/stBTC.sol +++ b/solidity/contracts/stBTC.sol @@ -253,8 +253,11 @@ contract stBTC is ERC4626Fees, PausableOwnable { address owner ) public override whenNotPaused returns (uint256) { uint256 currentAssetsBalance = IERC20(asset()).balanceOf(address(this)); - if (assets > currentAssetsBalance) { - dispatcher.withdraw(assets - currentAssetsBalance); + // If there is not enough assets in stBTC to cover user withdrawals and + // withdrawal fees then pull the assets from the dispatcher. + uint256 assetsWithFees = convertToAssets(previewWithdraw(assets)); + if (assetsWithFees > currentAssetsBalance) { + dispatcher.withdraw(assetsWithFees - currentAssetsBalance); } return super.withdraw(assets, receiver, owner); diff --git a/solidity/test/stBTC.test.ts b/solidity/test/stBTC.test.ts index 0346f69c4..6bedff977 100644 --- a/solidity/test/stBTC.test.ts +++ b/solidity/test/stBTC.test.ts @@ -12,13 +12,14 @@ import { beforeAfterSnapshotWrapper, deployment } from "./helpers" import { to1e18 } from "./utils" -import type { StBTC as stBTC, TestERC20, MezoAllocator } from "../typechain" +import { StBTC as stBTC, TestERC20, MezoAllocator } from "../typechain" const { getNamedSigners, getUnnamedSigners } = helpers.signers async function fixture() { const { tbtc, stbtc, mezoAllocator } = await deployment() - const { governance, treasury, pauseAdmin } = await getNamedSigners() + const { governance, treasury, pauseAdmin, maintainer } = + await getNamedSigners() const [depositor1, depositor2, thirdParty] = await getUnnamedSigners() @@ -36,6 +37,7 @@ async function fixture() { treasury, mezoAllocator, pauseAdmin, + maintainer, } } @@ -54,6 +56,7 @@ describe("stBTC", () => { let thirdParty: HardhatEthersSigner let treasury: HardhatEthersSigner let pauseAdmin: HardhatEthersSigner + let maintainer: HardhatEthersSigner before(async () => { ;({ @@ -66,6 +69,7 @@ describe("stBTC", () => { treasury, mezoAllocator, pauseAdmin, + maintainer, } = await loadFixture(fixture)) await stbtc @@ -650,125 +654,6 @@ describe("stBTC", () => { }) }) - describe("redeem", () => { - beforeAfterSnapshotWrapper() - - context("when redeeming from a single deposit", () => { - beforeAfterSnapshotWrapper() - - const amountToDeposit = to1e18(1) - let tx: ContractTransactionResponse - let amountToRedeem: bigint - let amountDeposited: 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) - amountDeposited = - amountToDeposit - feeOnTotal(amountToDeposit, entryFeeBasisPoints) - amountToRedeem = - amountDeposited - feeOnTotal(amountDeposited, exitFeeBasisPoints) - tx = await stbtc - .connect(depositor1) - .redeem(shares, thirdParty, depositor1) - }) - - it("should emit Redeem event", async () => { - await expect(tx).to.emit(stbtc, "Withdraw").withArgs( - // Caller. - depositor1.address, - // Receiver - thirdParty.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 receiver", async () => { - await expect(tx).to.changeTokenBalances( - tbtc, - [thirdParty.address], - [amountToRedeem], - ) - }) - - it("should transfer tBTC tokens to Treasury", async () => { - await expect(tx).to.changeTokenBalances( - tbtc, - [treasury.address], - [feeOnTotal(amountDeposited, 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() @@ -871,139 +756,428 @@ describe("stBTC", () => { ) }) - describe("withdraw", () => { + describe("redeem", () => { beforeAfterSnapshotWrapper() - context("when withdrawing from a single deposit", () => { + context("when stBTC did not allocate any assets", () => { + context("when redeeming from a single deposit", () => { + beforeAfterSnapshotWrapper() + + const amountToDeposit = to1e18(1) + let tx: ContractTransactionResponse + let amountToRedeem: bigint + let amountDeposited: 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) + amountDeposited = + amountToDeposit - feeOnTotal(amountToDeposit, entryFeeBasisPoints) + amountToRedeem = + amountDeposited - feeOnTotal(amountDeposited, exitFeeBasisPoints) + tx = await stbtc + .connect(depositor1) + .redeem(shares, thirdParty, depositor1) + }) + + it("should emit Redeem event", async () => { + await expect(tx).to.emit(stbtc, "Withdraw").withArgs( + // Caller. + depositor1.address, + // Receiver + thirdParty.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 receiver", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [thirdParty.address], + [amountToRedeem], + ) + }) + + it("should transfer tBTC tokens to Treasury", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [treasury.address], + [feeOnTotal(amountDeposited, 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, + ) + }) + }) + }) + + context("when stBTC allocated some of the assets", () => { beforeAfterSnapshotWrapper() - const amountToDeposit = to1e18(1) let tx: ContractTransactionResponse - let availableToWithdraw: bigint - let shares: bigint + let firstDepositBalance: bigint + + const amountToDeposit = to1e18(3) before(async () => { + await tbtc.mint(depositor1.address, amountToDeposit) await tbtc .connect(depositor1) .approve(await stbtc.getAddress(), amountToDeposit) - shares = 999500249875062468n - availableToWithdraw = 998501748126935532n + // Depositor deposits 3 tBTC. await stbtc .connect(depositor1) .deposit(amountToDeposit, depositor1.address) + // Allocate 3 tBTC to Mezo Portal. + await mezoAllocator.connect(maintainer).allocate() + firstDepositBalance = await mezoAllocator.depositBalance() + // Donate 1 tBTC to stBTC. + await tbtc.mint(await stbtc.getAddress(), to1e18(1)) + // Depositor redeems 2 stBTC. tx = await stbtc .connect(depositor1) - .withdraw(availableToWithdraw, thirdParty, depositor1) - }) - - it("should emit Withdraw event", async () => { - await expect(tx).to.emit(stbtc, "Withdraw").withArgs( - // Caller. - depositor1.address, - // Receiver - thirdParty.address, - // Owner - depositor1.address, - // Available assets to withdraw. - availableToWithdraw, - // Burned shares. - shares, - ) + .redeem(to1e18(2), depositor1, depositor1) }) - it("should burn stBTC tokens", async () => { + it("should transfer tBTC back to a depositor1", async () => { + // adjusting for rounding + const expectedRedeemedAssets = + (await stbtc.previewRedeem(to1e18(2))) - 2n await expect(tx).to.changeTokenBalances( - stbtc, + tbtc, [depositor1.address], - [-shares], + [expectedRedeemedAssets], ) }) - it("should transfer tBTC tokens to a Receiver", async () => { + it("should use all unallocated assets", async () => { await expect(tx).to.changeTokenBalances( tbtc, - [thirdParty.address], - [availableToWithdraw], + [await stbtc.getAddress()], + [-to1e18(1)], ) }) - it("should transfer tBTC tokens to Treasury", async () => { + it("should transfer redeem fee to Treasury", async () => { + const assetsToWithdraw = await stbtc.previewRedeem(to1e18(2)) + const redeemFee = feeOnRaw(assetsToWithdraw, exitFeeBasisPoints) await expect(tx).to.changeTokenBalances( tbtc, [treasury.address], - [feeOnRaw(availableToWithdraw, exitFeeBasisPoints)], + [redeemFee], ) }) + + it("should decrease the deposit balance tracking in Mezo Allocator", async () => { + const depositBalance = await mezoAllocator.depositBalance() + const assetsToWithdraw = await stbtc.previewRedeem(to1e18(2)) + const redeemFee = feeOnRaw(assetsToWithdraw, exitFeeBasisPoints) + // adjusting for rounding + const assetsToWithdrawFromMezo = + assetsToWithdraw + redeemFee - to1e18(1) - 2n + + expect(depositBalance).to.be.eq( + firstDepositBalance - assetsToWithdrawFromMezo, + ) + }) + + it("should emit Withdraw event", async () => { + // adjust for rounding + const assetsToWithdraw = (await stbtc.previewRedeem(to1e18(2))) - 2n + + await expect(tx) + .to.emit(stbtc, "Withdraw") + .withArgs( + depositor1.address, + depositor1.address, + depositor1.address, + assetsToWithdraw, + to1e18(2), + ) + }) }) + }) + + describe("withdraw", () => { + beforeAfterSnapshotWrapper() + + context("when stBTC did not allocate any assets", () => { + 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 = 999500249875062468n + availableToWithdraw = 998501748126935532n + await stbtc + .connect(depositor1) + .deposit(amountToDeposit, depositor1.address) + tx = await stbtc + .connect(depositor1) + .withdraw(availableToWithdraw, thirdParty, depositor1) + }) + + it("should emit Withdraw event", async () => { + await expect(tx).to.emit(stbtc, "Withdraw").withArgs( + // Caller. + depositor1.address, + // Receiver + thirdParty.address, + // Owner + depositor1.address, + // Available assets to withdraw. + availableToWithdraw, + // Burned shares. + shares, + ) + }) - 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 + it("should burn stBTC tokens", async () => { + await expect(tx).to.changeTokenBalances( + stbtc, + [depositor1.address], + [-shares], + ) + }) + + it("should transfer tBTC tokens to a Receiver", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [thirdParty.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 = 2998500749625187405n + availableToWithdraw = 2995505244380806598n + 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 deposit owner", 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)], + ) + }) + }) + }) + + context("when stBTC allocated some of the assets", () => { + beforeAfterSnapshotWrapper() + + let tx: ContractTransactionResponse + let firstDepositBalance: bigint + + const amountToDeposit = to1e18(3) before(async () => { - await tbtc.mint(depositor1.address, firstDeposit + secondDeposit) + await tbtc.mint(depositor1.address, amountToDeposit) await tbtc .connect(depositor1) - .approve(await stbtc.getAddress(), firstDeposit + secondDeposit) + .approve(await stbtc.getAddress(), amountToDeposit) + // Depositor deposits 3 tBTC. await stbtc .connect(depositor1) - .deposit(firstDeposit, depositor1.address) - - await stbtc + .deposit(amountToDeposit, depositor1.address) + // Allocate 3 tBTC to Mezo Portal. + await mezoAllocator.connect(maintainer).allocate() + firstDepositBalance = await mezoAllocator.depositBalance() + // Donate 1 tBTC to stBTC. + await tbtc.mint(await stbtc.getAddress(), to1e18(1)) + // Depositor withdraws 2 tBTC. + tx = await stbtc .connect(depositor1) - .deposit(secondDeposit, depositor1.address) - - shares = 2998500749625187405n - availableToWithdraw = 2995505244380806598n - withdrawTx = await stbtc.withdraw( - availableToWithdraw, - depositor1.address, - depositor1.address, - ) + .withdraw(to1e18(2), depositor1, depositor1) }) - 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, + it("should transfer 2 tBTC back to a depositor1", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, [depositor1.address], - [-shares], + [to1e18(2)], ) }) - it("should transfer tBTC tokens to a deposit owner", async () => { - await expect(withdrawTx).to.changeTokenBalances( + it("should use all unallocated assets", async () => { + await expect(tx).to.changeTokenBalances( tbtc, - [depositor1.address], - [availableToWithdraw], + [await stbtc.getAddress()], + [-to1e18(1)], ) }) - it("should transfer tBTC tokens to Treasury", async () => { - await expect(withdrawTx).to.changeTokenBalances( + it("should transfer withdrawal fee to Treasury", async () => { + const withdrawalFee = feeOnRaw(to1e18(2), exitFeeBasisPoints) + await expect(tx).to.changeTokenBalances( tbtc, [treasury.address], - [feeOnRaw(availableToWithdraw, exitFeeBasisPoints)], + [withdrawalFee], + ) + }) + + it("should decrease the deposit balance tracking in Mezo Allocator", async () => { + const depositBalance = await mezoAllocator.depositBalance() + const withdrawalFee = feeOnRaw(to1e18(2), exitFeeBasisPoints) + const assetsToWithdrawFromMezo = to1e18(2) + withdrawalFee - to1e18(1) + + expect(depositBalance).to.be.eq( + firstDepositBalance - assetsToWithdrawFromMezo, ) }) + + it("should emit Withdraw event", async () => { + const sharesToBurn = (await stbtc.previewWithdraw(to1e18(2))) + 1n // adjust for rounding + + await expect(tx) + .to.emit(stbtc, "Withdraw") + .withArgs( + depositor1.address, + depositor1.address, + depositor1.address, + to1e18(2), + sharesToBurn, + ) + }) }) }) From 01ccc4a5da5dda0d86a3d2924e76c54b9fbcf989 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 15 Apr 2024 14:31:26 +0200 Subject: [PATCH 2/6] Changing access of _feeOnRaw and _feeOnTotal to internal We need to calculate fees that are associated with withdrawal assets upon withdrawing when calculating how much assets should be withdarawn from the dispatcher contract. Instead of calling a preview and conversion functions we can modify access modifiers of _feeOnRaw and _feeOnTotal to internal and use it in stBTC. That would simplify calculations. --- solidity/contracts/lib/ERC4626Fees.sol | 4 ++-- solidity/contracts/stBTC.sol | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/solidity/contracts/lib/ERC4626Fees.sol b/solidity/contracts/lib/ERC4626Fees.sol index 235d69835..0bc0ee1c4 100644 --- a/solidity/contracts/lib/ERC4626Fees.sol +++ b/solidity/contracts/lib/ERC4626Fees.sol @@ -103,7 +103,7 @@ abstract contract ERC4626Fees is ERC4626Upgradeable { function _feeOnRaw( uint256 assets, uint256 feeBasisPoints - ) private pure returns (uint256) { + ) internal pure returns (uint256) { return assets.mulDiv( feeBasisPoints, @@ -117,7 +117,7 @@ abstract contract ERC4626Fees is ERC4626Upgradeable { function _feeOnTotal( uint256 assets, uint256 feeBasisPoints - ) private pure returns (uint256) { + ) internal pure returns (uint256) { return assets.mulDiv( feeBasisPoints, diff --git a/solidity/contracts/stBTC.sol b/solidity/contracts/stBTC.sol index 34c719dfe..e45d81cb0 100644 --- a/solidity/contracts/stBTC.sol +++ b/solidity/contracts/stBTC.sol @@ -255,7 +255,8 @@ contract stBTC is ERC4626Fees, PausableOwnable { uint256 currentAssetsBalance = IERC20(asset()).balanceOf(address(this)); // If there is not enough assets in stBTC to cover user withdrawals and // withdrawal fees then pull the assets from the dispatcher. - uint256 assetsWithFees = convertToAssets(previewWithdraw(assets)); + uint256 assetsWithFees = assets + + _feeOnRaw(assets, _exitFeeBasisPoints()); if (assetsWithFees > currentAssetsBalance) { dispatcher.withdraw(assetsWithFees - currentAssetsBalance); } From 97014f7e0a441abeb3f554007ef688ca6b8e6692 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 20 Apr 2024 09:01:20 +0200 Subject: [PATCH 3/6] Accessing 'exitFeeBasisPoints' var directly --- solidity/contracts/stBTC.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/solidity/contracts/stBTC.sol b/solidity/contracts/stBTC.sol index e45d81cb0..458364380 100644 --- a/solidity/contracts/stBTC.sol +++ b/solidity/contracts/stBTC.sol @@ -255,8 +255,7 @@ contract stBTC is ERC4626Fees, PausableOwnable { uint256 currentAssetsBalance = IERC20(asset()).balanceOf(address(this)); // If there is not enough assets in stBTC to cover user withdrawals and // withdrawal fees then pull the assets from the dispatcher. - uint256 assetsWithFees = assets + - _feeOnRaw(assets, _exitFeeBasisPoints()); + uint256 assetsWithFees = assets + _feeOnRaw(assets, exitFeeBasisPoints); if (assetsWithFees > currentAssetsBalance) { dispatcher.withdraw(assetsWithFees - currentAssetsBalance); } From 0e94c587a695813bff7e61edf44cdb36f91b14b7 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 20 Apr 2024 09:47:17 +0200 Subject: [PATCH 4/6] Adding tests when the entry and exit fees are 0 --- solidity/test/stBTC.test.ts | 124 ++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/solidity/test/stBTC.test.ts b/solidity/test/stBTC.test.ts index 6bedff977..13881daee 100644 --- a/solidity/test/stBTC.test.ts +++ b/solidity/test/stBTC.test.ts @@ -963,6 +963,68 @@ describe("stBTC", () => { ) }) }) + + context("when the entry and exit fee is zero", () => { + beforeAfterSnapshotWrapper() + + context("when redeeming from a single deposit", () => { + beforeAfterSnapshotWrapper() + + const amountToDeposit = to1e18(1) + let tx: ContractTransactionResponse + + before(async () => { + await stbtc.connect(governance).updateExitFeeBasisPoints(0) + await stbtc.connect(governance).updateEntryFeeBasisPoints(0) + + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), amountToDeposit) + + await stbtc + .connect(depositor1) + .deposit(amountToDeposit, depositor1.address) + tx = await stbtc + .connect(depositor1) + .redeem(amountToDeposit, thirdParty, depositor1) + }) + + it("should emit Withdraw event", async () => { + await expect(tx).to.emit(stbtc, "Withdraw").withArgs( + // Caller. + depositor1.address, + // Receiver + thirdParty.address, + // Owner + depositor1.address, + // Redeemed tokens. + amountToDeposit, + // Burned shares. + amountToDeposit, + ) + }) + + it("should burn stBTC tokens", async () => { + await expect(tx).to.changeTokenBalances( + stbtc, + [depositor1.address], + [-amountToDeposit], + ) + }) + + it("should transfer tBTC tokens to a receiver", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [thirdParty.address], + [amountToDeposit], + ) + }) + + it("should not transfer any tBTC tokens to Treasury", async () => { + await expect(tx).to.changeTokenBalances(tbtc, [treasury.address], [0]) + }) + }) + }) }) describe("withdraw", () => { @@ -1179,6 +1241,68 @@ describe("stBTC", () => { ) }) }) + + context("when the entry and exit fee is zero", () => { + beforeAfterSnapshotWrapper() + + context("when withdrawing from a single deposit", () => { + beforeAfterSnapshotWrapper() + + const amountToDeposit = to1e18(1) + let tx: ContractTransactionResponse + + before(async () => { + await stbtc.connect(governance).updateExitFeeBasisPoints(0) + await stbtc.connect(governance).updateEntryFeeBasisPoints(0) + + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), amountToDeposit) + + await stbtc + .connect(depositor1) + .deposit(amountToDeposit, depositor1.address) + tx = await stbtc + .connect(depositor1) + .withdraw(amountToDeposit, thirdParty, depositor1) + }) + + it("should emit Withdraw event", async () => { + await expect(tx).to.emit(stbtc, "Withdraw").withArgs( + // Caller. + depositor1.address, + // Receiver + thirdParty.address, + // Owner + depositor1.address, + // Withdrew tokens. + amountToDeposit, + // Burned shares. + amountToDeposit, + ) + }) + + it("should burn stBTC tokens", async () => { + await expect(tx).to.changeTokenBalances( + stbtc, + [depositor1.address], + [-amountToDeposit], + ) + }) + + it("should transfer tBTC tokens to a receiver", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [thirdParty.address], + [amountToDeposit], + ) + }) + + it("should not transfer any tBTC tokens to Treasury", async () => { + await expect(tx).to.changeTokenBalances(tbtc, [treasury.address], [0]) + }) + }) + }) }) describe("updateMinimumDepositAmount", () => { From d403a3c01a05de29d9f6318ebaebd0cd553fb71f Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 20 Apr 2024 10:23:29 +0200 Subject: [PATCH 5/6] Covering cases when all the assets were allocated to Mezo Portal --- solidity/test/stBTC.test.ts | 146 ++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/solidity/test/stBTC.test.ts b/solidity/test/stBTC.test.ts index 13881daee..b9a990c82 100644 --- a/solidity/test/stBTC.test.ts +++ b/solidity/test/stBTC.test.ts @@ -878,6 +878,77 @@ describe("stBTC", () => { }) }) + context("when stBTC allocated all of the assets", () => { + beforeAfterSnapshotWrapper() + + let tx: ContractTransactionResponse + let firstDepositBalance: bigint + + const amountToDeposit = to1e18(3) + + before(async () => { + await tbtc.mint(depositor1.address, amountToDeposit) + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), amountToDeposit) + // Depositor deposits 3 tBTC. + await stbtc + .connect(depositor1) + .deposit(amountToDeposit, depositor1.address) + // Allocate 3 tBTC to Mezo Portal. + await mezoAllocator.connect(maintainer).allocate() + firstDepositBalance = await mezoAllocator.depositBalance() + // Depositor redeems 2 stBTC. + tx = await stbtc + .connect(depositor1) + .redeem(to1e18(2), depositor1, depositor1) + }) + + it("should transfer tBTC back to a depositor1", async () => { + const expectedRedeemedAssets = await stbtc.previewRedeem(to1e18(2)) + await expect(tx).to.changeTokenBalances( + tbtc, + [depositor1.address], + [expectedRedeemedAssets], + ) + }) + + it("should transfer redeem fee to Treasury", async () => { + const assetsToWithdraw = await stbtc.previewRedeem(to1e18(2)) + const redeemFee = feeOnRaw(assetsToWithdraw, exitFeeBasisPoints) + await expect(tx).to.changeTokenBalances( + tbtc, + [treasury.address], + [redeemFee], + ) + }) + + it("should decrease the deposit balance tracking in Mezo Allocator", async () => { + const depositBalance = await mezoAllocator.depositBalance() + const assetsToWithdraw = await stbtc.previewRedeem(to1e18(2)) + const redeemFee = feeOnRaw(assetsToWithdraw, exitFeeBasisPoints) + const assetsToWithdrawFromMezo = assetsToWithdraw + redeemFee + + expect(depositBalance).to.be.eq( + firstDepositBalance - assetsToWithdrawFromMezo, + ) + }) + + it("should emit Withdraw event", async () => { + const assetsToWithdraw = await stbtc.previewRedeem(to1e18(2)) + + await expect(tx) + .to.emit(stbtc, "Withdraw") + .withArgs( + depositor1.address, + depositor1.address, + depositor1.address, + assetsToWithdraw, + to1e18(2), + ) + }) + }) + context("when stBTC allocated some of the assets", () => { beforeAfterSnapshotWrapper() @@ -1164,6 +1235,81 @@ describe("stBTC", () => { }) }) + context("when stBTC allocated all of the assets", () => { + beforeAfterSnapshotWrapper() + + let tx: ContractTransactionResponse + let firstDepositBalance: bigint + + const amountToDeposit = to1e18(3) + + before(async () => { + await tbtc.mint(depositor1.address, amountToDeposit) + await tbtc + .connect(depositor1) + .approve(await stbtc.getAddress(), amountToDeposit) + // Depositor deposits 3 tBTC. + await stbtc + .connect(depositor1) + .deposit(amountToDeposit, depositor1.address) + // Allocate 3 tBTC to Mezo Portal. + await mezoAllocator.connect(maintainer).allocate() + firstDepositBalance = await mezoAllocator.depositBalance() + // Depositor withdraws 2 stBTC. + tx = await stbtc + .connect(depositor1) + .withdraw(to1e18(2), depositor1, depositor1) + }) + + it("should transfer tBTC back to a depositor1", async () => { + const expectedWithdrawnAssets = to1e18(2) + + await expect(tx).to.changeTokenBalances( + tbtc, + [depositor1.address], + [expectedWithdrawnAssets], + ) + }) + + it("should transfer withdrawal fee to Treasury", async () => { + const assetsToWithdraw = to1e18(2) + const withdrawFee = feeOnRaw(assetsToWithdraw, exitFeeBasisPoints) + + await expect(tx).to.changeTokenBalances( + tbtc, + [treasury.address], + [withdrawFee], + ) + }) + + it("should decrease the deposit balance tracking in Mezo Allocator", async () => { + const depositBalance = await mezoAllocator.depositBalance() + const assetsToWithdraw = to1e18(2) + const withdrawFee = feeOnRaw(assetsToWithdraw, exitFeeBasisPoints) + const assetsToWithdrawFromMezo = assetsToWithdraw + withdrawFee + + expect(depositBalance).to.be.eq( + firstDepositBalance - assetsToWithdrawFromMezo, + ) + }) + + it("should emit Withdraw event", async () => { + const assetsToWithdraw = to1e18(2) + const sharesToBurn = + to1e18(2) + feeOnRaw(assetsToWithdraw, exitFeeBasisPoints) + + await expect(tx) + .to.emit(stbtc, "Withdraw") + .withArgs( + depositor1.address, + depositor1.address, + depositor1.address, + assetsToWithdraw, + sharesToBurn, + ) + }) + }) + context("when stBTC allocated some of the assets", () => { beforeAfterSnapshotWrapper() From 67c2be5bd9df4b7cdafc5148ac1d7b1ba336b322 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 20 Apr 2024 12:12:02 +0200 Subject: [PATCH 6/6] Replacing shares and assets partial calculation with feeOnRaw and feeOnTotal functions --- solidity/test/stBTC.test.ts | 53 ++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/solidity/test/stBTC.test.ts b/solidity/test/stBTC.test.ts index b9a990c82..546e5919a 100644 --- a/solidity/test/stBTC.test.ts +++ b/solidity/test/stBTC.test.ts @@ -531,9 +531,9 @@ describe("stBTC", () => { }) it("depositor 1 should receive shares equal to a deposited amount", async () => { - const expectedShares = await stbtc.previewDeposit( - depositor1AmountToDeposit, - ) + const expectedShares = + depositor1AmountToDeposit - + feeOnTotal(depositor1AmountToDeposit, entryFeeBasisPoints) await expect(depositTx1).to.changeTokenBalances( stbtc, @@ -543,9 +543,9 @@ describe("stBTC", () => { }) it("depositor 2 should receive shares equal to a deposited amount", async () => { - const expectedShares = await stbtc.previewDeposit( - depositor2AmountToDeposit, - ) + const expectedShares = + depositor2AmountToDeposit - + feeOnTotal(depositor2AmountToDeposit, entryFeeBasisPoints) await expect(depositTx2).to.changeTokenBalances( stbtc, @@ -733,7 +733,9 @@ describe("stBTC", () => { before(async () => { minimumDepositAmount = await stbtc.minimumDepositAmount() - const shares = await stbtc.previewDeposit(minimumDepositAmount) + const shares = + minimumDepositAmount - + feeOnTotal(minimumDepositAmount, entryFeeBasisPoints) sharesToMint = shares - 1n await tbtc @@ -773,7 +775,8 @@ describe("stBTC", () => { await tbtc .connect(depositor1) .approve(await stbtc.getAddress(), amountToDeposit) - shares = await stbtc.previewDeposit(amountToDeposit) + shares = + amountToDeposit - feeOnTotal(amountToDeposit, entryFeeBasisPoints) await stbtc .connect(depositor1) .deposit(amountToDeposit, depositor1.address) @@ -839,7 +842,6 @@ describe("stBTC", () => { await stbtc .connect(depositor1) .deposit(firstDeposit, depositor1.address) - await stbtc .connect(depositor1) .deposit(secondDeposit, depositor1.address) @@ -905,7 +907,8 @@ describe("stBTC", () => { }) it("should transfer tBTC back to a depositor1", async () => { - const expectedRedeemedAssets = await stbtc.previewRedeem(to1e18(2)) + const expectedRedeemedAssets = + to1e18(2) - feeOnTotal(to1e18(2), exitFeeBasisPoints) await expect(tx).to.changeTokenBalances( tbtc, [depositor1.address], @@ -914,8 +917,7 @@ describe("stBTC", () => { }) it("should transfer redeem fee to Treasury", async () => { - const assetsToWithdraw = await stbtc.previewRedeem(to1e18(2)) - const redeemFee = feeOnRaw(assetsToWithdraw, exitFeeBasisPoints) + const redeemFee = feeOnTotal(to1e18(2), exitFeeBasisPoints) await expect(tx).to.changeTokenBalances( tbtc, [treasury.address], @@ -925,8 +927,8 @@ describe("stBTC", () => { it("should decrease the deposit balance tracking in Mezo Allocator", async () => { const depositBalance = await mezoAllocator.depositBalance() - const assetsToWithdraw = await stbtc.previewRedeem(to1e18(2)) - const redeemFee = feeOnRaw(assetsToWithdraw, exitFeeBasisPoints) + const redeemFee = feeOnTotal(to1e18(2), exitFeeBasisPoints) + const assetsToWithdraw = to1e18(2) - redeemFee const assetsToWithdrawFromMezo = assetsToWithdraw + redeemFee expect(depositBalance).to.be.eq( @@ -935,7 +937,8 @@ describe("stBTC", () => { }) it("should emit Withdraw event", async () => { - const assetsToWithdraw = await stbtc.previewRedeem(to1e18(2)) + const assetsToWithdraw = + to1e18(2) - feeOnTotal(to1e18(2), exitFeeBasisPoints) await expect(tx) .to.emit(stbtc, "Withdraw") @@ -1374,7 +1377,25 @@ describe("stBTC", () => { }) it("should emit Withdraw event", async () => { - const sharesToBurn = (await stbtc.previewWithdraw(to1e18(2))) + 1n // adjust for rounding + // total assets and total supply right after the deposit of 3 tBTC, before + // the yield and withdrawal. This also includes the entry fee. + // totalAssets() -> 2998500749625187406n + // totalSupply() -> 2998500749625187406n + + // Donate 1 tBTC to stBTC. + // totalAssets() -> 3998500749625187406n (tBTC) + // totalSupply() -> 2998500749625187406n (stBTC) + + // Withdraw 2 tBTC. + // sharesToBurnWithNoFees = 2tBTC * totalAssets() / totalSupply() + // sharesToBurnWithNoFees = 2tBTC * 3998500749625187406n / 2998500749625187406n + const sharesToBurnWithNoFees = 1499812523434570678n + + // Add the withdrawal fee. Adjust for rounding. + const sharesToBurn = + sharesToBurnWithNoFees + + feeOnRaw(sharesToBurnWithNoFees, exitFeeBasisPoints) + + 1n await expect(tx) .to.emit(stbtc, "Withdraw")