Guardian's review of PurrSwap Balancer Aggregator for Abracadabra Money, published March 2025. The report records 72 findings across 2 review rounds, including 7 critical and 7 high.
- Published
- Review window
- March 5 to 29, 2025
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Hyperliquid
- Sector
- DEXs and AMMs
- 7 Critical
- 7 High
- 18 Medium
- 40 Low
- 0 Informational
Scope
- Abracadabra-money/abracadabra-money-contracts
-
GuardianOrg/purrswap-contracts-team1
484cb7dfdd
2 files in scope · 69 nSLOC
| File | nSLOC | Lines |
|---|---|---|
src/libraries/BalancerV2VaultReentrancyLib.sol | 11 | 34 |
src/oracles/aggregators/BalancerV2WeightedPoolAggregator.sol | 58 | 76 |
Findings 72
Main Review
60 findings · March 5 to 12, 2025-
C-01 Critical Fees Can Be Constantly Claimed Logical Error Resolved
Description
When a user first calls
claimFees, they fully synchronize their personal index to the latest global index and collect accrued fees. If that same user then transfers their LP shares to a different address the receiving address can re‐claim the same backlog of fees. This is because the new address’s index was never updated to the current global index at the time of receiving the shares. This effectively creates a second claim for the same fees. This step can be repeated multiple times until draining the contract. The underlying cause is that no function automatically updates the sender and receiver’s personal indexes during an LP token transfer.Recommendation
Override
_beforeTokenTransfer(from, to, amount)in thePurrLPcontract, ensuring bothfromandtoare updated at the moment of the transfer:function _beforeTokenTransfer(address from, address to, uint256 amount) internal override { if (from != address(0) && to != address(0) && from != to) { _updateFor(from); _updateFor(to); } } -
C-02 Critical Stale I Usage in _adjustTarget And buyShares Logical Error Resolved
Description
In the
PurrLPcontract, an oracle can be assigned to override the static_I_value during trades. ThegetPMMStatefunction checks if an oracle was set during initialization and if so, each trade will use the returned price from the oracle as_I_. However, certain other functions continue to rely on_I_directly, ignoring any oracle updates. Specifically, the_adjustTargetandbuySharesfunctions compute or expand the pool targets using the static_I_set at initialization rather than fetching a fresh oracle price. In scenarios where the actual market price has shifted significantly, these code paths will anchor to an outdated_I_, causing the pool to drift from the correct ratio. Liquidity providers may deposit or expand the pool at an obsolete price and any target adjustments fail to reflect the oracle’s data. This mismatch can produce arbitrage opportunities or incorrectly valued liquidity shares.Recommendation
Refactor the contract so that whenever
_I_is referenced in_adjustTargetandbuyShares, it first checks whether an oracle is set. If so, it should fetch the current oracle price rather than defaulting to the static_I_. -
C-03 Critical Untracked Shares Allow Fee Drain Exploit Logical Error Resolved
Description
The claimFees function enables users to claim their share of the PurrLPFees, which is collected and stored in the PurrLPFees contract when the AUTO_COMPOUND_FEES flag is set to false. The issue arises because the _updateFor function—which calculates a user’s earned fees—relies on balanceOf(recipient) to determine the user’s supplied amount without verifying if the user previously had that amount of shares. As a result, an attacker can repeatedly claim fees by transferring their shares to a different account and claiming fees again, eventually draining all fees from the contract.
Recommendation
Update the fee logic so that instead of relying on balanceOf(recipient) at claim time, the contract records and uses each user's most recent balance at the moment of any share-modifying action (i.e., when buying or selling shares).
-
C-04 Critical Static
_I_Should Not Be Read Directly Logical Error ResolvedDescription
The variable
_I_is set during deployment and represents the external market price in the PMM algorithm. This variable is never updated and relies on an external oracle to consistently reflect real-time market conditions. To achieve this, thegetPMMStatefunction returnsstate.i, which is set to the oracle price. However,_I_is used directly in certain functions likebuyShares(initial minting) and_adjustTarget. In contrast, trading functions likesellBaseandsellQuoteusegetPMMStateand thus utilize the most recent oracle price when available. The biggest concern is with_adjustTarget, which is called after every trading function to recalculate target reserves. Since the market price is expected to diverge from the initial_I_, the pool will always be adjusting its targets based on an outdated price, leading to suboptimal liquidity provision.Recommendation
Ensure that the functions
buySharesand_adjustTargetare reading fromgetPMMStateinstead of_I_. -
C-05 Critical Fee Claim Manipulation via LP Token Transfers Logical Error Resolved
Description
The
PurrLP::_updateForfunction is vulnerable to acollusion attackwhere multiple users canrepeatedly claim additional pool feesbystrategically transferring LP tokens. This allows them to extract a disproportionate share of fees,disadvantagingother legitimate liquidity providers._updateFor(recipient)calculatesclaimable fees(claimable0 and claimable1) based on thecurrent LP token balance(balanceOf(recipient)) of the recipient. It multiplies this balance by the difference between the global fee index (index0, index1) and the user's last recorded index (supplyIndex0, supplyIndex1). Let's consider the following scenario:- Let's assume User A and User B holds LP tokens.
- User B transfers
all their LP tokensto User A. - User A calls
claimFees(). Their claimable fees are calculated based onUser A's new large LP token balance(part of which belonged to User B) and transferred. - After the fee claiming
User A transfers all of his LP tokens to User B. - User B calls
claimFees(). Because_updateFor(User B)is called before the claim, the claimable fees are calculated based on User B's new (and large) LP token balance, even though those tokens just had their associated fees claimed by User A. - User B transfers the LP tokens to User C, and the process repeats. This cycle allows the colluding users to repeatedly claim fees based on the same underlying liquidity thus greiving the other legitimate liquidity providers.
Recommendation
It is recommended to call
_updateForinside the_transferinternal function of the LP contract to update thecheckpoints(supplyIndex0 and supplyIndex1) during theLPToken transfersfor both thetoandfromaddresses of the transfer. -
C-06 Critical No Validation For Oracles Used In Pools Logical Error Acknowledged
Description
External oracles were introduced to the PMM as a way to obtain the
ivalue, which reflects the external price of a token pair. When creating a pool, the deployer provides an oracle address. However, there is no validation for the oracle address provided, representing a significant security risk, as a malicious oracle can manipulate theivalue and control pricing. This risk is particularly dangerous because it may not be immediately visible—it could only present itself mid-transaction when the oracle is queried. Potential Attacks:- Price Manipulation: The oracle returns an artificially low
ivalue mid-swap, resulting in the user receiving significantly fewer tokens (or none at all). - Gas Griefing: The oracle performs computationally intensive operations when queried, causing transactions to become excessively expensive or fail due to insufficient gas.
- Read-only Reentrancy: While most of
PurrLP's functions are protected by thenonReentrantmodifier, external integrators may not have this protection. A malicious oracle could re-enter mid-transaction, potentially leading to unintended side effects. WhilePurrLPis designed to be permissionless, this flexibility introduces significant security risks—especially for pools deployed through the protocol’sRouterandFactorycontracts. Users may assume these pools are inherently safe, but without oracle validation, they remain just as vulnerable to manipulation as manually deployed pools.
Recommendation
- The
Factorycontract should enforce that only protocol-approved oracles are used. The protocol can maintain a whitelist of valid token-pair oracles. - At the
Poollevel, arbitrary oracles could still be allowed, but this risk should be explicitly documented for users.
- Price Manipulation: The oracle returns an artificially low
-
C-07 Critical Wrong initial target updates Logical Error Acknowledged
Description
As described in the following issue from the previous audit, if the ratio used in
buySharesis not 1:1, thenR_STATEwill be set toONE. During the initial mint,_setReserves()will also set the base and quote reserve to the raw contract balance of these tokens, resulting in aR = 1state even though the targets don't match the reserves. This leads to wrong target updates used for swap calculations and users receiving more or less from the token they are swapping depending on which one. In the attached POC you can find the following caseMIM-DAI Pair,
I = 3000 DAI- LP adds 2 MIM and 12000 DAI
R = 1,BASE_TARGET = 2,QUOTE_TARGET = 6000- A
sellQuoteof only 1 DAI is executed which makesR > 1 _adjustTarget()is called which adjusts the_BASE_TARGET, but uses the delta between_QUOTE_RESERVEand_QUOTE_TARGET. BecauseQUOTE_RESERVEis12000e18 + 1, but_QUOTE_TARGETis6000, the_BASE_TARGETwill experience a huge jump in its value, making the targets4:6when the real ratio is1:6. You will also find two examples of user selling12000DAIimmediately after initialization and when there is a 1 weisellQuote()in between. Because of the wrong target update, there is a difference in the value received (~1.33 MIM vs ~0.8 MIM).
Recommendation
During the initial mint, set the reserves to the initial targets and refund the excess amount of tokens to the LP. By doing so, you will ensure a true equilibrium for the
R = 1state. -
H-01 High Fee Misallocation When Selling Shares Through Router Logical Error Resolved
Description
The sellShares function internally calls the _updateFor(msg.sender) function to update a user’s earned fees, which can later be claimed using the claimFees function. However, when a user sells shares through the router contract, msg.sender becomes the router’s address instead of the individual user’s address. This causes any earned fees from the sold shares to be attributed to the router rather than the user. As a result, the user loses the fees they have accumulated from those shares, and since the router contract lacks functionality to call claimFees, those fees become permanently stuck in the contract.
Recommendation
Modify the sellShares function so that it uses the actual user's address rather than msg.sender when updating the fee balance when called from the router. This could be achieved by passing the originating user address as an additional parameter .
-
H-02 High PurrLP Cannot Be Used Without An Oracle Logical Error Acknowledged
Description
The PurrLP contract can be initialized with or without an oracle. If no oracle is set, the
getPMMStatefunction will return the_I_variable stored in the contract. The issue is that, in the old contract (MagicLP), the_I_variable could be updated periodically through the admin functionsetParameters(https://github.com/Abracadabra-money/abracadabra-money-contracts/blob/main/src/mimswap/MagicLP.sol#L502). However, in PurrLP, that function has been removed. This means that if no oracle is set,_I_would be a static value that can never be changed. Since_I_represents the price between the base and quote token (e.g. WETH/USDC), it is expected to change over time. Without an oracle or an admin update mechanism, price will be lower than market price, and tokens can be extracted at a cheaper cost. Additionally, it may be required for_K_value to be updated to match the changing volatility of a token pair. This also cannot be done without thesetParametersfunction.Recommendation
Either enforce that an oracle address must be set (i.e., ensure it is not a zero address) or re-implement the
setParametersandpausefunctions to allow updates to_I_and_K_values. -
H-03 High Fees Wrongly Assigned To Router Logical Error Resolved
Description
When the
sellSharesfunction is called through theRouter, the contract uses_updateFor(msg.sender)to account for external fees. Becausemsg.senderis the router address in this context, the fees end up credited to the router rather than the end user who owned and burned the LP shares. This misallocation causes the true LP holder to lose out on fees that should have accrued to their account.Recommendation
Consider calling
_updateForwith thetoaddress instead ofmsg.senderin thesellSharesfunction. -
H-04 High Stale Price Used For Initial Buy & Targets Logical Error Resolved
Description
The PurrLP contract uses the I variable to represent the base token price during the initial buy (buyShares) and in the _adjustTarget function. This variable is set once during initialization and is not updated afterward. In contrast, other parts of the contract fetch the latest price via the oracle (using IPurrLPOracle(ORACLE).price(address(BASE_TOKEN))). This means that after initialization, the buyShares and _adjustTarget functions rely on a potentially stale price, which can lead to mispricing in the initial buy and inaccurate target adjustments. Furthermore, since the rest of the code uses the latest price from the oracle, this creates a discrepancy between how trades are priced and how the pool's targets are adjusted—potentially resulting in imbalanced pool reserves and opportunities for arbitrage.
Recommendation
Update the buyShares and _adjustTarget functions to fetch the latest price using the oracle instead of relying on the outdated I variable.
-
H-05 High RState Is Set To ONE Despite The Initial Reserves Logical Error Acknowledged
Description
The
PurrLPcontract, designed to implement the Dodo’s PMM model, suffers an issue in the initialization and management of its_RState_variable. This variable, an enum with possible valuesRState.ONE(represented numerically as 0),RState.ABOVE_ONEandRState.BELOW_ONE, is intended to reflect the balance of the pool’s reserves, specifically, the ratio of base tokens (B) to quote tokens (Q) relative to their target values (B0andQ0). In the PMM model,_RState_dictates which pricing formula the contract uses for trades: a balanced-state formula when_RState_isONE, an excess-quote-token formula when_RState_isABOVE_ONEor an excess-base-token formula when_RState_isBELOW_ONE. However, a flaw in how_RState_is handled upon deployment and after the initial liquidity deposit creates a window of mispricing that can significantly distort the first trades. Upon deployment of thePurrLPcontract, the_RState_variable defaults toRState.ONEbecause its enum value of 0 is the natural initial state of an unassigned variable in Solidity. This default setting implies that the pool’s reserves are perfectly balanced, meaning the actual amount of base tokens (B) matches the target amount (B0) and the actual amount of quote tokens (Q) matches the target amount (Q0). In an ideal scenario, this assumption might hold if the first liquidity provider deposits tokens in exact accordance with the pool’s target ratio, defined by_I_(the target price). However, the contract does not enforce this constraint. Instead, it allows the first liquidity provider, through thebuySharesfunction, to deposit arbitrary amounts of base and quote tokens. For example, a depositor might supply a skewed ratio, say, far more base tokens than quote tokens relative to the target, resulting in reserves whereBsignificantly exceedsB0whileQfalls short ofQ0, or vice versa. Despite this potential imbalance, the contract does not update_RState_after this initial deposit. It remains set toRState.ONE, incorrectly signaling a balanced pool. This mischaracterization becomes problematic when the first trade occurs. ThePurrLPcontract provides functions likesellBaseTokenandsellQuoteToken, which rely on queries such asquerySellBaseorquerySellQuoteto determine the price of a trade. These queries, in turn, consult thePMMPricinglibrary, which uses_RState_to select the appropriate pricing logic. When_RState_isONE, the library invokes_ROneSellBaseTokenor_ROneSellQuoteToken, functions tailored for a balanced pool where the reserves align with their targets. These functions assume a specific relationship betweenB,Q,B0andQ0, yielding a price that reflects an equilibrium state. However, if the initial deposit created a skewed ratio, say,Bmuch greater thanB0andQless thanQ0, which should correspond toRState.BELOW_ONE,the actual reserve state does not match this assumption. The first trader, whether selling base tokens or quote tokens, receives a price quote based on the incorrect balanced-state formula rather than the appropriate formula for an imbalanced pool (e.g.,_RBelowOneSellBaseTokenor_RAboveOneSellQuoteToken). This discrepancy leads to a mispriced trade. The consequences of this mispricing are not trivial. Depending on the direction of the skew and the trade, the first trader might receive an overly favorable or unfavorable price. The root of this bug lies in thebuySharesfunction, which handles liquidity deposits. When the first liquidity provider adds tokenstotalSupply() == 0, this initial liquidity deposit is forced to match the price ratio declared by_I_:- It checks whether the deposited quote is less than (
base * _I_). - Depending on which side is “shorter,” it limits the effective number of “shares” so that
_BASE_TARGET_and_QUOTE_TARGET_end up being shares andshares * _I_, respectively. This implementation ensures the finalbase:quoteratio in the pool will be exactly_I_after that first deposit. In simpler terms, no matter how many tokens are deposited initially, the pool itself only “counts” as many tokens as needed to match_I_. If the first depositor tries to deposit more of one side than_I_implies, the “excess” effectively gets ignored in the share calculation, making the pool start out at a ratio that is locked to_I_. However, at the end of thebuyShareslogic, the function still call_setReserve(baseBalance, quoteBalance);when, in this situation, it should have ignored the excess of base or quote tokens. The contract here is assuming that aligning the targets with the actual reserves preserves theRState.ONEcondition, but this assumption only holds if the initial deposit matches the intended target ratio_I_. Only after the first trade modifies the reserves does the contract potentially reassess_RState_, flipping it toABOVE_ONEorBELOW_ONEas needed.
Recommendation
Update the
buySharesfunction to enforce the target ratio_I_for the initial deposit, refunding excess base or quote tokens to the first depositor:- Calculate the expected Q based on deposited B and I (or vice versa).
- Accept only the matching amounts, refunding the rest.
- Leave
_RState_ = ONE, as the pool will be balanced.
- It checks whether the deposited quote is less than (
-
H-06 High Pool Aggregator OOG When Fetching DoS Resolved
Description
The
BalancerV2WeightedPoolAggregatorPool.latestAnswer()function usesBalancerV2VaultReentrancyLib.ensureNotInVaultContext()which executes astaticcallto the vault. Thestaticcallwill always revert either because:- a reentrancy happens and the
nonReentrantmodifier reverts with data; - there is no reentrancy, but the
nonReentranttries to modify the state in astaticcall, reverting with empty data. If the revert data is empty, the contract allows fetching the price. When the storage is modified in astaticcall, all of the forwarded gas is used up. Because there is no hard limit set when calling the vault, all 63/64th of the gas in the aggregator will be wasted on each legitimate fetch call. Because of this behavior, fetching prices requires extremely high amounts of gas, which grow more the morepoolTokensthere are. 1/64 of the gas sent to the main call should cover all of the logic inside the aggregator. As seen in the POC, a simple price fetching for two tokens requires more than 3M gas. Once integrated into the Cauldrons, the aggregator will most likely cause DOS of the system because of its high gas expenditure.
Recommendation
Hardcode the
gasto a value around10_000, like than in the original implementation. - a reentrancy happens and the
-
M-01 Medium Incompatibility With Balancer Pools Validation Resolved
Description
As mentioned in the BalancerV2 docs, there are three potential functions to query when determining the BPT supply depending on pool type. The right function should be chosen accordingly to the pool type:
getActualSupply: This is the most common/current function to call. It is used by the most recent versions of Weighted and Stable Pools. It accounts for pre-minted BPT as well as due protocol fees.getVirtualSupply: This is used by Linear Pools and "legacy" Stable Phantom Pools. It accounts for pre-minted BPT but does not take due protocol fees into account. The omission of protocol fee calculations in Linear Pools is intentional since they do not pay protocol fees.totalSupply: Which should only be used for older "legacy" pools. The original Weighted and Stable Pools do not have pre-minted BPT, so they follow the typical convention of usingtotalSupplyto account for issued pool shares.totalSupplywill always returntype(uint211).maxin Balancer Pools with pre-minted BPT.
Recommendation
Be aware of this limitation and avoid using the
BalancerV2WeightedPoolAggregatorwith Balancer Pools with pre-minted BPT. -
M-02 Medium Fees Are Not Automatically Claimed Validation Resolved
Description
When a user transfers LP tokens without first calling
claimFees, the contract does not automatically update or settle their unclaimed fees. Consequently, fees accumulated for the transferred shares will be lost. This stems from the absence of a mandatory fee settlement step prior to reducing the sender’s LP balance. Users often remain unaware that they must manually callclaimFeesbefore transferring shares, leading to lost rewards.Recommendation
Modify the transfer flow so that each holder’s fees are automatically claimed before shares leave their wallet.
-
M-03 Medium Unrestricted Pool Creation Validation Acknowledged
Description
The
Factorycontract allows any user to create new pools without prior validation or permission checks. While this enables permissionless innovation, it also poses a significant risk that an attacker or inexperienced deployer could set critical parameters in a harmful way. For example, a malicious deployer could specify a completely incorrect target price_I_or chooseK = 0for a token pair where that setting makes no economic sense (non-stable pool). Worse yet, a deployer could assign a malicious oracle that returns manipulated prices, forcing unsuspecting liquidity providers into providing capital at unfair ratios. Because theFactorydoes not vet these parameters or oracles, liquidity providers joining such pools are exposed to extreme economic vulnerabilities or outright malicious configurations.Recommendation
Consider adding robust warnings and documentation that emphasize the high risk of joining pools with suspicious tokens, unrecognized oracles or extreme parameters such as K = 0 for non-stable tokens.
-
M-04 Medium Unfair Fee Distribution With FoT Tokens Logical Error Acknowledged
Description
The PurrLP contract presents a bug when used with
fee-on-transfer(FOT) tokens. The contract calculatesclaimable feesbased on theintended fee amount, but thePurrLPFeescontract receives less than that due to theFOT token's built-in fee. (The Sponsor said that any token can be used in the protocol henceFOTtokens are a valid concern here). The core issue is that thefee calculationin thePurrLP::_updateBaseFeesandPurrLP::_updateQuoteFees(index0, index1) uses thepre-fee-deduction amount, while thePurrLPFeescontract holds thepost-fee-deduction amount. This discrepancy leads to following issues:- Denial of Service (DoS): The
PurrLPFeescontract canrun out of tokensbefore all users have claimed their fees.Later userswill beunable to claim, causing a revert. - Unfairness: Early claimers get more shares than they truly deserve, while later claimers unable to claim since not enough fee left in the
PurrLPFeescontract.
Recommendation
It is recommended to
track the actual amount of tokensreceived byPurrLPFeesafter theFOT deductionand use that forfee calculations. - Denial of Service (DoS): The
-
M-05 Medium Risk Of Overflow On Sqrt Calculation Logical Error Acknowledged
Description
The original finding M-02 | Risk of Overflow on Sqrt Calculation (https://www.notion.so/guardianaudits/M-02-1848bda5828c8107802fccdddb2b0021?pvs=4) remains unaddressed. The risk of overflow has increased due to the expectation that
PurrLPwill be permissionless and could be used with exotic tokens. Such tokens may have large decimals, which can result in significantly largerdeltaorivalues. This increases the likelihood of an overflow occurring in the function_SolveQuadraticFunctionForTarget. During fuzz testing, overflow risk was also observed in_SolveQuadraticFunctionForTradewhen calculatingsquareRoot.Recommendation
Consider using libraries that support intermediate overflows. Furthermore, clearly document the risks with using large K values, and tokens with large decimals.
-
M-06 Medium Absence of Oracle Will Lead To Stale I Validation Acknowledged
Description
If a new
PurrLPpool is deployed without specifying an oracle, the_I_value remains the static ratio defined at initialization. Because asset prices typically evolve in real time, that constant_I_quickly becomes inaccurate relative to the market. This inaccuracy leads to large arbitrage opportunities or perpetual mispricing in the pool.Recommendation
Enforce in the
Factorycontract that a valid oracle is provided during pool deployment so the pool’s_I_can update as the market shifts. If tokens are expected to maintain a stable price, use an oracle that reliably confirms this peg; otherwise, choose an external feed that reflects the asset’s volatility. -
M-07 Medium Wrong Usage Of Weighted Pool's
totalSupplyLogical Error ResolvedDescription
The
latestAnswerfunction will obtain a price for Balancer Pool Token (BPT)by fetching the pool's TVL and dividing it by the pool's total supply of BPT tokens. The problem with the current approach is that the pool’stotalSupplyfunction is used, instead ofgetActualSupply. As stated in the weighted pool contractgetActualSupplyshould always be used instead oftotalSupply. This is because “the Pool owes debt to the Protocol in the form of unminted BPT, which will be minted immediately before the next join or exit.” Such BPT tokens need to be accounted for because “even if they don't yet exist, they will effectively be included in any Pool operation that involves BPT." As a result from this, some differences might arise between the values returned fromtotalSupplyand the ones returned by getActualSupply`. Looking at the USDC/STG pool at block22017511, thetotalSupplywas13581803776383036010328725but thegetActualSupplywas13589503378492964934776332-- a difference of7699602109928924447607 (7699e18)which will greatly impact the BPT token's price calculation.Recommendation
Use
getActualSupplyinstead oftotalSupplyto obtain the pool's BPT supply. -
M-08 Medium Oracle Might Return Stale Or Incorrect Data Validation Acknowledged
Description
In
AggregatorPriceProvider, the functiongetPriceuses Chainlink'slatestRoundDatabut there is no check if the return value indicates stale data. This could lead to potentially incorrect values being returned from the oracle. See Chainlink Docs.Recommendation
Add the following checks to verify the correctness of the data returned:
(uint80 roundID, int256 answer, uint256 timestamp , uint256 updatedAt, uint80 answeredInRound) = aggregator.latestRoundData(); if (updatedAt + tokenHeartbeat[token] < block.timestamp) revert StalePrice(); require(timestamp != 0,"Round not complete"); require(answeredInRound >= roundID, "Stale Price"); require(answer > 0, "Invalid Price"); -
M-09 Medium Blacklisted Users Cannot Claim Fees DoS Acknowledged
Description
Liquidity providers (LPs) can claim their earned fees via
PurrLP::claimFees, which internally callsPurrLPFees::claimFeesFor. This function transfers both base token and quote token fees in a single transaction. However, if an LP gets blacklisted by either the base or quote token, they can still withdraw liquidity usingRouter::removeLiquidityOneSideby choosing the token they are not blacklisted from. But this is not possible for fee claims because both tokens are transferred together and there is no option to claim only one token or convert fees to the non-blacklisted token. As a result, fees remain stuck inPurrLPFees.solindefinitely unless the LP is whitelisted again.Recommendation
Allow partial fee claims, enabling LPs to withdraw only the token they are not blacklisted from. Alternatively, implement a swap mechanism to convert fees to the non-blacklisted token before distribution.
-
M-10 Medium Risk Of Overflow On Sqrt Calculation Logical Error Acknowledged
Description
Within the usage of more volatile assets, there is now a greater risk of overflow when solving the quadratic functions. For example, the function
_SolveQuadraticFunctionForTargetrisks overflow within the else-if case:else if ((ki * delta) / ki == delta)Depending on the_K_and_I_values (which can be up to10^36and10^18) respectively, the multiplication with a large delta will overflow, which effects theadjustedTargetfunction and hence most protocol actions. Another example isuint256 squareRoot = DecimalMath.mulFloor((DecimalMath.ONE - k) * 4, DecimalMath.mulFloor(k, V0) * V0);within_SolveQuadraticFunctionForTradewhich can overflow when multiplyingK * V0 * V0ultimately leading to failed trades.Recommendation
Consider using libraries that support intermediate overflows. Furthermore, clearly document the risks with using large K values, and tokens with large decimals.
-
M-11 Medium getPrice Lacks Oracle Validation Oracle Acknowledged
Description
The getPrice function retrieves the price using Chainlink's latestRoundData() method. However, it does not validate the returned data for staleness or correctness, which could lead to potential issues if the data is outdated or invalid.
Recommendation
Implement validation checks on the data retrieved from latestRoundData(). Specifically, ensure that the price is greater than zero and that the data is not stale by verifying that updatedAt is within the expected tokenHeartbeat threshold.
-
M-12 Medium Preminted fees not included in
totalSupplyLogical Error ResolvedDescription
BalancerV2WeightedPoolAggregator.latestAnswer()uses thetotalSupply()function of the balancer pool to calculate the fair LP price. This is incorrect for the new balancer pools - for themgetActualSupply()should be used instead because it includes the fees not yet minted. In result, the aggregator will return wrong result for the LP price.Recommendation
Add a
try/catchwhere you use thegetActualSupply()if available and thetotalSupplyotherwise. -
M-13 Medium Pool Ratio Can Be Skewed Due To Rounding Logical Error Acknowledged
Description
When shares are calculated in
PurrLP.buyShares(), the base and quote balances are computed based on the initial price_I_. The calculations ensure the tokens are proportionally added. BecausemulFloor(baseBalance, _I_)rounds down, an attacker can abuse this for some tokens and skew the pool ratio. SupposequoteBalance = 1,baseBalance = 19999,I = 1e14. Then we have:1 < 19999 * 1e14 / 1e18 => 1 < 1 => FalseThereforeshares = 19999. It sets the targets:_BASE_TARGET_ = 19999_QUOTE_TARGET_ = 19999 * 1e14 / 1e18 = 1The result is the ratio1:19999, when the intended ratio from I is1:1000.Recommendation
Consider increasing the minimum shares to be minted requirement.
Resolution
Guardian: The main audit finding 'M-09 Pool Ratio Can Be Skewed Due To Rounding' has not been fixed. While the Router contract now performs adjustment of BASE/QUOTE input amounts via
_adjustAddLiquiditythis does not resolve the issue as_adjustAddLiquidityhas the same logic asPurrLP::buyShares. The pool ratio can still be skewed due to rounding which leads to a discrepancy between_BASE_TARGET_ / _QUOTE_TARGET_and theivalue.Recommendation Use
mulCeilinstead ofmulFloorwhen calculatingshares:shares = quoteBalance < DecimalMath.mulFloor(baseBalance, _i) ? DecimalMath.divFloor(quoteBalance, _i) : baseBalance; -
M-14 Medium Flashloan Sync Issues Logical Error Resolved
Description
There are several concerns about the
PurrLP.flashloan()function:- it can be called with 0 amounts to execute
syncand bypass theonlyImplementationOwnermodifier. - if the if branches are not entered, the Rstate won't be modified and can lead to a situation where an incongruity happens between the Rstate and the actual targets.
flashloan()checks if the contract balance of the two tokens is below the recorded balance in the respectiveRESERVEvariable and if it is, theRstateis recalculated. However, it's possible to skip both these checks if the balances are equal to or greater than the reserves. Then_sync()will be called, the reserves will be updated with the new balances and the targets will be adjusted in_adjustTarget()without updating the rState. This ability to execute_sync()and change the targets allows an attacker to do the following: For example,I = 4e18: - initialize the pool with
2000 BASEand8000 QUOTE(I = 4;R = ONE) - sell just 1 wei of
QUOTE, this setsR = ABOVE_ONE - execute the flashloan and transfer
3000 BASEthere. _adjustTarget()is called and sinceR == ABOVE_ONE, theBASE_TARGETwill be adjusted. SinceQUOTE_RESERVE - QUOTE_TARGET = 1 wei, theBASE_TARGETis set toBASE_RESERVE = ~5000(2000 from initialization and 3000 from flashloan)- In result the new targets are
BT = 5000,QT = 8000, the price ratio has been completely skewed
Recommendation
Consider reworking the flashloan function to work with exact amounts, i.e no surpluses. This will allow you not to call
_sync()and avoid all the issues. - it can be called with 0 amounts to execute
-
L-01 Low AggregatorPriceProvider Cannot Add or Update Code Best Practices Acknowledged
Description
In the current implementation, all aggregator references in the
AggregatorPriceProvidercontract are set during construction and stored in the aggregators mapping. Once deployed, no administrative function exists to add new tokens or change existing aggregator addresses. This restricts the contract to the initial mapping configuration and prevents reconfiguration if aggregator addresses change or new tokens need coverage. If any aggregator feed becomes invalid or compromised, integrators cannot simply update the address; they must redeploy the entire contract.Recommendation
Either incorporate an
onlyOwnerfunction to add or updateaggregators[token]after deployment or design the solution so that aggregator addresses can be retrieved from a more flexible registry. Otherwise, any aggregator change or new token listing will require redeploying the contract, which is costly and disruptive. -
L-02 Low Missing Checks In AggregatorPriceProvider Code Best Practices Acknowledged
Description
Within the
AggregatorPriceProvider.getPricefunction, there is no check to ensure the retrieved price from the underlying aggregator is positive, nor any mechanism to detect if the data is stale. If the aggregator feed returns zero, a negative value or a price timestamp beyond a freshness threshold,getPricestill returns it as-is. This can result in inaccurate on-chain pricing data being used by dependent systems, leading to potentially unsafe operations or unexpected reverts.Recommendation
Consider updating the
getPricefunction by adding checks for price positivity and staleness. One approach is to store the block timestamp or round timestamp from the aggregator, compare it to a maximum staleness window and revert or return an error if the data is outdated. Similarly, require the raw aggregator price to exceed zero to protect against zero or negative readings, which likely indicate invalid data. -
L-03 Low No Fallback Oracle Code Best Practices Acknowledged
Description
The
PurrLP::getPMMStatefunction will set thestate.ito theretrieved oracle priceif the oracle has been set. ThegetPMMStatefunction is being used in thequerySellBaseandquerySellQuotefunctions which are in turn used by thesellBaseandsellQuotefunctions to determine the amount ofquoteandbasetokens to send to therecipientand to lateradjust the targetvalues for both thequote and basetokens. Hencereceivingthecorrect pricesfrom the oracle is critical for thecorrect functioningof thePurrLPcontract. But there is no mechanism in place to handle the fallback scenario in case the oracle fails or starts providing stale price data. This will lead toDoSof thePurrLPtrades orincorrect executionof thePurrLPtrades.Recommendation
Hence it is recommended to add a fallback oracle in the
PurrLPcontract incase the original oracle fails or start providing stale price data. -
L-04 Low Unused Function
_resetTargetAndReserveCode Best Practices ResolvedDescription
In
PurrLP.sol, the function_resetTargetAndReserveis unused and should be removed.Recommendation
Remove the function
_resetTargetAndReserve. -
L-05 Low Wrong
FeesCollectedEvent Is Emitted Code Best Practices ResolvedDescription
The
PurrLP::_updateQuoteFeesandPurrLP::_updateBaseFeesfunctions emit theFeesCollectedevent as shown below (when theauto compounding is on):emit FeesCollected(_treasury, 0, amount);But the
amountemitted here is wrong since the_treasuryis only transferred(amount * _treasuryFeeBips) / BIPSamount when the auto compounding is on.Recommendation
Recommended to store the
(amount * _treasuryFeeBips) / BIPSvalue in a local variable and emit that variable in theFeesCollectedevent when theauto compounding is on. -
L-06 Low I Should Account For Token Decimals Validation Acknowledged
Description
The
Factorycontract lets users set the price ratio_I_of the deployedPurrLPwithout enforcing a specific decimal format. Although the code often uses arithmetic with a “1e18” style from DODO libraries, it does not actually require_I_to be passed in 18‐decimal form. This flexibility means the deployer is free to use an 8‐decimal ratio, for example, if combining an 18‐decimal token (likeWETH) with a 6‐decimal token (likeUSDC). If_I_is not correctly scaled to reflect each token’s actual decimals, the pool will end up at a mismatched or nonsensical price, leaving it highly susceptible to arbitrages. The contract does not self‐adjust or validate the ratio against the tokens’ decimals, so any misalignment can produce a major mispricing from day one.Recommendation
When deploying a new pool, ensure that the
_I_value accurately encodes the intended ratio in a consistent numeric scale. The code and front end must handle cases where base or quote tokens differ from the usual 18 decimals, factoring that difference into the ratio calculation. If, for instance, the base token is 18 decimals and the quote token is 6 decimals, confirm that_I_incorporates this 12‐decimal offset if you intend a1e18‐style ratio. Alternatively, provide documentation and checks in the factory contract that measure_I_against the tokens’ decimals at deployment to catch potential misconfigurations early. -
L-07 Low
onlyKnownPoolRestriction Can Be Bypassed Validation ResolvedDescription
The
Routerwon't allow executing actions for an unknown pool. The pool is checked to be known by querying theFactory.poolExists()function. However,Factory.create()is permissionless. This means any non removed pool will satisfy the condition inonlyKnownPool, not only the ones created from the router itself.Recommendation
Modify the
Factory.poolExistsmapping toaddress creator => address pool => bool exists. Then modify the check inonlyKnownPoolto allow only pools deployed by the router. IMPORTANT: you should usemsg.senderfor the creator, nottx.origin. -
L-08 Low Removed Pools Are Still Functioning Unexpected Behavior Acknowledged
Description
Whenever a pool is removed by calling
Factory.removePool(), the router stops supporting that pool, however the pool itself is still functioning as before.Recommendation
Consider allowing only
PurrLP.sellShares()if the pool is removed from the factory so LPs can withdraw their funds. -
L-09 Low Unused predictDeterministicAddress Function Code Best Practices Resolved
Description
The
predictDeterministicAddressfunction in theFactorycontract is never actually called by any other portion of the code or external contract. It represents a leftover utility that does not serve a current purpose and can cause confusion for developers or auditors.Recommendation
Remove the
predictDeterministicAddressfunction from theFactorycontract to streamline the code and avoid misleading future integrators. -
L-10 Low Unneeded _updateFor(address(0)) Call Validation Resolved
Description
Within the
buySharesfunction, thePurrLPcontract mints1001shares toaddress(0)and calls_updateFor(address(0)). Since the zero address is not an actual participant and cannot claim fees, this action creates meaningless accounting data.Recommendation
Avoid calling
_updateForin the internal_mintfunction if thetoaddress is the zero address. -
L-11 Low Fee Model Should Not Change Logical Error Acknowledged
Description
The function
setAutoCompoundFeesallows the implementation owner to switch between auto-compounding fees (where fees remain in the pool) and non auto-compounding fees (where fees are sent to an external contract for manual claiming). The issue arises because all existing pools reference the fee model from the implementation contract. This means that a pool deployed under the assumption of auto-compounding fees could later be forced into a non auto-compounding model due to a contract-wide change. This is particularly problematic for pool deployers that are smart contracts, as they may not have the necessary logic to manually claim fees. If the fee model is switched after deployment, these contracts could permanently lose unclaimed fees.Recommendation
Instead of dynamically reading the fee model from the implementation contract, enshrine the fee model at the time of pool deployment, making it immutable per pool. This ensures stability for deployed pools while still allowing the implementation owner to modify the fee model for future pools.
-
L-12 Low No Check For Active L2 Sequencer Logical Error Acknowledged
Description
Using Chainlink in L2 chains such as Arbitrum requires to check if the sequencer is down to avoid prices from looking like they are fresh although they are not according to their recommendation In the
getPricefunction, the protocol callsaggregator.latestRoundDatato retrieve the price from Chainlink. However, it does not check to see if the sequencer is down.Recommendation
Implement the recommended Chainlink example code.
-
L-13 Low
treasuryFeeBipsIs Not Fixed Logical Error AcknowledgedDescription
In the
PurrLP::_updateBaseFeesandPurrLP::_updateQuoteFeesfunctions are used to calculate the respective fee amounts. When theauto compound is offthe_LP_FEE_RATE_is used to calculate the fee amount eligible for theLiquidity providers. Since the_LP_FEE_RATEcan not be changed after initialization theLiquidity providersknow exactly the percentage of fees they are going to receive via trades. But the issue here is if theauto compound is switched onlater then an additionaltreasuryFeeBipsis counted on top of theLPFeeand thistreasuryFeeBipsamount can be updated by theimplementation contract owner. Hence the Liquidity Providers are no longer eligible for a fixed fee amount (since the treasuryFeeBips can be changed later)though they provided liquidity expecting that. Hence this could be opportunity cost to the Liquidity Providers and it is beyond their control.Recommendation
Hence it is recommended to keep the
treasuryFeeBipsfixed after the initialization similar to_LP_FEE_RATE_. This will ensure the Liquidity Provider fee distribution is fair, irrespective of whether theauto compounding is on or off -
L-14 Low Sandwich Large Trades To Steal Fees Frontrunning Acknowledged
Description
The
PurrLP.solcontract is vulnerable to a form ofunfair fee claimingdue to the absence of alockup periodforLP tokensafter they are minted viabuyShares. This allows an attacker tofront-run large trades(which updates the global fee indices (index0 and index1),deposit liquidityjust beforefees are generated, and thenimmediately withdraw liquidityafter thefees are accrued, disproportionatelyclaiming a shareof the fees that should have gone toexisting genuine liquidity providers.Recommendation
Consider adding a
lockup periodforLP tokensafter they are minted (viabuyShares). Hence once theLP tokensare minted the liquidity providers will not be able to redeem them during the lockup period. This will prevent users stealing fees using the same liquidity byfront-runningas described above. -
L-15 Low Unused Imports, Events And Errors Code Best Practices Resolved
Description
The following imports, events and errors are unused in PurrLP:
import {IWETH} from "./interfaces/IWETH.sol";event TargetChangedevent OracleChangederror ErrInvalidSignature()
Recommendation
Remove the unused imports, events and errors.
-
L-16 Low Missing Checks In
latestAnswerValidation AcknowledgedDescription
In
BalancerV2WeightedPoolAggregator::latestAnswer, the function retrieves theweightsandtokensarrays. However, two issues could lead to reverts:- Each
weightshould be checked to ensure it is not0. A zero weight would cause a division by zero, reverting the entire transaction. - The function iterates using
tokens.lengthbut assumes both arrays (tokens and weights) have the same length. If they are mismatched, an out-of-bounds error will occur when accessingweights[i], causing the transaction to revert mid-loop.
Recommendation
Before entering the loop, validate that
tokens.length == weights.lengthand ensure that no weight is0to prevent unexpected failures. - Each
-
L-17 Low Missing
nonReentrantOncorrectRStateCode Best Practices ResolvedDescription
Finding L-03 | correctRState LP Price Manipulation (https://www.notion.so/guardianaudits/L-03-1858bda5828c80488a6ec72be2b8d056) remains unaddressed.
Recommendation
Add a
nonReentrantmodifier to functioncorrectRState. -
L-18 Low Oracle Incompatibility Validation Acknowledged
Description
When launching a
PurrLPpool with a 18 decimals base token and a 6 decimals as the quote token, similar to the MIM/USDC currently deployed in Arbitrum, the target price_I_must account for the six‐decimal nature of the quote token. A 1:1 ratio from a user’s perspective equates to an_I_of1e6on chain. However, most oracles natively operate in 8 or 18 decimals, leaving no straightforward price feed that produces a price with 6‐decimal scaling. In this situation a custom oracle implementation with 6 decimals will be necessary.Recommendation
Consider implementing an oracle “adapter” that normalizes its output price to the required decimals.
-
L-19 Low Router Decimal Validation Can Be Bypassed Validation Acknowledged
Description
In
Router.sol, the_validateDecimalscheck prevents pool creation if:- The tokens have zero decimals
- A token has more than 18 decimals.
- The decimal difference between tokens is greater than 12.
This ensures that extreme decimal values do not cause overflow or precision issues.
However, this validation can be bypassed by calling
createdirectly in the factory contract. Pools created this way are still added to the Factory'spoolsmapping, which may give users a false sense of security, assuming they were properly validated.
Recommendation
In the factory contract's
createfunction, enforce that only theRoutercontract can call it. This ensures all pools go through proper validation -
L-20 Low Oracle Cannot Be Updated Code Best Practices Resolved
Description
Once the
_ORACLE_is initialized in thePurrLP::initthere is no ability to update it should the need arises in the future (due to an oracle failure etc.). Without a functioning oracle, all trade activity will fail. The presence of anOracleChangedevent also suggests that an update function should have been implemented.Recommendation
Consider implementing an admin-only oracle update function.
-
L-21 Low Target Fluctuations Because of Rounding Informational Acknowledged
Description
When targets are computed in
PurrLP._adjustTarget()andPMMPricing.adjustedTarget(),reciprocalFloor()is used to get the QUOTE/BASE price. This function performs division and rounds down the result. This difference in the price will cause the targets to slightly fluctuate over time.Recommendation
Be aware of this risk.
-
L-22 Low WETH Should Be WHYPE Informational Acknowledged
Description
The
Routeroffers functionality for working withETHby wrapping it toWETH. However, the protocol will be deployed to Hyperliquid where the native token is notETH, butHYPEand there is noWETH, butWHYPE.Recommendation
Rename the
ETH/WETHtoHYPE/WHYPE. -
L-23 Low Not All Tokens Implement decimals() DoS Acknowledged
Description
The PurrLP is designed to work permissionlessly with all types of tokens. However, in the
decimalsview function, the base token is expected to implement:IERC20Metadata(BASE_TOKEN).decimals()According to EIP-20, it is optional for tokens to implement thedecimals()function. So PurrLP’sdecimalsfunction should be able to handle tokens that do not implementdecimals().Recommendation
In PurrLP’s
decimalsfunction, add atry-catchand return the standard18decimals if the token does not implementdecimals(). -
L-24 Low Lack of Oracle Price Validation Checks In PurrLP Code Best Practices Resolved
Description
In the getPMMState function, the contract retrieves the latest price using IPurrLPOracle(ORACLE).price(address(BASE_TOKEN)) when an oracle is defined. However, there are no checks to validate this fetched price, which can lead to potential issues if the oracle returns unexpected data.
Recommendation
Implement validation checks on the price retrieved from the oracle. At a minimum, replicate the checks used in the init function to ensure the price is neither zero nor exceeds MAX_I.
-
L-25 Low Aggregator Integration Issues Code Best Practices Acknowledged
Description
The
BalancerV2WeightedPoolAggregatorcontract uses the same interface as the chainlink oracles. However, itslatestRoundData()hardcodes zeroes for anything besides the price. It's common for integrators to check whether the returned data is stale and etc... Because the returned values are 0, these calls will always revert.Recommendation
Document that integrators should expect 0s for these return values.
-
L-26 Low Possible Division By 0 Weight Code Best Practices Acknowledged
Description
The
BalancerV2WeightedPoolAggregator.soldivides byweightwithout checking if it's 0 or not. In practice, the weight should never be 0, but if it happens, this will DOS the entire function. On the other hand, a division by zero will also occur iftotalSupplyis 0.Recommendation
Consider returning a 0 price if
totalSupplyis equal to 0. -
L-27 Low Zero Address Token Aggregator Allowed Code Best Practices Resolved
Description
Once set in the constructor, the aggregators in
AggregatorPriceProvider.solcan't be changed. The constructor doesn't check foraddress(0)in the_tokenAggregatorsarray. If the deployer makes a mistake and passesaddress(0), the aggregator can never be set again.Recommendation
Consider reverting the transaction if any of the aggregators is
address(0). -
L-28 Low DOS for aggregators with > 18 decimals DoS Resolved
Description
The code in
AggregatorPriceProvider.soladjust the returned price by the aggregator so the end result will be in 18 decimals. However, it does so by subtracting the aggregator decimals fromWADprice * (int256(10) ** (WAD - decimals))This means for any aggregators which havedecimals > 18, the transaction will be reverting.Recommendation
Change the calculation to
price * (int256(10) ** WAD) / (10 ** decimals) -
L-29 Low Lack of Sippage Protection Informational Acknowledged
Description
There is no slippage protection in the functions of the
PurrLPcontract. This exposes users who directly interact with the contract without the router to a risk of losing their assets.Recommendation
Document the risk.
-
L-30 Low Division Before Multiplication Code Best Practices Resolved
Description
The
Math._GeneralIntegrate()function computes(v0^2 / v1) / v2) * kaspenalty. Notice how two divisions are executed before the actual multiplication. This will make theGeneralIntegrate()return inaccurate value.Recommendation
Rewrite the formula to
v0^2 * k / (v1 * v2). -
L-31 Low Auto-Compounding Fees Not Customizable Informational Acknowledged
Description
The autoCompoundFees variable is retrieved from the original implementation contract for every pool, meaning it cannot be customized on a per-pool basis. This limits the flexibility of the protocol, as all pools must follow the same auto-compounding setting, even if some pools would benefit from different fee handling strategies.
Recommendation
Consider storing autoCompoundFees as a state variable at the pool level to allow per-pool customization.
-
L-32 Low Direct Donations Can Create Imbalances Unexpected Behavior Acknowledged
Description
The PurrLP contract uses the RState, which is derived from token reserves and targets, to optimize trade calculations. However, in certain scenarios, RState may not accurately reflect the true contract state. If donations are made to the pool, for instance, if liquidity is added in an imbalanced manner, the excess amount may lead to a mismatch between the stored RState and actual reserves state. This discrepancy affects future traders, as they may not receive the optimized trade value expected under the protocol’s model.
Recommendation
Be aware of potential discrepancies in RState when liquidity is added in an imbalanced manner or through direct donations. These situations could lead to inconsistencies that impact future traders.
-
L-33 Low Managed Pools Lack Aggregator Flexibility Informational Acknowledged
Description
The AggregatorPriceProvider contract sets the aggregators during its construction, meaning that no new ones can be added later. This setup works fine for Balancer’s standard Weighted Pools. However, for Managed Pools—which are Weighted Pools with mutable tokens and weights—this limitation creates an issue. If new tokens are added to a Managed Pool, their aggregator cannot be registered in the current implementation.
Recommendation
Be aware of this limitation when working with Managed Weighted Pools.
Remediation Review
12 findings · March 27 to 29, 2025-
H-01 High Pre-deployment Token Transfers Can Bypass First Deposit Ratio Enforcement Validation Reported
Description
The
PurrLPcontract attempts to ensure that the initial deposit of base and quote tokens aligns with the target price specified by_I_(or by an oracle) whentotalSupply() == 0. This mechanism, implemented via an_adjustAddLiquidityinternal function in theRouter, forces the very first liquidity provider to deposit base and quote tokens in the correct proportion, preserving the PMM model’s assumptions about the pool’s initial balance and respecting theRState.ONE. However, because these pool addresses can be precomputed before deployment, it is possible for an attacker to transfer tokens to the soon-to-exist pool address before the pool contract is actually deployed and initialized. Once the contract is deployed and callsinit, it sets_BASE_RESERVE_and_QUOTE_RESERVE_to whatever tokens are already present, ignoring whether these tokens match the intended ratio. No LP shares are minted for those pre-deposited tokens, but the pool’s reserves are nonetheless “skewed” from the moment of deployment. This leaves the pool in a potentially imbalanced state (e.g.,_RState_remains set toONEby default, even though the actual base-to-quote ratio could diverge significantly). Subsequent calls tobuySharesor the normal “initial deposit” logic see those pre-initialization tokens already in the reserves and do not enforce the ratio on them. As a result, the first trades against the pool can be mispriced, and the protocol’s assumption that the pool was initially “balanced at ratio_I_” is violated.Recommendation
Enforce the correct ratio from the very start by handling any pre-deployed token transfers in the init function. This can be achieved by transferring to the pool deployer any base or quote token present in the
PurrLpcontract upon completion of theinitfunction. -
M-01 Medium Incorrect Refund In createPoolETH Function Logical Error Reported
Description
Within the
createPoolETHfunction, the contract attempts to refund any leftover ETH to the sender by transferring it as WETH tokens. The code deposits only a portion ofmsg.valueinto WETH (the amount actually needed for the liquidity addition), but then callsaddress(weth).safeTransferfor the remainder. Since the Router only holds exactlyethAmountInworth of WETH (the portion minted), it does not have the extra leftover portion in WETH form. Ifleftover > 0,this transfer fails because the contract’s WETH balance is insufficient, causing the transaction to revert. As a result, unused ETH cannot be reclaimed by the user and legitimate liquidity operations involving surplus ETH can break.Recommendation
Replace the final leftover transfer call with either a raw ETH transfer or a properly handled WETH transfer. The simplest fix is to perform a direct ETH transfer for any remainder, for example:
refundTo.safeTransferETH(msg.value - ethAmountIn). If the intended behavior is to refund WETH, the code must mint WETH for the entiremsg.valueup front, then transfer the remainder out as WETH. Either approach ensures that users can safely reclaim their unspent funds without causing the transaction to revert. -
M-02 Medium Salt collision for router pools Unexpected Behavior Reported
Description
The
poolExistsmapping was reworked to include the creator as suggested in L-12 to differentiate between pools created by the router and any other pools. Because of thisFactory.create()assignscreator = msg.senderand then uses the creator to compute the salt. For calls coming from the router thecreatorwill always be the router which makes the salt behave as if the field of the creator is not used at all in these cases. There are 2 important outcomes from this change:- Once a pool is created through the router, its exact settings can never be used again to create a new pool because the salt will be the same. This means if user A creates a pool with settings A through the router, user B can never use this settings to create its own pool.
- Building upon the previous bullet point,
Router.createPool()can be frontrun by a malicious user and make it revert. This is unexpected because the malicious user can as well deposit funds and force the initial caller to not be the first depositor anymore. In addition, ifRouter.createPool()is used by a smart contract that doesn't handle this revert case, it can be DOSed permanently, depending on the implementation.
Recommendation
There are several different solutions you can implement to fix the problem:
Solution 1
Instead of
creator, passtx.originto_computeSalt().Pros:
- minimal changes
saltstays unique per each user for different pool settings
Cons:
- The pools in the
userPoolsarray are accounted to the router instead of the original creator
Solution 2
Change back
creatortotx.originand add a newmapping(address pool => bool isRouted)and mark each pool created through the router astrue. Then modify theonlyKnownPoolmodifier to work with the mapping.Pros:
saltstays unique per each user for different pool settingsuserPoolsandpoolExistsare still tied to the original creator
Cons:
- Requires more code changes
-
M-03 Medium Incorrect router ETH refund Unexpected Behavior Reported
Description
The
Router.createPoolETH()function was changed to adjust the amount of tokens added to the current price ratio. It computes theethAmountInandtokenInAmountamounts to be added, depositsethAmountInintoWETH, sends the receivedWETHand its paired token to thePurrLPand finally buys shares. In cases wheremsg.value > ethAmountInthe excessETHshould be refunded to the user. Currently, the router transfers the excess to the user in the form ofWETH. However, the router deposited onlyethAmountIn. Because of this, all refunds will be reverting due to insufficient funds and the pool creations will be failing (unless the router somehow holdsWETH).Recommendation
Refund the excess amount in native token, not
WETH. -
M-04 Medium Pool Ratio Skewed Due To Rounding Rounding Reported
Description
The main audit finding 'M-09 Pool Ratio Can Be Skewed Due To Rounding' has not been fixed. While the Router contract now performs adjustment of BASE/QUOTE input amounts via
_adjustAddLiquiditythis does not resolve the issue as_adjustAddLiquidityhas the same logic asPurrLP::buyShares. The pool ratio can still be skewed due to rounding which leads to a discrepancy between_BASE_TARGET_ / _QUOTE_TARGET_and theivalue.Recommendation
Use
mulCeilinstead ofmulFloorwhen calculatingshares:shares = quoteBalance < DecimalMath.mulFloor(baseBalance, _i) ? DecimalMath.divFloor(quoteBalance, _i) : baseBalance; -
L-01 Low Flashloan Interface, Event And Error Not Removed Informational Reported
Description
In response to a main audit finding, the
flashloanfunctionality was removed from the codebase. However, the associated function interfaces, event and error were not removed.Recommendation
Remove the 1)
Flashloanevent, 2)ErrFlashLoanFailederror, 3)FlashLoanCallfromICallee.sol, and 4)flashLoanfromIPurrLP.sol. -
L-02 Low Obsolete Flashloan Artifacts Remain After Feature Removal Code Best Practices Reported
Description
The flashloan feature was removed from the codebase. However, remnants of this functionality remain defined in the contract: the flashloan interface, the associated event and the
ErrFlashLoanFailederror. Because the underlying logic has been taken out, this code is now superfluous and their presence implies functionality that no longer exists.Recommendation
Cleanly remove all references to the obsolete flashloan feature. This includes any interfaces, events and custom errors (
ErrFlashLoanFailed) associated with flashloans. -
L-03 Low createPools Calls Through The Router Can Be Front-run Warning Reported
Description
To differentiate router‐created pools from others, the
poolExistsmapping was modified to include the creator address in computing the salt. However, for calls originating from the router,creator = msg.senderis always the router itself, rendering that field effectively unused. As a result, the same salt will be reused whenever identical pool settings are passed through the router, leading to two critical outcomes:- If user A creates a pool with a particular set of parameters (baseToken, quoteToken, feeRate, etc.) via the router, user B cannot create another identical pool because the salt computation yields the same result, causing the second creation to revert.
- A malicious user can preemptively call the
Factorywith the same parameters and deposit funds before the genuine router call arrives. This forces the router’s creation attempt to revert. If a downstream contract depends onRouter.createPooland is not coded to handle reverts gracefully, it could be permanently DoSed, preventing that call from ever completing successfully.
Recommendation
Consider adding
tx.originalso to thesaltcomputation in theFactorycontract. -
L-04 Low
syncCan UsegetEffectiveI()Directly Code Best Practices ReportedDescription
The
sync()function builds the state by callinggetPMMState()and then reads only theifield, which is equivalent togetEffectiveI(). Instead, it can directly callgetEffectiveI(). The same is true for the_sync()call in thesellShares()function.Recommendation
function sync() external nonReentrant onlyClones onlyImplementationOwner { - PMMPricing.PMMState memory state = getPMMState(); - _sync(state.i); + _sync(getEffectiveI()); }- PMMPricing.PMMState memory state = getPMMState(); - _sync(state.i); + _sync(getEffectiveI()) -
L-05 Low Unused Event and Error Best Practices Reported
Description
The flashLoan function was removed in the recent update, but the associated FlashLoan event and ErrFlashLoanFailed error remain in the contract.
Recommendation
Remove the unused FlashLoan event and ErrFlashLoanFailed error.
-
L-06 Low
correctRStateCan Be Reentered Warning ReportedDescription
The
correctRState()function lacks thenonReentrantmodifier which means that if thePurrLPcontract uses tokens with hooks they can callcorrectRState()in the middle of the execution. For example,sellShares()calls_transferBaseOut()andtransferQuoteOut()before further interactions. This can lead to unexpected behaviors.Recommendation
Consider adding the
nonReentrantmodifier tocorrectRState() -
L-07 Low Pool Creation Griefed By Front-running Frontrunning Reported
Description
PurrLP pools deployed from the Router contract have a predictable
saltbecause the Factory contract usescreator = msg.sender, which is always the Router. This allows griefing of pool deployments by front-running with another transaction to create the same pool. While there is no significant risk as the owner of the clone does not have any special privileges, this behavior may be unexpected for pool deployers.Recommendation
Consider updating the
Factory::createfunction to use bothtx.originandmsg.sender:- Use
tx.originto compute the salt to ensure that the pool address is user-specific and unpredictable. - Use
msg.senderfor_addPoolto correctly associate the deployed pool with the Router.
- Use
No findings match.
More from Abracadabra Money
All 19 reports-
MagicGLP Upgrade
11 findings1 high 11 findings: 1 high, 10 low -
PurrSwap Aerodrome
105 findings9 high 105 findings: 9 high, 23 medium, 45 low, 28 informational -
PurrSwap ve33
74 findings5 critical · 10 high 74 findings: 5 critical, 10 high, 20 medium, 39 low -
Hyperliquid OFT Deployment
3 findings 3 findings: 1 low, 2 informational
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.
