botanix-stBTC
Cantina Security Report
Findings
High Risk
1 findings
1 fixed
0 acknowledged
Low Risk
1 findings
1 fixed
0 acknowledged
Informational
14 findings
9 fixed
5 acknowledged
High Risk1 finding
Unprotected stBTC.notifyRewardAmount allows griefing to DoS significant reward amounts
Severity
- Severity: High
Submitted by
noah.eth
Description
stBTC.notifyRewardAmountis an unprotected function meaning it can be called by anyone, at any time. Within the function there is rounding that occurs when determining the reward rate.rewardRate = rewardBalance / REWARDS_DURATION;rounds down by the amountrewardBalance % REWARDS_DURATION. E.g. in the extreme case a reward amount of 1209599, virtually half the rewards would be excluded from the reward rate(REWARDS_DURATION + REWARDS_DURATION - 1) / REWARDS_DURATION = 1A malicious actor may exploit this scenario by calling
stBTC.notifyRewardAmounteach block. The PoC below shows how 1 BTC worth of rewards is reduced considerably over 7 days by calling every 5 seconds.Proof of Concept
Add to
test/sBTC.t.solfunction testMaliciousNotify() public { // 1. Alice deposits pBTC into the sBTC vault vm.startPrank(alice); uint256 aliceBalanceBefore = pBTC.balanceOf(alice); uint256 depositShares = sbtc.deposit(INITIAL_DEPOSIT, alice); vm.stopPrank(); // 2. Send rewards to FeeReceiver and distribute vm.deal(address(feeTo), 1 ether); feeTo.harvest(); for (uint256 i = 0; i < 7 days; i += 5) { // next block in seconds. skip(5); sbtc.notifyRewardAmount(); } // Move all rewards to stake sbtc.harvest(); assertEq(sbtc.totalAssets(), INITIAL_DEPOSIT + 1 ether, "Total assets include deposits and all emitted rewards");}Recommendation
Limit how often
notifyRewardAmountmay be called and consider adding handling ofrewardBalance % REWARDS_DURATION.Botanix
Fixed in commit fa0411bf039a8c9aa463bdf3f68162aa5243310e
Spearbit
The
minHarvestIntervalremoves the DoS and leaves only minimal rounded amounts to emit later.Moving
lastHarvest = block.timestamp;to L68 would make maintain checks/effects/interactions ordering.For duration, over a 7 day period of emissions, the most delayed amount (by notifying every 2 days) is:
├─ [0] VM::assertEq(100999999999999734400 [1.009e20], 101000000000000000000 [1.01e20], "Total assets include deposits and all emitted rewards") [staticcall] │ └─ ← [Revert] Total assets include deposits and all emitted rewards: 100999999999999734400 != 101000000000000000000 └─ ← [Revert] Total assets include deposits and all emitted rewards: 100999999999999734400 != 101000000000000000000You can wargame scenarios by adding this test to
sBTCTestand modifying the variable forminHarvestInterval.function testMaliciousNotify() public { // 1. Alice deposits pBTC into the sBTC vault vm.startPrank(alice); uint256 aliceBalanceBefore = pBTC.balanceOf(alice); uint256 depositShares = sbtc.deposit(INITIAL_DEPOSIT, alice); vm.stopPrank(); // 2. Send rewards to FeeReceiver and distribute // First, send native tokens to FeeReceiver vm.deal(address(feeTo), 1 ether); feeTo.harvest(); uint256 minHarvestInterval = 2 days; for (uint256 i = 0; i < 7 days; i += minHarvestInterval) { skip(minHarvestInterval); sbtc.notifyRewardAmount(); } // Move all rewards to stake skip(7 days); sbtc.harvest(); assertEq(sbtc.totalAssets(), INITIAL_DEPOSIT + 1 ether, "Total assets include deposits and all emitted rewards"); }
Low Risk1 finding
Permanent locking of native tokens in directDeposit() if msg.value and assets don't match
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: High Submitted by
Sujith S
Description
The
directDeposit()function instBTC.solenables users to make deposits using native tokens rather than the vault asset. Behind the scenes, the function wraps the native tokens sent into pegged bitcoin (the vault asset).However, a problem arises in this function when the user submits an asset parameter that does not match the msg.value amount while having a sufficient allowance for the contract to transfer BTC tokens.
if (msg.value > 0 && msg.value == assets) { /// <--- if the msg.value != assets, then it enters the else case PeggedBitcoin(payable(address(asset()))).deposit{ value: msg.value }();} else { SafeTransferLib.safeTransferFrom(sERC20(asset()), caller, address(this), assets);}The issue occurs in the conditional branch. If msg.value > 0 but msg.value != assets, the function will skip converting the native tokens to pBTC and instead attempt to transfer pBTC tokens from the user. The sent native tokens become trapped in the contract as there's no mechanism to return them.
Proof of Concept
Place the following test in the
sBTC.t.solfile under the/testfolder:function test_lockingOfNativeTokens() public { vm.startPrank(bob); sbtc.directDeposit{value: 1.1 ether}(1 ether ,bob); assert(address(sbtc).balance == 1.1 ether); }The PoC mentioned above clarifies that rather than refunding the native tokens, the function locks them in the
stBTC.solcontract and utilizes the user's pBTC balance to finalize the direct deposit.Recommendation
Consider validating the msg.value against the assets parameter to prevent the function from executing when there's a mismatch:
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { // Update our elastic (non-shares) value to track balance totalStaked += assets; // Perform the underlying deposit if (msg.value > 0) { require(msg.value == assets, "ETH value must match assets"); // If the user sent ETH, deposit it to the asset token PeggedBitcoin(payable(address(asset()))).deposit{ value: msg.value }(); } else { // Otherwise, transfer the assets from the caller to this contract SafeTransferLib.safeTransferFrom(sERC20(asset()), caller, address(this), assets); } _mint(receiver, shares); emit Deposit(caller, receiver, assets, shares);}
Informational14 findings
Native token terminology mismatch
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
stBTC.solcontract is designed to work on a Bitcoin L2 blockchain where the native token is Bitcoin, yet the code comments consistently refer to the native token as "ETH." This inconsistency might confuse developers, auditors, and users engaging with the contract.Recommendation
Update all instances of "ETH" in comments to refer to the native Bitcoin token used on this L2 chain.
Remove unused file imports
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
stBTC.solimports theERC20Upgradeable.solcontract from theopenzeppelin-contracts-upgradeablelibrary, but the file is not referenced or used anywhere in the code.Recommendation
Consider removing the above-mentioned unused file import.
Redundant SafeERC20 library import
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
stBTC.solcontract imports theSafeERC20library from OpenZeppelin and declares its usage through a using statement but never actually utilizes it in the code. Instead, the contract uses Solmate's SafeTransferLib for token transfer operations.import { SafeERC20 } from "openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";....contract stBTC is Initializable, Ownable2StepUpgradeable, ERC4626Upgradeable { .... using SafeERC20 for ERC20; .... SafeTransferLib.safeTransferFrom(sERC20(asset()), caller, address(this), assets);}Recommendation
Remove the redundant import and using statement to improve code clarity.
- import { ERC20 } from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";....- import { SafeERC20 } from "openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";....- using SafeERC20 for ERC20;Botanix
Missing parameter validation in initialize() functions
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
FeeReceiver.solandstBTC.solcontractsinitialize()function lack sanity validations for its input parameters, potentially allowing the contract to be permanently lost with invalid addresses.Recommendation
Consider adding proper input validations in the
initialize()functions as follows:// contract stBTC.sol function initialize(IERC20 _asset, address _initialOwner) external initializer {+ require(address(_asset) != address(0), "invalid asset");+ require(_initialOwner != address(0), "invalid owner"); __Ownable_init(_initialOwner); __ERC20_init("Staked Bitcoin", "stBTC"); __ERC4626_init(_asset);} // contract FeeReceiver.sol function initialize(PeggedBitcoin _pbtc, address _stakingVault) external initializer {+ require(address(_pbtc) != address(0), "invalid _pbtc");+ require(_stakingVault != address(0), "invalid vault"); pBTC = _pbtc; stakingVault = _stakingVault;}Botanix
Move storage gap variable after all state variable declarations
Severity
- Severity: Informational
Submitted by
Sujith S
Description
In upgradeable smart contracts, using a storage gap is a general practice to reserve slots for future variable additions while maintaining compatibility with previous versions.
The
stBTC.solcontract declares a storage gap (uint256[50] private ______gap;) but places it before other state variables (periodFinish, rewardRate, totalStaked, etc.), deviating from the standard implementation of storage gaps, which usually is placed after all state variable declarations.This possibly leads to the following scenario (assuming the first 50 slots are reserved for future variables):
- Placing a variable before the
______gap:
In this case, slot 0 will be allocated to the new variable, and the
_____gapwill be reserved from slots 1 - 49, where 50 will beperiodFinishand so on.- Placing a variable after the
______gap:
In this case, slot 49 will be allocated to the new variable and the
_____gapwill be reserved from slots 0 - 48, where 50 will beperiodFinishand so on.- Placing a variable after the
totalStakedvariable: (with updating_____gapsize to 49)
In this case, storage collision happens, as slot 49 will now be reserved for the
periodFinishvariable instead of 50, and the new variable will be added to slot 56, which is reserved for thetotalStakedvariable.Recommendation
- Move the storage gap to the end of the contract's storage variables to ensure reserved slots are positioned after all current variables.
contract stBTC is Initializable, Ownable2StepUpgradeable, ERC4626Upgradeable { // ... uint256 public periodFinish; uint256 public rewardRate; uint256 public totalStaked; // ... (other variables) // Reserve storage slots for future upgrades uint256[50] private ______gap; // <-- Correct placement at the end}- Consider documenting this behavior if the slot is added before variable declarations. Warn that adding new variables at the end may cause data corruption.
WETH9 Notes
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
noah.eth
Description
pBTC is modified from the WETH9 contract. Key differences include:
- the token name and symbol
- solidity version change
- use of later solidity features such as the
receivefunction, checked math, and explicittype(uint256).max
Using
receiveover the fallback function means theSilent Fallback Methodissue has been eliminated.Checked math does have gas implications but overflows are not anticipated or desired.
Of note, WETH9 behavior is inherited:
totalSupplycan be greater than sum of eachbalanceOfdue toselfdestructor consensus layer balance updates- integration inefficiencies
Recommendation
No edits recommended. Consider documenting for integrators to be aware.
Outdated comment referencing fees
Severity
- Severity: Informational
Submitted by
noah.eth
Description
Recommend updating the comment to remove the reference to fees.
Asymmetric reward growth leads to minting zero shares
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
stBTC.solcontract has an issue with its share calculation logic. This issue causes users to receive zero shares when depositing assets after the contract has accumulated substantial rewards.The
_convertToShares()function in theERC4626Upgradeable.solcontract from OpenZeppelin is used to calculate the number of **shares ** a user will receive after a successful deposit:function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) { return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);}This function works well if the product of
assetsto deposit and thetotalSupply()is greater than thetotalAssets(). But instBTC.sol, thetotalAssets()value can increase over time due to accumulating rewards from the consensus layer without user deposits, leading to the scenario affecting users trying to deposit assets less than thetotalAssets()value.This issue can be mitigated by minting initial shares, but this action alone is insufficient. The issue can surface again:
- If the initial minter redeems his shares and
totalSupply()drops significantly, causing the product oftotalSupply()and assets to deposit to fall belowtotalAssets(). - If the contract rewards are too high, inflating the
totalAssets()will result in minting zero shares for small depositors.
Proof of Concept
The following scenario demonstrates the vulnerability:
- alice stakes 1e18
- fee receiver sends out 2e18 tokens in rewards
- 2 days after fee receiver sends out another 2e18 tokens
- alice withdraws her funds like 2 days after claiming some tokens
- now if a new user deposits any value < totalAssets() in the vault, they receive zero shares (meaning they lose their entire funds)
function test_flow() public { vm.startPrank(alice); sbtc.deposit(2e18, alice); /// 1e18 sent as rewards vm.deal(address(feeTo), 2e18); feeTo.harvest(); vm.warp(block.timestamp + 2 days); vm.deal(address(feeTo), 2e18); feeTo.harvest(); vm.warp(block.timestamp + 1 days); sbtc.redeem(sbtc.balanceOf(alice), alice, alice); vm.warp(block.timestamp + 7 days); console.log("convert to shares:", sbtc.convertToShares(100e18)); vm.startPrank(bob); sbtc.deposit(1e18, bob); }Recommendation
Consider validating the number of shares in the
_deposit()function:function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override {+ require(shares > 0, "insufficient shares"); ....}Mint enough shares upfront and avoid redeeming them to ensure the
totalSupply()value is high enough to support depositing smaller asset values.Incorrect min deposit protection in Invariant Suite
Severity
- Severity: Informational
Submitted by
chris
Description
This block of code is meant to mimic the expected behavior of the deployed code where Botanix will deposit some amount of pBTC to
address(1)in order to protect from the known 4626 minimum share vulnerability. However, this code does not work as expected in the invariant suite becausealiceis assigned toaddress(0x1)meaning thetotalSupplyof the invariant code can be 0.Recommendation
Reassign alice to a different address or use a different address to block full withdraw.
Botanix
Reassigned address in this fix
Cantina
Fix corrects overlapping addresses in invariant tests.
Precision tolerance in Invariant suite is too high
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
chris
Description
The invariant suite tests whether the conversion on a test amount to
sharesand back toassetswill result in a significant deviation between the initial asset amount and the calculated one. However, it allows for a 1% tolerance or 0.01 BTC which does not seem to be economically insignificant and acceptable, especially if the rounding can be replayed to compound the deviation though deposits and withdraws which also rely on the conversion of assets. Further adjusting this tolerance down towards0.00001e18or.0001%starts to surface failures in the invariant tests. For instance, at that tolerance, this sequence is a regression:withdraw(19463556330487544860944996498242426376, 115792089237316195423570985008687907853269984665640564039457584007913129639935); addRewards(102725981152976193958644346911775); addRewards(20); addRewards(429120403199586879185786549240266863405914766211794631360); advanceTime(815313407330125682287561546380472); advanceTime(11297); advanceTime(1); addRewards(1820089340640817); addRewards(154472958145110159646778455653642080347141362005244239732614); harvest(); advanceTime(4626); addRewards(230); advanceTime(86305048976766110354029840054447817439260457736930120094834181449486130216960); advanceTime(429120403199586879185786549240266863405914766211794631358); deposit(475346554570287, 952274666163953119829687520); addRewards(29863952071095354306011138689598935); advanceTime(1630); deposit(3901, 1950811); deposit(5081097062443745093, 6492); deposit(196964582580135832354, 5543); deposit(2299, 775); withdraw(3436740813065974005277814706002808830691299613, 20738117462531946536472336951887299); withdraw(449826859, 4615907); withdraw(10182, 11911205); withdraw(2003, 86); deposit(8250661375661, 16512951785028); deposit(443762893583409954872, 64781614); deposit(23467812620983302875874401741006364806302475982914375617978150223901214869, 119431984859561271468898683018725940709328801266203732955); harvest(); assertSharesValues();Recommendation
To have greater confidence in the rounding across different conditions, reduce the precision of this test and investigate failures.
One way to "tighten" the rounding precision of the protocol is to ensure calculations do not round down more than once. For instance, when performing
notifyRewardAmount's update to therewardRate, the math rounds down once inrewardPerTokenand once inearnedand a third time inrewardRate = rewardBalance / REWARDs_DURATION. This can be solved by "inflating the numbers" toRAYorRADprecision until then need to be stored so in this case the math inrewardPerTokenand inearnedwould be performed at a higher precision and only rounded down toWADprecision when it was being stored inrewardRate._convertToSharesand_convertToAssetshave similar multiple rounds in their math due tototalAssets()being calculated dynamically.Improved invariant testing
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
chris
Description
Currently, reverting does not stop the invariant suite from continuing to run. Further, it uses default
runsanddepthvalues (256 and 500 respectively).Recommendation
Consider adding to following configurations to the
foundry.tomlfile:[invariant]runs = 512depth = 1000fail_on_revert = trueThis could lead to a more robust invariant suite and identify some edge cases that need to be investigated.
Another improvement to the Invariant tests would be to add BTC based bounding to numbers used and possibly to add an invariant that the pBTC and stBTC balances should not exceed the total supply of BTC. This would force the invariant suite to use "real world" numbers and might surface issues (most likely improvements in the test suite).
Incorrect bounding in invariants
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
chris
Description
This is not entirely correct since
pBTC.balanceOf(actor)could be less than0.01 ether. This causesboundto revert withMax is less than min.Recommendation
Adding
fail_on_revertwith forge invariant testing will surface this issue. A quick work around is to slightly alter and move this line to before the bounding occurs.Tests won't run on Linux OS
Severity
- Severity: Informational
Submitted by
chris
Description
There are a couple of case mismatches in the test file imports.
Recommendation
These should be corrected so tests run on Linux as well as Mac OS.
import {console2} from "forge-std/console2.sol";Botanix
Fixed in this commit.
Cantina
Fixed.
Potential Upgrade bug due to inconsistency between stBTC.directDeposit and deposit in 4626
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
chris
Description
The new
directDepositfunction usesmsg.sender, but the 4626 OZ implementation uses_msgSender();.Recommendation
Use
_msgSender()instBTCso upgrades that include changes to the behavior with_msgSenderwill be applied appropriately fordirectDepositas well asdeposit.Botanix
Acknowledged, but
msgSender()should not change so will equalmsg.sender.