Guardian's review of Sapience for Sapience, published August 2025. The report records 87 findings across 2 review rounds, including 8 high and 18 medium.
- Published
- Review window
- July 16 to August 19, 2025
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Base, Arbitrum, Polygon
- Sector
- Derivatives
- 0 Critical
- 8 High
- 18 Medium
- 30 Low
- 31 Informational
Scope
Findings 87
Main Review
74 findings · July 16 to 28, 2025-
H-01 High Persistent Withdrawal Intent Blocks Subsequent Bond Withdrawals Logical Error Resolved
Description
In the
BondManagementabstract contract (inherited byUMALayerZeroBridge), theexecuteWithdrawalfunction marks a withdrawal intent as executed by settingintent.executed = truebut does not clear thewithdrawalIntents[msg.sender][bondToken].amountvalue. This leaves theamountfield unchanged, causing any future calls tointentToWithdrawBondfor the samesubmitterandbondTokento revert due to the checkrequire(withdrawalIntents[msg.sender][bondToken].amount == 0, "Withdrawal intent already exists");. As a result, users with remaining bond balances after a partial withdrawal cannot initiate new intents, locking their remaining funds in the escrow indefinitely.The relevant code in
BondManagement.executeWithdrawalonly updates theexecutedflag and subtracts from the balance, without resetting the intent:function executeWithdrawal(address bondToken) external virtual nonReentrant returns (MessagingReceipt memory) { // ... intent.executed = true; // Only this flag is set; amount remains >0 submitterBondBalances[msg.sender][bondToken] -= intent.amount; // ... }This persists the non-zero amount, triggering the revert in
intentToWithdrawBond:function intentToWithdrawBond(address bondToken, uint256 amount) external virtual nonReentrant returns (MessagingReceipt memory) { // ... require(withdrawalIntents[msg.sender][bondToken].amount == 0, "Withdrawal intent already exists"); // Fails if prior amount lingers // ... }- Exploitation scenario: (1) User deposits 100 bond tokens. (2) Creates intent to withdraw 50. (3) After delay, executes withdrawal of 50 (balance now 50, intent.amount still 50, executed=true). (4) Tries to withdraw remaining 50 →
intentToWithdrawBondreverts on non-zero amount. (5) Funds stuck; no way to clear intent without owner intervention.
Recommendation
When
CMD_FROM_ESCROW_WITHDRAWis executed on the market side, send confirmation back to the UMA chain which marks the intent as delivered. Then check if the intent is already delivered inintentToWithdrawBondand if so, allow creating a new one.IMPORTANT:Do not allow creating a new intent if theexecutedflag of the current one istrue. Doing so may introduce new race conditions and result in accounting mismatch. - Exploitation scenario: (1) User deposits 100 bond tokens. (2) Creates intent to withdraw 50. (3) After delay, executes withdrawal of 50 (balance now 50, intent.amount still 50, executed=true). (4) Tries to withdraw remaining 50 →
-
H-02 High Manual Settlement Yields Fractional/Innacurate Payouts Logical Error Resolved
Description
The protocol provides an emergency function
SettlementModule.__manual_setSettlementPricethat finalizes any still-unresolved market after a delay of twice the market’s duration. The function retrieves the instantaneous Uniswap V3 pool price (viaslot0.sqrtPriceX96), converts it to a decimal, clamps it to the configured[minPriceD18, maxPriceD18]band, and then marks the market as settled.While this may loosely approximate a numeric outcome, for binary (Yes/No) markets it utterly destroys the design intent as these markets are meant to resolve to exactly one of two canonical values,
minPriceD18ormaxPriceD18, representing a definitive “NO” or “YES.” By locking in whatever continuous value the pool happens to show, say, 0.73 e18, the contract transforms a winner-takes-all payoff into a proportional split, so YES-token holders receive only seventy-three per cent of their rightful collateral and NO-token holders keep the remainder. This shortfall is a pure, uncompensated loss of profit and loss (PNL) for the honest winning side.Worse still,
slot0’s spot price simply reflects the liquidity-weighted consensus of recent traders and liquidity providers, not the actual real-world truth of the question. It therefore advantages whichever side commanded more pool liquidity, allowing the majority sentiment to dictate a fractional “resolution” that can be entirelyfalse. Honest participants can see a perfectly valid “YES” resolution diluted or reversed simply because the pool was loaded more heavily on the NO side at that moment.This vulnerability is compounded by the broken dispute flow in
UMASettlementModule.assertionResolvedCallback. The code ignores theassertedTruthfullyflag returned by UMA as, any dispute, regardless of the final oracle verdict, setssettlement.disputed = trueand permanently blocks automatic settlement. An adversary anticipating an unfavorable DVM outcome can therefore pay a single bond to lodge a dispute, stall the oracle-verified resolution, wait out the forced delay, and then invoke manual settlement. At that point, the attacker chooses the block to sample the pool price, benefiting from the majority-weighted liquidity position to reshape payouts in their favor. Not only is the binary outcome falsified, but the rightful winners also forfeit a portion of their PNL and remain unable to withdraw until the market is forcibly closed.In summary, this design allows a losing party to salvage value or even profit by stalling the oracle and then hijacking the emergency mechanism, fundamentally breaking the economic guarantees and trust model of binary markets.
Recommendation
Disable the manual-settlement escape hatch for any market defining a
claimStatementNo(i.e., binary or categorical markets). If an emergency fallback is deemed necessary, require the caller to supply an explicit boolean, which the contract then maps internally tominPriceD18ormaxPriceD18, preserving the discrete nature of payouts. Additionally, updateassertionResolvedCallbackto honor theassertedTruthfullyflag: when UMA’s DVM confirms the original assertion, the settlement price must be finalized immediately regardless of prior disputes, and only afalseverdict should leave the market unsettled. These changes reinstate the oracle as the sole source of truth and eliminate the profit opportunity created by the current manual path. -
H-03 High Zero‑Price Settlement Can Create Immediate Bad Debt in Binary Markets Logical Error Resolved
Description
Binary (YES/NO) markets are implemented as a Uniswap‑V3 pool whose lower tick corresponds to the “NO” payoff and whose **upper tick **corresponds to the “YES” payoff.
Before a trade or an LP action is accepted, Sapience computes the worst‑case loss each position could suffer by assuming price will move to the pool’s configured
minPriceD18(derived fromlowTick) ormaxPriceD18and then requires collateral large enough to cover that loss.At settlement, governance (or the oracle) submits the final outcome through
setSettlementPriceInRange. That function clamps any non‑zero price into the configured[minPriceD18, maxPriceD18]interval, but makes an exception for an exact 0:function setSettlementPriceInRange(Data storage self, uint256 settlementPriceD18) internal returns (uint256) { // Special case: allow exact 0 for complete loss scenarios (e.g., "No" outcome in yes/no markets) if (settlementPriceD18 == 0) { self.settlementPriceD18 = 0; // <----------------------- } else if (settlementPriceD18 > self.maxPriceD18) { self.settlementPriceD18 = self.maxPriceD18; } else if (settlementPriceD18 < self.minPriceD18) { self.settlementPriceD18 = self.minPriceD18; } else { self.settlementPriceD18 = settlementPriceD18; } self.settled = true; return self.settlementPriceD18; }Consequently, if market creators pick a realistic “NO” tick (e.g. −92200 ⇒ ≈ 0.0001 $) to improve UX, the protocol:
- Collects collateral assuming the worst price is 0.0001 $.
- Allows settlement at exactly 0 $.
- Realizes losses up to 0.0001 $ per virtual base unit beyond what was collateralized.
Any long‑YES trader or full‑range LP is now under‑collateralized. Because repayments inside
Position.settle()are not optional, negative balances are socialised, creating protocol‑wide bad debt.The last users who call
settlePositionwill not receive their full payout as the call will revert with an underflow error.Recommendation
Consider removing the zero‑price special case. On the other hand, in
createMarket, when the market is a binary market, requirelowTickto be so close to the Uniswap minimum (≤ ‑887260) thatminPriceD18is economically indistinguishable from 0 (e.g. < 1 wei). -
H-04 High depositBond Function Can Be Abused To Drain Bridge ETH Validation Resolved
Description
The
BondManagement.depositBondfunction inherited byUMALayerZeroBridgeis public and lacks sufficient validation on thebondTokenparameter, allowing any user to deposit arbitrary ERC20 tokens. While it includes basic checks for non-zero address and amount, it performs asafeTransferFromand then triggers a LayerZero cross-chain message via_sendBalanceUpdateto sync the remote balance on the Market-side bridge. This LZ send is paid from the contract's ETH reserves (fee.nativeFee, typically 0.0002-0.005 ETH per message based on LZ docs and gas costs).The core issue is that the contract bears the LZ native fee cost for every deposit, regardless of the token's validity or usability for UMA assertions. Since the function is external, attackers can repeatedly call it with junk tokens (by deploying worthless ERC20s), forcing the bridge to pay fees each time. This drains the operational ETH (deposited via
depositETHfor relaying), potentially halting critical functions like assertions or callbacks when ETH drops below required fees.Code snippet showing the fee payment:
MessagingReceipt memory receipt = _sendBalanceUpdate( _getDepositCommandType(), msg.sender, bondToken, submitterBondBalances[msg.sender][bondToken], amount ); // ... (updates balance)Inside
_sendBalanceUpdate→_sendLayerZeroMessageWithQuote:fee = _quote(...); receipt = this._sendMessageWithETH{value: fee.nativeFee}(...); // Contract pays ETHRecommendation
Add token whitelisting by querying UMA's
CollateralWhitelist.isOnWhitelist(bondToken)before transfer, and enforce a minimum deposit amount covering the LZ fee quote (e.g.,amount >= fee.nativeFeein bondToken value, or simply require usermsg.value >= fee.nativeFee). -
H-05 High Total Loss For LPs In Binary Markets Logical Error Acknowledged
Description
For binary prediction markets, one of the tokens will always expire worthless (i.e., either “Yes” or “No” will have a price of 0 at settlement). This introduces a structural flaw: towards the end of the epoch, once the likely outcome becomes clear, a trader can simply swap heavily into the winning token, dumping the soon-to-be-worthless token onto the LPs.
This means LPs bear the full brunt of the market's resolution and are left with only the losing side. Since these tokens have no value post-settlement, LPs effectively suffer a complete loss every time a binary market ends. This is especially problematic for low-liquidity markets where price manipulation becomes easier and the cost to arbitrage late in the epoch is low.
As a result, LPs have no incentive to provide liquidity to binary markets under this design, as the risk-reward profile is heavily skewed against them.
Recommendation
Consider capping the winning side's payout to the total collateral contributed by the losing side. This would ensure that LPs do not get abused. However, the implementation may be more complex, requiring proportional distribution logic at settlement.
Alternatively, reconsider the use of AMM design for binary outcome markets. As described in this Paradigm article, AMMs are generally unsuitable for this use case due to asymmetric payoff structures and end-of-market exploits.
-
H-06 High Insufficient Slippage When Closing LPs MEV Resolved
Description
Users can pass a desired
liquiditySlippageparameter toLiquidityModule.closeLiquidityPosition()which should serve as a protection for the LPs during price movements. TheLiquidityModulewill calculate a minimum amount of tokens that should be received after the liquidity is removed based on that parameter.amount0Min: stack.previousAmount0.mulDecimal(DecimalMath.UNIT - params.liquiditySlippage), amount1Min: stack.previousAmount1.mulDecimal(DecimalMath.UNIT - params.liquiditySlippage),However,
stack.previousAmount0andstack.previousAmount1are the amounts currently held by the liquidity position. Any price movement that happened before that will impact these amounts, and when all liquidity is removed the full amounts will be received. This makes theliquiditySlippageparameter meaningless.An attacker can sandwich the liquidity removal transaction to change the price, the LP will then receive their tokens at a worse ratio causing loss for them and profit for the attacker when they complete the sandwich.
If the position in which the malicious trader is going is short, even the
tradeSlippageparameter will be meaningless, since it is applied todepositedCollateralAmount, but short positions cause increase in thebasetoken which is not added to that collateral.Recommendation
Use absolute values for minimum tokens received like you do in
decreaseLiquidityPosition() -
M-01 Medium Protocol Is Vulnerable To Just In Time Liquidity Warning Acknowledged
Description
The Sapience protocol, which leverages Uniswap V3 pools for liquidity provision and trading, is susceptible to Just-In-Time (JIT) liquidity attacks where users can temporarily provide concentrated liquidity in a narrow price range immediately before large swaps are executed, thereby capturing a significant portion (up to 99%) of the swap fees generated by that trade before removing the liquidity. This strategy exploits the pro-rata fee distribution in Uniswap V3, where fees are allocated based on liquidity share during the swap, allowing attackers to dominate the active tick range and self-capture fees from their own trades, reducing their net trading costs while diluting fee earnings for passive liquidity providers.
In the provided test scenario (
test_Just_In_Time_Liquidity), LP2 demonstrates this by adding 1000000e18 collateral in a narrow range (-7000 to -6800, around ~0.5$), followed by a large long trade (+50e18) and short trade (-50e18) that cross the range and generates fees, after which LP2 closes the position to collect ~0.5e18 in profit, resulting in a net gain.While general JIT carries risks like impermanent loss from price slippage in one-sided swaps, this specific test highlights a low-risk variant where two opposite trades of equal size (+50e18 long followed by -50e18 short) occur within the same block, balancing the price back to the starting point (~0.5000$ pre to ~0.5000$ post), minimizing impermanent loss to near-zero as the pool rebalances symmetrically without net drift, allowing the attacker to earn fees from both swaps (total ~0.5e18 at 1% fee on ~50e18 inputs each) with negligible exposure.
Recommendation
To mitigate this issue, implement anti-JIT mechanisms in the
LiquidityModuleby adding a minimum liquidity holding period (e.g., via timestamp checks indecrease/closeLiquidityPosition, requiring at least 1-5 blocks or a time delay before removal), which would force attackers to bear impermanent loss risk over multiple blocks and deter flashloan-based execution. -
M-02 Medium market.sqrtPriceMaxX96 Is Increased By Tick Spacing Warning Acknowledged
Description
In the Sapience protocol, binary markets, designed to resolve strictly at 0 (No) or 1 (Yes), require participants on the losing side to forfeit their entire stake, with winners claiming the full pot minus fees. However, testing revealed that when markets resolve to "Yes," "No" position holders (losers) can unexpectedly withdraw a portion of their collateral, violating the all-or-nothing expectation. This stems from an off-by-one error in how the market's maximum price is initialized.
During market creation, the upper bound
sqrtPriceMaxX96is set to the sqrt ratio atbaseAssetMaxPriceTick + tickSpacing(e.g., tick 0 + 200 = 200 for 1% fee tier), resulting in amaxPriceD18slightly above 1e18 (approximately 1.0202e18 for spacing=200). Consequently, collateral requirements ingetCollateralRequirementsForTradeuse this inflatedmaxPriceD18when valuing borrowedvBasefor short ("No") positions at the upper extreme, forcing them to post excess collateral (about 2% more) to cover a hypothetical debt value greater than 1. At settlement, with the price capped at exactly 1, this overage is returned to losers as unneeded collateral, allowing them to reclaim funds that should have been fully transferred to winners.This happens because binary markets intend an exact upper price of 1, but the
+spacingadjustment pushes it higher without corresponding adjustments. This inefficiently locks extra capital for "No" voters throughout the market's life, potentially deterring participation.Recommendation
To ensure binary markets enforce exact bounds of 0 to 1, adjust the initialization of
baseAssetMaxPriceTickduring market creation to be one tickSpacing below 0 (e.g., -200 for a 1% fee tier), so thatsqrtPriceMaxX96aligns precisely with a price of 1 after adding the spacing offset. -
M-03 Medium Unreduced Debt Following Partial Liquidity Withdrawals Amplifies Impermanent Loss Exposure Warning Acknowledged
Description
In the
LiquidityModule, the handling of partial liquidity decreases through thedecreaseLiquidityPositionfunction introduces a significant asymmetry compared to full closures, resulting in liquidity providers (LPs) maintaining full borrowed debt levels even after reducing their active liquidity exposure. This design leads to an over-leveraged position where the remaining liquidity bears the burden of the entire initial debt, thereby magnifying impermanent loss effects during subsequent market movements. To understand this, consider the core mechanics: when an LP adds liquidity, they borrow virtual base (vBase) and quote (vQuote) tokens proportional to the provided liquidity amount and current price, backing this debt with collateral. The debt ensures the protocol's solvency by representing the tokens minted and added to the Uniswap V3 pool.On a full closure via
_closeLiquidityPosition, the function explicitly collects all principal and accrued fees from the position manager, nets them against the full debt (reducingborrowedVBaseandborrowedVQuoteto zero where possible), rebalances any excess into virtual amounts or collateral and returns the net value, effectively derisking the entire position at the closure price.In contrast, a partial decrease burns the specified liquidity delta, accrues prorated fees and principal to
tokensOwed0andtokensOwed1(fixed token amounts no longer subject to IL), and updates the position's collateral requirements using these owed as credit offsets against the unchanged full debt. However, without collecting or prorating the debt downward, the position retains its original borrow levels, now supported by reduced active liquidity plus the fixed owed credits. This mismatch becomes problematic in volatile markets: the remaining liquidity continues to rebalance (suffering IL as price shifts concentrate holdings in the lower-value token), but the full debt amplifies any valuation shortfalls at extremes during settlement. For instance, when the market price drops after the partial liquidity withdrawal the remaining active part of the position ends up holding more of thevBasetoken (which is losing value in the drop). The part already withdrawn is locked as fixed amounts (owed to you) from when the price was higher, so it might have more stablevQuotetokens, but at settlement, the system looks at the whole position: the weakened remaining part (now worth less due to the drop) has to cover the original debt. This causes bigger losses than if the debt had been reduced proportionally when the liquidity was withdrawn.The POC attached illustrates this issue clearly: in the "exposed" path, where the LP performs a 50% partial liquidity withdrawal, the final profit and loss (PNL) at settlement (with the market price at $1 after a temporary price drop) comes out to about -8e18. In contrast, the "control" path, where the LP fully closes the position (securing value at a higher price) and then re-adds half the liquidity, results in a PNL of +1.5e18. This gap does not occur because of the impermanent loss on the uncollected owed amounts (which are locked as fixed tokens and immune to such loss), but rather from over-leverage: the full original debt must still be covered by the reduced liquidity, which has been weakened by impermanent loss during the price drop, leading to larger overall losses. This unfairly burdens the LP who aimed to lower their risk gradually. Although the protocol's cautious collateral checks at the market's minimum and maximum price points avoid complete undercollateralization or forced sales, it still creates unexpected drawbacks that might deter LPs from adjusting positions dynamically in volatile markets, whether numeric or binary. If LPs make multiple partial withdrawals without collecting in between, the buildup of unresolved debt could worsen these problems over time, yet there is no direct way to exploit this for quick gains or cause system-wide failure, since all debt is fully resolved at settlement.
Recommendation
Consider documenting this risk so liquidity providers are aware of it. On the other hand, consider also addressing the debt asymmetry by refactoring the
decreaseLiquidityPositionfunction to prorateborrowedVBaseandborrowedVQuotedownward by the liquidity reduction. This will also require more changes to differentiate between the actual accrued fees and withdrawn liquidity in the underlying UniswapV3 position, whencollectis called during position closure or settlement. -
M-04 Medium Dust-1-wei Debt Can Make closeLiquidityPosition Revert Rounding Resolved
Description
When an LP position is closed through
LiquidityModule.closeLiquidityPosition, the contract first:- Calls
INonfungiblePositionManager.decreaseLiquiditywith all the remaining liquidity. - Immediately collects all tokens & fees.
- Burns the Uni‑V3 NFT.
- Reconciles the LP’s virtual balances and, if every debt token has been repaid, withdraws collateral to the user.
- Otherwise it converts the position into a Trade and invokes
_closeTradePositionto repay whatever debt is left.
The intention of the reconciliation step is to eliminate the 1 wei rounding artefact produced by Uni‑V3:
// _closeLiquidityPosition() if (collectedAmount0 > 0) { // vBase side collectedAmount0 += 1; // try to cover the usual 1‑wei shortfall } ... if (collectedAmount0 > position.borrowedVBase) { position.vBaseAmount = collectedAmount0 - position.borrowedVBase; position.borrowedVBase = 0; } else { position.borrowedVBase = position.borrowedVBase - collectedAmount0; }In some edge‑paths
collectedAmount0is still one wei short (e.g. because no swap fees were accumulated in the last tick or precision rounding inLiquidityAmounts). Consequently the position exits the LP branch with:borrowedVBase = 1 borrowedVQuote = 0 vBaseAmount = 0_closeTradePositionthen tries to buy back exactly one wei of vBase via an exact‑output swap:amountOut (vBase) = 1 // therefore tradedVQuote = 2 and tradeRatio = 2 / 1 = 2e18Because the market’s configured upper‑bound price (
maxPriceD18) is much lower (≈ 1.02 e18 in the failing test),Trade.quoteOrTradereverts:if (tradeRatioRoundUp > market.maxPriceD18) { revert TradePriceOutOfBounds(tradeRatioRoundUp, market.minPriceD18, market.maxPriceD18); }The user is stuck: every close attempt reverts even though the remaining debt is only 1 wei and the position is economically safe.
Recommendation
Rather than rejecting the transaction, treat the price‑cap check as optional when the trade is merely clearing ≤ 1 wei of debt:
// inside Trade.quoteOrTrade, immediately before the price‑bound checks bool isDustClose = tradedVBase == 1 && // buying back exactly 1 wei of base tradedVQuote <= 2 && // paying at most 2 wei of quote targetSize == 0; // final size will be flat if (!isDustClose) { // normal safety guard if (tradeRatioD18RoundDown < market.minPriceD18 || tradeRatioD18RoundUp > market.maxPriceD18) { revert Errors.TradePriceOutOfBounds( tradeRatioD18RoundDown, market.minPriceD18, market.maxPriceD18 ); } } // else: skip the bound‑check — the protocol is only forgiving ≤ 2 wei of valueThe check is bypassed only when
|borrowedVBase| == 1 weiandborrowedVQuote == 0;the maximum value that can be over‑ or under‑charged is therefore 2 wei, which is economically negligible while restoring UX. - Calls
-
M-05 Medium Race Condition in Bridged Bond Withdrawal Race Condition Resolved
Description
The bridged bond withdrawal process across
UMALayerZeroBridge(UMA side) andMarketLayerZeroBridge(Market side) is vulnerable to a race condition due to asynchronous LayerZero message delivery, causing temporary desynchronization between escrowed balances and remote views and a permanent loss of funds for the user affected.When a withdrawal intent is created on the UMA side, it validates sufficient balance and no existing intent, creates a local
withdrawalIntentsentry, and sends a delayed LZ message to update the Market side'sremoteSubmitterWithdrawalIntent. However, if the message is delayed, an intervening assertion on the Market side can deduct fromremoteSubmitterBalancesbefore the intent reserves it, leaving the UMA escrow sufficient for execution but causing the subsequent withdrawal LZ message to revert on the Market side due to insufficient remote balance. This keeps the UMA-side intent marked as executed (funds transferred to user), but the Market-side remoteIntent remains set and balance undeducted, blocking new intents for that user/token pair (viarequire(withdrawalIntents[msg.sender][bondToken].amount == 0)) without a reset mechanism.No cancel function exists, trapping the remaining user’s escrowed
bondTokens.Proof of Concept
A scenario illustrating this with an initial 1200 USDC balance for the user Bob, 800 USDC intent and 500 USDC assertion bond:
- T=0: Bob calls
intentToWithdrawBond(USDC, 800)on UMA side, validating balance and no prior intent, sending a LayerZero message (that will be delayed), then creating local intent. UMA balance: 1200 USDC.
// UMA Chain - UMALayerZeroBridge contract withdrawalIntents[msg.sender][bondToken] = BridgeTypes.WithdrawalIntent({amount: amount, timestamp: block.timestamp, executed: false}); withdrawalIntents[Bob][USDC] = BridgeTypes.WithdrawalIntent({amount: 800, timestamp: block.timestamp, executed: false});- T=1: MarketGroup calls
MarketLayerZeroBridge.forwardAssertTruth(marketGroup, marketId, claim, asserter, liveness, currency, bond), whereasserteris Bob.
// Market Chain - MarketLayerZeroBridge contract remoteSubmitterBalances[asserter][currency] -= bond; remoteSubmitterBalances[Bob][USDC] = 1200 - 500 = 700;- T=2 min: Assert processes on UMA side, deducting 500 from escrow (balance now 700), escrowing in UMA.
- T=6 min: Withdrawal intent arrives on Market side, setting
remoteIntent=800(view: balance=700, intent=800).
// Market Chain - MarketLayerZeroBridge contract - _lzReceive remoteSubmitterWithdrawalIntent[submitter][bondToken] = deltaAmount; remoteSubmitterWithdrawalIntent[Bob][USDC] = 800;- T=1441 min (1 day later = withdrawal delay): Bob calls
executeWithdrawalsuccesfully on the UMA Chain -UMALayerZeroBridgecontract. When this LayerZero message arrives to the Market Chain -MarketLayerZeroBridgecontract it reverts as (700 < 800):
// Market Chain - MarketLayerZeroBridge contract (address submitter, address bondToken,, uint256 deltaAmount) = data.decodeFromBalanceUpdate(); remoteSubmitterBalances[submitter][bondToken] -= deltaAmount; remoteSubmitterBalances[Bob][USDC] = 1200 - 500 = 700 -= 800; // Reverts hereBob, at this point, loses access to escrowed bonds indefinitely.
Recommendation
Implement a user-callable
cancelWithdrawalIntentfunction on the UMA side to clear stuck intents (set amount=0, executed=false) after failed execution, sending an LZ message to reset Market-side remoteIntent to 0, conditional on no active assertions referencing the bond. - T=0: Bob calls
-
M-06 Medium Risk Of assertionId Collision In UMAOracle Logical Error Acknowledged
Description
In UMA’s
OptimisticOracleV3, theassertTruthfunction assigns each assertion a unique ID based on the hash of several input parameters:_getId(claim, bond, time, liveness, currency, callbackRecipient, escalationManager, identifier)However,
UMASettlementModulesubmits highly standardized data toassertTruth, which can result in ID collisions when multiple assertions are submitted within the same block.In particular, the call from
submitSettlementPriceuses the following:market.assertionId = optimisticOracleV3.assertTruth( claim, params.asserter, address(this), address(0), marketGroup.marketParams.assertionLiveness, IERC20(marketGroup.marketParams.bondCurrency), marketGroup.marketParams.bondAmount, optimisticOracleV3.defaultIdentifier(), bytes32(0) );These parameters are often identical across markets. The one parameter expected to introduce uniqueness is
claim. However,getClaimcurrently only returns claim statements (which might be simple Yes/No string values). Thus, when two or more assertions with identical data are submitted in the same block, they produce the same assertion ID causing collision.For bridged settlements, this issue is particularly concerning because the unexpected revert will occur at the UMA chain, while the market chain assumes the assertion was successful, preventing a re-submission.
Recommendation
Include a unique identifier such as
marketIdwithin theclaimstring to prevent collisions between assertions tied to different markets. -
M-07 Medium Arbitrary Asserter Allows Bond Theft Logical Error Acknowledged
Description
In the
submitSettlementPricefunction, if the market group isbridgedSettlement, the bond amount is deducted from the asserter’s balance:remoteSubmitterBalances[asserter][currency] -= bond;However, there are no validations to ensure that the asserter is associated with the correct market group, market ID, or even the caller. This omission enables a serious attack where an adversary can drain the bond balance of honest users by exploiting the UMA assertion-dispute mechanism.
Example attack scenario:
- Setup: Alice (attacker) is the owner of a market group using
MarketLayerZeroBridgefor settlement. - Target: Bob (honest user) deposits a bond, intending to participate in assertions for a different market group but also using the same bridge.
- Exploit: Alice calls
submitSettlementPrice, settingasserter = Bob-- this deducts Bob’s bond balance even though he is unrelated to this assertion. - Profit: Alice asserts a deliberately false claim, immediately disputes her own assertion, and ultimately wins the dispute, gaining Bob’s bond.
This attack is made possible because in the bridged flow:
- The bond is tied to the asserter, not the caller.
- There is no guarantee that the asserter is the rightful owner or participant in the context of the market group or market ID.
In contrast, the non-bridged flow correctly ensures that the caller is always the one who posts the bond, preventing such abuse.
Recommendation
The bond deduction logic in
submitSettlementPriceshould be redesigned to always charge the caller, not an arbitrary asserter. The caller’s bond can then be transferred cross-chain to the UMA bridge and tied to their assertion.An alternate solution could be to ensure that bond deposits are mapped to market group and market id. Then during
submitSettlementPricevalidate that the asserter had deposited a bond for that specific market group and market id. - Setup: Alice (attacker) is the owner of a market group using
-
M-08 Medium Incorrect Market Params Used Logical Error Resolved
Description
The current implementation updates
epochtomarketandmarkettomarketGroup. ThismarketGroupis basically theSapiencediamond contract, that will be initialized with certainmarketParams.When a
marketis created, themarketGroup.marketParamsis cached inside themarketstruct in order to prevent any changes to themarketGroupby the owner. However, the market's cached params are not always used in the current implementation and instead use themarketGroup.marketParamswhich can change at any time.This opens up different issues when
marketGroup.marketParamsare updated like:bondCurrencychanges, user that already deposited a different token will need to withdraw and deposit the new bond currency- when creating liquidity positions, if the
feeRateis updated, it will try to mint tokens from a completely different UniswapV3 pool - when there is a bridged settlement,
marketParams.optimisticOracleV3is theMarketLayerZeroBridge. If this contract is updated, users that have previously deposited or initiated a withdrawal will be DoS'ed as the new contract won't have these values.
Recommendation
Consider using the cached
market.marketParamsvalues instead of themarketGroup.marketParams.Keep in mind there is an edge case for the
bondAmountin non-bridge settlements. If OOV3 increases the minimum bond and the market cached a lower value during creation, settlements will revert as it will be force to use that cached value instead. Even if the owner increases thebondAmount, this will not affect the cached values. -
M-09 Medium Slippage Should Be Applied For Trading Positions MEV Acknowledged
Description
When traders create or modify their positions, they trade virtual tokens in the Uniswap pool via
Trade.sol. There are no slippage parameters provided there -sqrtPriceLimitX96andamountOutMinimumare 0,amountInMaximumistype(uint256).max.According to the comment provided next to these slippage parameters, that's okay since the more tokens a position has borrowed, the more collateral it will require and the user has control over how much collateral can be pulled from their account via the
maxCollateralanddeltaCollateralLimitfields.However, when the price is being manipulated during a sandwich, the
PnLof the position will affect the currentdepositedCollateralAmountof the position, therefore the delta value as well.For example:
- Alice opens a X long and deposits Y collateral @ Z$
- Later, Alice wants to increase her position, she quotes Sapience and sees the needed collateral is
Y + D, so she sets the deltaCollateralLimit =1.2 * D(20% slippage). - Bob frontruns her transaction with his long which will move the price up
- Alice will buy the base tokens at a much higher price, but because the price has increased, she will also accumulate
PnLwhich will increasedepositedCollateralamount and will result in a lowerdeltaCollateral - If the
deltaCollateralis in the limits ofD * 1.2, the transaction will pass through, Alice will buy the tokens at unfavorable price and Bob will frontrun her for profit.
Recommendation
Allow the traders to specify
amountInMaximumandamountOutMinimum -
M-10 Medium Repeated Liquidity Decrease Reverts Due Rounding Logical Error Resolved
Description
Liquidity providers (LPs) calling
decreaseLiquidityPositionmultiple times experience a revert due to the required collateral being1 weihigher than deposited.The root cause lies in how debt amounts stay constant when partially decreasing liquidity, and how additional collateral cannot be added if there is shortfall after decreasing liquidity.
Example scenario:
- Alice adds liquidity in the range [-200, 200], borrows 100 base and quote tokens
- Price moves to tick 300. Alice's position is out of range (only Quote tokens remains)
- On the first
decreaseLiquidityPosition, she reduces her position by 10%. Though out of range, she receives both base and quote tokens from earned fees . Her credit is calculated from the remaining liquidity converted toquoteplustokensOwed1. Her debt is the borrowed base and quote tokens, less any amounts offset by tokens owed. Excess collateral is refunded to Alice - On the next
decreaseLiquidityPositioncall, she again removes 10% liquidity. She receives no base tokens due to being out of range, and anytokensOwed1returned by Uniswap is rounded down - This causes her credit to be
1 weiless than the previous round, but her debt remains constant - Because the deposited collateral has already been refunded from the first call, it is now
1 weishort and the transaction reverts
Recommendation
When
decreaseLiquidityPositionis called and the deposited collateral is insufficient by a small amount, allow the LP to top up the deficit by transferring the shortfall into the Sapience contract. This prevents unnecessary reverts caused by rounding issues. -
M-11 Medium Market Settlement May Be Blocked Unexpected Behavior Acknowledged
Description
When
UMASettlementModulehandles crosschain requests, it setsmarket.assertionId = bridge.forwardAssertTruth(). Then it doesn't allow making any new assertions until that id is cleared. The problem is that if the LayerZero message initiated fromforwardAssertTruth()reverts on the destination chain, there will be no way to clear theassertionId. This means the market is stuck and cannot be settled, therefore nobody can close their positions and withdraw their collateral. The__manual_setSettlementPrice()function inSettlementModuleworks only for the last market in the group.Possible reasons for revert are:
- the bond amount sent to the oracle is below the minimum (there is no validation for the crosschain case)
- the used bond token has been removed from the whitelisted collaterals before the destination transaction arrived
Recommendation
Modify
__manual_setSettlementPrice()in such a way that it accepts a market id to settle. This will save the markets from the described scenario. -
M-12 Medium Manual Settlement May Be Manipulated MEV Resolved
Description
The manual settlement feature allows anyone to settle the market with whatever the current price of the underlying pool is. This action can be executed if the market hasn't been settled after its duration has passed twice. While it seems safe to use the pool price because nobody besides the Sapience diamond proxy holds virtual tokens, it's still possible for a manipulation to happen if certain conditions are met. For example, if the legitimate price ended up at the bounds of a given liquidity position, i.e one of the tokens of that position has been fully depleted, a malicious user can execute empty swaps. These swaps allow them to move the price to the point where the next liquidity position starts. In the end, the market will use the manipulated price and the
settlePositioncalculations will credit users incorrectly.Recommendation
One possible solution is to globally keep track of the latest pool price after each swap by using the
tradeRatio. Then you can use that price in the manual settlement flow. -
M-13 Medium Race Condition May Block The Settlement Race Condition Acknowledged
Description
The following possibility for a race condition blocking a market from being settled exists:
- Alice deposits a bond of 1000 tokens
- Her transaction is executed on the market side and her balance is set to 1000
- She initiates a withdrawal for 600 tokens
- Before her intent is delivered on the market side, an assertion with a 500 bond is created
- The intent transaction is delivered and her intent value becomes 600
- The asserting transaction is being delayed for 1 day (for example, DVN/executor issues, etc...)
- Since 1 day passed, Alice can call
executeWithdrawal(), which will deduct her balance to 400 - When the asserting transaction is finally delivered, it will try deducting 500 from Alice's balance, which will cause a revert.
If Alice never deposits bond again, the claim can never be asserted and since the
assertionIdwas already recorded inUMASettlementModule, this market can now never be resolved.Recommendation
This scenario should be very rare since in most cases transactions are expected to be delivered in less than a day. Because of that it's best to fix the other issue with
__manual_setSettlementPrice()to allow settling any market. -
M-14 Medium FeeCollectorNFT Enables Under‑collateralized LP Actions Warning Resolved
Description
The
FeeCollectorNFTis intended to mark privileged accounts, but its current integration gives holders two sweeping powers: they can supply or remove liquidity without meeting therequiredCollateralthreshold and they are the only addresses permitted to inject collateral into an existing LP position. The first capability is a direct solvency hazard. A malicious or compromised holder can mint an LP position with negligible collateral, continuously extract fees and then abandon the position, leaving an uncovered shortfall that must be absorbed by honest users. Because minting the NFT is governed solely by the contract owner, this privilege introduces a centralization vector and bypasses the protocol’s invariant that every position remains fully collateralized at all times.Recommendation
Eliminate every special privilege attached to
isFeeCollectorexcept the ability to deposit additional collateral into positions. In practice this means removing the collateral‑bypass branch inLiquidityModule.updateValidLpand any other code paths that waive minimum‑collateral checks, while preservingdepositCollateralso that trusted parties can still top‑up positions and avert potential bad debt. -
M-15 Medium Market Creations DoS'ed By Existing Pool DoS Acknowledged
Description
When a market is created, a Uniswap V3 pool is deployed with the base and quote tokens. These tokens are deployed with a deterministic approach, using a salt and CREATE2.
Once the market creation transaction is pending, any user can frontrun the owner and create the Uniswap V3 pool with the deterministic tokens and fee tier. This is possible as the Uniswap V3 Factory does not validate if the tokens exist.
Recommendation
Consider validating if the pair exist in the Uniswap V3 Factory. However, if it does exist, revert only if the pool has not been initialized yet.
-
L-01 Low Incompatibility With Non Standard ERC20 Tokens Best Practices Resolved
Description
The Sapience protocol's token approval mechanisms, particularly in contracts like
UMALayerZeroBridgeand potentially others interacting with ERC20 tokens such as bond currencies, rely on the standardIERC20approve function without safeguards for non-compliant tokens. This implementation assumes thatapprovewill return a boolean value indicating success or failure, as specified in the ERC20 standard; however, certain widely used tokens like USDT (Tether) on Ethereum do not adhere to this, either returning no value or reverting under specific conditions, such as when resetting a non-zero allowance without first setting it to zero. As a result, calls to approve can fail silently or revert unexpectedly when used with these tokens, leading to integration failures, denied operations like bond deposits or assertions and potential denial-of-service scenarios where users cannot interact with the protocol using affected tokens. This incompatibility is exacerbated in cross-chain or bridge contexts, where token approvals are critical for escrow and transfer operations or preventing settlements if non-standard tokens are used as bond currencies.Recommendation
To resolve this issue, replace direct calls to approve with
SafeERC20'ssafeApproveor, preferably,forceApprovefunction, which handles non-compliant tokens by first resetting the allowance to zero if necessary before setting the new value, ensuring compatibility without relying on return values. Update all relevant contracts, such asUMALayerZeroBridge, to useforceApprovefor bond token approvals during assertion handling or escrow deposits. -
L-02 Low Missing Price Checks For Binary Markets Settlement Validation Acknowledged
Description
The Sapience protocol's settlement process for binary markets, which are identified by a non-empty
claimStatementNoand designed to enforce absolute outcomes where winners receive the full opposing stake minus fees while losers get nothing, permits submissions of intermediate prices between 0 and 1 despite UMA verifying only the binarized claim statement. In thesubmitSettlementPricefunction, asqrtPriceX96that is converted to decimalPrice is provided without checking if it exactly matches the market's binary extremes (minPriceD18 ≈0ormaxPriceD18 ≈1).This means that an erroneous submission (e.g.,
sqrtPriceX96yielding 0.6 instead of 1 if yes or 0 if no) could dilute rewards if not disputed, as UMA confirms the outcome category but not the precise value.On the other hand, the manual fallback in
__manual_setSettlementPriceexacerbates this by directly using the pool's currentslot0price, which may land between 0 and 1 in unresolved or manipulated end-states, applying an unintended partial settlement without binary enforcement.Recommendation
Enhance binary market handling by adding validation in
submitSettlementPrice: for markets withclaimStatementNo, require the submitteddecimalPriceto be exactlyminPriceD18(for No) ormaxPriceD18(for Yes), reverting on intermediates and mapping the boolean choice to the precise bound prices internally while generating the corresponding claim statement. For__manual_setSettlementPriceof binary markets, replace the slot0-derived price with a binary determination, e.g., ifcurrent price > (min + max)/2, set tomaxPriceD18(Yes); elseminPriceD18(No), to ensure strict outcomes even in manual overrides. -
L-03 Low Missing Multiple UMA Basic Checks Upon Settlement Validation Acknowledged
Description
The Sapience protocol's bridged settlement mechanism, which forwards assertions to UMA's Optimistic Oracle V3 (OOV3) via the
forwardAssertTruthfunction inMarketLayerZeroBridge, fails to enforce critical safeguards recommended by UMA for secure oracle operations. Specifically, the function accepts user-providedbondandlivenessvalues without verifying against OOV3's minimum bond (queriable viagetMinimumBond(token)) or ensuring the bond scales appropriately to the market's total value at risk (e.g., TVL or potential payout shifts).As per UMA documentation, bonds should exceed the minimum to incentivize disputers, who receive half the proposer's bond on successful challenges, and should be elevated for high-value requests: "If data received from the oracle could potentially move large amounts of value, you may want to set a higher bond to make it more costly for an attacker to attempt to steal funds through a false proposal."
In Sapience's prediction markets, where resolutions can redistribute significant collateral (e.g., winner-takes-all in binaries), a low bond relative to TVL enables cheap attacks, proposers could submit false outcomes, forcing disputers to risk more capital than the reward justifies, or succeed if disputes are uneconomical.
Furthermore, the
livenessparameter (assertion challenge window) lacks a minimum enforcement insubmitSettlementPrice(UMASettlementModule), permitting arbitrarily short periods that violate UMA's guidance: "At this time, it is generally not recommended to set a challenge window shorter than two hours.".Short liveness reduces time for community scrutiny and disputes, increasing the likelihood of unchallenged falsehoods, especially in low-visibility markets where monitoring is sparse.
Without these checks, the protocol risks economic inefficiencies (e.g., proposers underpaying for risk) and security lapses, such as attackers proposing false settlements with minimal cost.
Recommendation
In
forwardAssertTruthandsubmitSettlementPrice, implement runtime checks to enforce UMA's minimum bond by queryingoov3.getMinimumBond(bondCurrency)and requiring the providedbond >= minBond, while scaling the required bond dynamically to a percentage of the market's TVL or potential value moved (e.g., 1-5% of total collateral at stake, as implied by UMA's value-at-risk guidance). Similarly, enforce a minimum liveness period (e.g., 2 hours or 7200 seconds) to align with UMA recommendations, reverting on shorter submissions. -
L-04 Low Opaque Reverts in Swap Functions During Insufficient Liquidity Scenarios Validation Resolved
Description
The
Tradelibrary, responsible for executing and quoting swaps via Uniswap V3 integrations in theswapOrQuoteTokensExactInandswapOrQuoteTokensExactOutfunctions, encounters silent or generic reverts when attempting to perform trades that exceed the available liquidity in the underlying pool, without emitting a specific error to indicate the root cause.Recommendation
Wrap the Uniswap router and quoter calls in try-catch blocks within
swapOrQuoteTokensExactInandswapOrQuoteTokensExactOutto intercept low-liquidity reverts, then revert with a custom error likeErrors.InsufficientPoolLiquiditythat includes details such as the attempted amount and market ID. -
L-05 Low Excessive Gas Allocation for LayerZero Balance Updates Configuration Acknowledged
Description
In the bond management operations such as depositing tokens, creating withdrawal intents and executing withdrawals, initiate a cross-chain synchronization of balances via the
_sendBalanceUpdatefunction, which leverages LayerZero for message delivery. This update mechanism reuses the same_sendLayerZeroMessageWithQuotelogic employed for assertion callbacks, incorporating executor options that allocate 1M gas for receipt on the destination chain. However, the receiving execution in theMarketLayerZeroBridgefor these balance-related messages involves only simple payload decoding and state modifications, consuming far less resources.While the gas limit itself does not dictate consumption, serving instead as a cap to prevent out-of-gas failures during execution, as per LayerZero's enforced options mechanism (where developers set the maximum gas the executor should allocate), requesting an excessively high limit can inflate the upfront fee quote, as LayerZero's pricing factors in the potential execution cost based on the specified cap. Essentially LayerZero uses this gas limit to calculate the fee quote: the amount you pay upfront to cover the cost of executing the message on the destination chain. The fee is based on the gas limit you request, multiplied by the gas price on the destination chain, plus other protocol fees.
Consequently, the protocol may incur elevated bridging expenses without corresponding benefits, particularly in scenarios with frequent bond activities, though actual gas used remains low and unspent portions are not refunded.
Recommendation
Adjust the LayerZero executor
lzReceivegas option to a lower value specifically for balance update messages to better align with observed consumption. -
L-06 Low UMA Bridge Owner Can Influence Settlements Trust Assumptions Acknowledged
Description
Market groups with
bridgedSettlement = trueuse theMarketLayerZeroBridgecontract as itsoptimisticOracleV3. Anyone can deploy such bridge to be used by market groups. EachMarketLayerZeroBridgeis paired with anUMALayerZeroBridgeon the UMA side. The UMA oracle notifies the UMA bridge for any resolved or disputed assertions and a message toMarketLayerZeroBridgeis initiated. Once executed, the market price is either settled or disputed.The problem is that
UMALayerZeroBridge.setOptimisticOracleV3()allows the owner of the bridge to change theoptimisticOracleV3Address. This would allow them to use settle or dispute any pending assertion on the market side. Given the permissionless nature of the bridge setup, this issue poses a significant risk for all connected market groups.Recommendation
Consider removing the
setOptimisticOracleV3()function. -
L-07 Low Dispute Is Blocked When Contract Is Underfunded Logical Error Acknowledged
Description
UMALayerZeroBridgehas to pay native token fees to LayerZero when it sends crosschain messages. That's why it inherits fromETHManagementwhich allows users to deposit funds in the contract. It also emits events when the balance of the contract falls below given thresholds. Because of that, we can assume the contract will be regularly funded. However, it's still possible to have periods where there aren't enough funds to pay the LZ message. In these cases all UMA transactions will revert.This is problematic because
OptimisticOracleV3.disputeAssertion()callsUMALayerZeroBridge.assertionDisputedCallback(). If the bridge is underfunded, the whole dispute transaction will revert. By the time the contract's funded again, the dispute window may have already passed, which results in invalid price being used by the market.Recommendation
Consider implementing a retry mechanism where if the balance of the contract is not enough, the transaction doesn't revert, but instead keeps the message and allows it to be sent again in the future. By implementing this approach, disputes will not be blocked and once the bridge gets funded, they will be executed on the market side as well.
-
L-08 Low Binary Markets Cannot Resolve Early Warning Acknowledged
Description
In binary markets, outcomes are often known well before the
endTime. For example, in a market like "Will BTC reach $100,000 by end of 2024?", the answer becomes clear the moment BTC hits that price. However, current implementation only allows settlement atendTime, even if the result is already known. This delays resolution and locks up capital unnecessarily. In contrast, mature platforms like Polymarket allow immediate resolution once the outcome is confirmed.Recommendation
Consider allowing settlement of binary markets before
endTime. -
L-09 Low Ambiguous Token Data Configuration Acknowledged
Description
After
Market.createValiddeploys the virtual tokens, it sorts them in a way thatbaseTokenis always the one with the smaller numerical address. However, the two tokens have already been deployed with their names and symbols which hold information if they are base or quote. Because of that it's possible to have base token which is namedQuote Tokenand has a symbolvQuoteand the opposite as well.Recommendation
You can expose a new
onlyOwnerfunction in theVirtualTokenwhich changes the name and symbol of the token and use it inMarketif needed. -
L-10 Low Market Group Initialized With Arbitrary Nonce Events Acknowledged
Description
MarketGroupFactory.cloneAndInitializeMarketGroup()will initialize a new market instance and emit theMarketGroupInitializedevent which accepts anonceargument. Thisnonceis an arbitrary value passed by the user which can lead to wrong event emissions when groups are initialized, unordered nonces, a nonce being used multiple times, etc...Recommendation
Implement a per-user nonce tracking mechanism inside the contract.
-
L-11 Low Potential UMA Price Manipulation By Whales Validation Acknowledged
Description
Currently, the
bondAmountshould be greater than theminBond, however there is no restriction about how big it can be. To dispute an assertion, a disputer has to first pay the samebondAmountthat was used at the time of the assertion creation. This allows whales to submit a price assertion with unreasonably big bond that regular users cannot dispute. This is especially dangerous for markets with shorterassertionLiveness.Recommendation
Consider implementing a maximum cap for
bondAmount. This cap should not be lower than the “TVL” of the actual market being settled. -
L-12 Low Gas Thresholds Are Checked After Sending The LZ Message Events Resolved
Description
In both
MarketLayerZeroBridgeandUMALayerZeroBridgethe call to_checkGasThresholds()in_sendLayerZeroMessageWithQuote()is executed before the LayerZero message is sent. The contract's balance is higher before sending the message because it still hasn't paid for the fees. This can lead to crossing the threshold, but not emitting an event.Recommendation
Consider checking the thresholds after sending the messages.
-
L-13 Low UMA Callbacks Fail After Manual Settlement Unexpected Behavior Resolved
Description
When a market is manually settled via
__manual_setSettlementPrice(), subsequent UMA settlement callbacks (assertionResolvedCallback()andassertionDisputedCallback()) will fail due to thevalidateUMACallback()function checkingrequire(!market.settled, "Market already settled"). This prevents the bond from being returned to the asserter or disputer, effectively locking their funds.Recommendation
Consider either warning the users of this possibility to lose their bond or adding early returns to the two callbacks if the market is settled.
-
L-14 Low Duplicate Event Emission During Balance Update Events Resolved
Description
The
MarketLayerZeroBridgecontract emits the sameBondWithdrawnevent for two different operations:CMD_FROM_ESCROW_INTENT_TO_WITHDRAWandCMD_FROM_ESCROW_WITHDRAW. This creates confusion for event listeners and makes it difficult to distinguish between withdrawal intent creation and actual withdrawal execution.Recommendation
Create a new event for intents and emit it instead of
BondWithdrawn. -
L-15 Low Timestamp is not enforced in UMA claim Best Practices Acknowledged
Description
UMASettlementModule.getClaim()constructs the asserted claim by usingclaimStatementYesOrNumericandclaimStatementNo, but it doesn't append the start or end date of the market. According to step 5 from theImplementationsection in UMIP-170, if the assertion was disputed and the timestamp is not found in the claim itself, the voters will use theblock.timestampat the moment whenassertTruth()was called. This is problematic because the message goes through LayerZero and there will be a certain delay before it's executed. By that time, the result of the vote may already have changed, which leads to wrong dispute result.Recommendation
Add the
endTimeof the market to the claim. -
L-16 Low FeeCollectorNFT Is Transferrable And Can Not Be Burned Trust Assumptions Resolved
Description
The current
FeeCollectorNftcontract allows NFTs to be freely transferred and does not provide a way to burn (revoke) them. This poses a security and governance risk: the NFT could be transferred to an untrusted party, or remain active even if the fee collector becomes malicious or needs to be shut off.In addition, if a fee collector becomes inactive or it simply doesn't provide the needed funds, the owner of the market group won't be able to deposit liquidity on their behalf if they are not a fee collectors themselves.
Recommendation
- Override the
_transferfunction so it's not possible to transfer the NFT - Add a public
burn()function that's guarded by theonlyOwnermodifier and add make it callable by the owner of each MarketGroup, so they are able to revoke fee collector access in case it's needed. - Consider allowing the owner of the market group to call
depositCollateralas well in case they need to do it on behalf of the fee collector.
- Override the
-
L-17 Low Changing The NonFungiblePositionManager Address Breaks The Protocol Configuration Acknowledged
Description
The
ConfigurationModule.updateMarketGroup()function allows changing themarketParamsof the current group, including theuniswapPositionManager. However, changing the manager means losing access to all of the current LP positions, since the new one won't have ownership over them.In addition, the project is mixing
market.marketParams.uniswapPositionManagerandmarketGroup.marketParams.uniswapPositionManager. For example,decreaseLiquidityPosition()callsPool.getCurrentPositionTokenAmountsandPooluses the marketuniswapPositionManager, while later in the flow the new one will be used.Places where
market.marketParams.uniswapPositionManageris used:- Pool.getCurrentPositionTokenAmounts()
- SettlementModule._settleLiquidityPosition()
Recommendation
Consider adding a restriction to not change the
uniswapPositionManagerwhen updating the parameters. -
L-18 Low Trading Is Allowed Before Market Start Time Unexpected Behavior Acknowledged
Description
Although each market includes both a
startTimeand anendTime, the protocol does not enforce those bounds. In its current form, trading and liquidity‐provision actions may occur well before the declaredstartTime. Conversely, if someone maliciously setsendTime = startTime + 1second, the market becomes eligible for “manual settlement” just two seconds after its supposed launch window, even though positions could have been opened long before. In the opposite scenario, if a market is created with astartTimein the past, the manual settlement path via__manual_setSettlementPricemay never become available at all. This disconnect allows both out-of-window activity and incorrect settlement timing.Recommendation
Enforce that startTime must be strictly in the future when a market is created, and add a precondition in every trading and liquidity function requiring
block.timestamp >= startTime. This guarantees that no positions can be opened or modified before the intended launch, and that settlement timing accurately reflects the market’s defined window. -
L-19 Low Insufficient Minimum Collateral Requirement Validation Acknowledged
Description
Position.afterTradeCheck()enforces that the minimum deposited collateral for a position is at leastminTradeSize, but becauseminTradeSizeis inbaseterms, there are two potential problems:- If the price difference between
baseandquoteis too large, the check may be inefficient - The comparison is done in 18 decimals, but the collateral token may have less decimals. For example,
minTradeSize = 10000would guarantee only 1 wei of actual collateral if the collateral is with 6 decimals, because thendepositedCollateralAmount = 1e12 > 10000.
Recommendation
Consider using a different approach for setting minimum collateral requirement.
- If the price difference between
-
L-20 Low Extra Collateral Can Be Withdrawn Warning Acknowledged
Description
If a position being modified in
Trade.quoteOrTradeincurs a loss greater than the deposited collateral, the following lines are executed:if (collateralLoss > params.oldPosition.depositedCollateralAmount) { // If the collateral to return is more than the deposited collateral, then the position is in a loss // and the collateral should be reduced to zero output.position.depositedCollateralAmount = 0; extraCollateralRequired = collateralLoss - params.oldPosition.depositedCollateralAmount; }This will reset the deposited collateral to 0 and force the user to pay the rest as extra collateral. The problem is that this collateral will be attributed to the position's
depositedCollateralagain. The user can just modify the position again after that and recalculate thenewPositionCollateralRequired, which will allow them to get back the extra collateral or use it to back their new position.Recommendation
This case should never happen in practice because the protocol limits the price range of the virtual tokens. One way to fix it is to not add the extra collateral towards the
depositedCollateralAmount. -
L-21 Low Market Initial Price Can Be Changed MEV Acknowledged
Description
When a market is created via
Market.createValid(), its pool is initialized withstartingSqrtPriceX96. However, since there is no liquidity added, anyone can move that price to whatever new price they desire, even if its not in the pool's allowed range.This can be utilized by a first liquidity provider to try sandwiching future LPs with no slippage protections:
- Move the price to an extreme end
- Honest LP adds liquidity at unfavorable price
- Trade against honest LP and profit
Recommendation
If the honest LP provides reasonable slippage parameters, they should be safe, so make sure that LPs are aware of this possibility.
-
L-22 Low Claim Max. Length Is Not Restricted Validation Acknowledged
Description
During market creation via Market.createValid(), market owners can specify arbitrarily long claim messages that will be used for truth assertions during settlement. However, the protocol does not enforce any maximum length restriction on these claims at creation time.
This can create an issue during the settlement phase: when the settlement process attempts to send the claim data through LayerZero's cross-chain messaging infrastructure, it may exceed the
maxMessageSizelimit imposed by the LayerZero libraries, which will lead to inability to settle the market.Recommendation
Apply a maximum length check for the claims.
-
L-23 Low assertTruth Can Revert DoS Acknowledged
Description
Since
OptimisticOracleV3.assertTruth()doesn't store any information aboutmsg.senderwhen computing the assertion id, a malicious user can use the same information right before theUMALayerZeroBridgecallsassertTruth(). This will result in the LZ transaction reverting and creating a risk of blocking the settlement of the market. For example, if the transaction is not retried and a withdrawal request is executed, the next time the assertion is retried, a revert can happen because of underflow in_updateBondBalance().This action is not free though, the malicious user has to pay the bond with no possibility of getting it back, which makes the attack highly unlikely, but still something to keep in mind.
Recommendation
Document the risk and check regularly for failed messages.
-
L-24 Low 0 Value Transfers Are Allowed in
updateCollateral()Unexpected Behavior ResolvedDescription
Position.updateCollateral()checks if thedeltaCollateralis 0 and skips any transfer logic if it's. However, this value is in 18 decimals. If it's negative,denormalizeCollateralAmount()will be applied to it, which means it's possible for the end result to end up being 0. Then,collateralAsset.safeTransfer(msg.sender, 0);will be triggered. If the collateral token reverts on 0 transfers, all actions usingupdateCollateralare in a risk of DOS.Recommendation
Skip the token transfer if the end amount is 0.
-
L-25 Low assertionResolvedCallback Function Ignores assertedTruthfully Logical Error Acknowledged
Description
UMASettlementModule.assertionResolvedCallback(bytes32 assertionId, bool)is the final hook that receives theOptimisticOracleV3(OOv3) verdict.The function currently performs the following logic (simplified):
function assertionResolvedCallback(bytes32 assertionId, bool) external { MarketGroup.Data storage marketGroup = MarketGroup.load(); uint256 marketId = marketGroup.marketIdByAssertionId[assertionId]; Market.Data storage market = Market.load(marketId); validateUMACallback(market, msg.sender, assertionId); Market.Settlement storage settlement = market.settlement; if (!market.settlement.disputed) { market.setSettlementPriceInRange(DecimalPrice.sqrtRatioX96ToPrice(settlement.settlementPriceSqrtX96)); emit MarketSettled(marketId, assertionId, settlement.settlementPriceSqrtX96); } // clear the assertionId market.assertionId = bytes32(0); }- The boolean
assertedTruthfully** **is completely ignored. - The only gate for calling
setSettlementPriceInRangeis whenmarket.settlement.disputedisfalse.
Once a single dispute has been raised, no matter the final oracle ruling, disputed is locked to
true, so the market never settles (market.settledremainsfalse). This behavior is fundamentally incorrect: once the DVM has accurately resolved an assertion, the protocol should accept that outcome and finalize the settlement without requiring further manual intervention. Ignoring theassertedTruthfully == truesignal undermines the integrity and purpose of the optimistic oracle, turning a one-time verification into an endless manual loop and removing trust in the oracle’s result.A malicious actor can repeatedly dispute every settlement submission, freezing the market indefinitely even if the UMA DVM ultimately rules the claim true, forcing the owner to perform a manual settlement and incur gas and operational overhead, while the attacker’s cost remains capped at a single bond per round.
┌────────────────────────────────────────────────┐ │ 1. Owner calls submitSettlementPrice() │ └──────────────┬─────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────┐ │ 2. UMA OOv3 assertTruth() → assertionId stored │ └──────────────┬─────────────────────────────────┘ │ │ (Optional) Disputer raises dispute │ ──────────────────────────────► │ ▼ ┌────────────────────────────────────────────────┐ │ 3. UMA DVM resolves → │ │ assertionResolvedCallback(assertionId, │ │ assertedTruthfully = TRUE | FALSE) │ └──────────────┬─────────────────────────────────┘ │ ▼ (A) **Current logic** (B) **Proposed logic** ┌───────────────────────────────┐ ┌───────────────────────────────┐ │ if (settlement.disputed) ? │ │ if (assertedTruthfully) { │ └──────────────┬────────────────┘ │ settleMarket(); │ │ YES │ } else { │ │ │ settlement.disputed = true│ │ │ } │ ▼ └──────────────┬────────────────┘ ┌───────────────────────────────┐ │ │ 4A. *Skip* settleMarket() │ │ │ (disputed == true) │ │ │ ─ market.settled = false │ │ │ ─ assertionId cleared │ │ └───────────────────────────────┘ │ │ │ │ ←───────────────┐ │ │ │ │ │ Market forever │ │ │ UNSETTLED ◄────┘ │ │ │ ▼ ▼ (owner must fall back to ┌─────────────────────┐ manual settlement path) │ 4B. settleMarket() │ │ – market.settled│ │ = true │ │ – collateral is │ │ unlocked │ └─────────────────────┘Recommendation
Consider the
assertedTruthfullyflag and settle the market whenever the oracle rules in favour of theasserter, even if a dispute occurred:function assertionResolvedCallback(bytes32 assertionId, bool assertedTruthfully) external { MarketGroup.Data storage mg = MarketGroup.load(); uint256 marketId = mg.marketIdByAssertionId[assertionId]; Market.Data storage market = Market.load(marketId); validateUMACallback(market, msg.sender, assertionId); Market.Settlement storage settlement = market.settlement; if (assertedTruthfully) { // Oracle confirmed the submitted price – finalise settlement market.setSettlementPriceInRange( DecimalPrice.sqrtRatioX96ToPrice(settlement.settlementPriceSqrtX96) ); emit MarketSettled(marketId, assertionId, settlement.settlementPriceSqrtX96); } else { // Oracle rejected the price – leave the market unsettled settlement.disputed = true; } // Clear assertion so a new one can be posted if needed market.assertionId = bytes32(0); } - The boolean
-
I-01 Informational Use Call Instead Of Transfer For Native Assets Transfers Warning Resolved
Description
The
transferfunction onpayableaddresses forwards a fixed 2300 gas stipend to the recipient and reverts on failure, which can lead to unexpected reverts if the recipient is a contract consuming more gas in its fallback/receive function (e.g., due to logging or checks). Some multi-signature wallets require more than 2300 gas to receive a native asset transfer. For example, Gnosis Safe supports forwarding via fallback. Usingtransfer()with a Safe's multi-sig can lead to gas depletion and failed transfers.In this codebase, the a raw
transfercall is used in the abstractETHManagement.withdrawETHfunction (inherited by bridges likeUMALayerZeroBridgeandMarketLayerZeroBridge), where ETH is sent to the owner:payable(owner()).transfer(amount);If the owner becomes a contract (e.g., Gnosis Safe’s multi-sig) withdrawals could revert.
Recommendation
Replace
transferwith a low-levelcall{value: amount}(""), check success via a bool, and handle failures gracefully (e.g., revert with custom error). -
I-02 Informational Debug Statements In Production Code Best Practices Resolved
Description
The
UMALayerZeroBridge.solcontract includes an import statement forconsole2fromforge-std/console2.sol, as shown below:import {console2} from "forge-std/console2.sol";This import is a debugging utility typically used in development environments (e.g., Foundry testing) for logging purposes, but it is not intended for production code.
Recommendation
Remove the unused
import {console2} from "forge-std/console2.sol";statement fromUMALayerZeroBridge.sol. -
I-03 Informational Unused Parameters Best Practices Resolved
Description
The
Market.Datastruct inMarket.soldeclares a mappingmapping(uint256 => Debt.Data) lpDebtPositionsintended to track liquidity provider debt positions, but it is never read from or written to anywhere in the codebase. This is evident from a full code search showing no accesses to this field beyond its declaration. The struct is defined as:struct Data { uint256 startTime; uint256 endTime; int24 baseAssetMinPriceTick; int24 baseAssetMaxPriceTick; VirtualToken baseToken; VirtualToken quoteToken; IUniswapV3Pool pool; bool settled; uint256 settlementPriceD18; mapping(uint256 => Debt.Data) lpDebtPositions; //<------------------ bytes32 assertionId; Settlement settlement; ISapienceStructs.MarketParams marketParams; uint160 sqrtPriceMinX96; uint160 sqrtPriceMaxX96; uint256 minPriceD18; uint256 maxPriceD18; uint256 feeRateD18; uint256 id; bytes claimStatementYesOrNumeric; bytes claimStatementNo; };On the other hand, something similar occurs in the
SettlemendDatastruct with theuint256 epochIdvariable:struct SettlementData { address market; uint256 epochId; //<------------------ uint256 settlementPrice; uint256 timestamp; }Unused storage like this wastes deployment gas and increases contract size without functionality, complicating future maintenance or upgrades.
Recommendation
Remove the unused
lpDebtPositionsmapping from theMarket.Datastruct and theepochIdparameter from theSettlementDatastruct to reduce contract size and eliminate dead code. -
I-04 Informational Lack Of A Double Step Transfer Ownership Pattern Best Practices Partially resolved
Description
The current ownership transfer process for all the contracts inheriting from the
Ownablecontract involves the current owner calling thetransferOwnershipfunction:function transferOwnership(address newOwner) public virtual onlyOwner { require(newOwner != address(0), "Ownable: new owner is the zero address"); _transferOwnership(newOwner); }If the nominated EOA account is not a valid account, it is entirely possible that the owner may accidentally transfer ownership to an uncontrolled account, losing the access to all functions with the
onlyOwnermodifier.Recommendation
It is recommended to implement a two-step process transfer ownership process where the owner nominates an account and the nominated account needs to call an
acceptOwnershipfunction for the transfer of the ownership to fully succeed. This ensures the nominated EOA account is a valid and active account. This can be easily achieved by using OpenZeppelin’s Ownable2Step contract. -
I-05 Informational Missing Events for Configuration Setter Functions Events Resolved
Description
In DeFi protocols, setter functions that modify critical configuration parameters should emit events to enable off-chain monitoring, and alerting for changes (e.g., by indexers like The Graph or frontend apps). This ensures transparency and helps detect unauthorized or erroneous updates. However, several setter functions across the codebase do not emit events, making it difficult to track modifications without scanning transaction logs manually or relying on indirect effects. The affected functions include:
UMALayerZeroBridge.setOptimisticOracleV3(address _optimisticOracleV3): Updates the UMA oracle address, a core dependency for assertions, but no event is emitted to log the new address.UMALayerZeroBridge.setLzReceiveCost(uint128 _lzReceiveCost): Sets LayerZero receive gas cost, impacting fee calculations, without emission.UMALayerZeroBridge.setGasThresholds(uint256 _warningGasThreshold, uint256 _criticalGasThreshold): Configures gas alerts, no event.MarketLayerZeroBridge.setLzReceiveCost(uint128 _lzReceiveCost): Similar to above, no event.MarketLayerZeroBridge.setGasThresholds(uint256 _warningGasThreshold, uint256 _criticalGasThreshold): No event.MarketLayerZeroBridge.enableMarketGroup(address marketGroup)anddisableMarketGroup(address marketGroup): Toggles market group access, critical for operations, but no events to track enables/disables.
Other setters (e.g.,
setBridgeConfigin both bridges) do emit, showing inconsistency.Recommendation
Add dedicated events (e.g.,
OptimisticOracleV3Updated(address newOracle)) to all listed setters, emitting old/new values where applicable for auditability. -
I-06 Informational Inconsistent Storage Slot Naming in ERC721 Implementation Warning Resolved
Description
The Sapience protocol's ERC721 storage slots in
ERC721StorageandERC721EnumerableStoragelibraries, are hashed using a namespace prefix tied to "io.synthetix.core-contracts" which appears to be a remnant from forked or imported Synthetix code. Specifically, the constants_SLOT_ERC721_STORAGEand_SLOT_ERC721_ENUMERABLE_STORAGEemploy keccak256 hashes of strings like"io.synthetix.core-contracts.ERC721"and"io.synthetix.core-contracts.ERC721Enumerable"respectively, to derive their slots. This is mismatched with the Sapience protocol's branding.Recommendation
Consider updating the storage slot constants to use a Sapience-specific namespace, such as hashing
"io.sapience.core-contracts.ERC721"for_SLOT_ERC721_STORAGEand"io.sapience.core-contracts.ERC721Enumerable"for_SLOT_ERC721_ENUMERABLE_STORAGE. -
I-07 Informational Protocol Do Not Support Fee On Transfer Tokens Warning Acknowledged
Description
Sapience, which relies on precise collateral transfers for maintaining position solvency in its trading and liquidity modules, does not accommodate ERC20 tokens with built-in transfer fees. This limitation happens primarily in functions handling collateral deposits, such as
createTraderPositionandmodifyTraderPositioninTradeModule, as well as the underlyingupdateCollateralinPosition, where amounts are normalized/denormalized based on the token's decimals and transferred via OpenZeppelin'ssafeTransferFrom. For standard tokens, this ensures the protocol receives the full intended amount, but with fee-on-transfer tokens, the recipient (the protocol contract) gets less than requested, e.g., a 2% fee on a 100-unit transfer yields only 98 units, while the sender's balance deducts the full 100. This discrepancy can lead to undercollateralization: the position'sdepositedCollateralAmountis updated assuming the full normalized value arrived, but the actual balance is short, allowing insolvent positions to pass validation checks inafterTradeCheckorvalidateCollateralRequirementsForTrade.Essentially, the protocol is not prepared to support fee-on-transfer tokens as collateral.
Recommendation
Document the non-support for fee-on-transfer tokens in the protocol documentation to inform users and prevent incompatible asset usage.
-
I-08 Informational Reference To Deprecated vGas Tokens In Error Message Warning Resolved
Description
In the
TradeModulecontract, the closure logic withinmodifyTraderPositionincludes validation checks to ensure no residual vBase tokens remain before finalizing a position close, which is crucial for maintaining single-sided token states and preventing invalid settlements. However, the revert error messages incorrectly reference "vGas tokens" in both cases, ifposition.vBaseAmount > 0orposition.borrowedVBase > 0, despite the code and protocol using "vBase" (the virtual base token representing the variable outcome asset). This discrepancy stems from legacy naming conventions in an earlier protocol version where "vGas" might have been a placeholder or prior term for vBase.The
LiquidityModuledoes also contain some references to this old code.Recommendation
Update the error strings in the revert statements to accurately reference "vBase tokens" (e.g., "Cannot close position with vBase tokens" and "Cannot close position with borrowed vBase tokens") to align with the current protocol terminology.
-
I-09 Informational Omission of Explicit Security and Executor LZ Configurations Warning Acknowledged
Description
The Sapience protocol's LayerZero bridge contracts (
UMALayerZeroBridgeandMarketLayerZeroBridge), built on the OApp framework for cross-chain interactions, do not include explicit setups for security parameters (e.g., Destination Verification Networks or DVNs) or executor configurations. As outlined in LayerZero V2 documentation, OApps without custom configurations automatically revert to default settings provided by LayerZero Labs, encompassing a predefined suite of DVNs for message authentication and standard executor behaviors for handling receipts. These defaults are engineered for broad compatibility and security in typical deployments, leveraging LayerZero's rigorously audited verifiers and operational baselines to ensure reliable message delivery and verification. However, they may not be optimally tailored to Sapience's unique requirements, such as elevated verification thresholds for sensitive assertion callbacks in prediction markets.Recommendation
Merely informative. While the current approach can be considered secure, do consider defining the OApp's security parameters (e.g., DVN assignments) and executor configurations during the bridge setup to customize beyond defaults, in order to ensure optimal verification and execution for Sapience's cross-chain flows and document any reliance on defaults for clarity in protocol maintenance.
-
I-10 Informational Absence of Excess Collateral Refund in LP Position Creation Logical Error Acknowledged
Description
In the
createLiquidityPositionfunction within theLiquidityModulecontract, users provide acollateralAmountthat is normalized and fully locked into the position'sdepositedCollateralAmountviaposition.updateCollateral(totalDepositedCollateralAmount), even if it exceeds the computedrequiredCollateralAmountfrommarket.requiredCollateralForLiquidity. This differs fromdecreaseLiquidityPosition, where excess is refunded ifnewCollateralAmount > requiredCollateralAmountthrough a conditionalupdateCollateral(required). As a result, any over-deposit (common due to uncertainty in exact requirements influenced by pool state and range) remains locked until a future decrease or close, inefficiently tying up capital. For example, if a user deposits 1000 units but only 800 are required, the full 1000 is held, preventing redeployment elsewhere. The logic inupdateValidLpcomputes but doesn't act on excess (beyond revert for shortfall, bypassed for fee collectors).Recommendation
Implement a refund mechanism in
createLiquidityPositionmirroringdecreaseLiquidityPositionby adding a conditional release afterupdateValidLp. Modify the collateral handling as follows:(requiredCollateralAmount, totalDepositedCollateralAmount,,) = position.updateValidLp( market, Position.UpdateLpParams({ uniswapNftId: uniswapNftId, liquidity: liquidity, additionalCollateral: normalizedCollateral, additionalLoanAmount0: addedAmount0, additionalLoanAmount1: addedAmount1, lowerTick: params.lowerTick, upperTick: params.upperTick, tokensOwed0: 0, tokensOwed1: 0, isFeeCollector: marketGroup.isFeeCollector(msg.sender) }) ); int256 deltaCollateral; if (totalDepositedCollateralAmount > requiredCollateralAmount) { deltaCollateral = position.updateCollateral(requiredCollateralAmount); totalDepositedCollateralAmount = requiredCollateralAmount; // Adjust for event } else { deltaCollateral = position.updateCollateral(totalDepositedCollateralAmount); }This refunds excess via safe transfer in
updateCollateral, updates the locked amount and ensures consistency. -
I-11 Informational Unused Code Superfluous Code Resolved
Description
The following code is not used or missing implementation
- Library
Errors:OnlyInitializerandInvalidTickSpacingerrors are not emitted. - Library
DecimalMath: Imported inTradeModuleandViewsModulebut never used. - Struct
SettlementData: declared inBridgeTypesbut not used. Ownable; contract inherited fromVirtualTokenbutonlyOwneris not used.
Recommendation
Remove the unused code and imports.
- Library
-
I-12 Informational Missing Access Control For Virtual Token Access Control Resolved
Description
The
VirtualToken.mintfunction has no access control. Althoughtype(uint256).maxis minted during market deployment preventing new minted tokens, users can still call it with zero amount and emit aTransferfrom the zero address.Recommendation
Consider adding
onlyOwnermodifier to themintfunction, or remove the Ownable inherited contract. -
I-13 Informational Unnecessary Returned Values Superfluous Code Resolved
Description
The UMALayerZeroBridge
assertionResolvedCallbackandassertionDisputedCallbackfunctions can only be called by OOV3, which does not expect any return values.Recommendation
The return value
MessagingReceipt memoryshould be avoided as it only wastes execution gas. -
I-14 Informational Deadline Is Validated Twice Superfluous Code Resolved
Description
Uniswap
NonfungiblePositionManagerliquidity actions require adeadlineparam, provided by the user. Transaction will revert in caseblock.timestamp > deadline.However, the
LiquidityModulealso validates this deadline, spending unnecessary gas.Recommendation
Remove the
deadlinecheck as this is handled by theNonfungiblePositionManager. -
I-15 Informational Overwritten Returned Parameters Logical Error Resolved
Description
During
closeLiquidityPosition, thedecreasedAmount0anddecreasedAmount1are assigned to the values returned by theNonfungiblePositionManager.decreaseLiquiditycall.However, these parameters are overwritten by the returned values of the internal
_closeLiquidityPosition, which include all collected amounts, not just the decrease liquidity amounts.Recommendation
If this is expected behavior, consider not storing the returned values of
NonfungiblePositionManager.decreaseLiquidityas they are not used and is only wasting gas.Alternatively, if the idea was to only return liquidity amounts decreased, do not overwrite them with the
returnstatement. -
I-16 Informational Unused Param In
_sendBalanceUpdateGas Optimization ResolvedDescription
The
finalAmountparam in_sendBalanceUpdateis never used and could be removed for optimization. Additionally, indepositBond, thisfinalAmountis set as the pre-deposit bond balance, which may be confusing to the user or integrators.Recommendation
Remove the
finalAmountparameter completely. -
I-17 Informational Missing
INftModuleInISapienceInformational ResolvedDescription
The
ISapienceinterface contains all module's functionality except for theNftModule, in order to execute ERC721 related functions.Recommendation
Create the
INftModuleand inherit from it inISapience. -
I-18 Informational Inefficient Usage of
DURATION_MULTIPLIERGas Optimization ResolvedDescription
In
__manual_setSettlementPrice, theDURATION_MULTIPLIERis hardcoded as2. However, it is not stored as a constant or variable, which is suboptimal from both a gas efficiency and configurability standpoint.If this multiplier is intended to be fixed, consider declaring it as a
constantorimmutablevariable to benefit from gas savings. If it is meant to be adjustable, store it as a regularuint256state variable and implement a setter function restricted to the contract owner or admin.Recommendation
- If fixed: Declare as
uint256 constant DURATION_MULTIPLIER = 2; - If configurable: Store it as a state variable with appropriate setter access control.
- If fixed: Declare as
-
I-19 Informational Layer Zero Refunds Trigger
ETHDepositedEvent Events ResolvedDescription
When sending LZ messages, the bridge contract is used as the refund recipient. Although there the bridge fee is calculated before sending the cross-chain message, any refund will execute the
receivefunction, emitting theETHDeposited. This event is only expected when contract is funded with ETH by depositors, which may create confusion for offchain services.Recommendation
Document this behavior to make sure offchain services are aware of LZ refunds, as they are not actual deposits.
-
I-20 Informational Avoid Using
_lzSendGas Optimization AcknowledgedDescription
The
_lzSendfunction relies on themsg.valueto pay the bridge fee. Therefore, the bridge contract have to invokethis._sendMessageWithETHwith an ether value in order to make it work.Although is a good practice to use the available
_lzSend, this is just a helper function to verifymsg.value > nativeFeebefore callingendpoint.send. As the contract does not receive ether from users when sending a message (i.e. duringdepositBond), the external call can be avoided to save gas.Recommendation
Consider manually validating validating
msg.value > nativeFeeand callingendpoint.senddirectly to avoid the external call. -
I-21 Informational Quote Functions Use Normalized Collateral Logical Error Acknowledged
Description
The
quotefunctions are helper functions to determine the required collateral for trades or liquidity positions.However, the collateral amount handling is inconsistent when the scaling factor is greater than 1. For example:
- In
quoteLiquidityPositionTokens, thedepositedCollateralAmountneeds to be normalized in order to return correct values - In
quoteCreateTraderPositiontherequiredCollateralneeds to be denormalized in order to know the actual collateral in the original token values.
This not only creates bad UX but can introduce rounding issues if the actual amount needs to be divided by the scaling factor.
Recommendation
Quote function should accept and return the denormalized collateral amounts, so traders and liquidity providers can use quote functions without modifications to the amounts.
- In
-
I-22 Informational Closing Liquidity DoS'ed By Dust Amounts Rounding Resolved
Description
The
feeCollectorfeature allows these users to create liquidity position without depositing collateral.Due to rounding on the Uniswap side, decreasing liquidity returns 1 wei lower than the amount originally added. During position close flow, we decrease all liquidity and collect tokens from Uniswap and add 1 wei to collected amounts to fix the rounding issue.
However, in case the fee collector's liquidity position was entirely in
vQuote(added liquidity to lower range), the following scenario can arise:- add single sided liquidity in vQuote, borrowedVQuote = X
- decrease liquidity once
- close position, amount collected is X - 2 wei.
- Add 1 wei to collectedAmount1 to fix rounding issue
- Final position amounts: vQuoteAmount = X, borrowedVQuote = X - 1
Due to the fact that
borrowedVQuote > 0while vBase amounts are 0, position is not entirely closed, and converted into aTradeposition. When attempting to close the trade, it reverts withInvalidData("At least one token should be traded")as the position size isvBaseAmount - vBorrowedVBase = 0Recommendation
Consider removing the
feeCollectorfeature, as not only this leads to rounding errors like the one described above, but also invalidates certain solvency assertions in the protocol if these users are allowed to create positions without collateral.Alternatively, only attempt to close the trade position if position size (net vBase) is non zero.
-
I-23 Informational Incorrect FeeManagement Comment Informational Resolved
Description
One of the listed actions of the
FeeManagementcontract is:Checking gas thresholds and revert if necessary
However, the contract only emits events if the thresholds are crossed, no reverts happen.
Recommendation
Remove the
contractcomment if the intention is to only emit events. -
I-24 Informational Disputes Force Settlement Process To Be Reset Informational Acknowledged
Description
As already mentioned in previous rounds of Foil, any user that's willing to pay the price for a dispute can cause valid price settlements to be discarded, causing a restart of the settlement process.
Recommendation
Keep this limitation in mind.
-
I-25 Informational getPnl Function May Return Stale Data Warning Acknowledged
Description
Position.getPnl()is a view function which returns the currentPnLfor a given position. If the position is of typeLiquidity, thePnLcalculation depends on thetokensOwed0andtokensOwed1variables which may not have been updated since the last fee accumulation. In result, thePnLreported will be lower than the actual one. This will affectViewsModule.getPositionPnl()andViewsModule.getPositionCollateralValue().Recommendation
Mention this lagging behavior explicitly so external integrators are aware of it.
-
I-26 Informational Manual Settlement Can Not Set The Price To 0 Warning Resolved
Description
SettlementModule.__manual_setSettlementPrice()can be executed if the market hasn't been settled for a given time. It sets the price to whatever the current Uniswap price is, but it can't set the price to 0, for example if the market is of typeYES/NO.Recommendation
Keep that in mind.
-
I-27 Informational Unused Encoder Function Informational Resolved
Description
When sending
LayerZeromessages,Encoder.encodeType()is not used, the message is constructed usingabi.encode()instead. The existence ofEncoder.encodeType()is dangerous because it usesabi.encodePacked()which can lead to malformed data if the function is used in the future.Recommendation
Either remove the function from the
Encoderlibrary or change it toabi.encode()and use it when sending crosschain messages. -
I-28 Informational Note About withdrawCollateral Return Value Informational Acknowledged
Description
MarketGroup.withdrawCollateral()accepts an 18 decimals amount to be withdrawn and returns the withdrawn amount, again in 18 decimals. Because it performsdenormalize()and thennormalize()again, if the collateral token has less than 18 decimals, the end result ofwithdrawCollateralwill be a smaller value than the initially passed amount, even if there are enough funds in the contract to pay the user.Recommendation
Be aware of this behavior.
Remediation Review
13 findings · August 18 to 19, 2025-
H-01 High Withdrawal Race Condition Causes Balance Desync Logical Error Acknowledged
Description
The fix for H-01 was incomplete. As withdrawal intent on UMA chain is cleared immediately after
executeWithdrawal, this allows a newintentToWithdrawBondto be submitted in the same transaction, leading to a possible race condition and accounting mismatch.Example scenario:
- Alice holds 1000 tokens on both UMA and Market chains
- She withdraws 600 → intent set to 600 on both chains
- After delay, calls
executeWithdrawalon UMA chain and immediately submits a new withdrawal intent for 400 in the same transaction - If
intentToWithdrawBond(400)is processed on Market chain beforeexecuteWithdrawal(600), the withdrawal intent is overwritten with 400 executeWithdrawal(600)then underflows when subtracting from intent, reverting on Market chain- Alice still receives 600 tokens on UMA chain while retaining 1000 on Market chain
This attack path can also be achieved with the newly implemented
removeWithdrawalIntent.Recommendation
Do not clear withdrawal intent on UMA chain immediately after
executeWithdrawalandremoveWithdrawalIntent. Instead, clear it only afterCMD_FROM_ESCROW_WITHDRAWandCMD_FROM_ESCROW_REMOVE_WITHDRAWAL_INTENTis executed on Market chain and a confirmation message is received back on UMA chain. This prevents immediate re-submission of withdrawal intent and eliminates the race condition. -
H-02 High
removeWithdrawalIntent()can be abused Gaming AcknowledgedDescription
The recently added
removeWithdrawalIntent()function in theBondManagementcontract allows malicious bond depositors to withdraw their bond without only on the UMA side, i.e without reflecting it on the Market side.Example:
- Alice has
1000bond balance on both sides - She initiates a
1000bond withdrawal - Her intent chains becomes
1000on both chains - She waits 1 day
- Now she can atomically execute
removeWithdrawalIntent()andintentToWithdrawBond(amount=1000), which will queue 2 LZ messages - The attacker frontruns the executor and executes the
intentToWithdrawBondtransaction first. It will have no effect on the storage because the intent is already1000and the code uses=, not+= - Next, the intent removal transaction will reduce the intent to 0.
- When 1 days passes and Alice calls
executeWithdrawal(), she receives her tokens on the UMA side, but the transaction will revert on the Market side because it will try to subtract 1000 from the 0 intent.
In result Alice withdrew her funds, but the system kept her balance recorded on the Market side.
Recommendation
Consider:
- enforcing a delay between
removeWithdrawalIntent()andintentToWithdrawBond() - using
+=instead of=inMaketLayerZeroBridge._lzReceive()for theCMD_FROM_ESCROW_INTENT_TO_WITHDRAWcase.
- Alice has
-
M-01 Medium Dispute Flag Prevents Market Settlement Even After UMA Confirms Truth Unexpected Behavior Acknowledged
Description
In
UMASettlementModule, once an assertion is disputed the contract sets a sticky flag that blocks automatic settlement even if UMA’s final verdict later confirms the assertion as truthful. The critical paths are:// UMASettlementModule.sol function assertionDisputedCallback(bytes32 assertionId) external nonReentrant { ... Market.Settlement storage settlement = market.settlement; settlement.disputed = true; // <— permanently flipped on first dispute emit SettlementDisputed(marketId, block.timestamp); } function assertionResolvedCallback(bytes32 assertionId, bool assertedTruthfully) external { ... // Settles only if the assertion was truthful AND no dispute ever happened if (assertedTruthfully && !market.settlement.disputed) { market.setSettlementPriceInRange( DecimalPrice.sqrtRatioX96ToPrice(settlement.settlementPriceSqrtX96) ); emit MarketSettled(marketId, assertionId, settlement.settlementPriceSqrtX96); } // Regardless of outcome, the assertion is closed market.assertionId = bytes32(0); }The effect is that any single dispute sets
settlement.disputed = trueand preventsassertionResolvedCallbackfrom ever settling the market for that assertion, even whenassertedTruthfully == trueafter UMA’s DVM resolution. The assertion is then closed (assertionIdcleared) without settling, leaving the market in the “unsettled” state. Operators must re‑submit a new assertion and wait through another liveness period to settle. This exposes the system to an application‑level griefing vector: a disputer willing to stake UMA’s dispute bond can force at least one extra round and extra latency for settlement, even if they ultimately lose on UMA. On bridged deployments, the Market side already deducts the escrowed bond when forwarding the assertion and the UMA side returns bonds directly to the submitter wallet rather than escrow; there is no balance top‑up message on “truthful” resolution, so the submitter must re‑deposit before the required re‑assertion. During this period, positions cannot be finalized because settlement gatesSettlementModule.settlePositiononmarket.settled == true, so users wait longer than necessary despite UMA having confirmed the truth.Recommendation
If the intention is to trust UMA’s final verdict, remove the
!market.settlement.disputedgate and optionally clear the flag on resolution to reflect state accurately.If, instead, the business rule is to require human intervention after any dispute, add an explicit post‑UMA finalize path that can be called once with UMA’s
assertedTruthfully == true, rather than silently dropping settlement and forcing a full re‑assertion cycle. -
M-02 Medium Innacurate tradeSlippage Parameter Unexpected Behavior Acknowledged
Description
The close paths enforce a “max‑loss” check against the collateral baseline rather than bounding the swap cost needed to repay debt. When a position is closed through a
closeLiquidityPositioncall, the quantity that must be controlled is the repayment swap’s execution price, not the final net collateral returned. Because the current guard compares payout to(1 − tradeSlippage) × depositedCollateraland is evaluated after the Uniswap swap, it fails to provide execution‑price protection and can allow the swap to clear at a price far worse than the user’s tolerance.- Swap performed without any slippage:
// swapOrQuoteTokensExactOut ISwapRouter.ExactOutputSingleParams memory swapParams = ISwapRouter.ExactOutputSingleParams({ tokenIn: tokenIn, tokenOut: tokenOut, amountOut: amountOut, fee: market.marketParams.feeRate, recipient: address(this), deadline: block.timestamp, // Notice, not limiting the trade in any way since we are limiting the collateral required afterwards. sqrtPriceLimitX96: 0, amountInMaximum: type(uint256).max });Notice as well the comment “Notice, not limiting the trade in any way since we are limiting the collateral required afterwards”.
- Actual collateral
tradeSlippagelimitation:
function _closeTradePosition(Market.Data storage market, Position.Data storage position, uint256 tradeSlippage) internal { int256 deltaCollateralLimit = -int256(position.depositedCollateralAmount.mulDecimal(DecimalMath.UNIT - tradeSlippage)); // Trade & rebalances ... // Check if the collateral is within the limit Trade.checkDeltaCollateralLimit(deltaCollateral, deltaCollateralLimit); }Proof‑of‑Concept (PoC): Collateral‑anchored “slippage” check lets the same >1% price impact pass or fail depending only on deposited collateral
The test shows that
closeLiquidityPositionusestradeSlippageas a collateral loss tolerance, not as execution price protection. As a result, the exact same Uniswap swap with ~5.69% slippage (relative to the pre‑trade mid) reverts when the LP has “normal” collateral, but succeeds when the LP stuffs more collateral into the position, even though the execution price does not change. This proves the guard is not bounding price impact, it only gates how much of your own collateral the contract is willing to burn.Test flow at a glance:
- Setup a market and two LPs: LP1 adds deep, wide liquidity. LP2 adds concentrated liquidity over [-7000, -6800] with 200e18 collateral.
- Open a 200 base long, nudging the pool from tick -6932 to -6812. At this point the pre‑trade mid for the later close is:
Current pool price (D18) ............................... 506032891067404866 Current pool tick ...................................... -6812- Snapshot state, then top up LP2’s collateral by 100e18 (to ~300e18) via
increaseLiquidityPosition, keeping the same borrowed base/quote exposure. - Attempt to close with a 1%
tradeSlippage. It reverts. The revert comes after a Uniswap swap that pushes price to tick -5906. - Revert to snapshot, then repeat the same steps but inject much more collateral (the test uses
collateralAmount: 10000e18 on the increase). Exposure is left the same, only the collateral buffer is larger. - Attempt the exact same close with the same 1%
tradeSlippage. This time it does not revert, even though the identical Uniswap swap executes (same amounts, same final tick), i.e. the same ~5.69% slippage.
Passing/failing is therefore explained solely by how much collateral was deposited (and the PNL which was constant in both closures), not by the execution price relative to any pre‑quote reference.
This is conclusive evidence that the current
tradeSlippagecheck is not an execution‑price bound and can be bypassed by inflating collateral, exposing users to fills far outside their stated slippage tolerance.Recommendation
Consider documenting this behaviour so traders are aware of this functionality.
LiquidityCloseParams.amount0MinandLiquidityCloseParams.amount1Minshould be enough to mitigate any unwanted slippage. -
M-03 Medium UMALayerZeroBridge Only Supports A Single bondToken Configuration Acknowledged
Description
The
UMALayerZeroBridgecontract is designed to handle bond deposits and assertions for cross-chain UMA settlements but enforces a single enabled bond token via the_isValidTokencheck, which only allows the pre-configuredenabledBondToken. This creates a logical inconsistency with the broader protocol design, where market groups can be initialized with arbitrary bond currencies (viaMarketGroup.createValidandISapienceStructs.MarketParams.bondCurrency), the UMAOptimisticOracleV3supports multiple currencies (per itsgetMinimumBondfor any ERC20), and theMarketLayerZeroBridgemaintains a flexible mapping (remoteSubmitterBalances[submitter][bondToken]) to track balances per token.As a result, if a market group uses a
bondCurrency != enabledBondToken, submitters cannot deposit bonds throughUMALayerZeroBridge(reverts ondepositBonddue to_isValidTokenfailure), preventing assertion submissions and blocking market settlements entirely for those markets. The constructor sets a singleenabledBondToken, with no mechanism to add more post-deployment.Recommendation
Replace the single
enabledBondTokenwith amapping(address => bool) approvedBondTokens;add owner-only functions to approve/revoke tokens. Update_isValidTokento check the mapping and ensure constructor/market params validate against it. -
L-01 Low Incorrect ERC-721 Safe Mint Check Before Minting And With Wrong From Address Unexpected Behavior Acknowledged
Description
In the
LiquidityModule'screateLiquidityPositionfunction, the protocol attempts to ensure safe transfer of the position NFT by calling_checkOnERC721Receivedbefore actually minting the token. This violates the ERC-721 standard, which specifies that formintoperations, thefromaddress in theonERC721Receivedcallback should beaddress(0)and the callback should only be invoked after theminthas occurred (i.e., after the token exists and ownership is assigned).Here, the check is performed prematurely with
fromset toaddress(this)instead ofaddress(0), and thetokenIdis passed before it's minted. This can lead to inconsistent behavior in recipient contracts that implementonERC721Receivedwith logic depending on thefromparameter or token existence.Recommendation
Move the
_checkOnERC721Receivedcall after_mintand all the state updates and pass from asaddress(0)to align with ERC-721 mint semantics, ensuring the token exists before the callback. -
L-02 Low Redundant
intent.executedWithdrawal Flag Gas Optimization AcknowledgedDescription
The
executeWithdrawalfunction now setsintent.amountto zero when executed, which fully prevents double-execution attempts. This makes theintent.executedflag redundant, as it no longer provides any additional protection and only adds unnecessary state writes.Recommendation
Remove all instances of
intent.executedfrom the codebase -
L-03 Low Dust Liquidity Decrease Can Trigger Revert Rounding Acknowledged
Description
When decreasing very small amounts of liquidity (e.g. 100 wei), the operation can revert with an
InsufficientCollateralerror. This occurs due to rounding behavior in Uniswap’sdecreaseLiquidityflow.In
decreaseLiquidityPosition, the call toINonfungiblePositionManager.decreaseLiquidityexpects a non-zerodecreaseAmount0 / decreaseAmount1which accrue tostack.tokensOwed. For dust liquidity amounts, these round down to zero.Despite receiving no tokens back, the subsequent call to
updateValidLpreduces the recorded liquidity by the dust amount.When
requiredCollateralForLiquidityis called, the remaining liquidity position is valued lower, but without a corresponding increase instack.tokensOwed. This can raise the required collateral above what is already deposited, triggering anInsufficientCollateralrevert.This typically occurs after consecutive
decreaseLiquidityPositioncalls, where the first call correctly returns excess collateral, but subsequent dust removals create a mismatch between collateral requirements and accounted tokens.Recommendation
Consider introducing a minimum liquidity threshold for
decreaseLiquidityPositionto prevent operations that would round down to zero. Otherwise, add clear warnings or revert messages so that users are aware of the potential revert condition. -
L-04 Low Gas threshold are checked after message is sent Events Acknowledged
Description
L-12 from the main review points out how
_checkGasThresholds()is executed before sending the crosschain message in bothMarketLayerZeroBridgeandUMALayerZeroBridge. The order of execution was changed inMarketLayerZeroBridge, but not inUMALayerZeroBridge, which creates a greater inconsistency.Recommendation
Change the order of execution in
UMALayerZeroBridgeas well. -
L-05 Low Race Condition May Block The Settlement DoS Acknowledged
Description
Even though a remediation link for M-13 was provided, the issue was never fixed and the described scenario can still happen. In fact, it's impact is even bigger now because the function for manual market settlement doesn't exist anymore.
Recommendation
You can allow the owner of the
BondManagementto deposit on behalf of other users. This would require them to pay bond tokens in the described scenario in the issue, but it will save the market from halting. -
I-01 Informational Invalid Comment About
depositCollateralDocumentation AcknowledgedDescription
In the function
validateLp, the comment that states:// called from depositCollateral, that way the check for self.kind is not needed as // both trader and lps can deposit collateral as long as they are the owner of fee collector NFTis invalid as the
depositCollateralfunction has been removed.Recommendation
Update the comment to reflect how it is only called by
preValidateLp. -
I-02 Informational Outdated Comment On Fee Collector Informational Acknowledged
Description
In
decreaseLiquidityPosition, a comment mentionsfeeCollectorwhich has been removed.Recommendation
Remove all references to the outdated
feeCollector. -
I-03 Informational Note about
assertedTruthfullyInformational AcknowledgedDescription
UMASettlementModule.assertionResolvedCallback()was changed to respect theassertedTruthfullyflag which handles the crosschain race condition where an assertion has been disputed but the call toassertionDisputedCallback()hasn't been received yet.Keep in mind that this does not solve the issue with anyone being able to delay the dispute, because
assertionResolvedCallback()will still skip setting the price if the assertion was disputed (no matter if it was a successful dispute or not)Recommendation
Acknowledge it
No findings match.
More from Sapience
All 6 reports-
LayerZero Composer
7 findings 7 findings: 5 low, 2 informational -
Foil Vault and Prediction Market
61 findings2 critical · 3 high 61 findings: 2 critical, 3 high, 7 medium, 26 low, 23 informational -
Foil Updates
36 findings2 critical · 4 high 36 findings: 2 critical, 4 high, 7 medium, 23 low -
Foil Vault
35 findings2 critical · 2 high 35 findings: 2 critical, 2 high, 14 medium, 17 low
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.
