Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements to the mintDebt and repayDebt functions in stBTC contract #723

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions solidity/contracts/stBTC.sol
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ contract stBTC is ERC4626Fees, PausableOwnable {
/// Reverts if the dispatcher address is the same.
error SameDispatcher();

/// Reverts if a conversion between shares and assets results in zero, which
/// may happen for small amounts division with rounding.
error ConvertedToZero(uint256 shares, uint256 assets);

/// @notice Emitted when the debt allowance of a debtor is insufficient.
/// @dev Used in the debt minting function.
/// @param debtor Address of the debtor.
Expand Down Expand Up @@ -305,7 +309,7 @@ contract stBTC is ERC4626Fees, PausableOwnable {
uint256 shares,
address receiver
) public whenNotPaused returns (uint256 assets) {
assets = convertToAssets(shares);
assets = previewMintDebt(shares);

// Increase the debt of the debtor.
currentDebt[msg.sender] += assets;
Expand All @@ -326,17 +330,18 @@ contract stBTC is ERC4626Fees, PausableOwnable {

// Mint the shares to the receiver.
super._mint(receiver, shares);

return shares;
}

/// @dev This function proxies `mintDebt` call and provides compatibility
/// with Mezo IReceiptToken interface.
function mintReceipt(address to, uint256 amount) external {
mintDebt(amount, to);
uint256 assets = mintDebt(amount, to);
if (assets == 0) {
revert ConvertedToZero(amount, assets);
}
Comment on lines +338 to +341
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So do I understand it correctly that in mintReceipt the ConvertedToZero error will be triggered when user will request too low amount of stBTC in the context of a given state of contract, right?

And alike - in burnReceipt user can trigger ConvertedToZero by requesting to burn a low amount of asset.

But what if that's all that was minted by this user? They will not be able to burn their stBTC?

}

/// @notice Repay the asset debt, fully of partially with the provided shares.
/// @notice Repay the asset debt, fully or partially with the provided shares.
/// @dev The debt to be repaid is calculated based on the current conversion
/// rate from the shares to assets.
/// @dev The debtor has to approve the transfer of the shares. To determine
Expand All @@ -347,7 +352,7 @@ contract stBTC is ERC4626Fees, PausableOwnable {
function repayDebt(
uint256 shares
) public whenNotPaused returns (uint256 assets) {
assets = convertToAssets(shares);
assets = previewRepayDebt(shares);

// Check the current debt of the debtor.
if (currentDebt[msg.sender] < assets) {
Expand All @@ -368,14 +373,15 @@ contract stBTC is ERC4626Fees, PausableOwnable {

// Burn the shares from the debtor.
super._burn(msg.sender, shares);

return shares;
}

/// @notice This function proxies `repayDebt` call and provides
/// compatibility with Mezo IReceiptToken interface.
function burnReceipt(uint256 amount) external {
repayDebt(amount);
uint256 assets = repayDebt(amount);
if (assets == 0) {
revert ConvertedToZero(amount, assets);
}
}

/// @notice Mints shares to receiver by depositing exactly amount of
Expand Down Expand Up @@ -528,10 +534,18 @@ contract stBTC is ERC4626Fees, PausableOwnable {
return convertToAssets(balanceOf(account));
}

/// @notice Previews the amount of assets that will be burned for the given
/// amount of repaid shares.
/// @notice Previews the amount of assets that will be added to debt when
/// minting the given amount of shares.
/// @dev Rounds the assets up in favor of the vault.
function previewMintDebt(uint256 shares) public view returns (uint256) {
return _convertToAssets(shares, Math.Rounding.Ceil);
}

/// @notice Previews the amount of assets that will be repaid when returning
/// the given amount of shares.
/// @dev Rounds the assets down in favor of the vault.
function previewRepayDebt(uint256 shares) public view returns (uint256) {
return convertToAssets(shares);
return _convertToAssets(shares, Math.Rounding.Floor);
}

/// @return Returns entry fee basis point used in deposits.
Expand Down
14 changes: 14 additions & 0 deletions solidity/contracts/test/stBTCStub.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity 0.8.24;

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

import "../stBTC.sol";

contract stBTCStub is stBTC {
using SafeERC20 for IERC20;

function workaround_transfer(address to, uint256 amount) external {
IERC20(asset()).safeTransfer(to, amount);
}
}
2 changes: 1 addition & 1 deletion solidity/deploy/01_deploy_stbtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => {
const tbtc = await deployments.get("TBTC")

const [, stbtcDeployment] = await helpers.upgrades.deployProxy("stBTC", {
contractName: "stBTC",
contractName: hre.network.name === "hardhat" ? "stBTCStub" : "stBTC",
initializerArgs: [tbtc.address, treasury],
factoryOpts: {
signer: deployerSigner,
Expand Down
148 changes: 128 additions & 20 deletions solidity/test/stBTC.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3141,9 +3141,7 @@
const earnedYield = to1e18(6)

// assets = shares * total assets / total supply
// the -1n comes from convertToAssets implementation in
// ERC4626Upgradeable
const expectedDebt = to1e18(15) - 1n
const expectedDebt = to1e18(15)

before(async () => {
await tbtc.mint(await stbtc.getAddress(), earnedYield)
Expand All @@ -3155,6 +3153,53 @@
expectedDebt,
)
})

describe("when there is loss generated", () => {
beforeAfterSnapshotWrapper()

const lossAmount = to1e18(4)

before(async () => {
await stbtc.workaround_transfer(

Check failure on line 3163 in solidity/test/stBTC.test.ts

View workflow job for this annotation

GitHub Actions / solidity-format

Unsafe call of an `any` typed value
thirdParty.address,
lossAmount,
)
})

describe("for big amounts", () => {
beforeAfterSnapshotWrapper()

// Initial state:
// Deposited assets: 24
// Debt: 0
// Loss: 4
// New mint:
// Shares: 12
// Expected debt: 12 * (24 - 4) / 24 = 10

const sharesAmount = newDebtShares
const expectedDebt = to1e18(10) + 1n

testMintDebt(
() => sharesOwner1.address,
sharesAmount,
expectedDebt,
)
})

describe("for minimum amount", () => {
beforeAfterSnapshotWrapper()

const sharesAmount = 1n
const expectedDebt = 1n

testMintDebt(
() => sharesOwner1.address,
sharesAmount,
expectedDebt,
)
})
})
})
})
})
Expand Down Expand Up @@ -3225,6 +3270,8 @@
let initialTotalDebt: bigint
let initialTotalSupply: bigint
let initialTotalAssets: bigint

let mintDebtResult: bigint
let tx: ContractTransactionResponse

before(async () => {
Expand All @@ -3236,6 +3283,9 @@
initialTotalSupply = await stbtc.totalSupply()
initialTotalAssets = await stbtc.totalAssets()

mintDebtResult = await stbtc
.connect(minter)
.mintDebt.staticCall(newShares, receiverAddress)
tx = await stbtc
.connect(minter)
.mintDebt(newShares, receiverAddress)
Expand Down Expand Up @@ -3287,6 +3337,10 @@
newShares,
)
})

it("should return the expected debt in assets", () => {
expect(mintDebtResult).to.be.eq(expectedNewDebt)
})
})
}
})
Expand Down Expand Up @@ -3466,11 +3520,11 @@

