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, + ) + }) }) })