Guardian's review of AMM, Round 2 for Baseline Markets, published December 2025. The report records 47 findings across 2 review rounds, including 4 critical and 14 high.
- Published
- Review window
- October 20 to December 23, 2025
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Blast, Base
- Sector
- Token launches
- 4 Critical
- 14 High
- 8 Medium
- 13 Low
- 8 Informational
Scope
Findings 47
Main Review
45 findings · October 20 to November 10, 2025-
C-01 Critical Leverage Mistakes Debt As Yield Logical Error Partially resolved
Description
When a pool is allocated to a vault, the harvestable yield is calculated as the amount in excess of a tracked watermark. In the
getHarvestableYieldfunction this watermark is defined aspool.totalReserves - credit.totalDebt + unclaimedFeesand is compared to the value of the systems shares of the vault token.However in the leverage function, the
totalDebtand pooltotalReservesare updated before the vault shares are redeemed and deducted from thepool.shareBalance. And theBStaking.depositfunction, which updates the harvestable yield is called after thetotalDebtupdate and before theshareBalancededuction. As a result, thetotalDebtthat was just incurred appears like an immediate yield, since theshareBalancedoes not decrease, but the watermark does.This inflates the yield generated by the vault significantly when a leverage action occurs where the debt borrowed is greater than the collateral purchase cost, in other words, when user’s receive reserves out of the leverage which would be removed from the vault.
Furthermore, when the
reserveDeltawould be positive during the leverage flow, e.g. when the user must pay net reserves in. The yield can be accidentally decreased because the current worth of the vault is compared to the watermark before depositing the new reserves from the user to receive additional vault shares.Recommendation
Perform the
BStakingdeposit and lock on behalf of the user at the end of the leverage flow, after the vault has been deposited into or withdrawn from. This way the yield calculation reflects the final current state of the system. -
C-02 Critical Incorrect Rounding Allows Price Collapse Rounding Resolved
Description
In the
quoteReservesForTokensOutfunction the protocol is careful to round against the user and in the favor of the AMM to maximize the amount of reserves collected from the user.However when denormalizing the amount of reserves to actually collect from the user the
denormalizeWadfunction is used which has no rounding logic and instead always rounds down. This means that for pools where the reserve token has less than 18 decimals a non-trivial amount of reserves were computed as having come from the user in the quote logic, but that are not actually supplied by the user.This is especially bad because the calculation of pAvg will use this dust size trade execution as the average execution price to determine the safe price for a sell action. The safe price may then be far lower than the actual current pool price, and subsequently becomes the new pool price after the sell execution. Now users are able to buy BTokens using the full liquidity of the pool at this much lower manipulated price.
Consider the following case:
- BTokens have 18 decimals, reserves have 6 decimals
- Trader A executes a swap where they buy 0.01e18 BTokens for a normalized 1.9e12 reserves in
- The 1.9e12 reserves are denormalized to 1 wei of reserves
- The quoted execution rate was 1.9e12 / 0.01e18 = 0.00019
- The actual execution rate was 1e12 / 0.01e18 BTokens reserves = 0.0001
- The actual rate was almost 50% cheaper than the quoted execution rate, this now becomes the new regime’s average price
- The attcker triggers a small sell, the pAvg calculated for the safe bid price calculation uses this average price which was only experienced for dust amounts, this now becomes the active price of the pool
- Anyone can now buy BTokens at roughly half off because the pool price has been assigned to this much lower “safe price” that was assigned
Recommendation
Introduce directional rounding for normalization and denormalization and be sure to round against the user and in the favor of the protocol in all normalization and denormalization related activities.
-
C-03 Critical Arbitrage Guards Bypassed Gaming Acknowledged
Description
Arbitrage guards such as the
snapshotCirculatingandsnapshotReserveshave been introduced to avoid allowing any net positive gain swaps for malicious actors.The arbitrage guard is enforced in the
computeSellTokensfunction when themaker.accumulatedDeltais negative, indicating a current buy regime.However in the
_updateArbGuardfunction the accumulatedDelta is reset to the new_tokenDeltaimmediately whenever an action is a reversal of the current trend:bool isReversal = (accumulatedDelta > 0 && _tokenDelta < 0) || (accumulatedDelta < 0 && _tokenDelta > 0);However this means that a malicious actor can easily avoid the arb guard after buying tokens buy initiating a small sell to reset the
_maker.accumulatedDeltavalue to a positive one, thus avoiding thegetSafePriceBidconstraint in thecomputeSellTokensorcomputeSellReservesfunctions.This allows for net gain arbs as shown in the attached PoC.
Recommendation
Consider updating the isReversal criteria to only reset the snapshots when the net volume switches sides from net buy to net sell or vice versa.
int256 nextAccumulatedDelta = accumulatedDelta + _tokenDelta; // Boolean to check if trade is adding to the trend or reversing it bool isReversal = (accumulatedDelta > 0 && nextAccumulatedDelta < 0) || (accumulatedDelta < 0 && nextAccumulatedDelta > 0);And always updating the
accumulatedDeltato simply reflect whether the pool is in a net buy or net sell. -
C-04 Critical Withdrawing collateral without repaying debt Logical Error Resolved
Description
Upon creating a pool,
initialCollateralandinitialDebtare assigned to theRelayaddress in the credit system. These amounts are to be claimed by users provided in the merkle root configuration. As users claim, both the collateral and the debt is transferred to them from the Relay credit account and the amount is deposited to their staking account.BStaking(address(this)).deposit(_bToken, msg.sender, _collateral); // update the proxy credit account credit.accounts[address(this)].collateral -= _collateral; credit.accounts[address(this)].debt -= _debt; // update the user's credit account credit.accounts[msg.sender].collateral += _collateral; credit.accounts[msg.sender].debt += _debt;They should repay their debt afterwards and unlock their collateral. However, the collateral was never locked to begin with. This lets all of the users eligible for a claim withdraw their collateral via
BStake.withdraw()and leave the debt unpaid. Furthermore, honest users which try to repay their debt first will have their transactions reverted becauserepay()will try to unlock collateral that has never been locked.Recommendation
Lock the user collateral after depositing it to the stake in
BCredit.claimCredit():BStaking(address(this)).deposit(_bToken, msg.sender, _collateral); + State.staking(_bToken).lockCollateral(msg.sender, _collateral); -
H-01 High pnlOffset Uses Incorrect Decimals Logical Error Acknowledged
Description
In the
_updateAskfunction theinventoryOffsetandpnlOffsetserve as two distinct caps on theoffsetSupply. The inventoryOffset is derived from the_params.poolReservesand_params.poolTokensvalues which have been normalized to 18 decimals.However the
pnlOffsetrelies on thepool.totalBTokensvalue which has not been normalized to 18 decimals. Thus thepnlOffsetis made inconsequential when the BToken has less than 18 decimals, and perturbs the rest of theoffsetSupplycapping when the BToken has greater than 18 decimals.Recommendation
Use the normalized
params.poolTokensinstead of the unnormalizedpool.totalBTokensto compute thepnlOffsetvalue. -
H-02 High Missing Harvest DoS’s Buys DoS Resolved
Description
In the
exitVaultfunction thepool.totalReservesvalue is re-assigned to account for the redeemed amount from the vault after pulling all funds less the unclaimed fees value.However, the unclaimed fees has not been increased by the pending yield amount if any has been accrued, as a result the
totalReservescan unexpectedly increase as a result of exiting the vault.This unexpected increase, where the yield incorrectly goes straight to the pool reserves rather than the fee collectors and stakers, leads to a DoS on token buys in the
getSafePriceAskfunction because the poolReserves amount is unexpectedly larger than thesnapshotReservesamount, leading to an underflow DoS.It should never be the case that in a regime of selling the
snapshotReservesare less than thepoolReserves, and this lacking harvest violates that invariant.Recommendation
Harvest the pending yield before pulling out of the vault in the
exitVaultfunction. -
H-03 High Exit Perturbs Pool Reserves By Missing Sweep Logical Error Resolved
Description
In the
exitVaultfunction there is no logic to sweep the tokens that may be sitting within thepoolManagercontract instead of the vault. This perturbs the_pool.totalReservesassignment that occurs in theexitVaultfunction because these token amounts are not included in the redeemed value, thus making the pool appear as if it has much less reserves than it actually does.Recommendation
Enforce a sweep at the very beginning of the
exitVaultfunction. -
H-04 High Sweep DoS DoS Resolved
Description
The sweep action may revert in cases where dust is held in the poolManager contract that results in 0 shares being awarded to the pool upon depositing into the underlying vault.
Most vault implementations simply revert when zero shares are awarded, thus blocking actions like swaps and vault updates when there are dust amounts to be claimed from the poolManager.
A malicious actor could intentionally perform tiny swaps through the router in order to continuously DoS swaps and vault updates for any pool that has both a Uniswap hook and a vault configured.
Recommendation
If the sweep would result in zero vault shares being awarded to the system, consider leaving the dust tokens in the pool manager.
-
H-05 High Xmin Slippage Can Be Bypassed Logical Error Acknowledged
Description
In the BToken buy flow, in the
_recordSwapfunction the_updateBenchmarkfunction will reset theoffsetSupplyto zero when the book value increases and the poolTokens decreases.This will often occur on a BToken buy, since the book value is represented as the reserves in the pool (increases with a BToken buy) divided by the circulating supply (increases with a BToken buy) and thus any buy that occurs above book price increases the book price. Furthermore, the poolTokens will always decrease on a BToken buy, qualifying it for this case.
When the
_bTokenDeltais negative, as is the case for BToken buys, the_updateBidfunction is used which does not re-assign theoffsetSupply. Therefore the buy leaves the offsetSupply at 0, no matter how large the offset was prior.This means that buyers can significantly improve the execution for their own buys and all following buys by first triggering this case before performing subsequent buys. This way the natural AMM dynamics of the Xmin value are effectively bypassed at the expense of the protocol.
Recommendation
Do not re-assign the
offsetSupplyto zero in the_updateBenchmarkfunction and consider computing and assigning what it ought to be in the_updateBidfunction. -
H-06 High
defaultSelf()issues Logical Error ResolvedDescription
BCredit.defaultSelf()is a function that anyone to exit their credit position - losing their collateral and having their debt forgiven. There are a few issues with the function.First, its selector is not exposed in the
ROUTES()function so at the moment it cannot be called on theRelay.Second, it executes
credit.totalDebt -= debt, which meansdebtamount of the reserves are leaving the system forever andcollateralamount ofbTokenare incoming, thereforepool.totalReservesshould be decreased andpool.totalBTokensincreased. This is not happening in the current implementation which will break the tokens accounting in the system as more and more assets are being moved. One example of where it creates a problem is when the harvestable yield is calculated.uint256 assetsWithYield = getAllocatedReserves(pool); uint256 holdings = pool.totalReserves - State.credit(_bToken).totalDebt + FeeLib.getUnclaimedFees(_bToken);The
totalDebtis subtracted fromtotalReserves, so when the debt is cleared,holdingswill experience an increase in its value, whileassetsWithYieldstays the same. In contrast, ifrepay()happened instead ofdefaultSelf(), bothholdingsandassetsWithYieldwould have increased.Third, the user collateral is reduced to 0 and is not unlocked. While this ensures they cannot withdraw it, their staking position remains active and keeps accumulating yield, resulting in permanently inaccessible rewards at the expense of other participants in the system.
Fourth, in practice, this function allows users to perform
sellTokensbypassingBSwap.sellTokens(), but at exactlyBVLprice, which means they lose the premium. This behavior is dangerous because it performs swap, but doesn't update the curve, nor it applies any other restrictions.Recommendation
If the function is not needed, consider removing it. Otherwise, fix the issues from the report and consider adding the
permissionedmodifier to reduce the risk of having unauthorized swaps. -
H-07 High Yield can be manipulated Gaming Resolved
Description
VaultLib.getHarvestableYield()subtracts theholdingsfrom theassetsWithYieldto calculate the harvestable yield.uint256 assetsWithYield = getAllocatedReserves(pool); uint256 holdings = pool.totalReserves - State.credit(_bToken).totalDebt + FeeLib.getUnclaimedFees(_bToken); return int256(assetsWithYield) - int256(holdings) - 1;The
assetsWithYieldvalue stores the notional deposited value to the vault plus any yield earned.Direct buys through the UniswapV4 pool update
pool.totalReserves, but because the assets are not yet in the system, they are not deposited to the vault. This causes unilateral increase in the above expression and results in less harvestable yield reported. This can be abused by a malicious user in the following way:- poolReserves = 1000
- depositedInVault = 1000
- vault generates 500 yield over a period of time
- Alice sees this and performs a Uniswap buy for 500.
- poolReserves = 1500
- depositedInVault = 1000
- assetsWithYield = 1500
When yield is calculated, the result will be
1500 - 1500 = 0. Alice would then be able to enter the vault and earn the past yield at the expense of the real stakers.Recommendation
Consider performing a sweep at the beginning of
BStaking._sync(). This way any outstanding reserves will be deposited to the vault and the expression will increase bilaterally. -
H-08 High Vault Rounding Perturbs Accounting Rounding Resolved
Description
In the BSwap component, when a vault is attached to a pool the resulting value of the vault shares is not considered when updating the pool’s reserves or the fee distribution amounts.
This creates a scenario where the Mercury system thinks it has received X assets to allocate in it’s accounting in the totalBTokens and totalReserves variables, when at the end of the vault deposit action it only has e.g. x - 100 value of assets to allocate.
In particular this causes reverts when performing a sell or deleverage of any size after an
emergencyExitor vault update has occurred, because the rounding error is realized, almost always making the pool.reserves less than the snapshot reserves, which leads to an underflow revert in thegetSafePriceBidfunction because the_params.poolReservesis less than the_maker.snapshotReserves.This can also cause the BLV invariant to be effectively breached when the system thinks that it holds more reserves than it’s backing vault shares are actually worth.
The first attached POC shows that precision loss on the order of hundreds of wei is easily possible.
The second attached POC shows the DoS that occurs when this rounding is realized by the system.
Recommendation
Instead of assuming that the provided user tokens value is what the system receives, for pools that have an attached vault consider doing the deposit up-front or previewing the results of the deposit and performing the swap accounting based on the min(userProvidedReserves, vaultSharesValueInReserves). This way the Mercury system will not over-allocate value which it ultimately does not receive after a vault deposit due to rounding precision loss.
-
H-09 High Swap reentrancy enables yield manipulation Reentrancy Resolved
Description
VaultLib.takeReserves()first callsNativeLib.handleIncoming()and after that -depositToVault(). There is a native token refund logic inhandleIncomingthat allows themsg.senderto reenter the system before the assets are deposited to the vault.A user can send a small surplus of native token to
BSwap.buyTokens(), hijack the execution flow and reenter theBStakecomponent to manipulate the yield.VaultLib.getHarvestableYield()subtracts the holdings from the assetsWithYield to calculate the harvestable yield.uint256 assetsWithYield = getAllocatedReserves(pool); uint256 holdings = pool.totalReserves - State.credit(_bToken).totalDebt + FeeLib.getUnclaimedFees(_bToken); return int256(assetsWithYield) - int256(holdings) - 1;The
assetsWithYieldvalue stores the notional deposited value to the vault plus any yield earned. SincetakeReserves()is the last step executed during swaps, when the user reenters,pool.totalReserveswill have increased, butassetsWithYieldwill stay unchanged becausedepositToVaultis not executed yet.This can be abused by a malicious user in the following way:
- poolReserves = 1000
- depositedInVault = 1000
- vault generates 500 yield over a period of time
- Alice sees this and calls
buyTokens(), spending 500 reserves, but sending 500 + 1 wei - She inserts her own stake, which causes
_sync()to be called - poolReserves = 1500
- depositedInVault = 1000
- assetsWithYield = 1500
When yield is calculated, the result will be
1500 - 1500 = 0. Alice will now earn part of the previous yield at the expense of the legitimate stakers.Recommendation
Consider reworking the refunding mechanism to execute the funds transfer at the very end of each flow. You can implement a flash accounting feature like UniswapV4 and clear it at the end of swaps, etc...
-
H-10 High Leverage call is vulnerable to MEV sandwiches MEV Acknowledged
Description
The
BCredit._leverage()function calculatesborrowedamount from_totalCollateraland then buys the difference between_totalCollateraland_stakeToUsefrom the pool.(uint256 totalCost, ) = BSwap(address(this)).buyTokens( _bToken, _targetCollateral - _stakeToUse, borrowed + _maxReservesIn );The last argument passed to
buyTokens, themaxReservesIntells the pool how many tokens are we willing to pay. It's hardcoded toborrowed + maxReserveIn.This is unnecessary high for small leverages. For example if
blvPrice = 1and user callsleverage()with_totalCollateral = 21kand_stakeToUse = 20k, the system would be willing to use up21kreserves to buy only1kbTokens, which creates a MEV opportunity at the expense of the user.Recommendation
Consider leaving only the
_maxReservesInparameter as a slippage protection. -
H-11 High Inflation caused by claimed credits Logical Error Resolved
Description
When a pool is created via
BFactory.createPool(), theinitialCollateralandinitialDebtare added topool.totalBTokensandpool.totalReserves.Later, the credit is claimed by the users and the
bTokensare being deposited to their staking account. However,pool.totalBTokensstays unchanged, even though the tokens should technically not be counted as available liquidity. The problem arises when users start repaying their debts and start withdrawing the staked bTokens. Thepool.totalBtokensvariable will not account for this, which will lead to wrong pricing of the tokens. For example, when users buy tokens, the following formula determines how much reserves they should pay.Δy = Pt × Δx × (L₀/L₁)The term
L₀/L₁should adjust the price based on what part of the available supply is being traded. Becausepool.totalBTokensincludes assets not available in the pool, users would be able to buy tokens at a cheaper price.Also,
_getCirculating()will return a deflated value, which results in wrong results for all calculations depending on it, includingbookValue.This can also let users utilize the whole pool, because the system think it still holds the claimed tokens, and break the asymptote of
Xmin.Recommendation
Best solution would be to rework the claim system in a way that it's decoupled from the pool tokens.
-
H-12 High Slippage Affected By Circulating Supply Gaming Resolved
Description
In the formula for the bid side liquidity curve, the circulating supply plays a large role in determining the
bufferConvexityand thus the resulting slippage on trade execution.This is however unrelated to the convexity of the ask side curve and is arbitrary based upon the total supply of the token which becomes a BToken. For any token that is made into a BToken from a pre-existing token this will be a significant issue.
For any token with a large totalSupply and large circulating supply, the slippage approaches zero on the bid curve, allowing for trivial buy then sell arbs which are not prevented by the arb guard.
For the execution result on sells we have figure 1:
Where B, the bufferConvexity is a result of figure 2:
As circulating grows, B becomes increasingly smaller, meaning the denominator in the overall execution equation approaches one. This trends towards an execution of simply the active price, and trends towards zero slippage.
PoC demonstration:
User starts with 942696430814026941867726 (9.42) Reserves & 0 BTokens 0. Buy 773190501915385216937475 (7.7e23) reserves for 413054796 (4.13e8 BTokens) 1. Sell 108677438 (1e8) BTokens for 203430727519948932490009 (2.03e23) Reserves 2. Buy 4233671906931141026299 (4e21) reserves for 2235659 (2.23e6) BTokens 3. Sell 40 bTokens for 75715769641563155 (7.5e16) reserves 4. Buy 208317289973047920252490 (2.08e23) for 85869309 (8.58e7) BTokens The issue appears very clearly in the 4th step in this series of actions, but happens even in smaller magnitudes in previous steps. 3. Sell 40 BTokens for 7.5e16 reserves Execution price = 75715769641563155 / 40 = 1892894241039078 (1.89e15) Reserve / BToken We simulate the user selling their entire BToken balance back to reserves, their simulated total reserve balance is: 951482973394659285126224 (9.51 Reserves) 4. Buy 15e7 BTokens for 4.36e23 reserves Execution price = 436247359521852589227386 / 150017250 = 2907981312294770 (2.9e15) Reserve / BToken User BToken Balance 255926579 (25.5e7) We simulate the user selling their entire BToken balance back to reserves, their simulated total reserve balance is: 744229277605024711208198 (7.4e23) reserves Simulated execution = 744229277605024711208198 / 255926579 = 2907979626473359 (2.9e15) Reserve / BToken Notice! That the execution of this simulated (quoted) sell of the user's 25e7 BTokens produces nearly zero slippage relative to the execution price of step 4. This means the arb guard cannot protect against this action with the way that the arb guard is currently being applied (sets the starting price, does not control net execuiton price). So we push the price up with action 4, and then the simulated sell has _no_ slippage, so it's always an immediate arb. To understand the reason there is _no_ slippage on the bid side curve, let's have a look in the calculation of the quote result. Inside quoteReservesForTokensIn: console::log("_params.activePrice", 291673390191713469080108 [2.916e23]) console::log("tokensIn", 255926579000000000000 [2.559e20] console::log("BLV Price: ", 5080225833270765 [5.08e15]) console::log("bookPrice", 537935974247305203 [5.379e17]) console::log("curvePremium: ", 291672976511886445648115 [2.916e23]) console::log("bufferCoefficient", 5473767584 [5.473e9] console::log("Reserves out: ", 74646868365599268927602776 [7.464e25]) We have the formula figure 1 above which represents the quote result. The key here as we can see is that the bufferCoefficient is very small: B = 5473767584 This is a 1e18 decimal thing, the reason it's so small is because the circulating supply is large in this example. console::log("_params.totalSupply", 100000000011294742780000000000000 [1e32]) (In the trillions of BTokens) console::log("_params.poolTokens", 450008156000000000000 [4.5e20]) Notice that BTokens have 6 decimals so these are scaled amounts. Examine the bufferCoefficient calculation: P - Pblv = 291673390191713469080108 - 5080225833270765 = 291673385111487635809343 Pbook - Pblv = 537935974247305203 - 5080225833270765 = 532855748414034438 => bufferCoefficient = ((291673385111487635809343 * 1e18 / 532855748414034438) - 1e18) / 100000000011294742780000000000000 = (547377758388850847567689 - 1e18) * 1e18 / 100000000011294742780000000000000 = 5473767584 (round up) This result is small because the totalSupply is simply very large. This is an extreme example which shows a very large arbitrage percentage wise that demonstrates this behavior in the AMM. Such a scenario as shown explicitly in this PoC is reflective of any BToken that previously existed outside of the AMM before being added, if a significant portion of it's totalSupply will start outside the AMM. However even on typical BTokens created within the protocol, a more realistic example can be crafted which still uses the fact that the Arb guard cannot protect against the overall execution of the sell here.Recommendation
The idea of the sell side execution formula is that the execution approaches the book price as the entire circulating supply is sold. The convexity with which it does so however needs to be much more steep to account for the fact that the slippage on the ask side can often be much less than the bid side when circulating supply is large.
However in having a higher slippage on the bid side and lower slippage on the ask side this would introduce a sell-then-buy-arb.
There is a fundamental fact that with two shifting curves that have disagreeing convexities there will always be arbitrage (not MEV) opportunities which take reserves from the pool, reducing book value.
-
H-13 High Arb Guard Does Not Effectively Protect Execution Gaming Acknowledged
Description
The Arb guard attempts to protect against trade arbs by assigning the starting price for a trade, however this is ultimately ineffective at restricting arbs since it is not the starting price of a trade which makes an arb, it is the overall execution.
When one curve has notably less slippage than the other, for example as described in H-13, then the arb guard ineffectively prevents the arbitrage from occurring. Since a much larger volume of tokens could be sold with very little execution slippage relative to the “safe price”.
This allows for arbitrages of a high magnitude to still occur in the system.
Recommendation
Some combination of solving the convexity differences between the two underlying curves and re-architecting the arb guard’s method of restriction can address this. However the solution may entirely perturb the goal of the AMM in the first place and make for a significantly sub-optimal trading experience.
-
M-01 Medium Unused Benchmark Offset Logical Error Acknowledged
Description
In the
_updateAskfunction themaker.offsetSupplyis updated based on theinventoryOffsetandpnlOffset. TheoffsetSupplyis what defines the slippage that will be applied on the ask side, however it does not consider thebenchmarkOffseteven though it is calculated.As a result the
benchmarkOffsetdoes not affect theoffsetSupplyresult and therefore the benchmark tracking does not affect the Swap execution.Recommendation
Include the
benchmarkOffsetas a contributor to theoffsetSupplyor remove it entirely if it is not needed. -
M-02 Medium Incorrect
PnLoffset for short positions Logical Error AcknowledgedDescription
BSwap._getPnlOffsetModifier()uses the current realizedPnLto compute abreakevenPrice. This is the price that would makePnLgo to 0 if it was the true price. The function implements the following early returnif (_params.activePrice > uint256(breakevenPrice)) return 0;While this may work for long positions, where negative PnL will result in higher
breakevenPricecompared to the entry price, it fails for losing shorts. For example:- size = -1
- entryPrice = 100
- pnl = -20
Here,
breakevenPricewill be calculated as 80, since then the system would make +20 pnl and it would be zeroed out. However, anyactivePricehigher than80would cause the function to return early.In addition to that, the
priceRatiowill always default to the second case.uint256 priceRatio = _params.activePrice > uint256(breakevenPrice) ? _params.activePrice.divWad(uint256(breakevenPrice)) : uint256(breakevenPrice).divWad(_params.activePrice);Recommendation
Invert the early return statement for short positions.
-
M-03 Medium Vault interactions don't check its limits Compatibility Acknowledged
Description
Pools in the system can be associated with a vault where the pool assets are deposited to and withdrawn from. However, the standard ERC4626 functions
maxDeposit()andmaxWithdraw()are not checked.In the event of the protocol trying to deposit or withdraw more than possible, the transaction will be reverted. Both sweeping and transferring assets depend on the deposit logic which creates a risk of DOS-ing important user flows.
Recommendation
Consider implementing a fallback for not executing deposits if
maxDeposit()doesn't allow it. In that case, you will also have to account for that sum when calculating the harvestable yield and introduce a way to deposit it to the vault at a later point in time.If
maxWithdraw()doesn't allow withdrawals, assets will be trapped so there is nothing that can be done except waiting for the restriction to be removed. Note that in this case exiting the vault may also not be possible. You can consider pausing the system in such cases. -
M-04 Medium Pool creator can steal reserves from the pool Reentrancy Resolved
Description
BController.claimPoolFees()first resets the creator and protocol fee variables to 0 and then executesgiveReserves()for each of them in order to pay the funds to the recipients.// Reset the creator claimable pool.creatorClaimable = 0; pool.protocolClaimable = 0; // Transfer the fees to creator and protocol pool.giveReserves(pool.creator, creatorFees, _asNative); pool.giveReserves(meta.protocolFeeRecipient, protocolFees, true);Each of the
giveReservescalls performs a transfer. If the pool reserve is the wrapped token for the specific chain andisNative = truewas used, the recipient will be sent native tokens.The creator of the pool can benefit from the fact that both
creatorClaimableandprotocolClaimableare reset to 0, but onlycreatorClaimablehas been withdrawn from the vault. This will decrease the relative value of theholdingsvariable inVaultLib.getHarvestableYieldcompared to theassetsWithYield, andgetHarvestableYield()will return inflated yield, which will be redistributed back to the creator and the rest of the fee recipients. The creator can repeat this as many times as possible until theprotocolClaimablevariable is of meaningful amount. Note that this attack increases the risk of insolvency and may hinder important components of the AMM system - like the arbitrage guard, because the fees are paid of the reserves, buttotalReservesis not changed.Recommendation
The simplest solution would be to always send the wrapped version to the creator. This way you entirely eliminate the reentrancy vector, unless the wrapped token has hooks implemented.
Otherwise, you can withdraw the vault assets before the external call and redeposit them again after it.
-
M-05 Medium Yield miscalculation because of
deleverage()Logical Error ResolvedDescription
During
deleverage,credit.totalDebtis first decreased, thenBStaking.liquidate()is called and only after that is the swap performed and the assets withdrawn from the vault.The call to
liquidate()will also execute_sync(). BecausetotalCreditwas decreased, buttotalReservesis still not touched, the reported yield will be deflated. This causes uneven yield distribution and can be used by a malicious users to artificially decrease the harvestable yield, enter the system and start earning from it.Recommendation
Perform the collateral unlock and the liquidation at the very end of the public
deleverage()function. -
M-06 Medium Execution price doesn't account for fees on sell Logical Error Acknowledged
Description
BSwap._updatePosition()is ran after every swap to update thePnLtracking state. It calculates the execution price of the current trade by dividing the amount of reserves by the amount of bTokens.uint256 executionPrice = resWad.divWad(tokWad);Before a buy is executed, fee is applied to the reserves amount that has to be sent by the user. Then
resWadwill represent_reservesIn - fee_, which is the net amount of reserves that enters the pool.However, when selling, the fee is applied after the swap. Then
_updatePosition()is called withuserReservesOut_. Execution price will be computed as if the pool paiduserReservesOut, while the actual amount isuserReservesOut + fee.This will result in incorrect
PnLcalculated and therefore wrongXminapplied to the pool tokens.Recommendation
Account for the fee when selling in
_updatePosition(). -
M-07 Medium Bid inverse pricing asymmetry Logical Error Acknowledged
Description
The inverse function for the bid curve -
quoteTokensForReservesOut()- uses flat price when the convexity of the curve is 0.// Zero-convexity fallback when book price is at or below baseline if (bookPrice <= _params.blvPrice) return _reservesOut.divWadUp(bookPrice); // Zero-convexity fallback when active price is at or below book if (_params.activePrice <= bookPrice) return _reservesOut.divWadUp(bookPrice);The first case, when
bookPrice < blvPrice, ensures tokens are sold ad the lowerbookPrice, because ifblvis used, some users may not be able to sell their tokens, due to the pool not having enoughyBacking. While this violates the property that bTokens can be sold for at leastblvat any point in time, it's better to have that check to avoid insolvency.The second case, when
_params.activePrice <= bookPrice, uses the higherbookPricewhich will result in better trade for the user.In contrast, the non-inverse
quoteReservesForTokensIn()function prices the bTokens atactivePricewhenconvexity == 0. This allows users to extract more value from the pool:- via
sellTokens()compared tosellWithReserves()in case 1 - via
sellWithReserves()compared tosellTokens()in case 2
Note that this issue appears only after selling is enabled when convexity drops to 0.
Recommendation
Consider implementing case 1 in both functions:
if (bookPrice <= _params.blvPrice) return _reservesOut.divWadUp(bookPrice);And using
activePriceinstead ofbookPricein case 2- if (_params.activePrice <= bookPrice) return _reservesOut.divWadUp(bookPrice); + if (_params.activePrice <= bookPrice) return _reservesOut.divWadUp(_params.activePrice); - via
-
M-08 Medium Risk of insolvency due to debt Unexpected Behavior Acknowledged
Description
The debt taken from
BCreditis intentionally not reduced from the pool reserves. Debt given isblv * collateral, but because it's still considered part of the pool, it will contribute for a bigger curve premium during sells. If a lot of sells happen or there is a lot of debt, the pool may become insolvent since the debt is not yet returned.Recommendation
Consider redesigning the credit system or at least have a global maximum amount of debt that can exist.
-
L-01 Low Unnecessary Price Comparison Gas Optimization Resolved
Description
In the
_getPnlOffsetModifierfunction when the_params.activePrice > uint256(breakevenPrice)case holds the function early returns a 0 value.However the following ternary operator uses
_params.activePrice > uint256(breakevenPrice)as the condition. This is unnecessary as thetruecase has already been handled directly above.Recommendation
Assign the
priceRatioasuint256(breakevenPrice).divWad(_params.activePrice)always instead of including the ternary operator. -
L-02 Low
AdminTransferredevent is emitted twice Events ResolvedDescription
The
Relay.acceptAdmin()function:- makes sure
msg.sender == pendingAdmin - calls
_setAdmin(pendingAdmin) - emits
AdminTransferred(msg.sender)
function acceptAdmin() external { require(msg.sender == pendingAdmin, Relay_OnlyPendingAdmin()); // Unset current admin's executor privileges executorExpiry[admin] = 0; // Complete transfer of admin privileges to pending admin _setAdmin(pendingAdmin); // Finally, clear out pending admin pendingAdmin = address(0); emit AdminTransferred(msg.sender); }The internal
_setAdmin(_admin)function also emits theAdminTransferred(_admin)event. Since_admin == msg.sender, the exact same event is emitted twice when ownership is accepted.function _setAdmin(address _admin) internal { admin = _admin; executorExpiry[_admin] = type(uint256).max; emit AdminTransferred(_admin); }Recommendation
Remove the event emission from
acceptAdmin(). - makes sure
-
L-03 Low Initiating ownership transfer should emit event Events Resolved
Description
The
Relaycontract implements two-step ownership transfer by usingtransferAdmin()andacceptAdmin(). CallingtransferAdmin()is an important action since it sets thependingAdminto prepare the contract for its new owner. However, no event is emitted in that function even though it makes sense for a potential owner to listen for such event and callacceptAdmin()when it's emitted.Recommendation
Emit an event when
transferAdmin()is called. -
L-04 Low Component doesn't properly support
ERC165Compatibility ResolvedDescription
The
Componentcontract implements thesupportsInterface()function fromERC165, but it returnstrueonly for its own interface.function supportsInterface(bytes4 _interfaceId) external pure virtual returns (bool) { return type(Component).interfaceId == _interfaceId; }This means external integrators will receive
falsewhen they check the contract forERC165support.Recommendation
Add the
IERC165interfaceId to the supported interfaces. -
L-05 Low claimPoolFees Does Not Sweep Unexpected Behavior Resolved
Description
The
claimPoolFeesfunction does not sweep funds from the Uniswap pool manager before attempting to give reserves to the relevant recipient addresses. As a result the function can unexpectedly fail when balances are pending a sweep.This may even unexpectedly cause the claiming to fail if a swap is executed through the Uniswap V4 router before the
claimPoolFeestransaction is recorded in a block.Recommendation
Consider invoking
SweepLib.sweep(_bToken)at the beginning of theclaimPoolFeesfunction. -
L-06 Low
getBufferConvexity()is not accessible DoS ResolvedDescription
BLens.getBufferConvexity()is external function that computes the buffer convexity by reading storage variables, but its selector is not included in the array returned by theROUTES()function. Because of this, any attempt to call it on the Relay will result in revert -Relay_RouteNotFound(0xac556f9c).Recommendation
Add the function's selector to the
ROUTES()return value. -
L-07 Low Rounding Asymmetry Causes getMaxBorrow Revert DoS Resolved
Description
In the
BCreditmodule, the leverage flow is careful to round the debt amount up for the user, usingmulWadUpto compute thedebtWadvalue in thegetBorrowForCollateralfunction.However in the
getMaxBorrowand_previewBorrowfunction thedebtWadvalue is not rounded up, yet this is also correctly rounding in the protocol’s favor.This is because the
leveragefunction computes a debt value that will be assigned to the user based on thetargetCollateralprovided, thus rounding that debt value up.And the
_previewBorrowfunction computes a newTotalDebt to maxDebt ratio, which determines how much of the account’s total collateral ought to be used for the corresponding debt amount. So the ratio should be maximized, and using maxDebt in the denominator means that maxDebt should therefore be minimized.While both of these methods in leverage and borrow round in the correct manner, they create a disagreement in what the maxLeverage of an account can be, differeing by 1 wei.
The leverage function says that the max leverage can be 1 wei higher than the borrow function because leverage rounds the maxDebt up while the borrow function rounds it down.
The getMaxBorrow function uses the more conservative rounding method of the borrow function, and this ultimately results in a revert when an account has levered to the maximum borrowable as determined by the leverage flow. Because the maxDebt computed conservatively in the getMaxBorrow function is actually 1 wei less than the amount that the account has already borrowed through leverage.
Recommendation
Consider adding the following logic to avoid underflow panic reverts in the
getMaxBorrowfunction in this case:uint256 availableDebt; if (account.debt == maxDebt + 1) { availableDebt = 0; } else { availableDebt = maxDebt - account.debt; } -
L-08 Low Pool can be misconfigured Validation Resolved
Description
BFactory.createPool()allows specifyinginitialCollateralandinitialDebtfor claim which are being added towardstotalReservesandtotalBTokens. However, the credit state is updated only ifinitialCollateral > 0. This allows the creation of a pool withinitialDebtonly. Due to the credit state not being updated, the pool will be misconfigured since its creation becausetotalReserveswill increase, butcredit.totalDebtwon't. The debt cannot be cleared because a merkle root is not set.Recommendation
Either update the state when
initialCollateral > 0 || initialDebt > 0or revert in case ofinitialCollateral == 0 && initialDebt > 0. -
L-09 Low Possible division by 0 Math Acknowledged
Description
The
gaincalculation inBStaking.getAccumulator()divides bytimeToAdapt. There is amax()used and a comment saying iftimeToAdaptis too low, it will round to 1.uint256 gain = FixedPointMathLib.min( ((err * timeElapsed) / uint256(State.meta().timeToAdapt)).max(1), // round up 1 if the timeToAdapt is too small err );However, the
maxfunction is applied after the division is performed, which bounds the end result to 1, not the denominator. In result a configuration oftimeToAdapt = 0will revert during computation ofgain, which may be unexpected if the anticipated behavior is to apply the wholeerrasgain.Recommendation
Apply the
max(1)call to the denominator instead.uint256 gain = FixedPointMathLib.min( - ((err * timeElapsed) / uint256(State.meta().timeToAdapt)).max(1), // round up 1 if the timeToAdapt is too small + ((err * timeElapsed) / uint256(State.meta().timeToAdapt).max(1)), // round up 1 if the timeToAdapt is too small err ); -
L-10 Low Leverage rounds in users favor Rounding Resolved
Description
BCredit.leverage()callsgetBorrowForCollateral()to calculateborrowing, or how much the user should be paid.(uint256 borrowed, uint256 fee) = getBorrowForCollateral(_bToken, _targetCollateral);getBorrowForCollateral()usesmulWadUpto multiply the user collateral by theblvprice. This meansdebtWadwill be rounded up and respectivelydebtandborrowAmountwill be larger values as well.uint256 debtWad = blv.mulWadUp(collateralWad); uint256 debt = NormalizeLib.denormalizeWad(debtWad, rDec); // ceil fee_ = debt.mulWadUp(feeRate); borrowAmount_ = debt - fee_;In result, the system will give the user 1 wei more debt in some cases. While this sounds negligible, it may be an incentive no not return the debt, since performing a
sellToken()may not yield better result.Recommendation
Round
debtWaddown.- uint256 debtWad = blv.mulWadUp(collateralWad); // ceil for obligation + uint256 debtWad = blv.mulWad(collateralWad); -
L-11 Low Bid inverse calculates some values in user favor Rounding Resolved
Description
CurveLib.quoteTokensForReservesOut()computestokensInasnumerator / denominator.uint256 sqrtTerm = sqrtWadUp(base.mulWad(base) + 4 * K.mulWadUp(_reservesOut)); // Numerator: (bufferCoefficient×Δy - P₀) + √(...) uint256 numerator = bufferCoefficientDy >= _params.activePrice ? (bufferCoefficientDy - _params.activePrice + sqrtTerm) : (sqrtTerm - (_params.activePrice - bufferCoefficientDy)); // Solution: Δx = numerator / (2K) // Protocol-favorable: add a 1-ULP cushion to denominator so floor/ceil bias never overstates tokensOut uint256 denom_ = 2 * K; unchecked { denom_ += 1; } tokensIn_ = FixedPointMathLib.fullMulDivUp(WAD, numerator, denom_);However, it rounds
base.mulWad(base)down, which is a part of the numerator and then adds 1 wei towards the denominator. This is rounding in favor of the user and may make the AMM give more bTokens that it should.In addition,
Kwhich is used in the denominator is rounded down, is equal toblv * (active - book) / [(book - blv) * circulating].While the end result is rounded down, the denominator(book - blv) * circulatingis computed withmulWad, which will round it down as well, raising the value ofK.uint256 K = FixedPointMathLib.fullMulDiv( _params.blvPrice, _params.activePrice - bookPrice, (bookPrice - _params.blvPrice).mulWad(circulating) //@audit -> rounds down => K is higher );Recommendation
Round
base * 2up, remove the 1 wei addition towards the denominator and round up the denominator ofK -
L-12 Low Wrong benchmarks due to dynamic total supply Logical Error Resolved
Description
In the
BSwap._updateBenchmark()function, the circulating benchmark tokens are calculated by using the current total supply and the pool tokens from the benchmark.uint256 benchCirculating = _getCirculating( _params.totalSupply, _maker.benchmarkTokens );The function assumes
totalSupplywill stay constant, butBTokenhas a publicburn()function which can reduce it. On top of that, any additional added token viasetDeployer()that has amint()function will also have its total supply increasing.In result, benchmarks will be recorded incorrectly.
Recommendation
Record the totalSupply in the benchmark as well and use it when neeeded.
-
I-01 Informational Labels cause truncation of longer contract names Warning Acknowledged
Description
The helper function
Component.toLabel()takes astringparameter and converts it to abytes32value. The first 32 bytes will be used, while the rest of it will be discarded.function toLabel(string memory _typeName) internal pure returns (bytes32) { return bytes32(bytes(_typeName)); }In result the
LABELfor a component with longer name than 32 bytes won't properly represent that name. This also increases the risk of label collisions.Recommendation
Make sure contracts inheriting from the
Componentare not using names longer than 32 bytes. -
I-02 Informational Native token is not supported Informational Acknowledged
Description
A reserve can be paired together with a BToken to create a UniswapV4 pool. To use native token, the reserve should be set to
address(0). However, Baseline doesn't support this setting. For example,BSwap.initialize()callsreserve.decimals()which will revert if the token isaddress(0).Recommendation
Document that the project doesn't support native tokens.
-
I-03 Informational Swap fails if
hookData.lengthis in the range [1;32) Unexpected Behavior AcknowledgedDescription
BHook._swap()decodes thelimitout of thehookDataspecified if the variable is not empty.if (_hookData.length > 0) { // decode the limit from the hook data limit = abi.decode(_hookData, (uint256)); } else { // if no limit was provided via hook data then set to the max, and handle outside of the hook (in a router) limit = isExactIn ? 0 : type(uint256).max; }This will result in a failure if
hookData.length > 0 && hookData.length < 32. While this protects the user if they pass a wrong limit value, it can be an unexpected behavior.Recommendation
Consider documenting this.
-
I-04 Informational
solveBlvForConvexity()is a misleading name Best Practices AcknowledgedDescription
CurveLib.solveBlvForConvexity()takes a_targetConvexityparameter and returns a newBLVprice. The names of the function and its parameter imply that it will find aBLVprice resulting in the desired buffer convexity. In reality, it accepts abufferPremiumRatioand solvesBLVaccording to it, notbufferConvexity.Recommendation
Rename the function to
solveBlvForPremiumRatio() -
I-05 Informational Incorrect
PnLcomment Best Practices ResolvedDescription
The comment above the
pnlPerTokencalculation inBSwap._getPnlOffsetModifier()says the absolute value of the position size is used, but that's not true.// Breakeven calculation: P_be = P_entry - (PnL / |position|) int256 pnlPerToken = FixedPointMathLib.sDivWad(_maker.realizedPnL, _maker.positionSize); int256 breakevenPrice = int256(_maker.entryPrice) - pnlPerToken;Recommendation
Remove the modulus from the comment.
-
I-06 Informational
borrowWithNative()should be renamed Best Practices ResolvedDescription
The name of the
BCredit.borrowWithNative()function suggest users can use native tokens to borrow reserves, but what really happens is they perform a normal borrow and receive the reserve asset as a native token (if applicable).Recommendation
Rename the function to
borrowNative(). -
I-07 Informational
sync()frequency impacts the error correction Warning AcknowledgedDescription
The correction to be applied on each step when
sync()is called is calculated as the absolute difference betweentargetTpsanddecayedTps.decayedTpsis calculated as:uint256 decayedTps = tokensPerSecond_.mulWad(_decayFactorWad(timeElapsed, timeToDistribute));This is the implementation of the exponential decay. The smaller the
timeElapsed, the biggerdecayedTps. Therefore, assync()is called more frequently,decayedTpswill be a larger value compared to if it was called less often. This impacts the error correction, orgain, in the following way:- less of it is applied if
targetTps > decayedTps - more of it is applied if
decayedTps > targetTps
Recommendation
Document that
sync()frequency impacts how much of a gain is being applied to thetokensPerSecond - less of it is applied if
-
I-08 Informational Console logs should be removed Best Practices Acknowledged
Description
CurveLib.getSafePriceAsk()has numerous calls toconsole2.log(). These increase the gas cost of the transaction and increase the risk of a revert if the network the contract is deployed on doesn't support such logs.Recommendation
Remove the logs before deployment.
Remediation Review
2 findings · December 23, 2025-
H-01 High Fees Perturb Harvestable Yield Logical Error Resolved
Description
In the leverage function, there is no longer any transferring of BTokens or Reserves in or out in the implementation of the function. Users are expected to have already deposited the requisite BTokens into their BStaking balance, and their leveraged assets go directly into their collateral and debt in the BCredit account.
As a result, no funds go into or come out of the vault during the accounting for the leverage flow. This means at the end of the leverage flow, the increase in poolReserves and unclaimedFees are must be offset by the increase of the credit.totalDebt.
This is because the harvest logic calculates the vault reserve holdings of the protocol as follows:
/// @notice Get the amount of assets in the vault the protocol has earmarked function getReserveHoldings(BToken _bToken) internal view returns (uint256) { State.Pool storage pool = State.pool(_bToken); return pool.totalReserves + FeeLib.getUnclaimedFees(_bToken) - pool.idleReserves - State.hook(_bToken).outstandingReserves - State.credit(_bToken).totalDebt; }The leverage function upholds the invariant of not changing the reserve holdings of the vault at the end of it’s execution, this is clearly visible with the following example:
- BToken Price: $1
- BLV: $0.50
- Borrowing fee = 10%
- Leverage: totalCollateral = 10e18, collateralIn = 8e18
- Borrow 5e18 reserves for 10e18 collateral
- Pay 0.5e18 in fees
- 4.5e18 virtually goes into a swap, only 2e18 is actually used in the swap, 2.5e18 is refunded debt
- The final debt increase is 2.5e18
- The poolReserve Increase is 2e18
- The unclaimed fees increase is 0.5e18
- 2e18 + 0.5e18 - 2.5e18 = 0 net change in reserves as tracked by the getReserveHoldings function
This is correct, however the intermediate result which is used to track the vault holdings when the BStaking.deposit function is invoked right after distributing fees but right before incrementing the totalDebt is incorrect.
At the time of invoking BStaking.deposit the accounting looks like this:
- BToken Price: $1
- BLV: $0.50
- Borrowing fee = 10%
- Leverage: totalCollateral = 10e18, collateralIn = 8e18
- Borrow 5e18 reserves for 10e18 collateral
- Pay 0.5e18 in fees
- Unclaimed fees increases by 0.5e18
- totalDebt has not increased at all
- getReserveHoldings reports 0.5e18 more assets in the system than exist, thus perturbing the yield calculation that is synced in deposit
Recommendation
Perform the
distributeFeesinvocation after the collateral is deposited and locked, since the depositing of collateral triggers a sync that relies on the state of theunclaimedFeesaccounting to be in line with thecredit.totalDebtaccounting. -
L-01 Low No Fee Refund Warning Acknowledged
Description
In the leverage function when the amount of borrowed reserves are greater than the amount required to obtain the collateralFromSwap the debt for this delta is forgiven with the debtRefund. However this debtRefund does not also apply to the fee amount, so the user pays the entire fee amount that would have applied to borrowing the full amount.
Recommendation
It may introduce more complexity than it’s worth to try to account for this and refund a portion of the fee, especially with respect to the harvestable yield calculations. Instead, simply be aware of this behavior and consider if it is acceptable for the protocol.
No findings match.
More from Baseline Markets
All 12 reports-
Mercury, Round 3
109 findings4 critical · 9 high 109 findings: 4 critical, 9 high, 28 medium, 33 low, 35 informational -
AMM
54 findings3 critical · 6 high 54 findings: 3 critical, 6 high, 13 medium, 11 low, 21 informational -
Fixed Supply
34 findings4 high 34 findings: 4 high, 10 medium, 20 low -
bToken
8 findings 8 findings: 4 medium, 4 low
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
