Skip to content
$1,000,000 in security audit grants are live now, Apply here →

Security review · April 2025

PurrSwap Final Review

for Abracadabra Money

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

2 resolved · 1 partially resolved · 3 acknowledged

Scope

11 files in scope · 1,259 nSLOC
FilenSLOCLines
contracts/Factory.sol6192
contracts/interfaces/IPurrFactory.sol35
contracts/interfaces/IPurrLP.sol35
contracts/interfaces/IPurrLPOracle.sol36
contracts/interfaces/IWETH.sol35
contracts/libraries/DecimalMath.sol3955
contracts/libraries/Math.sol115216
contracts/libraries/PMMPricing.sol99136
contracts/PurrLP.sol451602
contracts/PurrLPFees.sol2329
contracts/Router.sol459611

Findings 6

  1. H-01 High RState Manipulation Through Liquidity Donation Math Resolved
    Location
    https://github.com/GuardianOrg/purrswap-contracts-fuzz/blob/a8b18881cc35adcee8e667223d03e0ff4e783659/contracts/PurrLP.sol#L387-L388

    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 recipient to. 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. In buyShares():

    } 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 calling sellShares() with minimal shares.

  2. M-01 Medium Reentrancy Allows Liquidity Donations Reentrancy Partially resolved
    Location
    https://github.com/GuardianOrg/purrswap-contracts-fuzz/blob/a8b18881cc35adcee8e667223d03e0ff4e783659/contracts/PurrLP.sol#L290 https://github.com/GuardianOrg/purrswap-contracts-fuzz/blob/a8b18881cc35adcee8e667223d03e0ff4e783659/contracts/PurrLP.sol#L319

    Description

    In the sellBase and sellQuote functions, the contract calls _transferQuoteOut or _transferBaseOut before 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 calls balanceOf to 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:

    1. sellBase is called. The pool calculates receiveQuoteAmount and then does _transferQuoteOut(to, receiveQuoteAmount).
    2. During that transfer, the token’s on-transfer hook triggers, sending additional tokens back into the pool.
    3. After the transfer, _setReserve calls QUOTE_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 balanceOf to 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, in sellBase() 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);
    
  3. L-01 Low Lazy Updating of Pool State When Oracle Price Changes Warning Acknowledged
    Location
    PurrLp.sol

    Description

    The PMM framework in PurrLP does not immediately re‐label the pool’s RState (e.g. from BELOW_ONE to ABOVE_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 RState might look “behind” until a user triggers a recalculation through a swap.

  4. L-02 Low Gas Optimization In sellBase And sellQuote Gas Optimization Acknowledged
    Location
    https://github.com/GuardianOrg/purrswap-contracts-fuzz/blob/a8b18881cc35adcee8e667223d03e0ff4e783659/contracts/PurrLP.sol#L298 https://github.com/GuardianOrg/purrswap-contracts-fuzz/blob/a8b18881cc35adcee8e667223d03e0ff4e783659/contracts/PurrLP.sol#L327

    Description

    In both sellBase and sellQuote, the contract performs

    _BASE_TARGET_ = newBaseTarget.toUint112();
    

    and

    _QUOTE_TARGET_ = newQuoteTarget.toUint112();
    

    respectively, even though newBaseTarget == state.B0 == _BASE_TARGET_ and newQuoteTarget == state.Q0 == _QUOTE_TARGET_. This makes the assignments effectively redundant.

    Recommendation

    Consider removing these assignments to reduce the gas costs.

  5. 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.

  6. 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.

More from Abracadabra Money

All 19 reports
  1. MagicGLP Upgrade

    11 findings1 high 11 findings: 1 high, 10 low
  2. PurrSwap Aerodrome

    105 findings9 high 105 findings: 9 high, 23 medium, 45 low, 28 informational
  3. PurrSwap ve33

    74 findings5 critical · 10 high 74 findings: 5 critical, 10 high, 20 medium, 39 low
  4. 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.

Get a quote