// Initial state:
// Deposited assets: 24
// Debt: 10
// Debt: 12
// Yield: 6
// Repayment:
// Shares: 6
// Expected asset repayment: 6 * (24 + 10 + 6) / (24 + 10) = ~7
// Expected asset repayment: 6 * (24 + 12 + 6) / (24 + 12) = 7

const earnedYield = to1e18(6)

Expand All @@ -3484,6 +3538,45 @@

testRepayDebt(requestedRepayAmount, expectedDebtRepay)
})

describe("when there is loss generated", () => {
beforeAfterSnapshotWrapper()

const lossAmount = to1e18(4)

before(async () => {
await stbtc.workaround_transfer(

Check failure on line 3548 in solidity/test/stBTC.test.ts

View workflow job for this annotation

GitHub Actions / solidity-format

Unsafe call of an `any` typed value
thirdParty.address,
lossAmount,
)
})

describe("for big amounts", () => {
beforeAfterSnapshotWrapper()

// Initial state:
// Deposited assets: 24
// Debt: 12
// Loss: 4
// Repayment:
// Shares: 6
// Expected asset repayment: 6 * (24 + 12 - 4) / (24 + 12) ~= 5.33

const sharesAmount = requestedRepayAmount
const expectedDebtRepay = 5333333333333333333n

testRepayDebt(sharesAmount, expectedDebtRepay)
})

describe("for minimum amount", () => {
beforeAfterSnapshotWrapper()

const sharesAmount = 1n
const expectedDebtRepay = 0n

testRepayDebt(sharesAmount, expectedDebtRepay)
})
})
})
})
})
Expand All @@ -3496,6 +3589,8 @@
let initialTotalDebt: bigint
let initialTotalSupply: bigint
let initialTotalAssets: bigint

