Guardian's review of PurrSwap Final Review for Abracadabra Money, published April 2025. The report records 6 findings, including 1 high and 1 medium.
- Published
- Review window
- April 21 to 30, 2025
- Language
- Solidity
- Chains
- Hyperliquid
- Sector
- DEXs and AMMs
- 0 Critical
- 1 High
- 1 Medium
- 4 Low
- 0 Informational
Scope
11 files in scope · 1,259 nSLOC
| File | nSLOC | Lines |
|---|---|---|
contracts/Factory.sol | 61 | 92 |
contracts/interfaces/IPurrFactory.sol | 3 | 5 |
contracts/interfaces/IPurrLP.sol | 3 | 5 |
contracts/interfaces/IPurrLPOracle.sol | 3 | 6 |
contracts/interfaces/IWETH.sol | 3 | 5 |
contracts/libraries/DecimalMath.sol | 39 | 55 |
contracts/libraries/Math.sol | 115 | 216 |
contracts/libraries/PMMPricing.sol | 99 | 136 |
contracts/PurrLP.sol | 451 | 602 |
contracts/PurrLPFees.sol | 23 | 29 |
contracts/Router.sol | 459 | 611 |
Findings 6
-
H-01 High RState Manipulation Through Liquidity Donation Math Resolved
Description
A malicious user can create a new pool and steal funds from subsequent liquidity providers through a series of liquidity donations. The donated liquidity will over-inflate B and Q while not inflating B0 and Q0. A subsequent minimal swap (sellBase() or sellQuote()) will cause the RState to flip to 1 or 2 and cause a major target imbalance. The specific steps to perform the exploit are listed below:
- Malicious user (Alice) creates pool with min liquidity (1 ether quote/base)
- Alice donates a lot of base and a little bit of quote and buys shares. This increases base reserves while barely increasing total supply.
- Similarly Alice donates a lot of quote and a little bit of base and then buys shares. At this point, the pool reserves are large but the total supply is still low. The base and quote targets are also very low.
- Bob comes along and mints liquidity as normal by transferring equal ratio base/quote and buying shares.
- Alice sells a single wei of quote. This puts the pool in a wonky state where B0 becomes much larger than B.
- Alice swaps some amount of base for a larger amount of quote.
- Alice sells all shares, makes a profit.
Recommendation
Our initial recommendation is to send the excess Base or Quote received during
buyShares()to the recipientto. If the malicious user intentionally sends a lot of one token and a little of the other, the extra value received from the first token will be returned and will not contribute to any imbalance. InbuyShares():} else if (baseReserve > 0 && quoteReserve > 0) { // case 2. normal case uint256 baseInputRatio = DecimalMath.divFloor(baseInput, baseReserve); uint256 quoteInputRatio = DecimalMath.divFloor(quoteInput, quoteReserve); uint256 mintRatio = quoteInputRatio < baseInputRatio ? quoteInputRatio : baseInputRatio; shares = DecimalMath.mulFloor(totalSupply(), mintRatio); if (baseInputRatio < quoteInputRatio) { // Return excess quote token to user _QUOTE_TOKEN_.safeTransfer(to, (quoteInput * (quoteInputRatio - baseInputRatio)) / quoteInputRatio); quoteBalance = _QUOTE_TOKEN_.balanceOf(address(this)); } else if (baseInputRatio > quoteInputRatio) { // Return excess base token to user _BASE_TOKEN_.safeTransfer(to, (baseInput * (baseInputRatio - quoteInputRatio)) / baseInputRatio); baseBalance = _BASE_TOKEN_.balanceOf(address(this)); }It’s also important to block the donation path in
sellShares(). Currently, it’s easy to donate to the reserves by transferring base and quote to the LP contract and callingsellShares()with minimal shares. -
M-01 Medium Reentrancy Allows Liquidity Donations Reentrancy Partially resolved
Description
In the
sellBaseandsellQuotefunctions, the contract calls_transferQuoteOutor_transferBaseOutbefore updating the_BASE_RESERVE_/_QUOTE_RESERVE_through_setReserve. If the token supports “on-transfer” hooks (e.g. some tokens or ERC777-like hooks), an external actor could abuse this to deposit extra tokens into the contract during that outgoing transfer. Because the pool subsequently callsbalanceOfto set its reserve, the newly donated tokens become part of_BASE_RESERVE_/_QUOTE_RESERVE_. This effectively forces the contract to accept an involuntary “donation,” which can skew the pool’s ratio and circumvent the intended “no donation” checks. Example:sellBaseis called. The pool calculatesreceiveQuoteAmountand then does_transferQuoteOut(to, receiveQuoteAmount).- During that transfer, the token’s on-transfer hook triggers, sending additional tokens back into the pool.
- After the transfer,
_setReservecallsQUOTE_TOKEN.balanceOf(address(this)), which now includes that extra deposit. The pool’s_QUOTE_RESERVE_is updated to the new higher balance, accepting the donation.
Recommendation
When sending tokens out, do not rely solely on post-transfer
balanceOfto update_BASE_RESERVE_/_QUOTE_RESERVE_. Instead, record the pre-transfer token balance and compute the new balance by subtracting the known outgoing amount. For example, insellBase()when transferring quote out:uint256 oldQuoteBal = _QUOTE_TOKEN_.balanceOf(address(this)); // do the transfer _transferQuoteOut(to, receiveQuoteAmount); // compute new balance by subtracting // any donation from an on-transfer hook uint256 newQuoteBal = oldQuoteBal - receiveQuoteAmount; // now setReserve using newQuoteBal _setReserve(baseBalance, newQuoteBal); -
L-01 Low Lazy Updating of Pool State When Oracle Price Changes Warning Acknowledged
Description
The PMM framework in
PurrLPdoes not immediately re‐label the pool’sRState(e.g. fromBELOW_ONEtoABOVE_ONE) as soon as the oracle price I changes. Instead, it employs a “lazy updating” design: the new price_I_only takes effect when a user action (such as a swap, a deposit, or a withdrawal) calls (getPMMState) and re-derives the equilibrium state via_adjustTarget. This can lead to a temporary mismatch where, for instance, the pool’s records show it is short on quote when in fact, based on the updated oracle, it should be short on base.Recommendation
Retain or clearly document this lazy-updating approach. It is normal for a DODO-style PMM, as all actual arithmetic for trades or deposits uses the up-to-date oracle price at execution time. No additional immediate code changes are necessary, but clarity in documentation can help integrators understand that
RStatemight look “behind” until a user triggers a recalculation through a swap. -
L-02 Low Gas Optimization In sellBase And sellQuote Gas Optimization Acknowledged
Description
In both
sellBaseandsellQuote, the contract performs_BASE_TARGET_ = newBaseTarget.toUint112();and
_QUOTE_TARGET_ = newQuoteTarget.toUint112();respectively, even though
newBaseTarget == state.B0 == _BASE_TARGET_andnewQuoteTarget == state.Q0 == _QUOTE_TARGET_. This makes the assignments effectively redundant.Recommendation
Consider removing these assignments to reduce the gas costs.
-
L-03 Low Inefficient Oracle Design Informational Resolved
Description
The price, I, is returned via a call to an oracle. The call is performed as `uint256 i = IPurrLPOracle(ORACLE).price(address(BASE_TOKEN));` The oracle contract does not allow for base/quote token pair prices as it only takes a single input, base token. Therefore, a separate oracle contract must be created for each quote token.
Recommendation
This could be optimized to take a base token and quote token parameter. A single oracle contract could serve multiple LP contracts using this approach.
-
L-04 Low Pools can be skewed when AUTO_COMPOUND_FEES is true Informational Acknowledged
Description
Similar to direct donations, the pool targets can be skewed when AUTO_COMPOUND_FEES = true. For a stable pool, it can always be expected that the pool will achieve equilibrium at roughly the same token balance of base and quote tokens. However, when the pool is auto-compounding, the fees remain in the pool and swaps that generate large fees result in increased target values for the token received on the swap. For example, if a user swaps a large portion of the pool’s base for quote, the LP fees generated from the swap will remain in the pool and will be added to the quote’ target, thus moving it away from the base target. When the pool is rebalanced, the targets will still have some amount of delta. This can cause minor fund loss in the scale of wei due to precision loss. In our testing, we found that swapping massive amounts of token in the scale of hundreds of millions can result in less than a gwei of unwarranted profit. This is not a valid theft vector in practice.
Recommendation
Note this possibility and monitor for stable pools. Major skews of token targets are unlikely to occur.
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.
