Organization
- @tadle
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
High Risk
1 findings
1 fixed
0 acknowledged
Medium Risk
3 findings
2 fixed
1 acknowledged
Low Risk
3 findings
2 fixed
1 acknowledged
Informational
7 findings
5 fixed
2 acknowledged
Gas Optimizations
3 findings
3 fixed
0 acknowledged
High Risk1 finding
Reentrancy in redeemValidityBond
Severity
- Severity: High
≈
Likelihood: High×
Impact: High Submitted by
slowfi
Description
In
EulerSwapRegistryredeemValidityBondtransfers ETH torecipientbefore clearingvalidityBondsmapping. Becauserecipientis externally controlled, its fallback can reenter registry while the bond mapping still holds the value, allowing repeated withdrawals of the same bond and interleaving state changes in a single transaction. After draining the registry’s bond balance, it is not possible to unregister other pools because there is no ETH left to pay their bonds; doing so would require sending funds back to the contract first.This finding did not put LP funds at risk.
Recommendation
Consider to clear state before the external transfer, set
validityBonds[pool] = 0prior to the call and protect all entry points that can reach bond payout with a simplenonReentrantguard; optionally adopt a pull-payment pattern (record owed amounts and let recipients withdraw) to eliminate this surface entirely, ensuring the registry cannot be locked out of uninstalling pools unless the drained funds are first returned.+ validityBonds[pool] = 0; (bool success,) = recipient.call{value: bondAmount}(""); require(success, ChallengeMissingBond());- validityBonds[pool] = 0;Euler: A reentrancy guard was added, and a storage write was reordered to preserve the checks-effects-interactions pattern.
Note that this did not affect LP funds, just the posted liquidity bonds (which are expected to be small -- just enough to cover gas costs of a challenge).
Cantina Managed" Fix verified.
Medium Risk3 findings
Exploitable false positive in challengePool validation
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
Cryptara
Description
The
challengePoolfunction in theEulerSwapRegistrycontract is vulnerable to manipulation by tokens with transfer hooks or malicious tokens. The current implementation wraps both theIERC20(tokenIn).safeTransferFrom(...)and the swap call inside a self-call (challengePoolAttempt). This design allows a token's transfer hook to revert withE_AccountLiquidity.selector, which is indistinguishable from the intended failure path (a genuine liquidity failure during the swap).As a result, a malicious challenger can exploit this mechanism to force a false positive, redeem the validity bond, and drain the contract. This issue is further exacerbated by the potential for re-entrancy attacks, which could amplify the impact of the exploit.
POC
// test/Basic.t.sol: function test_swap_hook() public monotonicHolderNAV { deal(holder, 1e18); eulerSwap = createEulerSwap(60e18, 60e18, 0, 1e18, 1e18, 0.4e18, 0.85e18); uint256 amountIn = 1e18; uint256 amountOut = periphery.quoteExactInput(address(eulerSwap), address(assetTST), address(assetTST2), amountIn); assertApproxEqAbs(amountOut, 0.9974e18, 0.0001e18); assetTST.mint(address(this), amountIn); assetTST.transfer(address(eulerSwap), amountIn); eulerSwap.swap(0, amountOut, address(this), ""); assertEq(assetTST2.balanceOf(address(this)), amountOut); // The bond should not be 0 assertNotEq(eulerSwapRegistry.validityBond(address(eulerSwap)), 0); // Expecte behaviour uint256 snapshot = vm.snapshotState(); vm.expectRevert(); eulerSwapRegistry.challengePool( address(eulerSwap), address(assetTST), address(assetTST2), 0, true, address(5555) ); vm.revertToState(snapshot); // Lets emulate a transfer hook reverting on assetTST with E_AccountLiquidity.selector // showcasing why we need to have the `safeTransferFrom` outside of the call vm.mockCallRevert( address(assetTST), 0, // data abi.encodeWithSelector( TestERC20.transferFrom.selector ), abi.encodeWithSelector(E_AccountLiquidity.selector) ); eulerSwapRegistry.challengePool( address(eulerSwap), address(assetTST), address(assetTST2), 0, true, address(5555) ); // The bond will be 0 assertEq(eulerSwapRegistry.validityBond(address(eulerSwap)), 0); }Recommendation
Refactor the
challengePoolfunction to separate thesafeTransferFromcall from the swap execution. This can be achieved by using atry/catchblock for the swap logic only preventing from untrusted parts of the code to manipulate the returned selector.Euler
Euler did acknowledge the issue but stated that the risk is minimal. They justified this by explaining that tokens with transfer hooks are extremely rare and not supported in their EVK. Additionally, even if such tokens were used, recipients must opt in, and the potential impact would be limited.
Unchecked borrow vault access in QuoteLib causes swap revert when borrowing is disabled
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
slowfi
Description
In
QuoteLibcontract thecaclLimisreads debt viadebtOfon the borrow vault unconditionally. Pool configurations allowborrowVault0/1to beaddress(0)to disable borrowing; in that case, any quote or swap that reaches this path will attempt to calldebtOfon the zero address and revert due to empty return data or a call to a non-contract, breaking otherwise valid “no-borrow” configurations.Recommendation
Consider to guard the optional borrow vault before use and treat missing borrowing as zero debt; when the borrow vault is unset, compute
debt = 0and proceed with limit math, while ensuring execution paths that would require a borrow cleanly reject with a clear error (e.g.,BorrowDisabledorSwapRejected). You may also validate at activation or reconfigure that when a borrow vault is unset the configuration cannot rely on borrowing, but the quote path should not hard revert solely because the borrow vault isaddress(0).Flash loan path always reverts
Severity
- Severity: Medium
Submitted by
slowfi
Description
EulerSwapwas intended to support flash loans on the direct path (in addition to swaps), but the current flow computes amounts before the callback. Specifically, amounts are accounted in the call toSwapLib.amounts, which measures the contract balances and snapshots them ahead of invoking the user callback. After that pre-callback snapshot, the contract performs withdraws, calls the callee, then deposits “all available” funds and verifies the curve. Because the snapshot happens before the callback, any tokens returned during the callback are not validated against an exact repay target: a direct flash attempt that requests output with zero net input will always revert at the invariant check, and if a caller sends back more than required under permissive reserves, that surplus is treated as extra input and can remain on the pool contract rather than being refunded.This is medium severity because it breaks flash-loan usability on the direct path and can strand tokens on the pool contract if users transfer back during the callback; it is not a direct theft vector, but it can cause operational loss and DoS for integrators expecting flash-loan semantics.
Proof of Concept
The test below activates the pool on the boundary and tries to “flash borrow” token0 by asking for output with zero input, returning funds during the callback. The swap reverts with
CurveLib.CurveViolation, confirming the flash path is not usable. After revert, no funds remain stuck on either the pool or the callee.function test_direct_flashloan_reverts_and_no_stuck_funds() public { // Boundary activation (equilibrium == reserves), any out with zero input should fail (IEulerSwap.StaticParams memory s, IEulerSwap.DynamicParams memory d, IEulerSwap.InitialState memory i) = _params(60e18, 60e18, 0, 1e18, 1e18, 0.4e18, 0.85e18, true); // Activate on boundary i.reserve0 = 60e18; i.reserve1 = 60e18; EulerSwap pool = _deployHookPool(s, d, i); FlashReturnCallee callee = new FlashReturnCallee(address(pool)); uint256 out0 = 1e18; bytes memory data = abi.encode(address(asset0), out0); vm.expectRevert(CurveLib.CurveViolation.selector); pool.swap(out0, 0, address(callee), data); // Entire swap reverted; no tokens stranded on pool or callee assertEq(asset0.balanceOf(address(pool)), 0); assertEq(asset1.balanceOf(address(pool)), 0); assertEq(asset0.balanceOf(address(callee)), 0); assertEq(asset1.balanceOf(address(callee)), 0); assertFalse(callee.called());}Recommendation
Consider to implement explicit flash-loan semantics: snapshot balances, transfer the loan, invoke the callback, then require an exact repay plus fee before deposits and invariant checks, refunding any surplus to the caller. If flash loans are not desired, document that the callback is not a V2-style flash mechanism and add safeguards to prevent surplus from being silently retained.
Low Risk3 findings
Missing Validation for swapHook and swapHookedOperations
Severity
- Severity: Low
Submitted by
Cryptara
Description
The
EulerSwapcontract lacks proper validation for theswapHookandswapHookedOperationsparameters during configuration and reconfiguration. Specifically, if(swapHookedOperations & 3) != 0, theswapHookis expected to contain a valid non-zero address. Failure to validate this can lead to misconfigured pools that always revert during operations such asQuoteLib.getFee()orSwapLib.finish(). This misconfiguration causes the pool to become unusable and prevents challenges from being executed, as thechallengePoolmechanism relies on specific revert selectors likeE_AccountLiquidity.Additionally, the
challengePoolmechanism does not account for pools that fail due to invalid hooks or other misconfigurations. For example, a pool owner could set an invalidswapHookthat always reverts, effectively preventing the pool from being challenged and allowing the bond to remain locked indefinitely. This creates a loophole where invalid pools cannot be penalized or removed from the registry.Recommendation
Validation During Configuration and Reconfiguration:
- Ensure that if
(swapHookedOperations & 3) != 0, theswapHookis a valid non-zero address. - This validation should be enforced during both the
activateandreconfigurefunctions to prevent misconfigured pools.
Enhance
challengePoolMechanism:- Extend the
challengePoollogic to handle additional failure scenarios beyondE_AccountLiquidity. - Use a
try/catchblock to capture all reverts during the challenge process. - Maintain a whitelist of valid revert selectors (e.g.,
E_AccountLiquidity.selector,HookError) and treat any other reverts as invalid. - If the revert reason is not whitelisted, the bond should not be redeemed, and the challenge should fail.
Document Failure Scenarios:
- Clearly document the various ways a pool can fail (e.g., liquidity issues, invalid hooks, misconfigurations) and ensure the
challengePoolmechanism accounts for these scenarios.
Cantina
The client addressed the issue by implementing fixes in two parts (70cd473b9e5e96f7c35c4f968014641cbb41fdf4c and 22bb23aa456a72ae4bde1517e73d9a54da19ac28). They added sanity checks to the hook configuration as recommended, and introduced a custom
HookError()wrapper for hook call failures. This ensures that pools encountering such errors (e.g., duringafterSwap) can be safely removed from the registry via a challenge.Unnecessary Transfer When feeAmount is Zero
Severity
- Severity: Low
Submitted by
Cryptara
Description
In the
SwapLiblibrary, thedoDepositfunction performs a transfer to thefeeRecipienteven whenfeeAmountis zero:IERC20(assetInput).safeTransfer(ctx.sParams.feeRecipient, feeAmount);This can cause issues with certain tokens that revert on zero-value transfers, leading to unnecessary failures during the swap process. While most ERC20 tokens allow zero-value transfers, some implementations do not, which can make the contract incompatible with such tokens.
Recommendation
Add a conditional check to ensure that the transfer is only executed when feeAmount is greater than zero. This will prevent unnecessary transfers and ensure compatibility with tokens that revert on zero-value transfers.
Single-step curator transfer
State
- Acknowledged
Severity
- Severity: Low
Submitted by
slowfi
Description
In
EulerSwapRegistry,transferCuratoris gated byonlyCuratorand setscuratorimmediately in a single step. This makes the role change effective without confirmation by the new curator, which can be error-prone and reduces operational safety.Recommendation
Consider to adopt a two-step transfer pattern:
transferCurator→ setspendingCuratorand emits a start event.acceptCuratorship→ callable bypendingCuratorto finalize and clear the pending value, emitting a completion event.
Euler: We have not done this in the majority of our contracts and it hasn't been an issue so far. In the couple places we've done this previously, it added a lot of operational overhead, so we are not planning on adopting this pattern more broadly.
Cantina Managed: Acknowledged.
Informational7 findings
Inconsistent Naming Convention for Internal Functions
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Cryptara
Description
The
EulerSwapRegistrycontract uses inconsistent naming conventions for internal functions. While some internal functions like_uninstalland_redeemValidityBondfollow the underscore prefix convention, others such asgetSliceandisValidVaultdo not. This inconsistency can lead to confusion for developers and auditors, as it becomes unclear which functions are intended for internal use. Consistent naming conventions are critical for maintaining code readability and reducing the risk of unintended usage.Recommendation
Update all internal function names to follow a consistent naming convention. For example, prepend an underscore (
_) to all internal function names, includinggetSliceandisValidVault. This will align with the existing convention used for_uninstalland_redeemValidityBond, making the codebase more uniform and easier to understand.Unused Errors in CurveLib
Severity
- Severity: Informational
Submitted by
Cryptara
Description
Found by client.
The
CurveLiblibrary defines the following errors that are not used anywhere in the code:error Overflow();error CurveViolation();These unused errors increase the size of the compiled bytecode unnecessarily and may confuse developers or auditors by implying functionality that does not exist.
Recommendation
Remove the unused errors from the CurveLib library to improve code clarity. If these errors are intended for future use, document their purpose to avoid confusion.
Removal of Ternary Library Usage for Clarity
Severity
- Severity: Informational
Submitted by
Cryptara
Description
The
CurveLiblibrary uses theTernarylibrary to simplify conditional expressions, such as:shift = (shiftSquaredB < shiftFourAc).ternary(shiftFourAc, shiftSquaredB);While the Ternary library improves contract size and runtime gas efficiency, the same logic can be expressed using the native Solidity ternary operator:
shift = shiftSquaredB < shiftFourAc ? shiftFourAc : shiftSquaredB;This change improves code clarity and reduces dependency on the Ternary library. If the Ternary library is retained, unused overloads should be removed to reduce code complexity.
Recommendation
- Replace the Ternary library usage with the native Solidity ternary operator for clarity.
- If the Ternary library is retained, remove any unused overloads to improve brevity and maintainability.
beforeSwap name for hook is misleading
Severity
- Severity: Informational
Submitted by
slowfi
Description
Found by client.
The hook named
beforeSwapdoes not run at the very start of the swap. Amounts are first accounted (viaSwapLib.amountsreading contract balances and fixing deltas) and other mid-swap steps occur, after which the hook logic is involved. This naming suggests a pre-entry “before swap starts” hook that can gate or parameterize the entire operation, but in practice it executes mid-flow. The mismatch can mislead integrators who expect a true pre-swap point for auctions, circuit-breakers, or fee setting aligned with quoting.Recommendation
Consider to either rename the current hook to reflect its mid-swap placement, or introduce a new true pre-swap hook that runs immediately on swap entry (and during quoting) before any accounting or token movement. The pre-swap hook should be invoked under the lock, accept the
readOnlysignal for quote symmetry, be mandatory when enabled (non-zero address when the corresponding bit is set), and reject by returning the sentinel fee or reverting. Update the documentation to clarify the execution order and responsibilities of each hook to avoid misuse.Euler: What used to be beforeSwap has been renamed to getFee, and a new beforeSwap hook was added that actually does run before any tokens have moved.
Missing events for curator and registry parameter updates
Severity
- Severity: Informational
Submitted by
slowfi
Description
In
EulerSwapRegistry, the following governance/operational setters do not emit events:transferCurator(address newCurator)setMinimumValidityBond(uint256 newMinimum)setValidVaultPerspective(address newPerspective)
Without events, off-chain monitoring and indexers cannot reliably track changes to the curator, minimum bond requirement, or the vault verification perspective.
Recommendation
Consider to emit explicit events for each update, e.g.,
CuratorTransferred(address indexed oldCurator, address indexed newCurator),MinimumValidityBondUpdated(uint256 oldValue, uint256 newValue), andValidVaultPerspectiveUpdated(address indexed oldPerspective, address indexed newPerspective). Useindexedparameters where helpful for filtering, and emit after state changes to reflect final values.Outdated return-range comment in CurveLib conflicts with saturating behavior
Severity
- Severity: Informational
Submitted by
slowfi
Description
Found by client.
In
CurveLibNatSpec states that the function “returnsy… guaranteed to satisfyy0 ≤ y ≤ 2^112 - 1.” The implementation no longer guarantees that range and instead uses a saturating behavior that returnstype(uint256).maxon overflow. This mismatch can mislead readers and integrators into assuming a boundeduint112-range output, potentially masking overflow conditions and complicating downstream checks that expect an in-range reserve value.Recommendation
Consider to update the documentation to reflect the current saturating semantics and specify that
type(uint256).maxis a sentinel indicating overflow. If the sentinel is retained, ensure all call sites explicitly guard against it (treat as failure and revert) and add tests for the overflow path. Alternatively, replace saturation with a clear revert using a dedicated error to preserve the original “range-guaranteed” contract.Reconfigure above the curve enables zero-input dual-asset withdrawals
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
reconfigureaccepts anInitialStatethat is above/right of the curve (only “not below the curve” is enforced). From such a state, the pool contains excess, unclaimed value. Any caller can executeswap(out0>0, out1>0, …)with zero input and withdraw both assets for free until the reserves land on the curve. This behavior is prevented at activation (initial state must be on-curve, with a strict boundary check), but it is allowed during reconfiguration, which enables accidental donation of tokens.Observed invariants still hold:
- Curve invariant: execution rejects only when a post-swap point would fall below the boundary.
- Other guards such as
minReserve*, fees, andexpirationstill apply; they do not prevent the described withdrawal of the “above/right slack”.
Proof Of Concept
-
Deploy a pool whose activation uses on-curve reserves.
-
Call
reconfigure()withInitialStateabove/right of(x0, y0)(e.g.,{reserve0: 60e18, reserve1: 60e18}whileequilibriumReserve{0,1}=30e18). -
As any address, call:
pool.swap(out0 = 10e18, out1 = 10e18, receiver, "");with no prior input transfers.
-
The call succeeds;
receiverreceives both assets; pool reserves reduce to{50e18, 50e18}. Repeating is possible until the point reaches the curve; the next such call then reverts with the curve violation.
function test_poc_direct_double_free_withdraw() public { // Start well above equilibrium: reserves (60,60) with eq (30,30) EulerSwap pool = _deployUpRightPool(60e18, 60e18, 30e18, 30e18); address attacker = makeAddr("attacker"); // Request both outs without sending any input; keep new reserves >= equilibrium uint256 out0 = 10e18; // new reserve0 = 50e18 uint256 out1 = 10e18; // new reserve1 = 50e18 // No pre-transfers, empty callback vm.prank(attacker); pool.swap(out0, out1, attacker, ""); // Attacker received both tokens for free assertEq(asset0.balanceOf(attacker), out0); assertEq(asset1.balanceOf(attacker), out1); // Reserves updated down but still above equilibrium; no input deposited (uint112 r0, uint112 r1,) = pool.getReserves(); assertEq(r0, 50e18); assertEq(r1, 50e18); }Recommendation
Consider to:
- Enforce on-curve reconfigure: verify the provided
InitialStatelies on the curve (optionally allow a tiny epsilon, projecting to the curve internally when within tolerance). - Or require explicit opt-in: add a boolean (e.g.,
allowValueLeakage) that must betrueifInitialStateis above/right; emit an event with the donated deltas. - Or auto-snap: when
InitialStateis above/right, compute the curve boundary point and use it instead (emit an event). - Policy via hook (if hooks are used): in a
beforeSwaphook, reject zero-input withdrawals whilereserve0>x0 && reserve1>y0(or restrict tomsg.sender == eulerAccount). - UX guardrail: in periphery/CLI, warn on reconfigure if
InitialStateis above/right and display the implied token deltas.
These changes preserve the existing invariant (no below-curve states) while preventing unintended donation of pool assets during reconfiguration.
Euler: This is by design. It's functionally the same as when somebody "over-swaps" (ie provides more input token and/or takes less output token than necessary). The excess is available for next swapper. As mentioned, you can even get it with 0 input tokens.
We were on the fence about enforcing the strict on-curve boundary on a reconfigure. On one hand it prevents a reconfiguration from screwing up and losing value, but on the other hand getting it exactly precise after rounding and adds a large complexity overhead for some use-cases, such as pooled models that reconfigure on deposit.
This is documented in the developer guide, but we'll try to make it more clear that it is important to get this right. The recommendations are good ideas, but at this time we think it makes more sense to allow the reconfigurer to decide what to do.
Cantina Managed: Acknowledged.
Gas Optimizations3 findings
Duplicate bond redemption in challengePool
Severity
- Severity: Gas optimization
Submitted by
slowfi
Description
In
EulerSwapRegistrycontractchallengePoolfunction callsredeemValidityBondis beforeuninstall, whileuninstallalso invokesredeemValidityBond. Becauseuninstallis executed immediately after, the first redemption is redundant on the success path and incurs an unnecessary external call and gas cost.Recommendation
Consider to perform bond redemption in one place only. A simple change is to remove internal call to
redeemValidityBondand letuninstallhandle the payout. Keep event semantics consistent and ensure revert behavior is unchanged.Fold exact-out fee into the else branch in QuoteLib
Severity
- Severity: Gas optimization
Submitted by
slowfi
Description
In
QuoteLibthe branch that handlesexactOutfirst enforces limits and then inflatesquotefor fees using a separateif (!exactIn)after the branch. This is semantically identical to performing the inflation inside theelsebranch directly after the limits check, and doing so improves readability and can save a tiny amount of gas by avoiding an extra conditional.Current:
if (exactIn) { require(amount <= inLimit && quote <= outLimit, SwapLimitExceeded());} else { require(amount <= outLimit && quote <= inLimit, SwapLimitExceeded());}// exactOut: inflate required amountInif (!exactIn) quote = (quote * 1e18) / (1e18 - fee);Equivalent and clearer:
if (exactIn) { require(amount <= inLimit && quote <= outLimit, SwapLimitExceeded());} else { require(amount <= outLimit && quote <= inLimit, SwapLimitExceeded()); // exactOut: inflate required amountIn quote = (quote * 1e18) / (1e18 - fee);}Recommendation
Consider to move the
quoteinflation for the exact-out path into theelsebranch directly after its limits check. This keeps all exact-out logic localized, makes the control flow self-evident, and may produce a marginal gas improvement from one fewer conditional.if (exactIn) { // if `exactIn`, `quote` is the amount of assets to buy from the AMM require(amount <= inLimit && quote <= outLimit, SwapLimitExceeded());} else { // if `!exactIn`, `amount` is the amount of assets to buy from the AMM require(amount <= outLimit && quote <= inLimit, SwapLimitExceeded());+ // exactOut: inflate required amountIn+ quote = (quote * 1e18) / (1e18 - fee);} - // exactOut: inflate required amountIn- if (!exactIn) quote = (quote * 1e18) / (1e18 - fee);Duplicated reentrancy guard across EulerSwap and UniswapHook
Severity
- Severity: Gas optimization
Submitted by
slowfi
Description
EulerSwapdefines anonReentrantmodifier that flipsCtxLib.State.statusbetween1and2. The same guard logic exists inUniswapHook(nonReentrantHookin the linked file).Recommendation
Consider to centralize the guard in a single source of truth and have both
EulerSwapandUniswapHookuse it. Replace magic numbers with named constants (e.g.,STATUS_UNLOCKED = 1,STATUS_LOCKED = 2) to avoid drift, and keep the revert selector consistent across both call sites.