let repayDebtResult: bigint
let tx: ContractTransactionResponse

before(async () => {
Expand All @@ -3506,6 +3601,9 @@
initialTotalSupply = await stbtc.totalSupply()
initialTotalAssets = await stbtc.totalAssets()

repayDebtResult = await stbtc
.connect(externalMinter)
.repayDebt.staticCall(repaySharesAmount)
tx = await stbtc
.connect(externalMinter)
.repayDebt(repaySharesAmount)
Expand Down Expand Up @@ -3553,6 +3651,10 @@
repaySharesAmount,
)
})

it("should return the expected debt in assets", () => {
expect(repayDebtResult).to.be.eq(expectedDebtRepayment)
})
}
})
})
Expand Down Expand Up @@ -3582,9 +3684,8 @@

testRoundTrip(
10n,
10n,
{ totalAssets: 10n, totalSupply: 10n },
{ totalAssets: 0n, totalSupply: 0n },
{ totalDebt: 10n, totalAssets: 10n, totalSupply: 10n },
{ totalDebt: 0n, totalAssets: 0n, totalSupply: 0n },
)
})

Expand All @@ -3608,13 +3709,13 @@

testRoundTrip(
10n,
10n,
// totalDebt = 10 * 23 / 23 = 10
// totalAssets = 23 + 10 = 33
// totalSupply = 23 + 10 = 33
{ totalAssets: 33n, totalSupply: 33n },
{ totalDebt: 10n, totalAssets: 33n, totalSupply: 33n },
// totalAssets = 33 - 10 = 23
// totalSupply = 33 - 10 = 23
{ totalAssets: 23n, totalSupply: 23n },
{ totalDebt: 0n, totalAssets: 23n, totalSupply: 23n },
)
})

Expand All @@ -3629,26 +3730,29 @@

testRoundTrip(
10n,
// 10 * (23 + 5) / 23 = 12
12n,
// totalAssets = 23 + 5 + 12 = 40
// totalDebt = 10 * (23 + 5) / 23 = ceil(12,17) = 13
// totalAssets = 23 + 5 + 13 = 41
// totalSupply = 23 + 10 = 33
{ totalAssets: 40n, totalSupply: 33n },
// totalAssets = 40 - 12 = 28
{ totalDebt: 13n, totalAssets: 41n, totalSupply: 33n },
// assets = 10 * 41 / 33 = floor(12,17) = 12
// totalDebt = 1 due to rounding
// totalAssets = 41 - 12 = 29
// totalSupply = 33 - 10 = 23
{ totalAssets: 28n, totalSupply: 23n },
{ totalDebt: 1n, totalAssets: 29n, totalSupply: 23n },
)
})
})

function testRoundTrip(
shares: bigint,
expectedDebtAmount: bigint,

expectedMintDebtResult: {
totalDebt: bigint
totalAssets: bigint
totalSupply: bigint
},
expectedRepayDebtResults: {
totalDebt: bigint
totalAssets: bigint
totalSupply: bigint
},
Expand All @@ -3671,7 +3775,9 @@
})

it("should increase total debt", async () => {
expect(await stbtc.totalDebt()).to.be.eq(expectedDebtAmount)
expect(await stbtc.totalDebt()).to.be.eq(
expectedMintDebtResult.totalDebt,
)
})

it("should increase total assets", async () => {
Expand Down Expand Up @@ -3703,7 +3809,9 @@
})

it("should decrease total debt", async () => {
expect(await stbtc.totalDebt()).to.be.eq(0)
expect(await stbtc.totalDebt()).to.be.eq(
expectedRepayDebtResults.totalDebt,
)
})

it("should decrease total assets", async () => {
Expand Down
Loading