Capricorn pAMM
Cantina Security Report
Organization
- @capricorn-exchange
Engagement Type
Cantina Reviews
Period
-
Findings
Critical Risk
2 findings
2 fixed
0 acknowledged
High Risk
4 findings
4 fixed
0 acknowledged
Medium Risk
3 findings
3 fixed
0 acknowledged
Low Risk
7 findings
6 fixed
1 acknowledged
Informational
4 findings
0 fixed
4 acknowledged
Critical Risk2 findings
_balancing solves quadratic unstably, handing out negative-spread fills
State
Severity
- Severity: Critical
Submitted by
r0bert
Description
The
PricingEngine._balancinghelper computes the first-leg tranche usingamountIn = (sqrt(B.pow(2) + 4*A*nC) - B) / (2*A)followed byamountOut = reserveY - price * (reserveX + amountIn). Around equilibrium, where reserveY ≈ price * reserveX, the discriminant term4*A*nCis tiny, so PRB–Math floorssqrt(B² + ε)back down toB. The subtraction then underestimatesamountInand when the code plugs that value into the final subtraction the trader exits withamountOut > price * amountIn, a negative spread. Every swap that enters_balancingin this regime can be reversed immediately at the oracle price for a guaranteed profit, so LPs lose inventory even though the external price never moved.Proof of Concept
https://gist.github.com/r0bert-ethack/4295ad39730a76d0efe8361ca2f953af
Recommendation
Reformulate the quadratic using the numerically stable identity
amountIn = (2 * nC) / (B * (sqrt(1 + γ) + 1))withγ = 4 * A * nC / B², then recomputeamountOutfrom that root so_balancingalways returns an on-curve fill and never hands out prices richer than the oracle.Capricorn Exchange: Fixed in ac723f6.
Cantina: Fix verified.
_balancingno longer calls the naive(sqrt(discriminant) - B)/(2A)form. Instead it delegates to_safeQuadratic, which algebraically rewrites the root using the stablesqrt(1+γ)identity so the subtraction never collapses near equilibrium. This matches the recommended refactor (computeγ = 4AnC/B², take the stable root, then scale byB/(2A)), withtiny +wrap(1)epsilons inserted purely to round up after each division. After the stable solve,_balancingenforcesamountIn ≥ amountOut / pricevia theamountInMinclamp, guaranteeing that the tranche is never priced richer than the oracle even if rounding nudges the solve downward by 1 wei.Allowing c < 1 makes _backOutX invert the wrong curve, enabling round‑trip arbitrage
State
Severity
- Severity: Critical
Submitted by
r0bert
Description
configurePairParamsonly rejectsc == 0, so governors can set any c > 0:// contracts/PricingEngine.sol:180+function configurePairParams(..., uint256 c, ...) external onlyAdmin { if (c == 0) revert ParamsCZero(); // ← allows 0 < c < 1 pairParams[oracleId] = PairParams({ c: wrap(c), ... });}When the pool is quote-heavy (
price * reserveX < reserveY), the exact-in path enters the balancing branch and for large inputs, proceeds to the two-leg “extend” path. The first leg is computed, then_extendreconstructs a virtual base reserve for the second leg via _backOutX:// contracts/PricingEngine.sol (excerpt)function _backOutX(reserveTY, amountIn, amountOut, c) internal pure returns (UD60x18 reserveTX) { if ((c + UNIT) * amountOut <= reserveTY * c && c > UNIT) { reserveTX = (reserveTY - amountOut / c) * amountIn / amountOut; } else { reserveTX = ((reserveTY - amountOut) * amountIn * c) / amountOut; // <-used whenever c ≤ 1 }}For
c < 1, the if condition is false and the else branch is always selected. But in this regime the base curve used by_baseInis:Solving this equation for
reserveXgives the correct inverse:However, the code in the else branch computes instead:
which is only equal when
c = 1. For any , the implemented formula under-estimates the effective base reserve after the first leg. The second leg then prices the remaining input against an artificially shallower curve and over-pays. If the trader immediately reverses direction at the same oracle price, the reverse leg does not over-pay, so the round trip yields a net profit.Proof of Concept
https://gist.github.com/r0bert-ethack/95ff9dfb0d768a73ee491c30ed736867
Recommendation
Consider restricting unsafe configurations. Add the following check in the
configurePairParamsfunction to stay in the conservative regime:require(c >= 1e18, "params/c<1");Capricorn Exchange: Fixed in ac723f6.
Cantina: Fix verified.
PricingEnginenow rejects anycbelow 1e18 viaif (c < uUNIT) revert ParamsCInvalid();, so governors can no longer configure quote-heavy pools withc < 1.
High Risk4 findings
Exact‑in swaps revert near equilibrium due to catastrophic cancellation in _balancing and _smallInv
State
Severity
- Severity: High
Submitted by
r0bert
Description
When the pool is near equilibrium (
price * reserveX ≈ reserveY), the exact‑in path routes through_balancingto compute the first tranche._balancingsolves a quadratic and currently implements:// contracts/PricingEngine.sol:292-297 (approx)UD60x18 tmp = B.pow(convert(2)) + (convert(4) * A * nC);UD60x18 sqrtTerm = tmp.sqrt();UD60x18 amountIn = (sqrtTerm - B) / (convert(2) * A);Here, becomes very small near balance. PRB‑Math UD60x18 floors both pow and sqrt, so
sqrt(B^2 + x)often returns ≤ B once rounding collapses the tiny increment. The subtractionsqrtTerm - Bthen underflows in fixed‑point and triggers apanic(0x11). This causes valid exact‑in swaps to revert whenever reserves are sufficiently close to the oracle ratio, creating a denial‑of‑service window around the invariant even though all branch guards have been satisfied.The main impact of this issue is that traders cannot execute otherwise‑valid swaps near equilibrium; routers/aggregators experience intermittent reverts around the invariant.
The issue exists in
_smallInvas well.In
_smallInvthe code uses the unstable quadratic formfor mult < 1:
(sqrt(B^2 + 4*A*nC) - B) / (2*A)for mult > 1:
(B - sqrt(B^2 - 4*nA*nC)) / (2*nA)Both suffer catastrophic cancellation when
sqrt(...) ≈ B, which is exactly the regime the code often hit near balance or with small targets.Proof of Concept
https://gist.github.com/r0bert-ethack/fe7606d2443cc37590fa75e428a7e04e
Recommendation
Replace the unstable subtraction with the numerically stable identity that avoids catastrophic cancellation and keeps the square‑root input near unity:
A safe implementation that also avoids forming B^2 directly:
UD60x18 fourAnC = convert(4) * A * nC;UD60x18 ratio = fourAnC / B;ratio = ratio / B; // ratio = 4*A*nC / B^2UD60x18 sqrtOne = (UNIT + ratio).sqrt(); // √(1 + ratio)UD60x18 denom = B * (sqrtOne + UNIT);amountIn = (unwrap(denom) == 0) ? wrap(0) : (convert(2) * nC) / denom;This is algebraically equivalent to
but eliminates the cancellation that causes underflow and reverts.
Capricorn Exchange: Fixed in ac723f6.
Cantina: Fix verified.
_balancingnow calls_safeQuadratic, which implements the numerically stable identity recommended: it divides4*A*nCbyBtwice, computessqrt(UNIT + ratio)(so the operand stays close to 1) and only then reconstructs the large root. This eliminates thesqrt(B² + ε) - Bcancellation that used to underflow. Moreover,_smallInv’sUNIT > multbranch, the one that previously mirrored_balancingand suffered the same cancellation, now reuses_safeQuadraticas well, so exact-out mirrors of those trades also stay stable near equilibrium. Finally, the remaining_smallInvbranch (mult > UNIT) still uses the alternative form(B - sqrt(B² - 4*nA*nC))/(2*nA)because that case handles the opposite curvature and it never encounters the “almost-equal subtraction” that caused the original revert sincesqrt(B² - ε)is strictly smaller thanBin that regime._balancing discriminant uses huge square; sqrt overflows on large pools
State
Severity
- Severity: High
Submitted by
r0bert
Description
In the quote‑heavy path of exactIn,
_balancingsolves a quadratic by first building a large discriminant and then taking a square root:// contracts/PricingEngine.sol:292–299UD60x18 tX = reserveY / price;UD60x18 A = (convert(2) - mult) * price;UD60x18 B = ((convert(2) * c - UNIT) * reserveY) + price * reserveX;UD60x18 nC = c * (reserveY - price * reserveX) * tX; UD60x18 tmp = B.pow(convert(2)) + (convert(4) * A * nC);UD60x18 root = tmp.sqrt(); // ← can overflowUD60x18 amountIn = (root - B) / (convert(2) * A);UD60x18 amountOut = reserveY - price * (reserveX + amountIn);When reserves are very large (especially
reserveY) and/orcis sizeable,Bitself becomes huge. Squaring it viaB.pow(convert(2))dominates the discriminant and crosses PRB‑Math UD60x18’s maximum representable input for sqrt. At that pointsqrt()reverts withPRBMath_UD60x18_Sqrt_Overflow, which causes every exact‑in swap in the quote‑heavy branch to revert. Because this depends only on scale, the condition can persist across blocks and effectively denies service in one direction until reserves shrink or parameters change.Proof of Concept
https://gist.github.com/r0bert-ethack/1972cf246f0430a06f488092d3a3a92b
Recommendation
Reformulate the quadratic using a numerically stable identity that keeps the square‑root input near 1 and avoids building B². Let ratio =
(4*A*nC)/B²and compute:UD60x18 fourAnC = convert(4) * A * nC;UD60x18 ratio = fourAnC / B; // divide onceratio = ratio / B; // divide twice → (4*A*nC)/B²UD60x18 sqrtOne = (UNIT + ratio).sqrt();UD60x18 denom = B * (sqrtOne + UNIT);amountIn = (unwrap(denom) == 0) ? wrap(0) : (convert(2) * nC) / denom;This is algebraically equivalent to
(sqrt(B*B + 4*A*nC) - B) / (2*A)but never feeds a giant operand tosqrt, eliminating the overflow class.Capricorn Exchange: Fixed in ac723f6.
Cantina: Fix verified.
_balancingno longer buildsB.pow(2);it now delegates to_safeQuadratic. That helper rewrites the quadratic root with sequential divisions byB, keeping the square-root argument~1 + 4*A*nC/B²instead of a giantB²term. Consequently,determinant = convert(4)*A*nC-> divide byBtwice -> addUNIT->sqrt, matching the numerically stable identity from the recommendation._multedSpread underflows and reverts when mult > 1 in the balancing uncapped branch
State
Severity
- Severity: High
Submitted by
r0bert
Description
In the quote‑heavy region (
price * reserveX < reserveY), small/medium exact‑in trades (i.e.,amountInX18 ≤ balancingX) take the uncapped path:// contracts/PricingEngine.sol (quote-heavy small tranche)amountOutH = _multedSpread(tX, reserveY, amountInX18, params.c, price, mult, /*capped=*/false);_multedSpreadblends the base curve with a linear leg and then subtractsmult * price:function _multedSpread(..., UD60x18 price, UD60x18 mult, bool capped) internal pure returns (UD60x18) { UD60x18 baseOut = _baseIn(reserveTX, reserveTY, amountIn, c); UD60x18 finalPrice = price + (mult * baseOut / amountIn); if (finalPrice < mult * price && capped) { return _cappedOut(reserveTX, reserveTY, amountIn, c); // only for capped=true } else { finalPrice = finalPrice - mult * price; // <-- can underflow return finalPrice * amountIn; }}This assumes
mult ≤ 1in the uncapped path. However, governance can configurespreadMinMult > 1(the code explicitly allows it), or inRebalClass.EXPONENTIALthe quote‑heavy formula can producemult > 1when rebalParam0 > 1. In those cases, for realistic parameter sets the expressionprice + mult * baseOut / amountIncan be≤ mult * price, and the subtraction underflows in UD60x18 (unsigned), triggeringpanic(0x11). Becausecapped=falsein this tranche, the fallback guard is not taken and the entire swap/quote reverts, even though all branch guards are satisfied.The main impact is a persistent DoS for one side of the market whenever
mult > 1coincides with the quote‑heavy uncapped path.Proof of Concept
https://gist.github.com/r0bert-ethack/a240d29d4d47d34f07b14d2fe65cccae
Recommendation
Enforce
mult ≤ 1whenevercapped == false(i.e., in the balancing/uncapped tranche), or explicitly guard the subtraction:// Option A: clamp in caller when using the uncapped pathUD60x18 multBal = mult > UNIT ? UNIT : mult;amountOutH = _multedSpread(tX, reserveY, amountInX18, c, price, multBal, /*capped=*/false);// Option B: guard inside _multedSpreadUD60x18 baseOut = _baseIn(reserveTX, reserveTY, amountIn, c);UD60x18 tmp = price + (mult * baseOut / amountIn);if (tmp <= mult * price) { if (capped) return _cappedOut(reserveTX, reserveTY, amountIn, c); return wrap(0); // or revert with a descriptive error instead of panic}UD60x18 finalPrice = tmp - mult * price;return finalPrice * amountIn;Document and/or constrain configuration so that quote‑heavy (balancing) regimes never operate with
mult > 1.Capricorn Exchange: Fixed in ac723f6.
Cantina: Fix verified. In both swap directions the quote‑heavy (balancing, uncapped) path now enforces
mult <= UNITbefore_multedSpreadis called.Rebalancing allows draining the reserves
State
Severity
- Severity: High
Submitted by
r0bert
Description
The pricing engine’s rebalancing branch pays the entire reserve deficit to the first trader who moves in the “correct” direction. When
price * reserveX < reserveY,_exactInWithScorecalls_balancingand, if the caller’samountInX18is even slightly larger than the returnedbalancingX, it immediately invokes_extendto bridge the deficit. Because there is no cap tyingamountOutto the trader’s input or to the available reserves, a 1‑token1input can withdraw 499999token0from a 500 000‑token0 pool.PAMMPool::swapnever blocks this becausepricingEngine.setMaxInputAmount(token0/token1)is left at zero, meaning “unbounded”, so the vulnerable flow is:// contracts/PricingEngine.solif (price * reserveX < reserveY) { (UD60x18 balancingX, UD60x18 balancingY) = _balancing(...); if (amountInX18 > balancingX) { amountOutH = _extend(reserveY, amountInX18 - balancingX, balancingX, balancingY, params.c); } else { amountOutH = _multedSpread(...); }}The exploit test test/foundry/PAMMPoolExploit.t.sol reproduces the failing fuzz seed: it deploys the real components, sets the oracle mantissa to 1654860 (expo −6), and seeds the pool with 500000
token0/ 827430token1. Runningforge test --match-path test/foundry/PAMMPoolExploit.t.sol -vvshows[loop 1] direction=token1->token0 | input=1immediately returning 455400 token1 after the round trip, dropping the pool to 500 000/372031. Impact: an attacker can drain roughly 55 % of the quote reserves with negligible cost.Recommendation
Cap the rebalancing subsidy so
amountOutnever exceeds the lesser ofreserveOutandamountIn * oraclePrice * (1 - fee)and amortize_balancingacross multiple trades instead of paying it all to the first caller. Additionally, configurepricingEngine.setMaxInputAmountfor both tokens to prevent any single swap from draining the scarce reserve, even a conservative per-token cap would block the 1‑unit exploit.Capricorn Exchange: Fixed in fe6be10.
Cantina: Fix verified.
Medium Risk3 findings
EXP rebalancing uses an unsafe exponent bound
State
Severity
- Severity: Medium
Submitted by
r0bert
Description
In the
Exponentialrebalancing class, the spread multiplier for the base-heavy side (epsilon > 1) is computed as:// contracts/PricingEngine.sol:396–404 (excerpt)UD60x18 base = convert(2);UD60x18 exp = (eps - UNIT) / params.rebalParam1;if (exp >= convert(196)) { // overflow, default to spreadMaxMult mult = params.spreadMaxMult;} else { mult = base.pow(exp); // effectively exp2(exp) because base==2}UD60x18.powwithbase = 2reduces toexp2(exp)under PRB-Math.The maximum supported input forexp2in UD60x18 is approximately 192 * 10^18, not 196.With the current guard (196), configurations with a small
rebalParam1(intended to make spreads reactive) and moderate skew can yieldIn that range, the guard does not trigger,
base.pow(exp)callsexp2with an out-of-bounds exponent and PRB-Math reverts withPRBMath_UD60x18_Exp2_InputTooBig.Once the pool’s imbalance drives exp into this window, every swap that evaluates this branch reverts bricking the pool on the base-heavy side until parameters or state change.
Proof of Concept
https://gist.github.com/r0bert-ethack/fada79c4973546247bbc70f82bddceee
Recommendation
Clamp against the correct bound and short-circuit before calling
pow:// Use 192.0e18 as the safe exp2 upper bound in UD60x18if (exp >= convert(192)) { mult = params.spreadMaxMult; // saturate instead of reverting} else { mult = base.pow(exp);}Capricorn Exchange: Fixed in ac723f6.
Cantina: Fix verified. In the new
PricingEngineversion the base-heavy path computesexp = (eps - UNIT) / params.rebalParam1and immediately checksif (exp >= convert(192)) { mult = params.spreadMaxMult; } else { mult = base.pow(exp); }. That192e18threshold matches PRB-Math’s documented limit, sobase.pow(exp)is never invoked with an out-of-range exponent and the branch saturates instead of reverting.Endorser cannot cancel the endorsement when configuration changes
State
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
ladboy233
Description
The endorsement schema is mostly solid.
function hashStruct(EndorsementData memory data) internal pure returns (bytes32) { return keccak256( abi.encode( ENDORSEMENT_TYPEHASH, data.endorser, data.trader, data.oracleId, data.zeroForOne, data.amountSpecified, data.recipient, data.deadline, data.nonce ) );}Endorsements are non-cancelable once signed, and the signed payload does not bind to mutable protocol state beyond
oracleId.Because the pool’s pricing dependencies can change (e.g., the
PAMMPoolpricing engine address or the oracle configuration behind a givenoracleId), an otherwise valid signature may become stale yet still executable.That creates a mismatch between the signer’s intent and current risk parameters.
Recommendation
Invalidate endorsements whenever pricing dependencies change, and cryptographically bind endorsements to the current config.
Capricorn Exchange: Fixed in f87af74.
Cantina: Fix verified.
mult has to be ≤ 1 invariant is not properly enforced.
State
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
ladboy233
Description
The
_balancinghelper solves a quadratic whereA = (2 - mult) * priceappears in the denominator:UD60x18 A = (convert(2) - mult) * price;UD60x18 amountIn = (sqrt(B^2 + 4*A*nC) - B) / (2*A);The math assumes
A > 0, i.e.,mult < 2. However, the function’s comment (“mult has to be ≤ 1”) is not enforced anywhere, and_mult(...)can yieldmult > 1. Ifmult ≥ 2, thenA ≤ 0, making the division invalid (division-by-zero or negative denominator), and even when1 < mult < 2, the algebraic intent of the balancing solution no longer holds.This leads to fragile behavior, potential underflow, and revert paths in quote-heavy states.
Separately, the original implementation formed a huge discriminant (
B^2 + 4*A*nC) and fed it tosqrt, which can overflow PRB-Math’s UD60x18 sqrt domain for large reserves/c.POC
Add test to https://gist.github.com/r0bert-ethack/fe7606d2443cc37590fa75e428a7e04e
function test_Repro_MultGreaterThanTwo_TriggersBalancingUnderflow() public { address admin = makeAddr("admin"); address operator = makeAddr("operator"); address lp = makeAddr("lp"); vm.startPrank(admin); OracleRegistry oracleRegistry = new OracleRegistry(); PricingEngine pricingEngine = new PricingEngine(address(oracleRegistry), admin); PAMMPoolFactory factory = new PAMMPoolFactory(); SegmenterRegistry segmenterRegistry = new SegmenterRegistry(admin); TestERC20 token0 = new TestERC20("Token0", "TK0", 18); TestERC20 token1 = new TestERC20("Token1", "TK1", 6); oracleRegistry.grantRole(oracleRegistry.OPERATOR_ROLE(), operator); oracleRegistry.grantRole(oracleRegistry.GUARD_ROLE(), admin); segmenterRegistry.setOperator(operator); // Register pair and create pool bytes32 oracleId = oracleRegistry.registerOracle(address(token0), address(token1), 18, 6); // Choose EXPONENTIAL with rebalParam0 > 1, so when eps <= 1 ⇒ mult = rebalParam0^(positive) > 1. // Set spreadMaxMult high so clamping does not limit us; we want mult > 2. // We'll arrange eps ~= 0.5 so exponent ~= 1 and mult ≈ rebalParam0. pricingEngine.configurePairParams( oracleId, 1e18, // c PricingEngine.RebalClass.EXPONENTIAL, // rebal class 1e16, // spreadMinMult = 0.01 100e18, // spreadMaxMult = 100 3e18, // rebalParam0 = 3 (BASE > 1 ⇒ mult > 1) 1e18 // rebalParam1 (unused for eps<=1 path) ); pricingEngine.setSegmenterRegistry(address(segmenterRegistry)); pricingEngine.setMaxInputAmount(address(token0), 0); pricingEngine.setMaxInputAmount(address(token1), 0); // Create pool bytes32 salt = hex"1111111111111111111111111111111111111111111111111111111111111111"; address poolAddr = factory.createPool( address(token0), address(token1), oracleId, address(pricingEngine), operator, admin, 11, // fee bps (irrelevant here) salt ); PAMMPool pool = PAMMPool(poolAddr); pool.grantRole(pool.LP_ROLE(), lp); vm.stopPrank(); // Oracle price: 1 token0 = 4 token1 { bytes32[] memory ids = new bytes32[](1); int64[] memory prices = new int64[](1); uint64[] memory confs = new uint64[](1); int32[] memory expos = new int32[](1); ids[0] = oracleId; prices[0] = 4; confs[0] = 0; expos[0] = 0; vm.prank(operator); oracleRegistry.updateNativePrices(ids, prices, confs, expos); } // Reserves: make pool QUOTE-heavy so the code takes the _balancing branch (price*reserveX < reserveY). // Also set reserve0 small so eps ~ 0.5. // eps (zeroForOne=true) = (reserve0*price + reserve1) / (2*reserve1) = (price*R0/R1 + 1)/2 ⇒ ~0.5 if R0 is tiny. uint256 r0 = 1e12; // 1e-6 token0 uint256 r1 = 1_000_000; // 1,000,000 token1 token0.mint(lp, r0); token1.mint(lp, r1); vm.startPrank(lp); token0.approve(address(pool), type(uint256).max); token1.approve(address(pool), type(uint256).max); pool.deposit(r0, r1); vm.stopPrank(); // Now quote zeroForOne (token0 -> token1). With eps≈0.5 and EXPONENTIAL, mult ≈ rebalParam0 = 3 > 2. // That enters `_balancing` and computes A = (2 - mult) * price => underflows UD60x18 (since 2 - 3 < 0). uint256 amountIn = 1e9; // tiny input is enough to exercise the path vm.expectRevert(stdError.arithmeticError); pool.quoteExactIn(address(token0), amountIn); }Recommendation
Enforce the contract on
multfor the balancing branch Clampmultto≤ 1at the call site and add a defensive check inside_balancing:error BalancingRequiresMultLeOne(); function _clampLeOne(UD60x18 x) internal pure returns (UD60x18) { return x < UNIT ? x : UNIT;} // Call site (quote-heavy branch)UD60x18 mBal = _clampLeOne(mult);(UD60x18 balancingX, UD60x18 balancingY) = _balancing(reserveX, reserveY, params.c, price, mBal); // Inside _balancingif (mult > UNIT) revert BalancingRequiresMultLeOne();This preserves existing behavior elsewhere (where
mult > 1may be desired) while guaranteeing the balancing math’s preconditions.Capricorn Exchange: Fixed in ac723f6.
Cantina: Fix verified. In the
PricingEngineexact-in and exact-out the quote-heavy branch now begins withrequire(mult <= UNIT, "mult > UNIT");. Any configuration or runtime state that would pushmultabove 1 causes the call to revert before_balancingis invoked, soA = (2 - mult) * pricenever goes non-positive. Base-heavy branches retain the complementaryrequire(mult >= UNIT, "mult < UNIT");, so each regime only allows multipliers that satisfy its math assumptions._balancingitself doesn’t need an extra guard because the only paths that reach it already satisfymult ≤ 1.
Low Risk7 findings
Reserves can drop below accrued fees, permanently blocking fee claims
State
Severity
- Severity: Low
Submitted by
r0bert
Description
Both withdraw and
claimFeesoperate on the raw reserves without protecting the fee balance.withdrawonly checksamount0 <= reserve0andamount1 <= reserve1, then subtracts those amounts even if the remainder is less than the outstandingaccruedFee{0,1}. Later,claimFeesreadsclaim0 = accruedFee0andclaim1 = accruedFee1, zeroes the counters and reverts withInsufficientLiquidity()wheneverreserve0 < claim0orreserve1 < claim1. Swaps exacerbate this because swap updatesreserve0/reserve1and performsTransferHelper.safeTransfer(tokenOut, recipient, amountOut)without reserving the fee balance. A near-total one-for-zero swap can leavereserve0belowaccruedFee0, so the very nextclaimFeescall will revert. With only one LP today the effect is mostly cosmetic, but as soon as multiple LPs participate a withdrawing or trading LP can leave the pool owing more fees than it holds, permanently denying the remaining LPs access to their accrued rewards.Recommendation
Track fee collateral separately from trading reserves or, at minimum, enforce
reserve0 - amount0 >= accruedFee0andreserve1 - amount1 >= accruedFee1insidewithdraw, while makingswapoperate on net reserves that excludeaccruedFee{0,1}so the pool can never send out more than the spendable portion.Capricorn Exchange: Fixed in 0db8e9a.
Cantina: Fix verified.
Quoter/Swap revert with panic when a reserve is zero
State
Severity
- Severity: Low
Submitted by
r0bert
Description
PAMMPool.quoteExactIndelegates pricing toPricingEngine.exactIneven when one side of the pool is empty or effectively dust. Inside the engine, the spread helper_multcomputes the imbalance metricepsby dividing by twice the opposite reserve:// contracts/PricingEngine.sol (excerpt)UD60x18 eps;if (zeroForOne) { eps = (reserve0 * price + reserve1) / (convert(2) * reserve1);} else { eps = (reserve1 / price + reserve0) / (convert(2) * reserve0);}A fresh pool, or a pool that just withdrew an entire side, will have
reserve0 == 0orreserve1 == 0. The divisions above then hit a division‑by‑zero and Solidity throwspanic(0x12)before any rebalancing logic can run. BecausequoteExactInsimply forwards the engine’s revert, routers and integrators see an opaque low‑level panic instead of a structured “insufficient liquidity” error; the same path is reachable fromswapand will also cause a panic. Even when reserves are not exactly zero but extremely small, the denominator terms magnify the inputs that drive the EXP-class exponent logic, so downstream calculations hit their numerical limits more quickly and swaps continue to revert until liquidity is restored.Recommendation
Short‑circuit at the pool surface with a clear error before calling the engine, so integrators never see a low‑level panic. At the top of both quoteExactIn and swap, after you read reserves, add:
if (reserve0 == 0 || reserve1 == 0) revert InsufficientLiquidity();Optionally enforce a small minimum reserve threshold to avoid near‑zero denominators that can destabilize downstream math.
Capricorn Exchange: Fixed in e9a65c1.
Cantina: Fix verified.
Constructor-Based role setup breaks with proxy
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
The contract includes a
__gapfor upgradeability but assigns roles in a constructor. When deployed behind a proxy, the constructor does not execute, leaving roles unset and effectively no admin on the proxied instance.Recommendation
Make the contract either non-upgradeable or adopt a proper upgradeable pattern:
- Use OpenZeppelin’s
Initializable/AccessControlUpgradeable. - Move role setup into an
initialize(orreinitializer) function guarded byinitializer. - In the implementation contract’s constructor, call
_disableInitializers()to prevent misuse. - Ensure the proxy calls
initialize(...)immediately after deployment to grantDEFAULT_ADMIN_ROLEand any other roles.
Capricorn Exchange: Fixed in bbe1a13.
Cantina: Fix verified.
setPricingEngine missing token validation
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
When switching the pricing engine (or updating
oracleId), the contract does not re-validate that the referenced oracle is registered and oriented for the current pair (token0/token1). A misconfigured or staleoracleIdcan point to an unregistered market or to a(base,quote)that doesn’t match the pair, causing incorrect pricing, misquotes, or loss of funds.Recommendation
Replicate the constructor’s validation inside the setter that changes the pricing engine and/or
oracleId. Require that the market exists and that(base, quote)matches(_token0, _token1)before applying the change.Capricorn Exchange: Fixed in ca890ab.
Cantina: Fix verified.
withdraw in PAMMPool does not have whenNotPaused modifier
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
withdrawlacks a pause guard. During emergencies (oracle failure, pricing bug, exploit), LP withdrawals would remain callable, preventing the protocol from halting outflows and containing damage.Recommendation
Gate
withdrawwithwhenNotPaused.If withdrawals must remain available under specific conditions, document and implement a separate
emergencyWithdraw()with tighter limits and auditing hooks instead of leavingwithdrawunguarded.Capricorn Exchange: Fixed in bdc4ca1.
Cantina: Fix verified.
Avoid hardcode MAX_EXTERNAL_ORACLE_STALENESS and MAX_NATIVE_PRICE_STALENESS
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
Oracle and price staleness thresholds are hard-coded. This reduces flexibility across networks/feeds and forces redeploys to tune risk.
Calls to
getPriceNoOlderThanwill revert if the on-chain update is older than the provided heartbeat (e.g., 3600 seconds).Recommendation
Make staleness thresholds configurable.
Consider fetch with
getPriceUnsafeand enforce the staleness check in flexible manner.Capricorn Exchange: Fixed in ef9132e.
Cantina: Fix verified.
PAMMPool.sol is not compatible with rebasing token
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
The pool is **not compatible with rebasing (elastic-supply) tokens. The contract tracks internal reserves (
reserve0/ reserve1) and then asserts equality with live balances after each operation:uint256 balance0 = IERC20Minimal(token0).balanceOf(address(this));uint256 balance1 = IERC20Minimal(token1).balanceOf(address(this));if (balance0 != reserve0 || balance1 != reserve1) { revert FeeOnTransferDetected(balance0, reserve0, balance1, reserve1);}A positive/negative rebase changes
balanceOf(address(this))without a transfer, so the next call seesbalances ≠ reservesand reverts. Even if this equality check were removed, pricing/invariant math would be wrong because storage reserves would be stale relative to actual balances.Recommendation
Ensure neither base token nor quote token is a rebase token.
Capricorn Exchange: Acknowledged. The pool creation and token choice is at the discretion of the pool deployer, who needs to be be cautious with not using a rebasing token.
Informational4 findings
Blocklisted traders can swap by routing through a clean wrapper
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
PricingEngine.exactInenforces theSegmenterRegistryblocklist by looking at the immediate caller and the on-chain recipient:// contracts/PricingEngine.sol:452-458if (trader != address(0)) { bool p1 = segmenterRegistry.getTradeBlocked(trader); if (p1) revert UnauthorizedSwap();}if (recipient != address(0)) { bool p2 = segmenterRegistry.getTradeBlocked(recipient); if (p2) revert UnauthorizedSwap();}PAMMPoolpassesmsg.senderas trader and the swap recipient as recipient. A wallet that sits on the blocklist can trivially deploy an unblocked wrapper contract, fund it, and have the wrapper call swap withrecipient = wrapper. The registry now sees the wrapper address in both checks, finds it unblocked and returns without reverting. Once the pool transfers the output to the wrapper, the wrapper immediately forwards the tokens to the originally blocked wallet. Because the registry never inspects the ultimate beneficiary and the pool has no way to distinguish a wrapper from a genuine trader, the blocklist is effectively unenforceable: any banned user can route swaps through a single thin proxy and trade indefinitely.Graphical representation of the bypass:
Recommendation
Merely informative. In this case, the only way to enforce a proper user whitelist/blacklist is by requesting to pass signature validation upon every swap.
Capricorn Exchange: Acknowledged.
PAMM fuzzing harness
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
Link: https://gist.github.com/r0bert-ethack/efd1379c11f0164474e895db072ad95d
We built a dedicated fuzzing suite to exercise the PricingEngine’s swap math under randomized market configurations. The harness runs as a Foundry-style property test. It seeds random reserves, curve parameters, fee settings, and trade sizes, then simulates an exact-in swap in one direction using
PricingEngine.exactIn, updates the virtual reserves asPAMMPoolwould and immediately performs the reverse exact-in swap to push value back into the original asset. It asserts that the round trip never yields a positive net balance for the trader once fees are applied. Whenever the engine returns a profit, the harness records the full parameter set, providing reproducible counterexamples. This fuzz target gives broad coverage across the parameter space and quickly exposes curve inconsistencies or rounding edge cases that manual review would likely miss.Recommendation
Incorporate this fuzzing target (or an adapted in-repo version) into the automated test suite so every code change runs the round-trip invariant and flags numerical regressions before deployment.
Capricorn Exchange: Acknowledged.
Unused Code
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
- Error
RateLimitExceedis not used. - Event
EngineVersionUpdatedis not emitted.
Recommendation
Remove the unused code.
Capricorn Exchange: Acknowledged.
Token existence check is missing in TransferHelper.sol
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
ladboy233
Description
The contract does not verify that the token address points to a deployed ERC-20 contract. As a result, transfers could silently succeed (no revert) against a non-existent or non-compliant token, leading to unexpected behavior or lost funds.
Recommendation
Add an explicit token-existence check (e.g.,
address(token).code.length > 0) and useSafeERC20for transfers to handle non-standard return values.Reference: OpenZeppelin
SafeERC20(see call pattern around optional returns): https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/utils/SafeERC20.sol#L196Capricorn Exchange: Acknowledged.