Guardian's review of Mercury, Round 3 for Baseline Markets, published May 2026. The report records 109 findings across 6 review rounds, including 4 critical and 9 high.
- Published
- Review window
- January 21 to April 30, 2026
- Rounds
- Main Review, Remediation Review, Remediation Review 2, Remediation Review 3, Remediation Review 4, Remediation Review 5
- Language
- Solidity
- Chains
- Blast, Base
- Sector
- Token launches
- 4 Critical
- 9 High
- 28 Medium
- 33 Low
- 35 Informational
Scope
Findings 109
Main Review
57 findings · January 21 to February 19, 2026-
C-01 Critical Reserve tokens can be drained Logical Error Acknowledged
Description
In
BFactory.createPool()// pool asset accounting pool.totalReserves = (params.initialPoolReserves + params.initialDebt).toUint128(); // only transfer initial reserves from caller. pool.reserve.takeReserves(msg.sender, params.initialPoolReserves);we only collect
initialPoolReservesfrom the creator.and credit the pool with
initialDebtas if it were reserves by adding it intopool.totalReserves.a permissionless pool creator can set
initialDebtarbitrarily large and make the AMM believe the pool has huge reservesThe Sell path uses curve params derived from totalReserves, the curve now believes the pool is backed by a huge amount of reserves
params_.reserves = NormalizeLib.normalizeWad(pool.totalReserves, pool.reserveDecimals);And every place that pays out reserves uses the ERC20 balance held by the Relay, not per pool separated balance
reserve.giveReserves(msg.sender, _deltaUserReserves.toUint256(), false)VaultLib.giveReserves()pulls fromState.Allocation storage allocation = State.meta().allocations[_reserve];An attacker can drain reserve tokens of other pools by selling, as demonstrated in the PoC
a Guardian proof of concept
Recommendation
If initialDebt needs to be reflected in accounting, consider tracking it separately
-
H-01 High Pool fees are lost when claimed Logical Error Acknowledged
Description
In
createPooltheparams.creatorandparams.feeRecipientvalues are validated but never persisted to pool statewe never do
pool.creator = params.creator;and
pool.feeRecipient = params.feeRecipient;so after pool creation,
State.pool(params.bToken).creatorandState.pool(params.bToken).feeRecipientstays at its default valueaddress(0)And because
pool.creatoris never set, the intended creator can’t transfer creator later withtransferCreator()In
_claimPoolFeespool.reserve.giveReserves(pool.creator, creatorFees, false); pool.reserve.giveReserves(meta.protocolFeeRecipient, protocolFees, false);because both values are 0, any fees collected will be irreversibly lost when claiming it
Recommendation
Inside
createPool, store the creator and the fee recipientpool.creator = params.creator; pool.feeRecipient = params.feeRecipient;.
-
H-02 High Vault withdrawals DOS due to solvency check DoS Acknowledged
Description
VaultLib.depositToVaultrecordsdepositedReservesusingpreviewRedeem(shares)), so the tracked assets are rounded down and are equal to the amount of assets owned by the contract.VaultLib.withdrawFromVault()then burns shares viavault.withdraw(_reserveAmount,...), which internally uses previewWithdraw (rounds up). After this burn,_ensureVaultSolvency()checkspreviewRedeem(remainingShares) >= depositedReserves.Because
previewWithdraw()can burn extra shares for the same asset amount, the remaining share balance can preview to less than the tracked reserves even when the vault is solvent. This will result in unexpected DOS cases while normally using the protocol.Example:
- totalAssets=2, totalShares=3.
- deposit 2 assets → receive 3 shares, depositedReserves=2.
- withdraw 1 asset → previewWithdraw(1)=2 shares burned.
- depositedReserves is decreased to 1
- remaining shares=1, previewRedeem(1)=0 while depositedReserves=1, so the check reverts despite no real loss.
Recommendation
Reconsider if this check is really needed. You can also switch to tracking shares and comparing them against the real share balance of the protocol.
-
M-01 Medium Configured feeRecipient ignored in payouts Logical Error Acknowledged
Description
The configurable feeRecipient set via setFeeRecipient is never used when distributing creator fees. claimPoolFees/claimPoolFees send creator fees to pool.creator, making FeeRecipientSet ineffective. This misroutes funds compared to configuration intent and prevents creators from delegating revenue to a separate recipient without transferring creator ownership
Recommendation
We need to replace the payout target for creator fees with pool.feeRecipient if set, falling back to pool.creator only if feeRecipient is unset.
-
M-02 Medium Staking rewards can be globally griefed & frozen DoS Acknowledged
Description
This vulnerability allow an attacker to keep the global accumulator from ever increasing,
which means _getEarned() for everyone returns the old
prevEarnedfor very long time,while
pool.pendingYieldcontinues to grow. Then the attacker can stake right before distribution resumes and capture rewards that accrued while they were not staked or Just keep doing the DoSgetAccumulator() can decide not enough yield to distribute
uint256 yield = min(tokensPerSecond_ * timeElapsed, pendingYield); uint256 accumulatorIncrease = yield.fullMulDiv(RAY, totalStaked); // @audit, not enough yield to distribute evenly, return early if (accumulatorIncrease == 0) return (accumulator_, 0, tokensPerSecond_);So if yield * RAY < totalStaked, no accumulator increase happens, and
newYield_ = 0_sync() still updates
lastUpdatedeven when nothing was distributed(accumulator, newYield, tokensPerSecond) = getAccumulator(_bToken); // update the last updated timestamp every time if (staking.lastUpdated != block.timestamp) staking.lastUpdated = block.timestamp.toUint32(); // @audit, return early if user's accumulator up to date if (accumulator == staking.accounts[_user].userAccumulator) return;That means an attacker can keep
timeElapsedtiny forever, because it’s alwaysblock.timestamp - lastUpdated, ensuringyieldstays tiny, ensuringaccumulatorIncreasestays 0So rewards never move from
pool.pendingYieldinto the stakers accumulator, and usersearnedwon’t growAn attacker only needs a public function that calls
_sync()BStaking.deposit() is public and does not require
_amount > 0Calling
deposit(_bToken, attacker, 0)is enough to run_sync()So the attacker can cheaply send a Tx repeatedly to keep resetting
lastUpdatedand stakers cannot accrue `earnedThis can persist indefinitely as long as the attacker keeps calling.
Recommendation
We need to prevent
lastUpdatedfrom being advanced when the accumulator didn’t advance -
M-03 Medium Reverting swaps due to underflow DoS Acknowledged
Description
CurveLib.computeFee()computes theILfee by subtractingΔBfrom|Δc| * marginalPremium.// IL Fee = difference between marginal price and integral price fee_ = absDelta.mulWadUp(marginalPremium) - (newBufferDown - buffer);In the ideal case, the LHS of the equation should always be greater than the RHS. However, for some swaps LHS is actually lower, which results in swaps reverting due to underflow.
The same is true for the else branch as well:
fee_ = (buffer - _newBuffer) - (absDelta.mulWad(marginalPremium));Recommendation
Consider using
zeroFloorSub()when calculating the IL fee. Buy:fee_ = (absDelta.mulWadUp(marginalPremium)).zeroFloorSub(newBufferDown - buffer);Sell:
fee_ = (buffer - _newBuffer).zeroFloorSub(absDelta.mulWad(marginalPremium));In addition, you can add a maximum tolerable negative delta validation.
-
M-04 Medium User favorable rounding in sell path Rounding Acknowledged
Description
In
CurveLib.computeSwapthe sell branch is explicitly documented as “protocol‑favorable” rounding. It usesfullMulDivUpfornewBuffer, which rounds up to make reserves larger and the user payout smaller. However, the BLV component is added withmulWad, which rounds down:newReserves = newBuffer + BLV * c1Using
mulWadhere slightly reducesnewReserves, which makes the user receive more than the protocol‑favorable rounding intent. This is inconsistent with the stated rounding policy for sells and can leak small amounts of value on each trade.Over time, this can lead to swaps where the resulting
Kis smaller than the previous one even thoughBLVandndidn’t change. Such swaps will revert with theInvariantDecreased()error.Recommendation
Use
mulWadUpfor the BLV term in the sell branch so thatnewReservesis rounded up consistently:- newReserves = newBuffer + BLV.mulWad(c1) + newReserves = newBuffer + BLV.mulWadUp(c1) -
M-05 Medium BLV translation uses outdated state Logical Error Acknowledged
Description
MakerLib.recordSwap stores a memory copy of State.Pool before mutating state and then uses that snapshot to decide whether to translate the BLV floor price
( checking if pool.totalBTokens < pool.totalSupply * threshold ), Because this check happens after updateAccounting() but reads from the stale memory copy,
BLV translation decisions can be made using pre trade values, causing incorrect or delayed BLV updates and inconsistent curve evolution
swaps that cross the 95% boundary can wrongly skip or wrongly trigger BLV translation ( one trade late/early )
Recommendation
make the BLV translation threshold read post trade storage, not a pre trade memory snapshot
-
M-06 Medium Vault exit loss not propagated to curve math Math Acknowledged
Description
exitVaultUnsaferealizes a loss but doesn’t propagate it into pool / curve accounting,BController.exitVaultUnsafe(ERC20 _reserve)is exposed as a Relay route and callsVaultLib.exitVaultUnsafe(ERC20 _reserve)function exitVaultUnsafe(ERC20 _reserve) external permissioned nonReentrant returns ( uint256 amount_, uint256 loss_ ) { (amount_, loss_) = _reserve.exitVaultUnsafe(); emit VaultExited(address(_reserve), amount_, 0, loss_); }And in
VaultLib.exitVaultUnsaferedeemed_ = vault.redeem(vault.balanceOf(address(this)), address(this), address(this)); uint256 depositedReserves = allocation.depositedReserves; // requires a loss require(redeemed_ < depositedReserves, VaultLib_ExitBalanceMismatch()); loss_ = depositedReserves - redeemed_; // wipe vault config and accounting allocation.depositedReserves = 0; allocation.idleReserves = 0; allocation.vault = ERC4626(address(0));ExitVaultUnsafe successfully exits an ERC4626 vault when there is a loss,
redeemed_ < depositedReserves, and returnsloss_, but it does not apply that loss anywhere elseIn particular, it does not update
State.pool(bToken).totalReserves, the reserves number used by the AMM curve / pricing
State.maker(bToken).lastInvariant, the K source of truth used by
CurveLib.computeSwapSo after an unsafe exit
Actual reserves available to pay users are smaller because the vault lost funds
But Internal accounting still thinks the old amount exists
After
exitVaultUnsafe, users can trade / withdraw based on stale, overstatedpool.totalReserves. Early sellers can extract the remaining reserves at the pre loss price until the contract balance is exhausted, leaving late users unable to redeemRecommendation
If markets will not kept open after exitVaultUnsafe, then it would be safe
Otherwise apply the realized vault loss to the internal pool accounting and curve invariant for every pool that uses
_reserve -
M-07 Medium Zero share deposits can brick protocol sweeps DoS Acknowledged
Description
Inside
VaultLib.depositIdleReserves()function depositIdleReserves(ERC20 _reserve) internal { State.Allocation storage allocation = State.meta().allocations[_reserve]; ERC4626 vault = allocation.vault; uint256 idleReserves = allocation.idleReserves; if (!isAllocated(vault) || idleReserves == 0) return; uint256 harvestableYield = getHarvestableYield(allocation); if (harvestableYield == 0) return; uint256 vaultAssetValue = convertDepositToAssets(_reserve, idleReserves); uint256 precisionError = idleReserves.zeroFloorSub(vaultAssetValue); if (harvestableYield > precisionError) { allocation.idleReserves = 0; depositToVault(allocation, _reserve, idleReserves); // @audit, may revert / may mint 0 shares } }And
depositIdleReserves()which is reachable from user flows viaSweepLib.executeSweep()if (VaultLib.isAllocated(allocation.vault)) { if (VaultLib.convertDepositToAssets(reserve, reserveBalance) > 0) { VaultLib.depositToVault(allocation, reserve, reserveBalance); } else { allocation.idleReserves += reserveBalance.toUint128(); VaultLib.depositIdleReserves(reserve); // @audit this is the dangerous path } }a normal user triggered action that causes a sweep can end up calling
depositIdleReserves(), which is gated bygetHarvestableYield()depositIdleReserves()can attempt to depositidleReserveseven whenconvertDepositToAssets(_reserve, idleReserves) == 0That case means
vault.previewDeposit(idleReserves) is effectively 0 shares or redeem value 0
Yet if
getHarvestableYield(allocation) > idleReservesbecause there is enough yield, thenprecisionError = idleReserves - 0 = idleReserves
condition
harvestableYield > precisionErrorbecomesharvestableYield > idleReservesdeposit is attempted anyway, It could cause a global DoS, common with Solmate ERC4626
Many ERC4626 vaults revert on
deposit()ifshares == 0So if
idleReservesis still too small to mint sharesdepositToVault()callsvault.deposit(idleReserves, address(this)), Vault reverts, the entire sweep revertsand because sweeps are invoked in many important paths (swaps, staking syncs, fee claims) this can brick core interactions
This DoS is triggerable because users can create tiny
idleReservesvia very small swaps or any path that causesreserveBalancein the pool manager to be too small to deposit, and later force sweeps again.Recommendation
We must not attempt to deposit idle reserves unless the vault will mint non zero shares
-
M-08 Medium
takeReserves()can result in 0 shares minted Unexpected Behavior AcknowledgedDescription
SweepLib.executeSweep()checks the assets that were just swept will result in non-zero assets if deposited to the vault. If not, the idle reserves are increased instead.VaultLib.convertDepositToAssets(reserve, reserveBalance) > 0However, this logic is absent in
takeReserves(). TheredepositToVault()is called with the exact amount unconditionally. If this amount is small enough to cause 0 shares minting, the result is either:- loss of assets, since the relay receives 0 shares
- DOS for the whole transaction if the vault implementation reverts on 0 shares minted, for example Solmate.
Two very common paths enabling this issue are
BSwap.buyTokensExactIn()andBSwap.sellTokensExactOut()because they use an estimation amount and the difference between it and the actual user amount is stored industFee, which is later passed as an argument totakeReserves().Recommendation
Add the same check to
takeReserves()- if the current amount will mint 0 value, add it to idle reserves and calldepositIdleReserves(). -
L-01 Low Swaps bricks when convexityExp > 2e18 DoS Acknowledged
Description
_initialActivePrice can push the curve into the non quadratic convexityExp > 2 regime at initialization
And that regime immediately hits a code bug path due to a units mismatch introduced during initialize()
If _initialActivePrice is high enough, MakerLib.initialize() sets
maker.convexityExp > 2e18For
convexityExp > 2e18, every swap goes through_updateCurveConvexity()_updateCurveConvexity() compares native decimal pool totals against WAD normalized max values that were stored during initialization
require(pool.totalReserves > maker.maxReserves);With common reserves like USDC 6 decimals, this comparison will always fail, so the first buy and any trade that tries to push circulating > maker.maxCirc reverts, DoSing buys / growth for that pool
Recommendation
Consider normalizing current
circulatingandtotalReservesto WAD before comparing and assigning -
L-02 Low Unaccounted dust desync reserves accounting Unexpected Behavior Acknowledged
Description
Both exactIn and exactOut paths perform an extra reserve transfer called "dust" after executing the swap. This transfer pulls additional reserves from the user (buyExactIn pulls the difference up to amountIn; sellExactOut pulls back any overage above target) and increases the vault’s actual reserves. However, these extra reserves are not reflected in pool.totalReserves nor distributed via MakerLib’s accounting. This creates a persistent mismatch between actual reserves and the curve’s tracked reserves, underpricing the pool and leaking fees away from the configured fee distribution logic.
Recommendation
We could incorporated the dust into MakerLib accounting
-
L-03 Low Pool creation may fail due to underflow DoS Acknowledged
Description
CurveLib.computeInitialCurveParams()decides whether it can use then = 2branch based onquadraticPrice >= _initialActivePrice. However, quadraticPrice is computed with divWadUp (rounding up). This makes quadraticPrice a ceiling, not the exact value. The inequality required for then = 2BLV formula to be safe is:quadraticPrice * supply * circ <= 2 * reserves * totalSupplyThat inequality holds in exact math, but a 1‑wei upward rounding in quadraticPrice can flip it when multiplied by very large supply * circ. As a result, the code can enter the n=2 branch even though the exact inequality fails, and the subtraction
2 * reserves * totalSupply - initialActivePrice * circ * supplyunderflows and reverts.This results in unexpected DOS for some pools.
Recommendation
You can:
- Make the
quadraticPricecalculation equivalent to the one incomputeActivePrice()
- uint256 quadraticPrice = _initialBLV + FixedPointMathLib.divWadUp( - buffer.mulWad(2e18).mulWad(_params.totalSupply), - _params.supply.mulWad(_params.circ) - ); + uint256 quadraticPrice = _initialBLV + FixedPointMathLib.fullMulDiv( + buffer, + uint256(2e18).mulWad(_params.totalSupply), + _params.supply.mulWad(_params.circ) + );- Round the
convexityExpup son < 2is avoided
uint256 priceDelta = _initialActivePrice - _initialBLV; - uint256 term = FixedPointMathLib.fullMulDiv(priceDelta, _params.supply, _params.totalSupply); - convexityExp_ = FixedPointMathLib.fullMulDiv(term, _params.circ, buffer); + uint256 term = FixedPointMathLib.fullMulDivUp(priceDelta, _params.supply, _params.totalSupply); + convexityExp_ = FixedPointMathLib.fullMulDivUp(term, _params.circ, buffer);As an additional safety measure, you can assert the computed
convexityBufferis not below 2.if (convexityExp < 2e18) revert()If this behavior is not problematic, you can also revert with a clear reason instead of letting it underflow.
- Make the
-
L-04 Low Unused code in
quotefunctions Superfluous Code AcknowledgedDescription
BSwap.quoteBuyExactInandBSwap.quoteSellExactOutboth compute an initialdeltaestimate (based onreservesIn / priceWithFee), cap it, and then never use it. Each function immediately calls its respective solver (_solveBuy/_solveSell), which performs an independent bracketed binary search. As a result, the entiredeltablock in both functions is dead code: it does not affect outputs, gas usage in the solver, or correctness.This unused computation makes the code harder to reason about and may mislead reviewers into thinking the solver is seeded with this value. It also adds unnecessary computation and leads to increased gas costs.
Recommendation
Remove the unused
deltacomputation blocks in bothquoteBuyExactInandquoteSellExactOut. -
L-05 Low Smaller swaps reverting due to fee rounding to 0 DoS Acknowledged
Description
MakerLib.swapTokenscomputes swap fees in WAD viaCurveLib.computeSwapand then denormalizes them to native reserve decimals withNormalizeLib.denormalizeWad. For low‑decimal reserve tokens (e.g., 6 decimals), small swaps can produce a non‑zerofeesWadthat still rounds down to0in native units. The current guardrequire(feesReceived_ != 0 && deltaUserReserves_ != 0, InvalidOutput());treats that rounding artifact as an error and reverts. This creates a denial‑of‑service on small trades: the curve returns a valid quote and the user pays rounded‑against amounts, but the swap fails solely because the denormalized fee is zero. It also makes it harder to sell tokens as the pool supply increases.If the revert were simply relaxed without any additional logic, the rounding loss would silently divert fees away from the intended recipients. The fee is already embedded in the swap curve output, so users would still pay it, but the fee would remain inside pool reserves instead of being recorded as creator/protocol/staker fees. That is an accounting drift and changes fee distribution semantics.
Recommendation
Accumulate fee “dust” in WAD and only realize it once it reaches at least 1 native unit, while relaxing the revert to check that the WAD‑level fee is non‑zero. This prevents small‑trade DoS caused by rounding.
Add a WAD remainder bucket to pool state:
--- a/src/libraries/StateLib.sol +++ b/src/libraries/StateLib.sol @@ struct Pool { uint128 creatorFeePct; // Share of profit to creator uint128 creatorClaimable; // Total fees claimable by creator uint128 protocolClaimable; // Total fees claimable by protocol uint128 pendingYield; // Fees pending distribution to stakers + // Accumulated fee dust in WAD (kept until it reaches 1 native unit) + uint256 feeRemainderWad; }Accumulate fee dust and relax the revert to check
feesWadinstead offeesReceived_:--- a/src/libraries/MakerLib.sol +++ b/src/libraries/MakerLib.sol @@ function swapTokens(...) - feesReceived_ = NormalizeLib.denormalizeWad(feesWad, rDec); - require(feesReceived_ != 0 && deltaUserReserves_ != 0, InvalidOutput()); + // Accumulate sub-unit fee dust in WAD, only realize once it reaches 1 native unit + uint256 feesWadTotal = feesWad + pool.feeRemainderWad; + feesReceived_ = NormalizeLib.denormalizeWad(feesWadTotal, rDec); + pool.feeRemainderWad -= NormalizeLib.normalizeWad(feesReceived_, rDec); + + require(feesWad != 0 && deltaUserReserves_ != 0, InvalidOutput()); -
L-06 Low Remove final correction from buy/sell paths Best Practices Acknowledged
Description
At the end of the
_solveBuy()and_solveSell()functions, there is a correction logic which modifies the delta in a way that makes the user lose more value._solveBuy():// CONSERVATIVE: Reduce to ensure reserve-side swap is always worse than token-side. // Scale reduction based on decimal precision difference to handle 8-decimal reserves. uint256 reduction = FixedPointMathLib.max(1, delta_ / 10_000_000); // 0.00001% reduction (10x tighter) if (delta_ > reduction) delta_ -= reduction;_solveSell():// CONSERVATIVE: Increase to ensure reserve-side swap is always worse than token-side. // Scale increase based on decimal precision difference to handle 8-decimal reserves. uint256 maxDelta = _pool.totalSupply - _pool.totalBTokens; uint256 increase = FixedPointMathLib.max(1, delta_ / 10_000_000); // 0.00001% increase (10x tighter) if (delta_ + increase <= maxDelta) delta_ += increase;Since both of the functions are wrappers around
CurveLib.computeSwap(), no further adjustment to the delta is needed. The binary search approach is already not in favor of the user due to the dust they have to pay, adjusting the delta makes them incur unnecessary loses.Recommendation
Remove the final correction logic from the two functions.
-
L-07 Low Asymmetrical validation for buy path Validation Acknowledged
Description
BSwap.quoteBuyExactIn()limits certain buys, where the reserves received are more than 95% of the current reserves.if (reservesInWad > p.reserves * 95 / 100) revert AmountExceedsLiquidity();However, this check is not present in
buyTokensExactOut(), allowing users to bypass it by using the other function.Recommendation
Consider whether the check is needed, since it limits the token in, not the token out. If it's - add it to the other function as well, otherwise remove it.
-
L-08 Low Incorrect event emission for hook swaps Events Acknowledged
Description
MakerLib._recordSwap()emits theSwapevent withmsg.senderas the user.emit Swap( _bToken, msg.sender, CurveLib.computeActivePrice(getCurveParams(_bToken)), maker.blvPrice, _deltaCirc, _deltaUserReserves, _feesReceived, liquidityFee );For swaps performed through the
BHookcontract,msg.senderwill always be theRelayitself. This leads to incorrect value emitted and makes it harder to track swaps offchain.Recommendation
If emitting the correct user is important, you can introduce permissioned wrapper functions around
BSwapthat forward the sender. -
L-09 Low
solveBuy()can revert due to overflow Math AcknowledgedDescription
In
BSwap._solveBuy(), the code multipliestotalBTokens * 99. IftotalBTokens * 99 > type(uint128).max, the operation will revert (checked arithmetic in Solidity >=0.8), causing swaps/liquidity operations that reach this branch to fail even though the values may be otherwise valid for the surrounding logic. This creates a hard cap ontotalBTokensand can brick functionality once the pool grows beyondtype(uint128).max / 99.Recommendation
Cast
totalBTokensto uint256 before multiplying. -
L-10 Low Buy fee path underflow can revert swaps DoS Acknowledged
Description
In
CurveLib._computeFee(buy branch),newBufferDownis recomputed and then used infee_ = absDelta.mulWadUp(marginalPremium) - (newBufferDown - buffer).The whole recomputation path is rounded down (including division and multiplication steps, as well as
powWad), which can producenewBufferDown < buffereven while processing a buy.When that happens,
(newBufferDown - buffer)underflows and the swap reverts, causing DoS for otherwise valid buy paths.Recommendation
Prevent fee-path underflow by using a bounded buffer delta, e.g.
bufferIncrease = newBufferDown.zeroFloorSub(buffer)before subtraction. -
L-11 Low Buffer can grow during sells Math Acknowledged
Description
The sell path can violate expected buffer monotonicity due to compounded upward rounding. In
CurveLib.computeInvariant(), lastInvariant (K) is rounded up.. InCurveLib.computeSwap(), sell quotes derive newBuffer fromlastInvariantusing upward rounding as well. This is usually done to round in favor of the protocol and require more reserves from the user. However, the new buffer may become greater than the current one, especially for smaller sells. This will result in negativeinvariantDeltaand the sell will actually behave like a buy for the reserves. InCurveLib._computeFee()(sell branch), the fee path performs an unsigned subtraction equivalent to (buffer - _newBuffer) before IL-premium adjustment. When_newBufferexceedsbuffer, this subtraction underflows and the swap reverts, leading to unexpected failures, and inconsistent sell behavior.Recommendation
If this is acceptable, consider reverting with appropriate error if the new buffer increases during swaps. Otherwise, the buffer calculation should be reconsidered.
-
L-12 Low Hook sweep can revert batched swaps DoS Acknowledged
Description
MakerLib._updateAccounting()callsSweepLib.sweep()at the start of every swap. When swaps are routed through the hook, the input token is recorded as an outstanding balance and settled later by the router. If an integrator batches multiple hook swaps in the same unlock and defers settlement until the end (a common v4 routing pattern), the second swap will try to sweep the first swap’s unpaid outstanding balance and revert when poolManager.take cannot transfer funds that haven’t been settled yet. This makes multi-swap batching through the hook incompatible unless each swap is settled before the next one, reducing composability and potentially breaking routers that rely on open deltas.Recommendation
Either:
- Skip
SweepLib.sweep()if the pool manager is unlocked and the hook still has an unpaid delta, and defer sweeping to a later action. - Require integrators to settle after each hook swap when batching (and document the constraint).
- Skip
-
L-13 Low Recorded reserves drift due to vault operations Math Acknowledged
Description
When a swap occurs,
CurveLibcomputes the amount ofΔreservesand returns it asdeltaResWad. If the direction is buying and a vault is set for that reserve, the received tokens will be immediately deposited to that vault which will lead to a precision loss. To mitigate that,MakerLibovercharges the user in such a way that after the deposit, the system will have its balance increased with at leastΔreservesdeltaUserReserves_ = -( VaultLib.convertAssetsToDeposit( State.pool(_bToken).reserve, NormalizeLib.denormalizeWadUp(uint256(-deltaResWad), rDec) ).toInt256());Then
deltaUserReserves_is passed torecordSwap(), wheretotalReserveswill be increased with that value and the user will have to pay the exact same amount. Because of this, the rounding error that's covered by the user will be attributed tototalReserveseven though this amount will be lost.For example, let's say the invariant computes
Δyassets needed. In order to get these assets we have to depositΔy + εto the vault. The code will record an increase intotalReservesofΔy + εeven though the system received onlyΔy.Recommendation
You can add a new parameter to
_recordSwap()calledrawAmountand use it to pass the value before the vault deposit if the direction is a buy._recordSwap(_bToken, _deltaCirc, deltaUserReserves_, feesReceived_, deltaResWad < 0 ? -int256(NormalizeLib.denormalizeWadUp(uint256(-deltaResWad), rDec)) : int256(0));0is being used as a default value when the swap is sell, so_recordSwapwill handle that case.if (rawAmount == 0) { rawAmount = _deltaUserReserves; }Finally,
_updateAccounting()will use the raw amount instead.uint256 liquidityFee = _updateAccounting(_bToken, _deltaCirc, rawAmount, _feesReceived); -
I-01 Informational Missing guard causes sell side reverts Informational Acknowledged
Description
The BUY branch protects powWad and expWad from overflowing by enforcing n times ln(ratio) less than or equal to approximately 135e18. The SELL branch computes the same powWad term for ratio equal to x1 divided by c1 and greater than 1, but omits the guard. For large x1 divided by c1 or high convexityExp, int256(ratio).powWad(n) overflows due to the expWad limit and reverts. This makes otherwise valid sell quotes or executions revert, causing a denial of service in high convexity or deep sell scenarios.
Recommendation
Mirror the BUY branch overflow guard in the SELL branch before calling powWad
-
I-02 Informational Excess repayment funds not refunded Informational Acknowledged
Description
Both repay() and repayWithNative() compute the actual debt to repay via previewRepay/repay (which caps to the user’s remaining debt), but then unconditionally pull the full reservesIn/msg.value from the payer. Any excess over what is needed to repay debt is not refunded and is not credited to the user
Recommendation
We could only pull the exact amount required to cover debt, if the caller supplied a higher amount refund the difference by using
convertAssetsToDeposit -
I-03 Informational Repay preview revert when debt is 0 Suggestion Acknowledged
Description
In previewRepay()
debtToRepay_ = min(_debtAmount, _account.debt); collateralRedeemed_ = uint256(_account.collateral).mulDiv(debtToRepay_, _account.debt);If account.debt == 0, we divide by zero even though debtToRepay will be 0
So
previewRepay,previewRepayExactDebt, andrepaywill revertRecommendation
Consider handling zero debt early
if (_account.debt == 0) return (0, 0);
-
I-04 Informational harvestYield has a return but doesn’t return it Informational Acknowledged
Description
harvestYield declares a return but doesn’t return it
function harvestYield(ERC20 _reserve) external permissioned nonReentrant returns (uint256) { uint256 yield_ = _reserve.harvestYield(); emit Harvested(address(_reserve), yield_); }Recommendation
Consider returning yield_
-
I-05 Informational Missing upper bound blocks pool creation Validation Acknowledged
Description
In createBToken, totalSupply is only lower bounded, but the protocol later downcasts circulating supply to uint128
The user choose
_totalSupplyand the factory stores it as the pool supply_validateBTokenParams() only enforces a minimum
require(_totalSupply >= MIN_TOTAL_SUPPLY, TotalSupplyTooLow());The rest of the system assumes that circulating supply can fit into uint128
But during
createPool(), it doesmaker.maxCirc = NormalizeLib .normalizeWad(pool.totalSupply - pool.totalBTokens, bDec) .toUint128();If
_totalSupplyis large enough such thatpool.totalSupply - pool.totalBTokens > type(uint128).max
then
toUint128()reverts, and the pool cannot ever be initialized for that bToken.So a user can successfully deploy a token via
createBToken()but can not create a pool for itRecommendation
Cap
_totalSupplyso it can not exceeduint128by adding an upper bound in _validateBTokenParams -
I-06 Informational
isExecutor()can use dirty address Best Practices AcknowledgedDescription
Component._isExecutor(address)accepts an address and returns if this address is an executor. To do so, it hashes the address together with the mapping storage slot. However, it doesn't clean the upper 12 bytes of the address. This is fine for the current implementation, but if code changes and the address provided has dirty bytes, the returned value may be incorrect.function _isExecutor(address _user) internal view returns (bool isExecutor_) { assembly { mstore(0x00, _user) // executorExpiry mapping is at slot 5 mstore(0x20, 5) // find the slot for the user in the mapping let slot := keccak256(0x00, 0x40) // load the expiration timestamp and compare to current timestamp isExecutor_ := gt(sload(slot), timestamp()) } }Recommendation
Consider cleaning the upper 12 bytes of
_user -
I-07 Informational Relay routes are unnecessary Superfluous Code Acknowledged
Description
A
_setRelayRoutes()function was introduced to theRelaycontract and is invoked in its constructor. It setsaddress(this)as a route for each function selector of theRelaycontract.function _setRelayRoutes() internal { // state getters routes[this.admin.selector] = address(this); routes[this.pendingAdmin.selector] = address(this); routes[this.getComponentForLabel.selector] = address(this); routes[this.routes.selector] = address(this); routes[this.executorExpiry.selector] = address(this); // view functions routes[this.getLabels.selector] = address(this); routes[this.isExecutor.selector] = address(this); // non view functions routes[this.transferAdmin.selector] = address(this); routes[this.acceptAdmin.selector] = address(this); routes[this.addExecutor.selector] = address(this); routes[this.removeExecutor.selector] = address(this); routes[this.executeActions.selector] = address(this); }Doing this is unnecessary, because if the first 4 bytes of the calldata match any of the functions, they will be executed as a normal EVM call, and the
fallbackwon't even be entered.Recommendation
Remove the
_setRelayRoutes()function. -
I-08 Informational Incorrect
ceilcomments Best Practices AcknowledgedDescription
The comments in
BCredit.getBorrowForCollateral()say the divisions there are usingceil, while it's actuallyfloorthat's happening.uint256 debtWad = blv.mulWad(collateralWad); // ceil for obligation uint256 debt = NormalizeLib.denormalizeWad(debtWad, rDec); // ceilRecommendation
Remove the comments.
-
I-09 Informational Redundant
mulWad()in active price computation Superfluous Code AcknowledgedDescription
CurveLib.computeActivePrice()computes the premium to be added toBLV:uint256 premium = FixedPointMathLib.fullMulDiv( _params.reserves - _params.BLV.mulWad(_params.circ), _params.convexityExp.mulWad(_params.totalSupply), _params.supply.mulWad(_params.circ).mulWad(WAD) );On the last line of the code snippet, we can see
mulWad(WAD). BecausemulWadis equivalent tox * y / WAD, the end result will bex * WAD / WAD = x, which makes the operation unnecessary.Recommendation
Delete the last
mulWad()uint256 premium = FixedPointMathLib.fullMulDiv( _params.reserves - _params.BLV.mulWad(_params.circ), _params.convexityExp.mulWad(_params.totalSupply), - _params.supply.mulWad(_params.circ).mulWad(WAD) + _params.supply.mulWad(_params.circ) ); -
I-10 Informational Unreachable edge cases in
computeSwap()Suggestion AcknowledgedDescription
CurveLib.computeSwap()has code that handles specific edge cases - full buys and sells.// Edge case: buying from zero circulation if (_params.circ == 0) { return _computeZeroCircSwap(_params, uint256(_deltaCirc)); } uint256 c1 = (_params.circ.toInt256() + _deltaCirc).toUint256(); // Edge case: selling to zero circulation // User only receives BLV value (floor price), buffer becomes protocol fee if (c1 == 0) { uint256 blvValue = _params.BLV.mulWad(_params.circ); uint256 receipt = blvValue.mulWad(WAD - _params.swapFee); return (int256(receipt), _params.reserves - receipt); }However, the invariant curve
K = B × (x/c)ⁿis not defined forc = 0. Because of thatCurveLib.computeInvariant()won't allow the pool to ever reach a state wherec = 0, therefore these codepaths should never be executed.Recommendation
Reconsider whether the edge cases code is needed.
-
I-11 Informational Binary search loop can break earlier Gas Optimization Acknowledged
Description
The binary search loops in
_solveBuy()and_solveSell()compare midpoints against desired targets, but they don'tbreakin casemidPoint == target.This may cause unnecessary iterations and increased gas cost.
Recommendation
Consider breaking if the target is found.
-
I-12 Informational Dead
Pool.shareBalancestate field Superfluous Code AcknowledgedDescription
State.Pooldeclaresuint128 shareBalanceat , but this field is never read from or written to anywhere in the codebase. A search forpool.shareBalanceand assignments toshareBalanceinsrc/returns no usages.At the same time, vault share accounting is implemented through
State.meta().allocations[_reserve]andBLens.shareBalance()readsalloc.vault.balanceOf(address(this))rather than pool-level storage. This indicatesPool.shareBalanceis dead state.Recommendation
Consider removing the field
-
I-13 Informational
Kdecreases due to computation inconsistencies Rounding AcknowledgedDescription
When
convexityExpandBLVare unchanged,MakerLib._recordSwaprecomputes the invariant and enforces strict non-decrease.computeInvariantdepends onpowWad(approximation vialn/exp) plus fixed-point rounding (divWad,divWadUp,mulWadUp,divWadUp). This can introduce small negative numerical drift between the stored invariant and recomputed invariant, causing swap reverts even when the observed delta is tiny relative to invariant magnitude.Recommendation
Keep the check, but document this as an intentional precision-related limitation.
-
I-14 Informational Vaults are a security risk Warning Acknowledged
Description
Users can freely deploy their BTokens and provide
reservesfor backing to the Baseline Relay and start using its features to launch their token.Currently the executor of the
Relaycontract can choose a vault where the reserves will be deposited in order to generate additional yield. This feature is not riskfree. A compromised executor can provide any vault of their choice, including a fake address which just pulls the funds out of the contract.Even if the executor acts honestly, if the vault is not properly monitored, the relay contract can lose its reserves, either due to security risks in the vault itself or economic losses.
Recommendation
Be aware of the security risks that come with having the vault design and consider if changes are necessary.
-
H-03 High Stale K enables reserve extraction Logical Error Acknowledged
Description
updateAccounting() computes and adds liquidityFee_ into pool.totalReserves
recordSwap() decides whether to recompute maker.lastInvariant, the curve K using a different threshold test
here liquidity fee gating uses a ratio, wad division
// _updateAccounting liquidityFee_ = _feesReceived - LIQUIDITY_GROWTH_FEE_SHARE.mulWad(_feesReceived); if (uint256(pool.totalBTokens).divWad(pool.totalSupply) >= 0.95e18) { liquidityFee_ = 0; }If the pool has >= 95% of supply inside totalBTokens/totalSupply >= 95% , then no liquidityFee
If < 95% , then liquidityFee is kept inside the pool and added into pool.totalReserves
but here, Invariant recompute gating uses a floored product
// _recordSwap if (pool.totalBTokens < pool.totalSupply.mulWad(0.95e18)) { newInvariant = CurveLib.computeInvariant(getCurveParams(_bToken)); } maker.lastInvariant = newInvariant;So invariant recompute happens if
totalBTokens < floor( totalSupply * 0.95 )the vulnerability here is that we are using two different definitions of inside / outside the 95% safety band
Liquidity fee uses
floor(totalBTokens * 1e18 / totalSupply ) >= 0.95e18Invariant refresh uses
totalBTokens < floor(totalSupply * 0.95)Those differ at the boundary whenever 0.95 * totalSupply is not an integer, creating a fee kept but K not updated inconsistent state
https://gist.github.com/GuardianAudits/83303e7af8afb67a41b0b84355200a69
That leaves the system in an inconsistent state
pool.totalReserves has been increased by a fee that is intended to grow liquidity
but maker.lastInvariant K is still the pre fee value
Since CurveLib.computeSwap() treats lastInvariant as the source of truth
pricing is now computed using stale K against a reserves value that already includes extra buffer
after every swap ends exactly at totalBTokens = floor(0.95 * totalSupply), the pool reserves already include the liquidity fee, but K lastInvariant is still the old value,
so the next swap that moves totalBTokens one step below that floor refreshes lastInvariant, causing a sudden curve jump
An attacker can arbitrage this in one transaction
1 - Buy a tiny amount while K is stale ( cheaper )
2 - That buy crosses the floor, so _recordSwap updates lastInvariant
3 - The update now locks in the previously added liquidity fee into the curve
4 - Immediately sell back the same amount against the updated curve ( higher price ), pocketing reserves
In the poc, the attacker buys 1900 bTokens for 90 reserves using stale K,
crossing the boundary refreshes K after the buy, attacker sells back for 1906 reserves, netting 1816 profit
https://gist.github.com/GuardianAudits/1bbd127b943b837305385d214ddb6de6
Recommendation
make the liquidity fee decision and the invariant refresh decision use the same predicate
-
C-02 Critical Protocol reserves can be drained Validation Acknowledged
Description
Related with the added solvency check for C-01
(uint256 maxBorrow,uint256 fee) = BCredit(address(this)).getBorrowForCollateral(); require(maxBorrow + fee >= params.initialDebt, InsolventInitialCreditPosition());getBorrowForCollateral() depends on blvPrice, and blvPrice depends on pool.totalReserves, and we already added
initialDebtinto pool.totalReservesby inflating
initialDebt, we inflate pool.totalReserves then inflate bookPrice / BLV, which inflate maxBorrow + feeSo attacker could still drain pool reserves of any token
https://gist.github.com/GuardianAudits/8c790f2784f855ad91ff1ef85688885b
Recommendation
consider removing initialDebt for permissionless pool creation
-
I-19 Informational Lack of
BTokenparameters validation Validation AcknowledgedDescription
BController.setBTokenDeployment()doesn't run the logic in_validateBTokenParams(), which means a token with unexpected configuration may be added to the system.Recommendation
Consider validating the token parameters.
-
M-13 Medium Underreported supply drains shared reserves Gaming Acknowledged
Description
setBTokenDeployment()allows settingState.pool(_bToken).totalSupplyto a value lower than the actualBToken.totalSupply(). A pool creator can then hold moreBTokenunits than the pool accounting recognizes, stake them viaBStaking.deposit(), and inflategetMaxBorrow()/borrow()because credit capacity is computed from collateral andblvPricewithout capping to accounted supply or available reserves. Theborrow()payout is sourced from shared reserve custody viaVaultLib.giveReserves(), so the attacker can drain reserves funded by other pools.The risk is further amplified if there is an upgradeability logic. For example, a token with a fixed
totalSupplymay later introduce a logic for minting and successfully drain the pool reserves.Recommendation
Cap collateralization to accounted circulating supply, e.g., enforce
totalStaked + _amount <= State.pool(_bToken).totalSupply - State.pool(_bToken).totalBTokensinBStaking.deposit().Also prefer allowing tokens with fixed total supply.
-
I-18 Informational Check computed blv against book price Best Practices Acknowledged
Description
In
computeInitialCurveParams(), the final validation checks_initialBLVagainstbookPriceviarequire(), even though the function may compute a differentBLV_in the quadratic branch.BLV_is still bounded bybookPricewhen_initialActivePrice > bookPrice, so this is not a functional bug, but the check is semantically tied to the computed output and would be clearer and more future‑proof if applied to BLV_ directly.Recommendation
Replace the final check with
require(BLV_ <= bookPrice, InvalidBLVPrice()) -
M-12 Medium Convexity parameter is being overrelaxed Math Acknowledged
Description
The protocol adapts the curve’s convexity (
n) usingCurveLib.computeMinimumConvexityExpso a single curve can be consistent with both the current state and the historical max state(maxCirc, maxReserves). The invariant is:K = B * (x/c)^n B = y − BLV*c x = totalSupply − cTaking logs:
ln K = ln B + n(ln x − ln c)Solving for
nusing the current state and the max state (and eliminatingK) gives:n = (ln B_max − ln B_now) / [(ln x_now − ln c_now) − (ln x_max − ln c_max)]Here,
x_nowis the remaining supply (supply = totalSupply − circ). The code usesln(totalSupply), which inflates the denominator and yields a smallernthan required. This flattens the curve, so the resulting curve no longer passes through the max point as intended. Practically, it underprices buys in later states and weakens the intended safety margin above BLV.int256 lnSupply = (_params.totalSupply).toInt256().lnWad();Recommendation
Use
supplyinsteadfunction computeMinimumConvexityExp( CurveParams memory _params, uint256 _maxReserves, uint256 _maxCirc ) internal pure returns (uint256 convexityExp_) { int256 lnMaxBuffer = (_maxReserves - _params.BLV.mulWad(_maxCirc)).toInt256().lnWad(); int256 lnBuffer = (_params.reserves - _params.BLV.mulWad(_params.circ)).toInt256().lnWad(); int256 lnMaxCirc = _maxCirc.toInt256().lnWad(); - int256 lnSupply = _params.totalSupply.toInt256().lnWad(); + int256 lnSupply = _params.supply.toInt256().lnWad(); int256 lnCirc = _params.circ.toInt256().lnWad(); int256 lnMinSupply = (_params.totalSupply - _maxCirc).toInt256().lnWad(); convexityExp_ = FixedPointMathLib.divWad( (lnMaxBuffer - lnBuffer).toUint256(), (lnMaxCirc + lnSupply - lnCirc - lnMinSupply).toUint256() ); } -
M-11 Medium Entering vault for a bToken bricks its pool DoS Acknowledged
Description
When a bToken is also used as a reserve token, calling
setVault()on that reserve deposits the entire bToken balance held by the protocol into the vault (enterVault uses_reserve.balanceOf(address(this))). This drains the liquid bToken inventory that backs the bToken’s own pool, leaving the pool unable to transfer bTokens to users (e.g., swaps, staking withdrawals), effectively bricking the pool until the vault is exited.Recommendation
When setting a vault for a reserve that is also a bToken with an active pool, only deposit the excess reserves above
totalBTokens + totalStaked + credit.accounts[address(this)].collateral(or the pool’s required liquid inventory), not the full balance. -
L-20 Low Ratio rounding favors users on buys and sells Rounding Acknowledged
Description
In
CurveLib.computeSwap(), both buy and sell branches computeratio = x1 / c1usingdivWadUp(). Rounding the ratio up makespowWad()larger and reducesnewBuffer. For buys, this lowers the required reserves, so users pay less andeffectivePricecan fall belowactivePrice()as observed inFoundryRound3::test_replay(). For sells, the same rounding reducesnewReserves, increasing user payout. This is opposite to the documented protocol‑favorable rounding intent.Recommendation
Use
divWad()for theratioin both branches to round against the user, and update the comments to match the rounding policy. -
I-17 Informational Invariant error lacks drop context Best Practices Acknowledged
Description
MakerLib._recordSwap()reverts withInvariantDecreased()whenCurveLib.computeInvariant()returns a smaller value, but the error carries no values. This makes it difficult to distinguish between precision drift and meaningful invariant drops during fuzzing, monitoring, and debugging.Recommendation
Add parameters to
InvariantDecreased()(e.g.,prevInvariantandnewInvariant) and revert withrevert InvariantDecreased(prevInvariant, newInvariant)so tooling can assess the magnitude and context of the drop. -
M-10 Medium Swap fee can be bypassed when selling all tokens Gaming Acknowledged
Description
When all tokens are being sold to the pool, a flat
swapFeeis applied on top of theBLVprice and the user receives reserves belowBLVper bToken.if (c1 == 0) { uint256 blvValue = _params.BLV.mulWad(_params.circ); uint256 receipt = blvValue.mulWad(WAD - _params.swapFee); return (int256(receipt), _params.reserves - receipt); }However, during normal sells
_computeFee()applies the fee only to themarginalPremium, which depends on the buffer.fee_ += absDelta.mulWadUp(marginalPremium.mulWadUp(_p.swapFee));In a situation where a user will sell all the bTokens to the pool, they can split the swap in two parts: large and small one. For the big swap, they will end up paying
swapFeeonly on the newmarginalPremiumwhich is small because the new buffer is small as well. Then they will payswapFeeforBLVonly for the small swap.Recommendation
Consider applying the
swapFeetoBLV + marginalPremiumincomputeFee()- fee_ += absDelta.mulWadUp(marginalPremium.mulWadUp(_p.swapFee)); + fee_ += absDelta.mulWadUp((_p.BLV + marginalPremium).mulWadUp(_p.swapFee)); -
M-09 Medium BCredit.defaultSelf off curve Logical Error Acknowledged
Description
In
BCredit.defaultSelf()pool.totalBTokens += collateral.toUint128(); pool.totalReserves -= debt.toUint128();This function directly mutates the AMM core state variables
totalBTokens,totalReservesthat drive pricing insideMakerLib,CurveLibBut it does not update the curve source of truth invariant
State.maker(_bToken).lastInvariant
That invariant is what
CurveLib.computeSwap()uses to calculate swap deltas.The AMM uses
maker.lastInvariantas KSwaps don’t recompute K from the current reserves / supply, they use
maker.lastInvariant, stored K
plus current
pool.totalReserves/pool.totalBTokens/circSo
lastInvariantmust remain consistent with pool.totalReserves, pool.totalBTokens, circ, BLV, convexityExp`defaultSelf() changes pool.totalBTokens, pool.totalReserves
But leaves
maker.lastInvariantunchanged.That means after a default:
pool.totalReserves / pool.totalBTokens reflect a new curve point
But
maker.lastInvariantstill represents the old curve surfaceSo the pool is now off curve, and the next swap will compute prices from a stale K
Recommendation
After mutating
pool.totalBTokens/pool.totalReservesindefaultSelf(), recompute and store the invariant -
L-19 Low Leverage fee charged on max borrow Math Acknowledged
Description
In
leverage(),getBorrowForCollateral()computesborrowAmountandfeefrom_totalCollateralusingState.meta().originationFee. AfterbuyTokensExactOut()returnsreservesIn, the code reducesdebt_by the unusedborrowAmount, butfeeis not adjusted and is still distributed viaState.pool(_bToken).distributeFees(). WhenreservesInis lower thanborrowAmount, the effective fee rate becomesfee / reservesIn, which is higher than the intendedoriginationFeeand can overchargeleverage()users while inflatingpendingYieldrelative to the realized swap cost.Recommendation
Recompute
feefrom the realized borrow (reservesIn) or from the finaldebt_after the refund, and use that recomputedfeeindistributeFees(). -
L-18 Low Duplicate claim leaves enable griefing Trust Assumptions Acknowledged
Description
claimCredit()is permissionless and_processClaim()only trackscredit.claimed[user]per user. If a user appears twice in the Merkle root with different amounts, any third party can callclaimCredit()with the smaller leaf first, settingcredit.claimed[user] = trueand permanently blocking the larger claim viaBCredit_AlreadyClaimed(). This turns a root-construction mistake into a griefing vector where the user is forced to accept the lower allocation.Recommendation
Either document that users can claim only once and this must be enforced when generating the Merkle root, or change the tracking to be per-leaf instead of per-user so multiple leaves for the same user can be claimed without griefing.
-
L-17 Low Pool reserves overstated on
createPool()Rounding AcknowledgedDescription
createPool()setspool.totalReservestoparams.initialPoolReserves + params.initialDebtbefore collecting reserves. It then callstakeReserves()to pullparams.initialPoolReservesfrom the caller. If a vault is already allocated for the reserve (likely when the same reserve is shared across pools),takeReserves()deposits into the vault viadepositToVault(), which can round down and credit fewer assets than the amount transferred. This creates an initial shortfall wherepool.totalReservesis overstated relative to the vault‑redeemable value.Recommendation
When collecting initial reserves in
createPool(), charge the user withconvertAssetsToDeposit()so the vault credits the intendedparams.initialPoolReserves. This keepspool.totalReservesconsistent with the actual vault‑credited assets. -
I-16 Informational Outdated refund ordering comment Documentation Acknowledged
Description
The comment in
deleverage()states that issuing the refund beforeunlockCollateral()prevents it from being treated as yield during_sync(). Current yield accounting inBStakingis driven bypendingYield,claimableYield, andstaking.totalStaked, and is not affected by the order ofgiveReserves()versusunlockCollateral(). As a result, the comment does not reflect the present behavior.// refund should be given before unlocking collateral so it's not treated as yield during _sync if (refund_ > 0) State.pool(_bToken).reserve.giveReserves(msg.sender, refund_, true);Recommendation
Remove the comment.
-
I-15 Informational Unused
previewTakeReserves()helper Superfluous Code AcknowledgedDescription
The helper
previewTakeReservesis defined inVaultLibbut has no call sites in the codebase. It duplicatesconvertDepositToAssetsbehavior and adds unused surface area, which can confuse future refactors and audits.Recommendation
Remove
previewTakeReservesor add a concrete call site that relies on it. If it’s intended for future use, add a short comment explaining where/why it should be used. -
L-16 Low Harvest buffer can be dynamically adjusted Suggestion Acknowledged
Description
HARVEST_BUFFERinVaultLib.solis meant to absorb potential rounding losses from vault interactions. Currently it's hardcoded to 100 wei, but may not be enough if vault shares are expensive.A mixed approach of using both a hardcoded buffer and dynamically adjusted one can be made. You can measure losses when a vault is entered, withdrawals are performed or idle funds are deposited and accumulate the losses to a dynamic buffer. Then, when using buffer logic you can utilize
max(HARVEST_BUFFER, dynamicBuffer).Currently,
depositIdleReserves()tries to use a part of the available yield in order to cover the loss from the deposit. However, the only thing it achieves is to ensure there is more extractable yield than the realized loss. Once this yield is extracted, the proxy contract will still bear a loss. With the dynamic buffer approach, you can add theprecisionErrorto it, which will ensure that precision loss is actually not later withdrawn as yield and enough funds remain in the contract in order to sufficiently backtotalReserves.Recommendation
Consider implementing the dynamic buffer approach, especially in
depositIdleFunds(). -
L-15 Low Harvest buffer not retained on vault exit Math Acknowledged
Description
HARVEST_BUFFERis used ingetHarvestableYield()and inswitchVaults()’s pre-check to ensure there is enough yield to cover precision errors on vault entry/exit. However,exitVault()computesyield_ = redeemed_ - depositedReservesand immediately pays out the full yield to the fee recipient. This means the buffer is not retained during a vault exit. As a result, any rounding losses are no longer offset by the intended buffer and are effectively borne by the pool’s reserves instead of being absorbed by retained yield.Recommendation
When exiting a vault, retain the buffer by subtracting
HARVEST_BUFFERfrom the payout: computeyield_ = (redeemed_ - depositedReserves).zeroFloorSub(HARVEST_BUFFER)before distributing fees. Then update the switchVaults check to requireyield_ != 0so the original safety threshold remains intact. -
L-14 Low Hook sweep can under-credit vault Math Acknowledged
Description
When a reserve has a vault set, the funds from swaps are deposited to it during settlement. Swaps use
convertAssetsToDeposit()to charge the user an amount that would result intargetAssetsafter the deposit. With the same vault ratio at quote and deposit (r1 = r2), the rounding buffer (VAULT_ROUNDING_BUFFER = 2) ensurespreviewRedeem(previewDeposit(X)) >= targetAssets, so the pool does not end up under-credited.For swaps performed through
BHookthe user amount is computed at time t1 usingconvertAssetsToDeposit(), but the actual vault deposit happens later whensweepexecutes. This introduces a ratio gap (r1 = tS1/tA1at quote,r2 = tS2/tA2at deposit), so the original rounding guarantee no longer necessarily holds.Let
abe the target assets. The rounding pipeline is:$
\mathrm{previewWithdraw}(a)=\lceil a\cdot r_1\rceil = a\cdot r_1 + \varepsilon,\quad \varepsilon\in[0,1)$$
\mathrm{assetsNeeded}=\mathrm{previewRedeem}(\mathrm{previewWithdraw}(a)+2)=\lfloor a+\frac{\varepsilon+2}{r_1}\rfloor=a+\lfloor\frac{\varepsilon+2}{r_1}\rfloor$$
\mathrm{sharesMinted}=\lfloor \mathrm{assetsNeeded}\cdot r_2\rfloor=\mathrm{assetsNeeded}\cdot r_2-\delta,\quad \delta\in[0,1)$$
\mathrm{creditedAssets}=\lfloor \frac{\mathrm{sharesMinted}}{r_2}\rfloor=a+\lfloor\frac{\varepsilon+2}{r_1}\rfloor-\lceil\frac{\delta}{r_2}\rceil$$
\mathrm{creditedAssets}<a\iff \lfloor\frac{\varepsilon+2}{r_1}\rfloor<\lceil\frac{\delta}{r_2}\rceil$Case
r2 >= 1(share price <= 1 asset):ceil(δ/r2)is 0 or 1. Loss is only possible whenfloor((ε+2)/r1) = 0andδ > 0, which happens when(ε+2)/r1 < 1(i.e.,r1 > 2+ε). Using a buffer of one full asset's worth of shares (previewWithdraw(1) ~= ceil(r1)) guaranteesfloor((ε+buffer)/r1) >= 1, so the loss condition cannot hold when r2 >= 1.Case
r2 < 1(share price > 1 asset):ceil(δ/r2)can exceed 1, so even with a larger buffer a loss is still technically possible if r2 drops enough between t1 and t2.Recommendation
For
Case r2 >= 1, you can increase the share buffer inconvertAssetsToDepositto one full asset's worth of shares, and keep it at leastVAULT_ROUNDING_BUFFER: usemax(previewWithdraw(1), VAULT_ROUNDING_BUFFER)instead of a fixed +2. This removes the loss condition and improves accuracy under rate drift.Case r1 < 1cannot be fully solved becauser2is not known att1, but it can be improved by frequent sweeps.
Remediation Review
26 findings · March 2 to 9, 2026-
H-01 High K drops due to overcharged fee Rounding Acknowledged
Description
In response to
L-05thefeesReceivedvariable is rounded up.feesReceived_ = NormalizeLib.denormalizeWadUp(feesWad, rDec);This resolves the problem with the swaps reverting when swap fee cannot be represented as a whole unit of the reserve token. However, that same
feesReceived_variable is passed to_recordSwap()and will be accounted as a fee at the expense of the reserves. Let's look at an example where the reserve token is in 8 decimals.- invariantDelta = -40000000000000000221497
- feesWad = 4000000000000000218505
- feesReceived_ = 400000000001
- deltaResWad =
invariantDelta - feesWad= -44000000000000000440002
This means the user will pay
4400000000001reserves of which400000000001are fees. The rest4000000000000go to the backing reserves. If we normalize this number to 18 decimals, we can see that 400000000000000000000 < 40000000000000000221497 (invariantDelta).Once the swap is done, the actual
totalReservesin the contract are less than expected, therefore the calculated invariant will also be lower than it should. This case becomes more problematic in the region of the curve where more of 95% of the total supply is sitting in the pool because there isn't liquidity fee to compensate for the invariant drop. In result, the curve will use the outdatedlastInvariantwhich is bigger than the actual invariant and further will be blocked.Recommendation
Consider implementing the original recommendation of
L-05 -
H-02 High Sell side buffer rounding breaks swap DoS Acknowledged
Description
Sell side IL fee math can revert for a huge range of sell sizes
computeSwap() calls _computeFee()
_computeFee() sell branch _deltaCirc < 0 contains two requires
require(buffer > _newBuffer, InvalidFeeForSwap()); require(marginalBufferDelta < bufferDecrease, InvalidFeeForSwap());IL fee bufferDecrease - marginalBufferDelta is combined with swap fee on premium portion on sells
For certain reachable curve states, especially when
BLV is extremely close to book price reserves / circ
the remaining buffer is tiny buffer = reserves - BLV*circ ≈ 0
and convexity is moderate or high convexityExp > 2e18, but it can even happen for 2e18
The sell fee math becomes fragile due to inconsistent rounding between
newBuffer being computed rounded up in computeSwap
uint256 newBuffer = fullMulDivUp(lastInvariant, 1e18, pow(ratio,n));marginalPremium and marginalBufferDelta being computed rounded down or mixed in the SELL branch
uint256 marginalPremium = fullMulDiv(newBuffer, ); uint256 marginalBufferDelta = absDelta.mulWad(marginalPremium);and then enforcing strict inequalities
buffer > newBuffer
marginalBufferDelta < bufferDecrease
when buffer is tiny, the up rounding of newBuffer very easily makes
newBuffer = buffer so bufferDecrease = 0 then revert
or bufferDecrease so small that marginalBufferDelta >= bufferDecrease then revert
So the pool can end up with a minimum sell size, and that minimum can become large hundreds of thousands of tokens
and alongside sell swap DoS, it affects deleverage also
so borrowers cannot reduce debt by selling collateral
Therefore a pool can become effectively non tradable in one direction for most sizes
Below is a curve state where:
selling 100 tokens reverts with InvalidFeeForSwap
selling 175301 tokens succeeds
meaning all sells smaller than about 175k tokens are impossible
// All parameters in WAD units totalSupply = 1_000_000e18 pool.totalBTokens supply = 18_698e18 circ = totalSupply - supply = 981_302e18 bookPrice = reserves/circ = 4e18 BLV = 4e18 - 19 only 19 wei below book price buffer = reserves - BLV * circ = 18_644_738 tiny convexityExp = 10e18 swapFee = 0.01e18In that state
_deltaCirc = -100e18 revert InvalidFeeForSwap
_deltaCirc = -175_301e18 OK
The revert happens because the sell branch tries to compute
IL fee = bufferDecrease - marginalBufferDelta
then adds swap fee on premium but the IL fee step is guarded by
require(marginalBufferDelta < bufferDecrease, InvalidFeeForSwap());and rounding makes that condition fail for a huge range of _deltaCirc
The buy branch explicitly compensates for rounding by recomputing a different buffer newBufferDown for fee calculation
uint256 newBufferDown = fullMulDiv(lastInvariant, pow(), 1e18);But the sell branch does not do an analogous rounding safe recomputation, and instead uses newBuffer that was computed using fullMulDivUp rounded up, then enforces the strict require
https://gist.github.com/GuardianAudits/30d68bde1f917caf36e7f45036e55476
Recommendation
make the sell side fee math use a rounded down buffer, instead of using computeSwap() newBuffer which is fullMulDivUp ceil, this will help, but might be not enough, consider redesigning this area
-
H-03 High Inverse pow rounding inflates buy side fees Rounding Acknowledged
Description
buy side inverse pow rounding can turns most of the trade into creator claimable fees
In computeSwap(), the main swap math uses
uint256 ratio = x1.divWad(c1); uint256 newBuffer = fullMulDivUp(lastInvariant, WAD, powWad(ratio, n));Then the buy branch of _computeFee() recomputes a rounded down buffer using the reciprocal ratio
uint256 newBufferDown = FixedPointMathLib.fullMulDiv( _p.lastInvariant, int256(c1.divWad(x1)).powWad(_p.convexityExp.toInt256()).toUint256(), WAD );That is dangerous because for buys where the pool still holds more than half the supply after the trade
x1 > c1, we havex1 / c1 > 1in computeSwap(), main path is finebut
c1 / x1 < 1inside _computeFee(), powWad(c1/x1, n) can round to 0Example
If post trade
x1 / c1= 3and convexityExp = 50e18
Then
main path uses 3^50, which is still finite fee path uses (1/3)^50 ≈ 1.4e-24 In WAD math, (1/3)^50 rounds to 0That makes
newBufferDown = 0; bufferIncrease = 0;So buy IL fee becomes
fee ≈ marginalBufferDelta + BLV swap feeInstead of
fee = marginalBufferDelta - trueBufferIncrease + BLV swap feeSo the code reclassifies almost the entire premium spend as fee
This lets high convexity pools massively overcharge buys without reflecting that in the visible swapFeePct
also if we looked how that fee is handled afterward
liquidityFee_ = _feesReceived - 50% of _feesReceived; if (pool.totalBTokens >= 95% of totalSupply) { liquidityFee_ = 0; } pool.distributeFees(_bToken, _feesReceived - liquidityFee_);When the pool still holds at least 95% of supply, common early pool state, 100% of the inflated fee is distributed, not retained
So a pool creator can
- create a high convexityExp pool,
- keep inventory high (totalBTokens >= 95% totalSupply),
- let users buy,
- have _computeFee() silently explode the fee,
- claimPoolFees() and siphon those reserves
https://gist.github.com/GuardianAudits/a39a8e6b1afac439eec25ade8c4039ed
Recommendation
we need to not recompute newBufferDown via the reciprocal c1/x1, reuse _newBuffer and round it down
Inside _computeFee() buy branch, replace
uint256 newBufferDown = FixedPointMathLib.fullMulDiv( _p.lastInvariant, int256(c1.divWad(x1)).powWad(_p.convexityExp.toInt256()).toUint256(), WAD );with this, same math as computeSwap(), just rounded down
uint256 newBufferDown = FixedPointMathLib.fullMulDiv( _p.lastInvariant, WAD, int256(x1.divWad(c1)).powWad(_p.convexityExp.toInt256()).toUint256() );This removes the powWad(c1/x1, n) underflow to zero path on buys when x1 > c1 and makes newBufferDown the floor version of the exact buffer computation used for _newBuffer,
so the premium cannot be silently reclassified into creator claimable fees
-
H-04 High Convexity reset removes buy side price impact Math Acknowledged
Description
User triggered convexity relaxation erases buy side price impact, so split buys are massively underpriced
On a buy that pushes the pool to a new all time high book price, _translateConvexity() Path 1 computes a new exponent from the pre trade price
uint256 prevPrice = CurveLib.computeActivePrice(_prev); uint256 floor = FixedPointMathLib.fullMulDiv( prevPrice - _params.BLV, _params.supply.mulWad(_params.circ), actualBuffer.mulWad(_params.totalSupply) );But computeActivePrice() is
P = BLV + buffer * n * totalSupply / (supply * circ)So after the trade, if we set
n_new = (prevPrice - BLV) * supply * circ / (buffer * totalSupply)then the new post trade marginal price becomes
P_after = BLV + buffer * n_new * totalSupply / (supply * circ) = prevPriceSo the code is literally snapping the post trade price back to the pre trade price
_recordSwap() prices the current buy using the old, steeper curve first, then lowers convexityExp after accounting. That means
1 - Trade 1 is charged under old n
2 - State is then rewritten so the next trade starts from the old price again
3 - Repeating small buys lets the attacker walk through a convex curve at an almost flat marginal price
4 - maxCirc/maxReserves are updated first, so the attacker can staircase this repeatedly until n collapses to 2e18
That's order splitting underpricing issue, example state
BLV = 1 convexityExp = 6e18 totalSupply = 1000 pool inventory / circulating = 500 / 500 reserves = 1500 swap fee = 0.3%A single buy of 70 tokens costs about 9367 reserve units.
But if the attacker splits it into 7 buys of 10
total cost is only about 2021
convexityExp ratchets from 6 down to about 2.04
the post trade active price stays ~25 for the first several chunks instead of ratcheting up
That is not normal convex curve behavior. It means fragmentation itself manufactures a huge discount
So attacker can exploit this by
1 - Find a pool with convexityExp > 2e18 and premium gate satisfied
2 - Split a target buy into many small buys
3 - Each buy triggers Path 1 and cheapens the next state
https://gist.github.com/GuardianAudits/afbf268d14324d977507450fb4f7360a
Recommendation
-
H-05 High Round trip swaps enable reserves drain Math Acknowledged
Description
convexityExp can be permanently ratcheted downward with tiny sell / buy back round trips,
even if the attacker returns the pool to essentially the same inventory split
recordSwap() calls translateCurve(_bToken, prev) after every swap
when convexityExp > 2e18, _translateCurve() routes to _translateConvexity()
translateConvexity() has two paths ( currentBookPrice >= maxBookPrice and < maxBookPrice ) and both can only decrease convexityExp
nothing ever increases convexityExp, so it becomes a path dependent, global one way state
Allowing this attack
- Repeatedly do a tiny round trip
sellTokensExactIn()
buyTokensExactOut() (buy back)
- The sell can push price below maxBookPrice ( relaxing convexityExp via the below path ), and the buy-back can cross back above ( relaxing it again via the above path using prevPrice )
- After each loop, totalBTokens / circ are ~unchanged (fees aside), but convexityExp is permanently lower
- Repeat until convexityExp collapses toward 2e18 at trivial cost
- Then execute a single large sellTokens() or deleverage(), with a flatter curve, the protocol pays out materially more reserves than it would on an untouched curve
This differs from splitting a large sell, because here the attacker can pre soften the curve cheaply with micro round trips, end with nearly the same position, and monetize later in one big swap
In the PoC attacker spends ~2 reserves to unlock ~38 extra reserves on the later dump for example, which can be repeated to drain the buffer
https://gist.github.com/GuardianAudits/ed0e25a10793a0f761f7709c72464002
Recommendation
-
M-01 Medium computeSwap can DoS swaps DoS Acknowledged
Description
In computeSwap()
uint256 ratio = x1.divWadUp(c1); require( ratio <= WAD || _params.convexityExp.mulWad(uint256(int256(ratio).lnWad())) <= 135e18, TradeExceedsLimit() ); uint256 newBuffer = FixedPointMathLib.fullMulDivUp( _params.lastInvariant, WAD, int256(ratio).powWad(_params.convexityExp.toInt256()).toUint256() );when ratio < 1e18 ( x1 < c1 ), the require does not compute / guard anything,
and execution proceeds to the powWad() denominator
powWad() in solady is implemented as
return expWad(( lnWad(x) * y) / WAD);and expWad() explicitly returns 0 for sufficiently negative inputs
if (x <= -41446531673892822313) return r; // r = 0So when ratio < 1, lnWad(ratio) is negative, and convexityExp is large
the exponent (lnWad(ratio) * convexityExp) / WAD can become ≤ -41.4465e18
then expWad() returns 0, therefore powWad(ratio, convexityExp) returns 0
and fullMulDivUp( denominator = 0 ) reverts
that's a DoS reachable from pool state supply/circ and a high convexityExp
let n = convexityExp / 1e18
we get the failure when
ln(ratio) * n <= -41.4465 = ratio <= exp(-41.4465 / n)Examples
If n = 50, threshold is ratio <= exp(-0.8289) = 0.436Meaning once post trade x1/c1 drops below ~0.436,
powWad rounds to zero and the swap math reverts
If n = 20, threshold is ratio <= exp(-2.0723) = 0.126 If n = 10, threshold is ratio <= exp(-4.1446) = 0.0159So in high convexity pools, this can happen at non extreme circulation ratios
once the pool reaches a state where for a buy the resulting x1/c1 is below that threshold,
then all paths that call computeSwap for buys, buyTokensExactIn / buyTokensExactOut / leverage, will revert, until the state is moved back into a safe area via enough sells increasing x/c
Recommendation
This issue happens because we divide by powWad(ratio,n) when ratio < 1e18
we can do the same math, but remove the need of powWad()
Instead of
newBuffer = K / ( ratio^n )we can compute the same thing as
newBuffer = K * ( invRatio^n )Mathematically these are identical
-
M-02 Medium computeNextBLV can DoS swaps DoS Acknowledged
Description
computeNextBLV() computes
prevCirc = _params.totalSupply - _prevSupplythen uses prevCirc squared in the denominator
uint256 prevCirc = _params.totalSupply - _prevSupply; uint256 penalty = fullMulDivUp(, _params.supply.mulWad(_params.supply).mulWad(prevCirc).mulWad(prevCirc) // prevCirc^2 );prevCirc can be 0, whenever the previous pool state had
prevSupply = totalSupply ( pool holds all bTokens ), so prevCirc = totalSupply - prevSupply = 0
that can be triggerable DoS for swaps that cross the 95% threshold,
because _recordSwap() calls computeNextBLV() whenever
pool is in the update BLV path, maker.convexityExp = 2e18
and pool ownership is below the safety threshold
if (pool.totalBTokens < pool.totalSupply.mulWad(BUFFER_SAFETY_THRESHOLD)) { // if convexityExp == 2e18: maker.blvPrice = CurveLib.computeNextBLV(getCurveParams(_bToken), prev.supply, prev.reserves); }So If someone sells enough bTokens so the pool ends up with pool.totalBTokens = pool.totalSupply, that makes prevCirc = 0
Next, someone tries to do a single buy large enough that after the buy
pool.totalBTokens < 0.95 * totalSupply ( circulating becomes > 5% )
That swap triggers the BLV update path and calls computeNextBLV() with prevCirc = 0
And the swap revert
a small buy < 5% can move off circ = 0 and avoid the revert on the next swap
But any swap that tries to move from circ = 0 to pool has < 95% of supply in one trade will revert
This affects also leverage() / deleverage() flows because they go through _recordSwap() too
Recommendation
At the start of computeNextBLV()
uint256 prevCirc = _params.totalSupply - _prevSupply; // If previous circulation was zero, there is no meaningful penalty term // So we can keep BLV unchanged if (prevCirc == 0) return _params.BLV;This makes the circ = 0 to big buys transitions safe
-
M-03 Medium Yield distribution can be violated Rewards Acknowledged
Description
The contract is trying to drip rewards out over time
It uses lastUpdated as the clock to measure how much time passed, and tokensPerSecond as the speed of dripping rewards
_sync() only persists tokensPerSecond and lastUpdated when accumulator changes,
but getAccumulator() can and does change tokensPerSecond even when no yield is distributed newYield = 0
That creates a state where
staking.tokensPerSecond gets stuck at an old non zero value, doesn’t decay toward 0 when pendingYield = 0
staking.lastUpdated also gets stuck, timeElapsed grows large
That happens because sync() discards tokensPerSecond decay unless accumulator changed
(uint256 accumulator, uint256 newYield, uint256 tokensPerSecond) = getAccumulator(_bToken); if (accumulator != staking.accumulator) { pool.pendingYield -= newYield.toUint128(); staking.claimableYield += newYield.toUint128(); staking.tokensPerSecond = tokensPerSecond.toUint128(); staking.accumulator = accumulator; staking.lastUpdated = block.timestamp.toUint32(); }So if accumulatorIncrease = 0, happens when pendingYield = 0, then
staking.tokensPerSecond is not updated
staking.lastUpdated is not updated, so timeElapsed keeps growing
the next time pool.pendingYield becomes non zero again,
yield = min(tokensPerSecond * timeElapsed, pendingYield) collapses to pendingYield, meaning all pending yield becomes distributable in one _sync(), and therefore claimable immediately
this is MEV friendly. whoever can be the first to trigger _sync() after pendingYield refills gets to decide
who is staked at the exact instant the entire bucket is pushed into claimableYield/accumulator
This breaks the intended reward drip model
Preconditions for this
1- pendingYield is 0 for a period
2- nobody calls a staking function that runs sync() for some time, so staking.lastUpdated doesn’t
advance and tokensPerSecond decay isn’t persisted,
3- later pendingYield becomes > 0, from swaps, borrow, and someone triggers sync()
https://gist.github.com/GuardianAudits/445dde235979efc5e0b7e9fc35769a39
Recommendation
If this is acceptable consider acknowledging it
-
M-04 Medium Reopen buy bypasses stored invariant Logical Error Acknowledged
Description
In computeZeroCircSwap()
When a pool is in circ = 0 and someone reopens it with a buy, the code does
uint256 ratio = _deltaCirc.divWadUp(x1); uint256 newBuffer = FixedPointMathLib.fullMulDivUp( _p.lastInvariant, int256(ratio).powWad(_p.convexityExp.toInt256()).toUint256(), WAD );If the reopen buy is small enough, ratio < 1, and for sufficiently large convexityExp the WAD fixed point result of
powWad(ratio, convexityExp)rounds down to 0
In the normal swap path, that same kind of rounding results in the division by zero DoS issue
But here, in computeZeroCircSwap, the zero is used as a multiplier, so the trade does not revert. Instead
newBuffer = 0 bufferReserves = 0and the reopen buy is charged only
payment = _deltaCirc * BLV * (1 + swapFee)So the buyer gets reopened supply at pure BLV + fee, while the invariant implied premium is silently discarded
a user can push a pool into circ = 0 by selling all circulating bTokens through the special c1 = 0 branch in computeSwap
after that, recordSwap() does not refresh lastInvariant, because it only recomputes the invariant when
pool.totalBTokens < pool.totalSupply * 95%at circ = 0, the pool holds 100% of supply, so maker.lastInvariant stays as a stale positive K from the pre zero state
that stale K is exactly what computeZeroCircSwap() is supposed to use to price the first reopen buy. But the WAD rounding zeroes it out
The rounding condition is
(deltaCirc / x1) ^ convexityExp < 1e-18with convexityExp = 50e18 for example, a reopen buy below about 30.4% rounds to 0 and triggers the issue
So in high convexity pools, an attacker can reopen a large chunk of supply at floor pricing
The first reopen buyer can acquire supply far cheaper than the stored invariant requires
That matters because those bTokens can then be sold later once the pool is reopened, or used to capture upside while the pool’s premium was never paid on entry
So the premium encoded in lastInvariant is effectively bypassed
https://gist.github.com/GuardianAudits/4d947e3373396c6e391a01d385fe0ae5
Recommendation
we need to prevent the powWad(ratio, convexityExp) rounding to 0 from silently zeroing newBuffer / premium on reopen buys
-
M-05 Medium Non additive IL fee exploitable Logical Error Acknowledged
Description
In _computeFee() / computeSwap()
For a sell, the code does
invariantDelta = BLV * Δ + bufferDecrease fee = (bufferDecrease - marginalBufferDelta) + premiumSwapFee so userOut = invariantDelta - feethat simplifies to
userOut ≈ Δ * ( BLV + (1 - swapFee) * marginalPremium_after )So the seller is effectively paid the post trade marginal premium across the whole trade, not the integral over the interval
for a buy, the same thing happens in reverse
the buyer pays roughly the post trade marginal premium across the whole trade, plus the BLV fee,
Instead of paying the integral over the interval
That means the fee model is path dependent,
one big sell gets paid using the worst marginal price on the path,
but many small sells get paid using a sequence of better marginals,
so splitting strictly improves execution.
Likewise, one big buy pays the worst marginal price on the path,
but many small buys pay a staircase of cheaper marginals,
so splitting strictly lowers cost.
therefore, the IL fee is not enforceable on chain, because a user can always partition the trade
a contract can loop sellTokensExactIn() or buyTokensExactOut() in one transaction and collapse the non linear fee toward zero
https://gist.github.com/GuardianAudits/2debf9a39176280c73d7772b20e4f6c8
Recommendation
Consider replacing _computeFee() with additive fee
https://gist.github.com/GuardianAudits/cd76e271a4d4083beb5dfd2c4b609dc2
-
M-06 Medium TPS decay computed but never applied Logical Error Acknowledged
Description
In getAccumulator() the code compute
uint256 decayedTps = tokensPerSecond_.mulWad(_decayFactorWad(timeElapsed, timeToDistribute));but then we update from the old stored TPS
if (targetTps > decayedTps) { tokensPerSecond_ += gain; } else { tokensPerSecond_ -= gain; }It should really update from the decayed TPS, not the stale one
otherwise decay is only used for comparison, but not actually used as the new base rate
After long gaps decayedTps can go ~ 0 so gain goes ~ 0, leaving TPS stale
because getAccumulator() computes decayedTps but uses tokensPerSecond_ from the pre decay value and uses it for yield, so decay isn’t applied to the stored TPS
Recommendation
apply decay to the TPS state not just the comparison
// getAccumulator() uint256 decayedTps = tokensPerSecond_.mulWad(_decayFactorWad(timeElapsed, timeToDistribute)); tokensPerSecond_ = decayedTps; // use decayed TPS as the new base -
L-01 Low Pool initialization may fail Validation Acknowledged
Description
The following requirement was added to the first branch in
CurveLib.computeInitialCurveParams()require(convexityExp_ > 2e18 && convexityExp_ <= MAX_CONVEXITY, InvalidConvexityExp());Because of rounding during arithmetic operations, the resulting
convexityExpmay end up being exactly2e18. Since2e18is excluded from the allowed values, the pool initialization will fail.Recommendation
Consider relaxing the check to allow the convexity to be
2e18. -
L-02 Low Impossible to default if resulting
circ == 0DoS AcknowledgedDescription
On each call to
defaultSelf()thetotalBTokensare increased and therefore the circulating supply decreases. After that,CurveLib.computeInvariant()is executed. This call will revert if the resulting circulating tokens are 0, blocking the default from happening.Recommendation
Consider not recomputing the invariant if the resulting
circ == 0. In fact, you can modifycomputeInvariant()to return the oldKifcirc == 0. -
L-03 Low Hook swap bypasses reserve dust accounting Logical Error Acknowledged
Description
buyTokensExactIn() explicitly captures the dust difference between user supplied _amountIn and the curve’s computed actualCost
uint256 actualCost = (-userDeltaReserves).toUint256(); uint256 dustFee = _amountIn > actualCost ? _amountIn - actualCost : 0; if (msg.sender != address(this)) { pool.reserve.takeReserves(msg.sender, dustFee); FeeLib.distributeFees(pool, _bToken, dustFee); }In the Uniswap-v4 hook BHook.beforeSwap, the hook calls BSwap() as a self-call, so inside BSwap we have msg.sender == address(this). That means the dustFee capture block is skipped
So the protocol can end up with extra reserve tokens actually received (the exact-in amount) that are not reflected in per-pool accounting (pool.totalReserves, fee distribution, et.) because the dust capture path never ran
Recommendation
Replace the dust block in buyTokensExactIn with
uint256 dustFee = _amountIn > actualCost ? _amountIn - actualCost : 0; if (dustFee > 0) { State.Pool storage pool = State.pool(_bToken); if (msg.sender != address(this)) { pool.reserve.handleIncoming(msg.sender, dustFee); } FeeLib.distributeFees(pool, _bToken, dustFee); } -
L-04 Low Hidden reserves in hook swaps Logical Error Acknowledged
Description
In hook exact-output sells, BHook self-calls sellTokensExactOut,
so msg.sender = address(this) skips the dustFee clawback + distributeFees block.
But swapTokens / _updateAccounting() still updates pool.totalReserves using the full userDeltaReserves (the curve’s actual received amount),
while BHook.beforeSwap only transfers the requested _amountOut,
leaving actualReceived - _amountOut sitting in the Relay untracked
Recommendation
Replace the dust block in sellTokensExactOut with
uint256 dustFee = actualReceived > _amountOut ? actualReceived - _amountOut : 0; if (dustFee > 0) { State.Pool storage pool = State.pool(_bToken); if (msg.sender != address(this)) { pool.reserve.handleIncoming(msg.sender, dustFee); } FeeLib.distributeFees(pool, _bToken, dustFee); } -
I-01 Informational Initial credits may be unclaimable Warning Acknowledged
Description
New
getBorrowForCollateral()checks were added when a pool with credit position is created and when user claims. However,getBorrowForCollateral()performs math operations which include rounding and because of thatf(x) == f(x1 + x2), wherex = x1 + x2is not guaranteed.This can lead to valid states causing unclaimable credits. For example:
BLV = 0.05 etherinitialCollateral = 1000 etherinitialDebt = 50 ether
The pool will be created successfully because
initialDebt == maxDebt. However because of rounding issues some of the users may not be able to claim their part.Recommendation
Run a local simulation of the maximum allowed debt per each user before creating a pool with credit positions. It's important to not use
getBorrowForCollateral()for this simulation because the pool is not created and it values will be 0. -
I-02 Informational Pool creation cannot be paid with native tokens Best Practices Acknowledged
Description
BFactory.createPool()useshandleIncomingto charge the user the needed amount of reserve tokens. Usually, this function supports payment with native token to be converted to its wrapped version, but becausecreatePool()is not payable, such payments are impossible.Recommendation
If this feature is desired, consider adding
payabletocreatePool(). Otherwise, acknowledge the issue -
I-03 Informational Outdated comment Best Practices Acknowledged
Description
The comment at
BFactory.sol#L181-182is outdated as there is no vault anymore.// use the vault-credited amount (after rounding) for totalReserves pool.totalReserves = totalReserves.toUint128();Recommendation
Remove the comment.
-
I-04 Informational Lack of validation for
creatorFeePctValidation AcknowledgedDescription
BController.modifyCreatorFeePct()allows the executor to change the the creator fee for a given pool, but does not validate the new fee is in range (<= 1e18).Recommendation
Consider adding the same validation as in
BFactory.createPool()require(params.creatorFeePct <= 1e18, InvalidCreatorFee()); -
I-05 Informational Redundant function parameter Superfluous Code Acknowledged
Description
The internal
BCredit._previewRepay()function defines abTokenparameter that's never used.Recommendation
Remove the
bTokenparameter. -
I-06 Informational
previewRepay()should returndebtToRepaySuggestion AcknowledgedDescription
The debt repayment flow was reworked to repay the exact amount sent by the user and revert if it's more than the active debt.
The public
previewRepay()function now returns onlycollateralRedeemed_, but it still caps the debt that will be repaid to the maximum available amount._reservesIn = FixedPointMathLib.min(_reservesIn, account.debt);This may mislead offchain integrators that use this function.
Recommendation
Consider returning
_reservesInas a second parameter so it's known how much of the debt was repaid. -
I-07 Informational Redundant mulWad() in active price computation Superfluous Code Acknowledged
Description
CurveLib.computeActivePrice()computes the premium to be added toBLV:uint256 premium = FixedPointMathLib.fullMulDiv( _params.reserves - _params.BLV.mulWad(_params.circ), _params.convexityExp.mulWad(_params.totalSupply), _params.supply.mulWad(_params.circ).mulWad(WAD) );On the last line of the code snippet, we can see
mulWad(WAD). BecausemulWadis equivalent tox * y / WAD, the end result will bex * WAD / WAD = x, which makes the operation unnecessary.Recommendation
Delete the last
mulWaduint256 premium = FixedPointMathLib.fullMulDiv( _params.reserves - _params.BLV.mulWad(_params.circ), _params.convexityExp.mulWad(_params.totalSupply), - _params.supply.mulWad(_params.circ).mulWad(WAD) + _params.supply.mulWad(_params.circ) ); -
I-08 Informational Unused errors Superfluous Code Acknowledged
Description
The following errors in
BFactoryare not used:InsufficientPoolBTokens()InvalidBTokenDecimals()InvalidReserveDecimals()
Recommendation
Remove the errors
-
I-09 Informational Comment mismatch on premium threshold Documentation Acknowledged
Description
In
MakerLib._translateConvexity()the comment says “premium > 100% (activePrice > 2 × BLV)”, but the code returns only whenpremium < WAD, so relaxation happens whenpremium >= WAD, i.e.,activePrice >= 2 × BLV(subject to rounding). This is a comment‑logic mismatch that can mislead reviewers about the exact gating condition.Recommendation
Align the comment with the code behavior.
-
I-10 Informational Confusing plural used for variable naming Best Practices Acknowledged
Description
The credit account of the user in
defaultSelf()is namedaccountsinstead ofaccountwhich may be confusing for code readers.State.CreditAccount storage accounts = credit.accounts[msg.sender];Recommendation
Consider renaming the variable to
account. -
I-11 Informational K can overflow DoS Acknowledged
Description
Currently,
Kis stored asuint128.This may not be enough for pools with larger supply, which will cause overflow issues.Recommendation
Consider whether switching to
uint256is favorable.
Remediation Review 2
12 findings · March 24 to April 7, 2026-
C-01 Critical Permanent BLV breakage Logical Error Acknowledged
Description
computeNextBLV() have a dangerous denominator that is built with two nested floor normalizations
uint256 penaltyDenom = fullMulDiv( fullMulDiv(_params.supply, _params.supply, 1e18), fullMulDiv(prevCirc, prevCirc, 1e18), 1e18 );That means penaltyDenom can become 0 even when both params.supply > 0 and prevCirc > 0, once that happens, the later fullMulDivUp reverts
fullMulDivUp( , penaltyNumer, penaltyDenom)The condition is
currentSupply * previousCirculation < 1e27 in WAD units,which for 18 dec tokens is about
current inventory * previous circulation < 1e-9 token²Example on a 18-dec pool
totalSupply = 10_000e18 block-start prevCirc = 0.00001e18 tokens block-end supply = 0.00002e18 tokensThen inside computeNextBLV()
FullMulDiv(supply, supply, 1e18) = 4e8 FullMulDiv(prevCirc, prevCirc, 1e18) = 1e8 outer FullMulDiv(4e8, 1e8, 1e18) = 0So the next commit hits a zero denominator and reverts, allowing this
The poisoning swap in a block succeeds, In the next block, the first swap tries to preview/commit deferred maker state
previewDeferredMakerState() calls computeNextBLV() in the convexityExp = 2e18 path below the 95% threshold
BSwap buy / sell, BHook.beforeSwap, BCredit.leverage, deleverage are dosed
Because every future interaction retries the same poisoned commit first, the pool is stuck until privileged intervention.
There is no public recovery path that clears blockPricing or overwrites maker state
So the pool stays bricked across later blocks until doing a contract upgrade
https://gist.github.com/GuardianAudits/ca56bc481412b7389e46bb27169d82fa
Recommendation
uint256 penaltyDenom = FixedPointMathLib.fullMulDiv( FixedPointMathLib.fullMulDiv(_params.supply, _params.supply, 1e18), FixedPointMathLib.fullMulDiv(prevCirc, prevCirc, 1e18), 1e18 ); if (penaltyDenom == 0) return _params.BLV; -
C-02 Critical Pending commit can be permanently poisoned Logical Error Acknowledged
Description
Pending block commit can be permanently poisoned by an unbounded powWad, the path is
swapTokens() -> _advanceBlockPricing() -> _commitDeferredMakerState() -> previewDeferredMakerState() -> _previewTranslateConvexity() -> previewRecomputeMaxReserves()Inside _previewRecomputeMaxReserves()
uint256 positionRatio = _next.maxCirc.mulWad(_params.supply) .divWad(_params.circ.mulWad(minSupply)); uint256 impliedBuffer = _buffer.mulWad( int256(positionRatio).powWad(int256(_nNew)).toUint256() );There is no overflow check before powWad(positionRatio, nNew)
This executes only on a below ATH buy block when convexityExp > 2e18 and the pool is still below the 95% inventory threshold. so when
the pool previously reached a large maxCirc,
later sells pushed current circ back down,
then someone does a small buy while still below the old ATH
The key is that the protocol swap guards only bound the exponent for the current trade ratio supply/circ or circ/supply inside computeSwap
But _previewRecomputeMaxReserves() uses a different base
positionRatio = (maxCirc supply) / (circ (totalSupply - maxCirc))That base can be much larger than any ratio checked during swaps
A reachable shape is
previous ATH: maxCirc = 0.99 * totalSupply current state after sells: circ = 0.06 * totalSupply current convexityExp = 20e18Then current swap state ratio is only about 0.94 / 0.06 = 15.7, which is well inside the normal computeSwap safety bound, but
positionRatio = (0.99 * 0.94) / (0.06 * 0.01) = 1551For a tiny below ATH buy, _previewTranslateConvexity() computes nFloor from price continuity, and as the buy size goes to zero, nFloor approaches the current convexityExp. So nFloor can stay around 19–20e18
At that point
ln(1551) * 19 > 135So powWad(positionRatio, nFloor) overflow / revert
The triggering buy succeeds in its own block, because the maker update is deferred
Then on the next block, every future swap starts with _advanceBlockPricing()
advanceBlockPricing() must commit the old pending block,
The commit reenters the same reverting _previewRecomputeMaxReserves() path, so the pending block is never cleared
That means the pool gets stuck with a poisoned State.blockPricing entry. After that all of these revert for that pool, Swaps, Hook swaps, Leverage, deleverage
The pool stays bricked across later blocks until doing a contract upgrade
https://gist.github.com/GuardianAudits/7e851cfcdc8b861580c488e87ca3ccbf
Recommendation
-
H-01 High Active price mismatch censors exact buys DoS Acknowledged
Description
quoteBuyExactOut() mixes two different states in the same calculation
amountIn comes from quoteSwap(), which uses _previewBlockPricing() and prices off the frozen block start snapshot
but initialPrice comes from computeActivePrice(getCurveParams()), which can reflect the live pool state in the current block
on buys below BUFFER_SAFETY_THRESHOLD, _updateAccounting() immediately keeps part of the fee inside pool.totalReserves, raising the live active price
that means after one buy, the live initialPrice can be higher than the frozen marginal price used for the quote
when that happens, slippage = (effectivePrice - initialPrice) / initialPrice in quoteBuyExactOut() underflows and reverts
uint256 initialPrice = CurveLib.computeActivePrice(p); uint256 effectivePrice = amountIn / amountOut; slippage_ = (effectivePrice - initialPrice).divWad(initialPrice);This also breaks exact input buys, because _solveBuy() depends on _quoteBuyCost(), which calls quoteBuyExactOut()
So buyTokensExactIn() can be DoSed
Recommendation
Compute InitialPrice from the frozen snapshot
-
M-01 Medium Stale invariant doesn't account for fees Logical Error Acknowledged
Description
During each swap, the swap fee is added to the pool's liquidity only if the circulating supply is high enough
if (uint256(pool.totalBTokens) < uint256(pool.totalSupply).mulWad(BUFFER_SAFETY_THRESHOLD)) { liquidityFee_ = _feesReceived - `FEE_DISTRIBUTION_SHARE.mulWad(_feesReceived); }Even though the curve parameters are frozen for the duration of the block,
totalPoolTokensis updated after every swap. At the start of the next block, theinvariantwill be refreshed only if the new curve's circulating is not above theBUFFER_SAFETY_THRESHOLD. Previously, this logic was executed after every swap, which ensured there would be no fees distributed and therefore the invariant won't change. However, with the new design there may be fees generated in the block before the curve ended up in theBUFFER_SAFETYregion. The fees will not be accounted in the pricing mechanism because the invariant will remain stale.This results in users getting better execution prices, and in some cases the calculation may result in user receiving both
bTokenandreserveduring a swap. This will be caught by the validation inMakerLib()and users will have their transactions reverted withInvalidSwapDirection()Recommendation
Consider updating the invariant if a new valid value can be computed. Keep in mind this can result in the curve changing shape even in the buffer threshold area.
https://gist.github.com/GuardianAudits/81cd73fa047b1fc4476a087ee75f38c6
-
M-02 Medium Mixed direction block pricing not correct Logical Error Acknowledged
Description
BlockPricingLib says swaps should be charged on a frozen block start curve as
Q(cumulativeAfter) - Q(cumulativeBefore)But the implementation does not track one cumulative signed supply delta
It tracks two separate one sided deltas, blockBuyDeltaCirc and blockSellDeltaCirc
and cumulativeQuoteBounds() uses only the accumulator that matches the current trade direction
That means a buy after earlier sells in the same block is priced as if those sells never happened
as well a sell after earlier buys in the same block is priced as if those buys never happened
even though _updateAccounting() is mutating pool.totalBTokens and pool.totalReserves after every swap
So _cumulativeQuoteBounds() does this logically
For a buy, price from
buyCum -> buyCum + deltaFor a sell, price from
-sellCum -> -(sellCum + delta)a buy only sees earlier buys, and a sell only sees earlier sells
In a frozen curve cumulative pricing, the next trade must use the net signed block flow
Recommendation
TBD
-
M-03 Medium Swaps revert due to fee subtraction underflow DoS Acknowledged
Description
BlockPricingLib._quoteDeltaFromSnapshot()is computing the fee to be paid by the user as the delta betweendeltaUserWadanddeltaInvariantWaddepending on the trade directionThe two delta variables are computed as it follows:
int256 deltaUserWad = afterUserWad - beforeUserWad; int256 deltaInvariantWad = afterInvWad - beforeInvWad;Let's say
f(x) = fee of Q(x). Due to buffer roundings andzeroFloorSubs, a possible state isf(cumulativeAfter) <= f(cumulativeBefore). Then the relationship between the two delta variables is:deltaUserWad > deltaInvariantWadWhen the code reaches the
if/elseblock it will try to computefeesReceived_, but the subtraction will result in an underflow and the swap will failif (deltaUserWad < 0) { // BUY: ceil user payment, ceil curve need, fee is residual uint256 userPayNative = NormalizeLib.denormalizeWadUp(uint256(-deltaUserWad), rDec); uint256 curveNeedNative = NormalizeLib.denormalizeWadUp(uint256(-deltaInvariantWad), rDec); deltaUserReserves_ = -(userPayNative.toInt256()); feesReceived_ = userPayNative - curveNeedNative; } else { // SELL: floor user receipt, floor curve release, fee is residual uint256 userReceiveNative = NormalizeLib.denormalizeWad(uint256(deltaUserWad), rDec); uint256 curveReleaseNative = NormalizeLib.denormalizeWad(uint256(deltaInvariantWad), rDec); deltaUserReserves_ = int256(userReceiveNative); feesReceived_ = curveReleaseNative - userReceiveNative; }Recommendation
If this behavior is not acceptable, you can cap the fee at 0
https://gist.github.com/GuardianAudits/14f0e340c04a97546e3b92efc044953e
-
M-04 Medium Lower domain failure in _tryRecomputeMaxReserves DoS Acknowledged
Description
we only defends the positionRatio > 1 overflow side
if ( positionRatio > WAD && _nNew.mulWad(uint256(int256(positionRatio).lnWad())) > 135e18 ) { return false; }but the function does not defend the positionRatio = 0 or tiny positive < 1 side before calling
uint256 growth = int256(positionRatio).powWad(int256(_nNew)).toUint256();That matters because positionRatio is built with two floor divisions
num = fullMulDiv(_next.maxCirc, _params.supply, WAD); den = fullMulDiv(_params.circ, minSupply, WAD); positionRatio = fullMulDiv(num, WAD, den);So in below ATH high convexity pools, positionRatio can floor all the way to 0 when historical maxCirc is tiny relative to current circ
Example Inside _tryRecomputeMaxReserves
num = floor(1 * 9400e18 / 1e18) = 9400 den = floor(600e18 * (10000e18 - 1) / 1e18) ≈ 6_000_000e18 positionRatio = floor(9400 * 1e18 / 6_000_000e18) = 0So the next block deferred commit hits powWad(0, _nNew) and reverts
The impact is the same poisoned deferred state pattern but with lower likelihood
Recommendation
Keep _tryRecomputeMaxReserves fail closed on the lower side too
uint256 positionRatio = FixedPointMathLib.fullMulDiv(num, WAD, den); if (positionRatio == 0) return false; if (positionRatio > uint256(type(int256).max)) return false; if ( positionRatio > WAD && _nNew.mulWad(uint256(int256(positionRatio).lnWad())) > 135e18 ) { return false; } uint256 growth = int256(positionRatio).powWad(int256(_nNew)).toUint256(); if (growth == 0) return false; -
L-01 Low Dripping assymmetry when pending yield is 0 Gaming Acknowledged
Description
BStaking._sync()refreshestokensPerSecondandlastUpdatedwhen there is no accumulator change because of 0pendingYieldelse if (pool.pendingYield == 0 || staking.totalStaked == 0) { staking.tokensPerSecond = tokensPerSecond.toUint128(); staking.lastUpdated = block.timestamp.toUint32(); }Once new yield is generated, the instantly distributable amount will depend on whether
_sync()was called in the 0 yield period. If no interaction triggers_sync()when yield is 0,tokensPerSecondwill still be stuck, allowing a big part of the yield (if not the whole) to be distributed as soon as it enters the system.Recommendation
You can execute a 0 deposit in
FeeLib.distributeFees()if the yield was previously 0. This will trigger a_sync()right before the new yield arrives andtokensPerSecondwill be properly updated.if (remaining > 0){ if (_pool.pendingYield == 0) { BStaking(address(this)).deposit(_bToken, address(this), 0); } _pool.pendingYield += remaining; } -
L-02 Low
BLensmay return stale curve parameters Logical Error AcknowledgedDescription
The
BLensfunctionsblvPrice(),convexityExponent()andlastInvariantread the parameters' value from the committed state. This will result in integrators getting stale data when there is a pending state to be committed.Recommendation
Consider calling
MakerLib.getCurveParams()and reading each value from it. -
I-01 Informational
BCredit.claim()integration notes Documentation AcknowledgedDescription
BCredit.claim()allows anyone to initiate the claim for a given user, providingasNative. This can result in locked funds if integrators are not aware of it. For example, if they are using a contract that operates with only one type of token, anyone can trigger a claim with the inverseasNativevalue to lock the funds.Recommendation
Document this, so integrators can handle both types of rewards.
-
I-02 Informational Not all tokens can be bought Informational Acknowledged
Description
If a swap tries to buy all the tokens from the pool,
x1will be calculated as 0.uint256 x1 = (_params.supply.toInt256() - _deltaCirc).toUint256();Then it will be used as a denominator to compute
invRatio, which will result in a revert and inability to buy all tokens from the pool.uint256 invRatio = c1.divWadUp(x1);Even if a trade leaves 1 wei sitting in the pool, the resulting value of
invRatiowill be huge and the transaction will revert when performing next calculations. This creates a limit of how much tokens can be bought.Recommendation
Be aware of this behavior of the pool.
-
M-05 Medium Sell sign inversion from invariant rounding DoS Acknowledged
Description
when the pool is in the supply < circ regime, the protocol compresses the buffer into lastInvariant with one rounding direction, then later expands it back with the opposite rounding direction
computeInvariant() stores K using
invRatio = circ.divWad(supply) (round down) K = buffer.divWadUp(powWad(invRatio, n))computeSwap() later reconstructs the post trade buffer using
invRatio = c1.divWadUp(x1) (round up) newBuffer = fullMulDivUp(K, powWad(invRatio, n), WAD)That floor to ceil asymmetry is not an inverse map. In high convexity / low buffer supply < circ states, a sell can reconstruct a newBuffer that is too large
once that happens, the sell path can do something impossible
priceAfter >= priceBefore on a sell
and in worse cases newReserves > oldReserves, so invariantDelta becomes negative on a sell
That bad sign then blows up the sell fee path, because _computeFee() assumes sell side
_invariantDelta > 0 and does uint256(_invariantDelta)
State example In the poc
totalSupply = 1_000_000e18 totalBTokens (supply) = 200_000e18 circ = 800_000e18 BLV = 0.1e18 totalReserves = BLV * circ + 200e18 = 80_200e18 convexityExp = 30e18 lastInvariant = 174Now if we sell 1e18 bToken
priceBefore = 0.1375e18 priceAfter = 0.1376068710996149e18 <- price rises on a sell invariantDelta = -0.47073133411459367e18 <- negative on a sellIn that same state, sells of 0.1, 0.5, 1, and 2 tokens all hit the negative invariantDelta region
The first positive output sell only appears around 5 tokens, and even there the sell still raises price
_computeFee() sell branch casts uint256(invariantDelta) on a negative int that wraps to a huge uint then fee.toInt256() reverts
So once a pool enters this region, legitimate sell side flows could become uncallable
https://gist.github.com/GuardianAudits/9156c953cdbc2d0a553b71dbdeb631a5
Recommendation
In the computeSwap() x1 < c1 branch
uint256 invRatio = c1.divWad(x1); // not divWadUpThis matches the supply < circ rounding used when computeInvariant() stores K, so sells stop overstating newBuffer
But do it for sell only, because the x1 < c1 branch is used by buys too
uint256 invRatio = _deltaCirc < 0 ? c1.divWad(x1) : c1.divWadUp(x1);This might not completely mitigate it, but it will make the state harder to reach,
Also consider acknowledging if the Impact is acceptable
Remediation Review 3
7 findings · April 17, 2026-
L-05 Low
computeNextBLV()overflow DOSes pool DoS AcknowledgedDescription
In
computeNextBLV(), thepenaltyDenomcalculation squares an intermediate value:uint256 penaltyDenom = FixedPointMathLib.fullMulDiv( FixedPointMathLib.fullMulDiv(_params.supply, prevCirc, 1e18), FixedPointMathLib.fullMulDiv(_params.supply, prevCirc, 1e18), 1e18 );This computes
(supply * prevCirc / 1e18)² / 1e18. When bothsupplyandprevCircare large enough (token supplies exceeding ~1e33 at 18 decimals), the result overflowsuint256andfullMulDiv()reverts withFullMulDivFailed().Since
computeNextBLV()is called during_commitDeferredMakerState(), which runs inside_advanceBlockPricing()at the start of every swap in a new block, the revert permanently bricks the pool — pending state from the previous block can never be committed, and all subsequent swaps revert.Recommendation
If the use case allows, cap the accepted token
totalSupplytotype(uint108).maxinsetBTokenDeployment()orcreatePool(). With uint108-bounded values,penaltyDenomstays within2^252, safely belowtype(uint256).max.Additionally,
penaltyNumeris currently rounded down viafullMulDiv(), which makes thepenaltysmaller than the exact value. SincemaxBLV = bookPrice - penalty, a smaller penalty allows a highermaxBLV, letting BLV rise slightly past the K-preserving threshold. For correctness,penaltyNumershould be rounded up (usingfullMulDivUp()) to maximize the penalty and keepmaxBLVconservative. Note that rounding up the intermediate calculations increases the overflow risk at the boundary, which is another reason to reduce the accepted supply range from uint128 to uint108. -
L-04 Low Stale BLV in BCredit and quoteLeverage Logical Error Acknowledged
Description
BCreditandBLens.quoteLeverage()readblvPricedirectly from committed storage viaState.maker(_bToken).blvPriceinstead of previewing the deferred state throughMakerLib.getCurveParams(). The committedblvPriceis only updated when_commitDeferredMakerState()runs, which is triggered by swaps via_advanceBlockPricing(). In a new block with uncommitted state from the previous block's trades, any call to these functions before a swap sees a stale BLV.In
BCredit, three functions are affected:getBorrowForCollateral(),getMaxBorrow(), and_previewBorrow(). All compute max debt capacity asblv.mulWad(collateralWad). Since BLV is monotonically increasing, the stale value is always lower, meaning users receive less borrowing power than they should.BLens.quoteLeverage()has an additional inconsistency: it callsactivePrice(_bToken)which correctly previews the deferred state throughMakerLib.getCurveParams(), but then subtracts the stalemaker.blvPricefrom it. The expressionactivePrice(_bToken) - maker.blvPricemixes a live value with a stale one. On top of that, it callsBCredit.getBorrowForCollateral()which itself uses the stale BLV, soborrowAmountis already undervalued before the leverage math begins.The inaccuracy could cause failed transactions when a user acts on a stale quote that underestimates their capacity.
Recommendation
Read BLV through
MakerLib.getCurveParams()which previews the deferred state. InBCredit, replaceState.maker(_bToken).blvPricewithMakerLib.getCurveParams(_bToken).BLVingetBorrowForCollateral(),getMaxBorrow(), and_previewBorrow(). InBLens.quoteLeverage(), replace the directState.maker(_bToken)read with the curve params fromgetCurveParams()to keep BLV consistent with the already-liveactivePrice(). -
L-03 Low Fee rounding favors buyer over protocol Rounding Acknowledged
Description
In
_computeFee(),marginalPremiumis computed using floor rounding throughout —fullMulDiv()for the outer division, andmulWad()for both the numerator (convexityExp * totalSupply) and denominator (c1 * x1) — regardless of trade direction:uint256 marginalPremium = FixedPointMathLib.fullMulDiv( _newBuffer, _p.convexityExp.mulWad(_p.totalSupply), c1.mulWad(x1) );For sells this is partially correct — floor rounding on the numerator
mulWad()and outerfullMulDiv()understatesmarginalReceipt, overstates the fee, and the protocol captures the dust upfront. However, the denominatorc1.mulWad(x1)also floors, which makes the fraction larger and partially counteracts the understatement — ideally it should usemulWadUp()for sells to consistently round down.For buys, the uniform floor rounding understates
marginalCost, which understates the fee. SinceuserDelta_ = invariantDelta_ - fee_, the understated fee makesuserDelta_less negative — the buyer pays less while fee recipients receive less.The comment claims "~1 WAD wei dust stays in pool reserves rather than being extracted as fee," but this is inaccurate:
- For buys, the dust leaks to the user, not to the pool.
- The pool's reserves are determined by
invariantDelta_(fixed by ceilednewBuffer), so they are unaffected by fee rounding in either direction.
Additionally, the block pricing mechanism in
BlockPricingLibdifferences two cumulativecomputeSwap()calls. If the first buy's cumulative endpoint rounds down, the second buy's baseline shifts, causing it to overpay. With floor rounding, if no subsequent swap occurs, the dust from the understated fee is simply never collected. With ceil rounding, the protocol would have already captured it from the first buyer.Recommendation
Round
marginalPremiumup for buys and down for sells, applying consistent rounding to all intermediate operations:uint256 marginalPremium; if (_deltaCirc > 0) { // BUY: round up to not understate fee marginalPremium = FixedPointMathLib.fullMulDivUp( _newBuffer, _p.convexityExp.mulWadUp(_p.totalSupply), c1.mulWad(x1) ); } else { // SELL: round down to not overstate receipt marginalPremium = FixedPointMathLib.fullMulDiv( _newBuffer, _p.convexityExp.mulWad(_p.totalSupply), c1.mulWadUp(x1) ); } -
I-01 Informational Integration note completeness Documentation Acknowledged
Description
The following note was added to
BStaking.claim()in response to I-01/// @dev Since this function is permissionless all integrating contracts need to handle /// native token transfers or risk losing funds.However, it goes both ways. A contract that works only with native tokens can have its rewards claimed with
asNative = false, resulting in stuck rewards.Recommendation
Modify the note to mention both types of rewards.
-
M-01 Medium Reallocated fees commit divergency DoS Acknowledged
Description
When the pool is in the safety region,
_commitDeferredMakerState()computes the expected reserves derived from the implied buffer. Then it compares this value with the storedtotalReserves, and the excess is distributed as fees and subtracted fromtotalReserves. There are a few problems with this approach:The fee removal logic is not replayed in
_previewDeferredMakerState(). This leads to all quoting functions reading an inflated value fortotalReserves. When a quote callsCurveLib.computeSwap, theinvariantDeltavalue will also be inflated.invariantDelta_ = _params.reserves.toInt256() - newReserves;This can result in a positive
invariantDelta_for buys, DOS-ing theBSwap.buyTokensExactIn()flow. For sells, the inflatedinvariantDelta_value will result in wrong fee being calculated when thezeroFloorSubcase is not entered.Because
totalReservesis artificially increased until the fee is settled,GuardLib.ensureSolvent()becomes less restrictive and the risk of the protocol being left in a non-solvent state increases.function ensureSolvent(BToken _bToken) internal view { require(State.pool(_bToken).totalReserves >= State.credit(_bToken).totalDebt, GuardLib_Insolvent()); }This discrepancy will also affect third-party integrators that read from
BLens.The way the fee surplus is calculated based on the implied buffer and implied reserves is also risky because these calculations are never exact.
The current design of taking fees out of the pool opens up a new attack vector where stakers can push the pool in the safety regime intentionally in order to get these fees distributed to them. It becomes easier for block builders because they can order the transactions in a way that benefits them and requires minimal funds from them.
Recommendation
If the staker risk is acceptable and you plan to stick to the current design, consider tracking potential fees in a separate variable, not in
totalReserves. Then correct the preview logic to account for that variable as well. -
L-02 Low Buffer threshold precision mismatch Math Acknowledged
Description
The
BUFFER_SAFETY_THRESHOLDcheck uses native bToken precision in_updateAccounting()and_commitDeferredMakerState(), but wad (18-decimal) precision in_previewDeferredMakerState()._updateAccounting()and_commitDeferredMakerState()compare rawpool.totalBTokensagainstpool.totalSupply.mulWad(BUFFER_SAFETY_THRESHOLD)._previewDeferredMakerState()comparescurrentParams.supplyagainstcurrentParams.totalSupply.mulWad(BUFFER_SAFETY_THRESHOLD), where both operands were normalized to 18 decimals viaNormalizeLib.normalizeWad().For bTokens with fewer than 18 decimals,
mulWad()truncation at native precision produces a strictly lower threshold than the wad-precision check. At the boundary wheretotalBTokens == floor(totalSupply * 0.95e18 / 1e18), the native>=check in_commitDeferredMakerState()evaluates to true and the wad<check in_previewDeferredMakerState()also evaluates to true. Both branches in_commitDeferredMakerState()execute despite being mutually exclusive.When both branches fire, the curve-adjustment branch in
_previewDeferredMakerState()runs first, recomputing the invariant K usingcurrentParams.reserveswhich includes any liquidity fees added tototalReservesby_updateAccounting(). The new K absorbs these fees. Then the surplus-distribution branch in_commitDeferredMakerState()reads the freshly stored K, computesimpliedReserves, and finds no surplus because K already accounts for the fee.The result is that liquidity fees collected during swaps below the threshold get permanently locked into the curve invariant rather than being distributed to stakers, protocol, and creator via
distributeFees(). In correct behavior (safety region, K frozen), the old K would not account for the fee, yielding a distributable surplus.This affects pools with
bTokenDecimals < 18whentotalBTokenslands exactly at the native threshold boundary.Recommendation
Use the same precision for all three threshold checks. Make
_previewDeferredMakerState()use native precision, matching_updateAccounting()and_commitDeferredMakerState(). Read the pool state directly instead of relying on the wad-normalizedcurrentParams:State.Pool storage pool = State.pool(_bToken); if (uint256(pool.totalBTokens) < uint256(pool.totalSupply).mulWad(BUFFER_SAFETY_THRESHOLD)) { -
L-01 Low Undistributed fees when circ reaches zero Logical Error Acknowledged
Description
When
_updateAccounting()charges a liquidity fee, it retains a portion of swap fees intotalReserves. The surplus-distribution branch in_commitDeferredMakerState()is responsible for distributing these retained fees by reverse-engineering the surplus from the curve invariant. However, this branch is guarded byparams.circ > 0because the implied-reserves calculation requiressupply.divWad(circ), which would revert on division by zero.If multiple sells occur in the same block where earlier sells generate liquidity fees (pool below
BUFFER_SAFETY_THRESHOLD) and a later sell pushestotalBTokens == totalSupply(circulating supply to zero), the liquidity fees from the earlier sells remain intotalReservesbut the surplus-distribution branch is skipped due tocirc == 0. The curve adjustment branch in_previewDeferredMakerState()also does not fire sincesupply == totalSupplyis well above the 95% threshold.The retained liquidity fees are never distributed to stakers, protocol, or creator via
distributeFees(). When trading resumes and a buy makes circ non-zero again, the next commit refreshes K from the current state, absorbing the undistributed fees into the new invariant permanently.Additionally, the surplus recomputation is inherently imprecise.
lastInvariantis itself a rounded value, and the recovery path applies further rounding at each step —divWad()on the supply/circ ratio,powWad()for the exponentiation,fullMulDivUp()for the implied buffer, andmulWadUp()for the BLV component. Each layer compounds the inaccuracy, meaning the recomputed surplus can understate the actual retained fee even when circ is non-zero.Recommendation
Track retained liquidity fees in a dedicated accumulator (e.g.
pool.pendingSurplus) at the time they are charged in_updateAccounting(). In_commitDeferredMakerState(), distribute the accumulated value directly viadistributeFees()and reset the accumulator. This eliminates the dependency on curve math for fee recovery and avoids both thecirc == 0edge case and the cumulative rounding inaccuracy.
Remediation Review 4
3 findings · April 18 to 19, 2026-
L-02 Low View reserves inflated by unstripped surplus Unexpected Behavior Acknowledged
Description
_getCommittedCurveParams()returns livetotalReserveswithout simulating the batching-asymmetry surplus stripping that_commitDeferredMakerState()performs at L301-321. When both buys and sells occur in the same block while in safety (pooled >= 95%), the independent block-batched pricing creates a reserve surplus due to curve convexity — actualtotalReservesexceed the K-implied reserves. This surplus is stripped at commit time, but view functions that read_getCommittedCurveParams()see the inflated value.Additionally, the existing
pendingSurplusinjection at L619-626 runs unconditionally for any call where the pool is below safety, including intra-block calls. This meansgetCurveParams()previews liquidity fees being rolled into reserves mid-block, even though the actual rollover only happens at commit time and the final safety state at commit may differ from the mid-block state.Affected downstream consumers:
getCurveParams()(L555) — returns inflated reserves in both same-block and cross-block pathsactivePrice()viaBLens(L316) — computes price from inflated reservesswapTokens()Swap event (L148-152) — emitsactivePricefromgetCurveParams()with inflated reserves_previewBlockPricing()cross-block path (L507-510) — uses inflatedcommitted.reservesviacurveParamsFromDeferredState()
Same-block swap pricing and quoting are not affected because
_previewBlockPricing()uses the frozen snapshot viaapplyPoolSnapshot()(L501), which readsstartReserves/startSupplyrather than committed reserves.Recommendation
Limit reserve adjustments in
_getCommittedCurveParams()to the cross-block window where committed state is fully consistent (K, reserves, supply all from the same commit point). Add batching-asymmetry surplus stripping for the in-safety case, and movependingSurplusinjection into the same cross-block guard:function _getCommittedCurveParams(BToken _bToken) private view returns (CurveParams memory params_) { State.Pool storage pool = State.pool(_bToken); State.Maker storage maker = State.maker(_bToken); uint8 bDec = pool.bTokenDecimals; params_.BLV = uint256(maker.blvPrice); params_.circ = NormalizeLib.normalizeWad(pool.totalSupply - pool.totalBTokens, bDec); params_.supply = NormalizeLib.normalizeWad(pool.totalBTokens, bDec); params_.reserves = NormalizeLib.normalizeWad(pool.totalReserves, pool.reserveDecimals); params_.totalSupply = NormalizeLib.normalizeWad(pool.totalSupply, bDec); params_.convexityExp = uint256(maker.convexityExp); params_.swapFee = uint256(maker.swapFee); params_.lastInvariant = maker.lastInvariant; - if ( - pool.pendingSurplus > 0 && - params_.supply < params_.totalSupply.mulWad(BUFFER_SAFETY_THRESHOLD) - ) { - params_.reserves += NormalizeLib.normalizeWad( - uint256(pool.pendingSurplus), pool.reserveDecimals - ); - } + if (State.blockPricing(_bToken).blockNumber == 0 + || State.blockPricing(_bToken).blockNumber == uint64(block.number)) { + return params_; + } + + bool inSafety = uint256(pool.totalBTokens) + >= uint256(pool.totalSupply).mulWad(BUFFER_SAFETY_THRESHOLD); + + if (inSafety) { + if (params_.circ > 0) { + uint256 ratio = params_.supply.divWad(params_.circ); + uint256 ratioPowN = int256(ratio).powWad( + params_.convexityExp.toInt256()).toUint256(); + uint256 impliedBuffer = FixedPointMathLib.fullMulDivUp( + params_.lastInvariant, WAD, ratioPowN); + uint256 impliedReserves = impliedBuffer + + params_.BLV.mulWadUp(params_.circ); + if (params_.reserves > impliedReserves) { + uint256 surplusNative = NormalizeLib.denormalizeWad( + params_.reserves - impliedReserves, pool.reserveDecimals); + if (surplusNative > 0) { + params_.reserves -= NormalizeLib.normalizeWad( + surplusNative, pool.reserveDecimals).toUint128(); + } + } + } + } else if (pool.pendingSurplus > 0) { + params_.reserves += NormalizeLib.normalizeWad( + uint256(pool.pendingSurplus), pool.reserveDecimals); + } }This scopes adjustments to cross-block views only. Intra-block,
getCurveParams().reservesstill reflects rawtotalReservesincluding any surplus accumulated so far, since stripping mid-block would mix stale K with live reserves. Actual swap pricing is unaffected (frozen snapshot), and the view self-corrects once the next block's commit runs. -
I-01 Informational Quote slippage is cumulative, not per-trade Informational Acknowledged
Description
All four quote functions (
quoteBuyExactOut(),quoteBuyExactIn(),quoteSellExactIn(),quoteSellExactOut()) compute slippage as(effectivePrice - initialPrice) / initialPrice, whereinitialPriceis derived fromgetSnapshotCurveParams()— the frozen block-start curve. TheeffectivePriceis the average price the caller would pay, priced on the cumulative curve that accounts for all prior same-block trades.Because
initialPriceis the block-start snapshot price rather than the marginal price at the caller's cumulative position, the returned slippage measures deviation from the start-of-block price, not the caller's personal price impact. For the first trade in a block both values coincide. For every subsequent trade, the reported slippage includes the price movement caused by earlier same-block trades.Recommendation
Document that the slippage returned by quote functions represents the cumulative block slippage, not the individual caller's price impact. Integrators who need per-trade slippage should compute the marginal price at the current cumulative position and measure their effective price against that instead.
-
L-01 Low Safety threshold precision mismatch Rounding Acknowledged
Description
Similar to Buffer threshold precision mismatch, MakerLib._getCommittedCurveParams() uses WAD precision when calculating whether the pool is in safety regime in order to add the pending surplus.
if ( pool.pendingSurplus > 0 && params_.supply < params_.totalSupply.mulWad(BUFFER_SAFETY_THRESHOLD) ) { params_.reserves += NormalizeLib.normalizeWad( uint256(pool.pendingSurplus), pool.reserveDecimals ); }This is different from the checks in the rest of the codebase - all of them use native token decimals. It's possible for
totalBTokensto be treated as "in safety" by the native check, while the WAD check considers it "below safety."This causes
_getCommittedCurveParams()to addpendingSurplustoparams_.reserves, inflating the curve params passed to_previewDeferredMakerState()andgetCurveParams(). The inflated reserves view leaks intogetCurveParams()andgetSnapshotCurveParams(), which could produce incorrect quotes, potentially also impacting swaps throughBSwapsolver paths.Recommendation
Use the native tokens decimal precision.
if ( pool.pendingSurplus > 0 && - params_.supply < params_.totalSupply.mulWad(BUFFER_SAFETY_THRESHOLD) + uint256(pool.totalBTokens) < uint256(pool.totalSupply).mulWad(BUFFER_SAFETY_THRESHOLD) ) { params_.reserves += NormalizeLib.normalizeWad( uint256(pool.pendingSurplus), pool.reserveDecimals ); }
Remediation Review 5
4 findings · April 25 to 30, 2026-
M-03 Medium Same-block terminal exit pays below BLV floor Logical Error Acknowledged
Description
The terminal-exit branch in
CurveLib.computeSwap()returns a fixed cumulative receipt ofBLV·C₀·(1 − 2·swapFee)when cumulative sells reachsnapshot.circ. The inline comment states "User only receives BLV value (floor price), buffer becomes protocol fee" — implying BLV is the guaranteed floor.In
BlockPricingLib._quoteDeltaFromSnapshot(), this terminal-exit cumulative is split between in-block sellers via differencing:seller_B_receipt = BLV·C₀·(1−2·swapFee) − seller_A_cumulativeLet
Sbe the tokens Seller A already sold. Seller A's cumulative receipt is computed normally on the curve and equalsBLV·S + bufferShareCaptured, wherebufferShareCaptured ≥ 0is the proportional buffer share the curve gives intermediate sellers (always > 0 in any pool with non-zero buffer).Two failure modes depending on Seller A's cumulative:
seller_A_cumulative > BLV·S(essentially any sell capturing buffer share): residual > 0 but Seller B's per-token receipt drops below BLV (violates floor invariant)seller_A_cumulative > BLV·C₀·(1−2·swapFee)(large first sell):deltaUserWad < 0, the denormalize step routes through the buy branch wheredenormalizeWadUp(uint256(-deltaInvariantWad), ...)underflows → tx reverts
POC numbers (BLV=2, C₀=100, B₀=300, swapFee=0.3%, terminal-exit ceiling=198.8):
Scenario A's cumulative Outcome A sells 5 → 38.3 38.3 B sells 95, receives 160.5 (1.69/token, below BLV=2); shortfall ≈28 ether (~96% structural, ~4% fees) A sells 50 → 249 249 B's tx reverts (direction flip) The post-fix
_ensureSellWithinSameBlockCapacitycap (maxSellDelta = C₀ − S − B) only addresses the buy-then-terminal-exit case (B > 0). When B = 0, the cap allows cumulative sells to reach C₀ exactly, so both failure modes still trigger.Impact: any seller pushing cumulative sells to
snapshot.circafter another in-block seller will either receive less than the documented BLV floor or have their tx reverted — violating the floor invariant, or providing a same-block griefing/DoS vector against exit traffic.Recommendation
In the following gist you will find a recommended code fix, which doesn't apply
swapFeefor the final sell. If keeping the fee is the desired behavior,_applySellFloor()can be modified to account for it.https://gist.github.com/GuardianAudits/c585959c08ed97bc0987f6a48d3424bb
Example implementation: a Guardian proof of concept
-
I-01 Informational Rebalance unlocks surplus from initial credit Informational Acknowledged
Description
The new
_rebalanceCollateral()helper invoked at the end of_borrow(),_repay(),leverage(), anddeleverage()recomputes locked collateral ascollateral · debt/(maxBorrow+fee)and unlocks the surplus. This unifies the locking model: locked collateral always equals the minimum required to back the current debt.A side effect that's easy to miss: when a pool is created with an over-collateralized initial credit position (
initialCollateral > requiredCollateral(initialDebt)per merkle leaf), the surplus is no longer bonded to the position. After claiming, a user can trigger the rebalance with any state-changing call (e.g.borrow(1 wei)) and immediately withdraw the surplus throughBStaking.withdraw(), while keeping the cheap debt position open.Pre-fix, unlocking surplus required actually repaying debt —
_previewRepayredeems collateral in proportion to repaid debt, so the surplus was implicitly bonded to the debt's lifecycle.Concrete example: pool created with
initialCollateral=50, initialDebt=5, BLV=2. After claim, the position is collateral=50, debt=5, locked=50. The minimum required to back debt=5 is50 · (5 / maxBorrow) ≈ 2.5. A singleborrow(1)call triggers rebalance and unlocks ~47.5 of collateral, withdrawable in the same tx.This may be fine if deployers create over-collateralized initial positions purely as a safety margin / UX default. But if any deployer relies on the over-collateralization as a long-term commitment or "stake bond" against the claimant, the new fix silently invalidates that assumption.
Recommendation
Document that initial credit position collateral is no longer "locked beyond debt requirement". Deployers who want a permanent collateral lock unrelated to the debt position must enforce it through other mechanisms (e.g. an external time-lock, a separate stake position, or by sizing
initialDebtclose to the maxBorrow ofinitialCollateral). -
M-02 Medium Snapshot sell miscalculate circulation state Logical Error Acknowledged
Description
The pool takes a snapshot at the start of the block of how many tokens are circulating outside the pool
And how much reserve is in the pool then, inside the same block
If someone buys tokens, that buy is tracked in a buy bucket, If someone sells tokens, that sell is tracked in a sell bucket
when the pool later prices a sell, it only looks at the sell bucket, it ignores the earlier buy in the same block
There is a special branch in the code for the case when this is the final sell and circulation became exactly zero
if (c1 == 0) { uint256 blvValue = _params.BLV.mulWad(_params.circ); uint256 receipt = blvValue.mulWad(WAD - (_params.swapFee*2)); return (receipt.toInt256(), _params.reserves - receipt, _params.reserves.toInt256()); }The pool then pay the seller a final exit amount and classify almost all the remaining reserves as fees
That logic is only correct if there are truly no tokens left outside the pool
But the pool can think, a sell took circulation from 100 down to 0
when the real live state like "circulation was 100, then someone bought 5, so it became 105, and after selling 100 it is still 5" Just for example
If circulation is not zero, there are still people outside the pool holding tokens that should still have backing
So due the issue that branch can run even though other holders still exist
This could unlock a payout path that should only exist when nobody else is left
Recommendation
-
M-01 Medium Missed removal leave hidden extractable reserves Logical Error Acknowledged
Description
In the next block commit path for pools at or above the 95% safety threshold, commitDeferredMakerState() is supposed to remove any safety region surplus from pool.totalReserves and reclassify it as fees
CurveParams memory params = _getCommittedCurveParams(_bToken); if (params.reserves > impliedReserves) { pool.totalReserves -= surplusNative; pool.distributeFees(_bToken, surplusNative); }But getCommittedCurveParams() is not raw storage. when block pricing is stale and the pool is in safety, it already applies the same skim in memory
if (params_.reserves > impliedReserves) { params_.reserves -= normalizedSurplus; }So commitDeferredMakerState() asks for committed params, but receives preview. skimmed params
As a result, by the time it checks
if (params.reserves > impliedReserves)the surplus has already been removed from params.reserves in memory, so the storage side branch often never executes, that means
pool.totalReserves -= surplusNative; pool.distributeFees(_bToken, surplusNative);does not run, and the real surplus remains stranded inside pool.totalReserves
Exploit flow
1 - A block ends with the pool still in safety and with totalReserves > impliedReserves
2 - On the next block, advanceBlockPricing() calls commitDeferredMakerState()
3 - The commit fails to remove the surplus because getCommittedCurveParams() already skimmed it in memory
4 - BlockPricingLib.initialize() uses that skimmed view, so the new pricing snapshot is based on lower reserves
5 - Storage still keeps the higher real pool.totalReserves
6 - The attacker buys bTokens against the lower skimmed snapshot, so they do not pay for the surplus
7 - They buy enough to move the pool below the 95% threshold
8 - On the following block, the below safety commit no longer applies the safety skim, so the raw higher pool.totalReserves is merged back into live curve state
9 - The attacker sells back on the now surplus backed curve and extracts the previously stranded reserves
Recommendation
No findings match.
More from Baseline Markets
All 12 reports-
AMM, Round 2
47 findings4 critical · 14 high 47 findings: 4 critical, 14 high, 8 medium, 13 low, 8 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.
