Guardian's review of Foil Vault and Prediction Market for Sapience, published October 2025. The report records 61 findings across 2 review rounds, including 2 critical and 3 high.
- Published
- Review window
- September 29 to October 24, 2025
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Base, Arbitrum, Polygon
- Sector
- Derivatives
- 2 Critical
- 3 High
- 7 Medium
- 26 Low
- 23 Informational
Scope
4 files in scope · 1,078 nSLOC
| File | nSLOC | Lines |
|---|---|---|
packages/protocol/src/vault/PassiveLiquidityVault.sol | 415 | 715 |
packages/protocol/src/predictionMarket/PredictionMarket.sol | 359 | 504 |
packages/protocol/src/predictionMarket/utils/SignatureProcessor.sol | 28 | 39 |
packages/protocol/src/predictionMarket/resolvers/PredictionMarketUmaResolver.sol | 276 | 358 |
Findings 61
Main Review
49 findings · September 29 to October 6, 2025-
C-01 Critical PredictionMarket Signatures Can Be Replayed Signatures Resolved
Description
PredictionMarket.mint()is called by the maker and accepts atakerSignatureto verify the specified taker has approved the prediction. The signed message doesn't include any type of nonce. This allows the same signature to be used as many times as possible until either:- the allowance of the taker runs out
- the collateral balance of the taker runs out
- the signature expires due to its deadline
A malicious maker can drain the taker balance since most likely the take will have approved a large amount of tokens to the
PredictionMarketcontract.This poses a danger for the vault. If it signed a message for a prediction that's to be soon resolved and the signature still hasn't expired, the maker can drain all of its funds by repeatedly calling
mint().Recommendation
Add a replay protection mechanism, for example nonce.
-
C-02 Critical The Vault Available Assets Can Be Inflated Logical Error Resolved
Description
Users can artificially inflate the value of their shares by transferring losing predictions into the system.
For example:
- Eve enters the
PassiveLiquidityVault - Eve mints a prediction with the following stats to herself (takes on both the maker and taker side):
- the prediction will settle in the next days
- the prediction will extremely unlikely resolve to yes (something like an alien invasion will start tomorrow)
- the yes side holds a huge amount of liquidity and the no side almost nothing
- Eve transfers the yes side to the
PassiveLiquidityVaultand therefore increasesuserCollateralDepositsand therefore it's share value drastically - Eve exits the vault and gains from the increased share price
- Soon after that the prediction resolves to No and Eve gets her funds back
- Repeat
Another impact of this issue is causing high utilization rates. For instance:r
- the vault holds 2000 assets and 200 of them are deployed (10% utilization)
- the attacker inflates the collateral of the vault with
50_000assets =>utilizationRate = 50 200 / 52 200 = >96% - the rest of the funds of the vault cannot be used anymore.
Recommendation
If there is going to be only 1 vault ever, you can hardcode its address in the
PredictionMarketand forbid transfers to it. Othwerise:- Introduce the following mappings to the
PredictionMarketcontract
mapping(uint256 => address) private initialNftMaker; mapping(uint256 => address) private initialNftTaker; mapping(address => uint256) private vaultProvidedCollateral;- Record the initial owners of the NFTs in
_createPrediction()and also increase thevaultProvidedCollateralfor the maker and the taker
initialNftMaker[makerNftTokenId] = maker; initialNftTaker[takerNftTokenId] = taker; vaultProvidedCollateral[maker] += makerCollateral; vaultProvidedCollateral[taker] += takerCollateral;- In the
burn()function check if the owner of the NFT is the initial owner and if yes, decrease thevaultProvidedCollateral
if (ownerOf(prediction.makerNftTokenId) == initialNftMaker[prediction.makerNftTokenId]) { vaultProvidedCollateral[initialNftMaker[prediction.makerNftTokenId]] -= prediction.makerCollateral; } if (ownerOf(prediction.takerNftTokenId) == initialNftTaker[prediction.takerNftTokenId]) { vaultProvidedCollateral[initialNftTaker[prediction.takerNftTokenId]] -= prediction.takerCollateral; }- Add a public getter function
getVaultProvidedCollateraltoPredictionMarket.solandIPredictionMarket.sol
function getVaultProvidedCollateral( address user ) external view returns (uint256) { return vaultProvidedCollateral[user]; }- Change the
PassiveLiquidityVault._deployedLiquidity()to use the newly created view function
function _deployedLiquidity() internal view returns (uint256) { // get vault's owned NFTs and sum the collateral of each for each NFT uint256 totalDeployedAmount = 0; address[] memory protocols = activeProtocols.values(); for ( uint256 protocolIndex = 0; protocolIndex < protocols.length; protocolIndex++ ) { address protocol = protocols[protocolIndex]; IPredictionMarket pm = IPredictionMarket(protocol); uint256 userCollateralDeposits = pm.getVaultProvidedCollateral( address(this) ); totalDeployedAmount += userCollateralDeposits; } return totalDeployedAmount; }By doing this even if someone transfers tokens to the vault, they will not be counted as deployed liquidity.
Implemented in the fuzzing suite - a Guardian proof of concept
- Eve enters the
-
H-01 High Missing ERC721 Overwrite Compatibility Resolved
Description
The
PredictionMarketinherits the ERC721 standard as every maker and taker position is an NFT.However the advantage of using NFTs as positions (the transferability) is not used. It is possible to transfer the NFTs to other accounts however this account will not become the new maker or taker on transfer. Therefore it is not possible to transfer or sell positions.
Recommendation
Consider to implement logic that allows to transfer positions.
-
H-02 High Unconfirmed Assets Should Not Be Held Math Resolved
Description
When users requests deposits, their assets are transferred to the vault contract and the
unconfirmedAssetsvariable is increased with the transferred amount. Several different issues happen because of that:- The
availableAssetsand_getAvailableAssetsfunctions do not subtract theunconfirmedAssetsfrom the balance of the contract.
This leads to issues in multiple parts of the codebase:
- It is possible that the manager uses unconfirmed assets in predictions as the
approveFundsUsagefunction includes theunconfirmedAssetsin the available one - The calculated utilization ratio is wrong if there are any
unconfirmedAssetsin the contract
- There is a potential exploit when the
emergencyModeis active:
PassiveLiquidityVault.emergencyMode()can be executed whenemergencyMode == true.This allows the users to redeem their shares atomically, without any requests. The used price ratio is the following
uint256 vaultBalance = _getAvailableAssets(); if (vaultBalance == 0) revert InsufficientAvailableAssets(shares, 0); uint256 withdrawAmount = Math.mulDiv( shares, vaultBalance, totalShares, Math.Rounding.Floor );The
_getAvailableAssets()returns the raw balance of the contract, includingunconfirmedAssets. This is problematic since the price ratio can be manipulated by depositing a large amount viarequestDeposit()(which won't be settled by the manager) and later callingcancelDeposit()to get back the funds.The formula for the additional assets needed to drain the whole vault is
Δassets = totalAssets * (totalShares / attackerShares - 1)However, the attacker will still need to wait for
expirationTimeto pass, unless their largerequestDeposit()was executed in the past.Furthermore, even if the function is used normally, the depositors of the
unconfirmedAssetsdon't own shares, which means their assets are distributed to the rest of the share holders.- Because the
NAVof the vault depends on its balance + the deployed assets, some withdrawals may have the correct price ratio, but not enough assets in the vault backing them. Right now,unconfirmedAssetsare sitting in the contract and can be consumed by these withdrawals, leaving users unable to cancel their requests.
For example:
- There are 5000 assets in the vault and 5000 shares (1:1) - 80% of the funds (4000) are deployed to protocols - The contract is left with `balance = 1000` and `deployedLiquidity = 4000`. - Alice requests a deposit of `1000 assets` ⇒ `unconfirmedAssets = 1000` and `asset.balanceOf(vault) = 2000` - Now Bob can withdraw 2000 of their shares for 2000 assets and leave the vault with 0 assets - If Alice request expires, she won’t be able to cancel it because there are not enough assets in the contract to refund her. - Now Alice cannot perform any operations in the vault because of its one request per time design. She has to wait until new assets enter the vault or supply them herself, leading to potential losses.Recommendation
Instead of storing the unconfirmed assets in the vault, introduce a new contract,
UnconfirmedAssetsHolderand transfer the assets to it. You will allow the vault contract to spend the tokens on behalf ofUnconfirmedAssetsHolderand the vault will:- transfer the funds back to the user when
cancelDepositis executed - transfer the funds to itself when
processDepositis executed
- The
-
H-03 High Liquidity Vault Is Unable To Receive NFTs DoS Resolved
Description
To deploy liquidity to the prediction markets, the manager of the vault will periodically call
approveFundsUsage()and will sign messages which will turn thePassiveLiuqidityVaultinto a taker whenPredictionMarket.mint()is called.The
PredictionMarketuses_safeMint()to mint NFTs to both the maker and the taker. However, the taker (PassiveLiquidityVault) doesn't implement theonERC721Received()function. Because of that the transaction reverts, the vault is unable to receive the NFT and its liquidity can never be deployed.Recommendation
Implement the
onERC721Received()in thePassiveLiquidityVault(). -
M-01 Medium lastUserInteractionTimestamp DoS DoS Resolved
Description
As stated out in docs, the
lastUserInteractionTimestampis used to add a delay between user request to prevent rapid-fire interactions.However if the user's request was not processed and canceled instead after 10 minutes (for example because of price changes), this timestamp is not reset. Therefore if a user is unlucky and prices fluctuate a lot as many predictions are settled it may take the user days just to enter the vault.
Recommendation
Consider to reset the
lastUserInteractionTimestampon cancel. -
M-02 Medium Consolidation Should Not Be Permissionless Access Control Resolved
Description
PredictionMarket.consolidatePrediction()allows the owner of both the maker and taker NFT for a given prediction to receive all of the collateral in exchange of both the tokens, successfully exiting the prediction market.However, there is no access control applied to the function, so anyone is allowed to call it. Users on the market who hold both maker and taker tokens will always be in danger of being consolidated. They will still receive their collateral, but won't be able to sell the tokens anymore. Moreover, if the NFTs are used in a market contract for users to buy them, an attacker can just call
consolidatePrediction()to burn the NFTs, leading to unexpected behavior.Recommendation
Allow only the owner of the tokens to call the function.
if (prediction.maker != prediction.taker) revert MakerAndTakerAreDifferent(); + require (msg.sender == prediction.maker, <<SomeError>>); -
M-03 Medium Share Price Calculation Can Be Broken Logical Error Acknowledged
Description
The following edge case scenario will break the
PassiveLiquidityVault:- X amount of funds are deposited into the vault
- The manager opens positions with X*UtilRatio amount
- Users withdraw X - X*UtilRatio from the vault
- Utilization Ratio is 100% now
- The vault loses all open predictions
- Now the vault owns 0 assets but has a totalSupply of shares > 0
- A new user enters the vault with $Y assets and gets Y shares in return (1:1 ratio as there are no assets in the vault yet)
- X > Y which means another user who owns >= Y Shares can steal all funds from the new user
Recommendation
Make sure to pause the vault forever and redeploy the system if this edge case scenario should ever happen.
-
M-04 Medium Winning Taker Can Be Stuck Unexpected Behavior Resolved
Description
When a prediction is created, the
encodedPredictedOutcomesare checked by thePredictionMarketUmaResolver.validatePredictionMarkets()function. The validation performed there requires themarketIdfor each outcome to be different than 0.The prediction itself is a parley and the outcomes are treated as its legs, meaning if even one of the outcomes predicted is not correct, the whole prediction should resolve in favor of the taker.
The logic for this is implemented in
PredictionUmaResolver.resolvePrediction().if (market.marketId != marketId) { isValid = false; error = Error.INVALID_MARKET; break; } if (!market.settled) { // Do not immediately invalidate; record and continue. hasUnsettledMarkets = true; continue; } bool marketOutcome = market.resolvedToYes; if (predictedOutcomes[i].prediction != marketOutcome) { makerWon = false; // Decisive loss on a settled market: return valid with no error return (true, Error.NO_ERROR, makerWon); }If any of the markets is not settled yet, the function doesn't return yet, but checks for a losing leg and if it finds one, the parlay is resolved with
makerWon = false.However, if
market.marketId != marketId, the parlay is treated as invalid. BecausemarketIdcan be any value besides 0, it's possible to create parlays that contain marketId that will never be wrapped.At the time of creating the prediction, the taker will see all the legs, but since he joins a parlay, he will assume that even if one of the outcomes is wrong, he will win everything, so the taker will be incentivized to join. Later, even when the markets are settled, the bet will always resolve as invalid and the collateral will remain stuck in the
PredictionMarket. If the manager doesn't check that before signing, it's possible to even trap thePassiveLiquidityVault.Recommendation
Apply the same logic for the
market.marketId != marketIdcase like you do for!market.settled- if any of the other outcomes are already resolved in the opposite direction, resolve withmakerWon = false, otherwise return invalid. -
M-05 Medium Slippage Check Is Insufficient Logical Error Acknowledged
Description
Depositing into or withdrawing from the vault works in a 2-step flow:
- The user makes a request with a given amount of funds to deposit or withdraw together with the shares to receive or burn.
- The manager bot executes the request if the result of it would not lead to stealing from current users.
For the deposit flow the user should not provide the shares to mint based on the current share price but a bit less instead as slippage could happen when the vault makes profit.
However the vault could also make a loss and in that case the slippage check is insufficient, for example:
- The vault is empty
- Bob enters the with vault with $100 assets and receives 100 shares in return (share price = $1)
- Alice creates a deposit request with $100 assets and expects 98 shares in return (2% slippage)
- A big bet is lost and the vault loses 25% of it’s assets
- Now the vault has 75 assets and 100 shares
- Therefore the share price is 1 * 100 / 75 = $1.33 now
- Bob’s shares are now worth: 100 * 75 / 100 = $75
- Alice request is processed and she receives 98 Shares with a worth of: 98 * 175 / 198 = $86.61
- Bob’s shares are now worth: 100 * 175 / 198 = $88.38
- Therefore Alice accidentally gifted a big amount of her deposit to Bob
Recommendation
Consider adding additional
sharesToMintparameter toprocessDepositwhich is provided by the manager and uses the actual price and pass it to_mint().Then make sure the value is not lower than what the user specified
require(sharesToMint >= request.shares)Apply the same fix for
processWithdrawal()as well. -
M-06 Medium Already Settled Market Can Be Matched Logical Error Acknowledged
Description
Both
PredictionMarket.fillOrder()andPredictionMarket.mint()allows users to match their prediction against an opposite one.In the case of
fillOrder(), the taker matches an order placed by the maker and in the case ofmint(), the maker matches an order signed by the taker. The orders in both functions have deadlines that make them invalid after expiration, but there is no way to cancel them earlier. If the market outcome becomes known prematurely and before the deadline expires, these orders can be used to create 100% winning predictions and steal money from their creators.For example, a maker places
NOorder forWill PEPE hit $1 until the end of 2025market with deadline of 1 day. However,PEPEhits$1after 2 hours. Takers are incentivized to fill that order since it’s a sure win for them, while makers cannot do anything do stop them.Recommendation
- Consider to revert in
fillOrderif a market is already settled. - Allow canceling orders at any point in time
function cancelOrder(uint256 orderId) external nonReentrant { IPredictionStructs.LimitOrderData storage order = unfilledOrders[ orderId ]; if (order.orderId != orderId) revert OrderNotFound(); - if (block.timestamp < order.orderDeadline) revert OrderNotExpired(); if (order.maker != msg.sender) revert MakerIsNotCaller();- Add a per user
noncetowards the taker signatures used inmint()and expose a function that allows the user to invalidate nonces. Signing contracts, includingPassiveLiquidityVault, must have a way to call that function as well.
- Consider to revert in
-
L-01 Low Anyone Can Cause Emission Of
AssertionDisputedAccess Control ResolvedDescription
PredictionMarketUmaResolver.assertionDisputedCallback()will be called by the UMA oracle when someone disputes an assertion. The resolver doesn't perform any state changes on dispute, it only emits theAssertionDisputed()event. However, the function is not properly guarded and anyone can call it, resulting in incorrect event emissions, making offchain listeners think the assertion was disputed when it was not.Recommendation
Allow only the oracle to call the function
function assertionDisputedCallback(bytes32 assertionId) external { + if (msg.sender != address(config.optimisticOracleV3)) { + revert OnlyOptimisticOracleV3CanCall(); + } bytes32 marketId = umaSettlements[assertionId].marketId; // do nothing on disputes, just emit the event. We wait for the assertion to be resolved (truthfully or not) to close the loop. emit AssertionDisputed(marketId, assertionId, block.timestamp); } -
L-02 Low There's No way To Deactivate A Prediction Market Configuration Resolved
Description
PassiveLiquidityVault.approveFundsUsage()adds prediction markets to theactiveProtocolsset, but there is no way to remove a protocol from it.Whenever
_deployedLiquidity()is called, it will loop through all of the prediction markets, even if they are already not in use, increasing the gas usage and the risk of encountering an OOG exception.Furthermore, if
pm.getUserCollateralDeposits()reverts for any of these markets, all flows usingdeployedLiquidity()will experience DOS.Recommendation
Consider adding a way to remove protocols from the set.
-
L-03 Low ERC4626 Standard Broken Best Practices Resolved
Description
The
PassiveLiquidityVaultinherits the ERC4626 standard but does not follow it's rules.Recommendation
Consider to follow the rules of the ERC4626 standard or remove the inheritance.
-
L-04 Low requestWithdrawal Allowed During Emergency Validation Resolved
Description
The normal
requestWithdrawalfunction is still allowed if theemergencyModeis active. Depending on the emergency situation this may result in unexpected behavior or may even allow an exploit.Recommendation
Consider to add the
notEmergencymodifier to therequestWithdrawalfunction. -
L-05 Low Missing whenNotPaused Modifiers Best Practices Resolved
Description
The
cancelDepositandcancelWithdrawalfunctions do not have awhenNotPausedmodifier and therefore it is not possible to stop them.This could turn out bad, especially if there is a vulnerability in the
cancelDepositas it transfers funds to the user.Recommendation
Consider to add
whenNotPausedmodifiers to these functions. -
L-06 Low notProcessingRequests Is Redundant Gas Optimization Resolved
Description
The
notProcessingRequestsdoes the same as thenonReentrantmodifier and is therefore redundant.Recommendation
Consider to remove the
notProcessingRequestsmodifier -
L-07 Low onlyManager Modifier Not Used Gas Optimization Resolved
Description
The
onlyManageris not used and instead it's logic is written out in the process functions.Recommendation
Consider to use the
onlyManagermodifier in the process functions. -
L-08 Low Missing orderDeadline Validation Validation Resolved
Description
Users can set any value as
orderDeadline. If a user is not aware that the order can not be canceled before this timestamp passed and just uses the maximum uint all funds are permanently stuck if the order will not be matched.Recommendation
Consider to validate the
orderDeadlineparameter against a maximum. -
L-09 Low Missing Overrides DoS Resolved
Description
The
PassiveLiquidityVaulttries to overwrite and revert in allERC4626functions, but forgot about the convert functions:- convertToShares
- convertToAssets
Recommendation
Overwrite the convert functions as they will DoS anyway because the
totalAssetsfunction reverts. -
L-10 Low PassiveLiquidityVault Deposits Can Be Prevented DoS Acknowledged
Description
The
PassiveLiquidityVaultdoes not perform normal share price calculations on chain and instead a bot decides if the user's wished assets to shares ratio is allowed or not.This allows anyone to DoS deposits with a front run, for example:
- The vault is empty
- Eve requests to deposit 100 assets and receive 100 shares in return
- The manager processes eve’s transaction
- share price is now 100 / 100 = $1 per share
- Bob requests to deposit 100 assets and receive 100 shares in return (so in the current ratio)
- Eve front runs bob’s transaction and transfers 1 asset into the contract
- Share price is now 101/100 = $1.01 per share
- As bob tries to buy shares in a 1:1 ratio (cheaper than they currently are) the manager will not accept bob’s order
The same can happen accidentally when the vault receives profit from a settled prediction.
Recommendation
Consider to make users aware of that so they always calculate with a realistic slippage value.
-
L-11 Low Critical State Change Without Event Best Practices Resolved
Description
The
toggleEmergencyModefunction performs a critical state change but does not emit an event.Recommendation
Consider to emit an event to follow best practices.
-
L-12 Low approvedAsserters Is Immutable Best Practices Acknowledged
Description
There is not function to update the
approvedAssertersmapping after the deployment of thePredictionMarketUmaResolver.Recommendation
Consider to add a function to be able to update it if needed.
-
L-13 Low Bond Currency In
UmaResolverConfiguration AcknowledgedDescription
The
UMAoracle accepts a bond payment only if the given token is whitelisted.if (cachedCurrencies[currency].isWhitelisted) return true;PredictionMarketUmaResolverusesconfig.bondCurrencyas bond token.IERC20 bondCurrency = IERC20(config.bondCurrency);The
configis set only once during deployment. If theUMAoracle removes the asset from the whitelist, the resolver contract becomes unusable.Recommendation
Add ownership feature and a function to change the
config -
L-14 Low Centralization Risk Warning Acknowledged
Description
The
managerof thePassiveLiquidityVaultis able to rug thePassiveLiquidityVaultin multiple ways. For example by creating a request to withdraw all assets of the vault for 1 share and processing it, or by usingapproveFundsUsageto get all funds out of the contract.This is a centralization risk that could have very bad consequences if the private key behind this EOA falls into the wrong hands.
Recommendation
Be aware of this risk and consider to add more checks in the process functions to limit the power of the
manager.For example by calculating the estimated share value with on chain conditions and comparing it with the deposited or withdrawn asset amount to make sure they are not completely off.
Or for example by limiting the amount of funds that can withdrawn in a given time span.
Limiting the power of the
managerto not rug the vault in one transaction gives the owner a bit of time to pause the contract in an emergency situation. -
L-15 Low Funds Are Frozen If Prediction Can't Be Settled Logical Error Acknowledged
Description
In the case that a prediction can't be settled, for example as it's a bet on which sports team will win but the game is canceled. The prediction can't be burned and the funds of the users are permanently frozen.
Recommendation
Consider to add a deadline to predictions at which it can be canceled so that both parties get their funds back.
-
L-16 Low Wrong Default Expiration Time Configuration Resolved
Description
All comments talk about a default expiration time of 10 minutes, however it is set to 2 minutes instead, which may be too less.
Recommendation
Consider to update it to 10 minutes.
-
L-17 Low
MIN_DEPOSITIs Not Flexible Configuration ResolvedDescription
The
MIN_DEPOSITconstant inPassiveLiquidityVaultis hardcoded to 100e18. This is not sufficient for supporting wide range of tokens as different tokens have different value and decimals.Recommendation
Consider having this variable configurable.
-
L-18 Low
requestWithdrawalCan Be Partially DOSed DoS ResolvedDescription
The
PassiveLiquidityVault.requestWithdrawal()function requires the withdrawn shares to be more thanMIN_DEPOSITunless the users performs a full withdrawalif (shares < balanceOf(msg.sender) && expectedAssets < MIN_DEPOSIT) revert AmountTooSmall(expectedAssets, MIN_DEPOSIT);An attacker can leverage this check to DOS honest user withdrawals. For example, if
share:asset = 1and Alice tries to withdraw 20e18 shares, she should receive 20e18 assets. Now Bob can frontrun her transaction with a 1 wei donation and make the transaction fail because Alice now has20e18 + 1shares.Recommendation
Consider adding a function which always performs a full withdraw request and also accepts
maxSharesparameter to ensure the redeemed shares are not exceeding a given amount specified by the user. -
L-19 Low Shares Requested For Withdrawal Unexpected Behavior Resolved
Description
During
requestWithdrawal(), the shares being withdrawn are not burned or taken out from the account of the user. This allows the holder to transfer them after the request is created. By transferring the shares to different accounts, users can sort of bypass theInsufficientBalance()check and create a lot of pending requests.When
processWithdrawal()is invoked, the transaction will revert if the user balance is not sufficient. The users who know the shares can be transferred have an edge over the other participants in case a sudden price spike happens from the time the request was submitted until it's executed. They can just transfer the shares out of their account and leave the rest of the users bear a larger loss.Furthermore, if the manager batches several calls to
processWithdrawal()together such reverts can temporarily DOS the execution of other withdrawals.Recommendation
Consider if shares must be locked when
requestWithdrawal()is executed. -
L-20 Low
MIN_DEPOSITCan Be Bypassed Validation AcknowledgedDescription
The
MIN_DEPOSITcheck during withdrawals can be bypassed by using different accounts. Users can transfer 1 wei of shares to many different accounts and request withdrawal from them. Because 1 wei is their whole balance, the check will be successfully bypassed and they will be able to spam the withdrawal queue.Recommendation
Reconsider if the current check is good enough for your goals.
-
L-21 Low
approveFundsUsage()Can Use Up All Vault Funds Trust Assumptions ResolvedDescription
In
approveFundsUsage()there is validation performed to ensure the vault utilization after funds are approved doesn't surpass themaxUtilizationRateuint256 newUtilization = ((deployedLiquidity + amount) * BASIS_POINTS) / totalAssetsValue; if (newUtilization > maxUtilizationRate) revert ExceedsMaxUtilization(newUtilization, maxUtilizationRate);However, the real utilization increases only after funds are actually pulled from the contract. If a second approval to a different protocol is executed before the previous one pulled the funds, it's possible to use up all the funds. For example:
- Vault has 1000 assets
approveForFunds()gives 800 approval to protocol A (80%)approveForFunds()gives 200 approval to protocol B (20%)
If there are no funds pulled in between the two calls, both of them will succeed and the utilization will end up being 100%, leaving the vault with no assets.
Furthermore, the arbitrary
protocolparameter gives a lot of power to the manager as they can technically provide a malicious contract and drain the whole vault, which can be catastrophic if the manager account becomes compromised.Recommendation
- Include the
allowanceof each protocol towards thedeployedLiquidityvalue as they are funds that can be pulled at any time. - Consider having either immutable protocols or at least make sure the caller of
approveFundsUsage()is a proper multisig.
-
L-22 Low Modifying Vault Time Variables Affect Requests Configuration Acknowledged
Description
When
setInteractionDelay()andsetExpirationTime()functions of thePassiveLiquidityVaultare executed, the storage variablesinteractionDelayandexpirationTimeare being changes.When a request is created, the
block.timestampis saved inpendingRequests[user].timestampandlastUserInteractionTimestamp[user]. On further interactions,expirationTimeandinteractionDelayare added towards these timestamp to ensure the needed delay has passed.Modifying one of the two variables will affect all pending requests.
Recommendation
Consider adding the
expirationTimeandinteractionDelaywhen creating the request or performing an interaction and then only check if the timestamp has passed. -
L-23 Low UMA Settlement Is Not Marked As Settled Error Resolved
Description
When UMA resolves an assertion, the
PredictionMarketUmaResolverupdates the WrappedMarket as settled but does not setumaSettlements[assertionId].settledto true. Although on-chain logic currently does not use this flag, the stored struct can mislead off-chain indexers or future logic that might rely on it, creating inconsistent state.if (assertedTruthfully) { market.settled = true; market.resolvedToYes = umaSettlements[assertionId].resolvedToYes; // Missing: umaSettlements[assertionId].settled = true; }Recommendation
Set
umaSettlements[assertionId].settled = trueinassertionResolvedCallback()whenassertedTruthfullyis true. -
I-01 Informational Missing Min Collateral Check For Taker Validation Acknowledged
Description
The maker collateral amount is checked to be >=
minCollateralbut the taker collateral is not.Recommendation
Consider to check both the maker and taker collateral to be at least the
minCollateral. -
I-02 Informational Yield Is Missed Rewards Acknowledged
Description
The system works with USDe as collateral but doesn't stake any of it and therefore misses a lot of yield.
Recommendation
Consider to stake USDe to be more capital efficient.
-
I-03 Informational Changing Managers Impacts Signatures Signatures Acknowledged
Description
The manager in
PassiveLiquidityVaultwill sign messages to deploy the vault liquidity to prediction markets. Changing the manager viasetManager()will cause:- all signed, but not yet executed messages to become invalid
- all previously signed messages by the new manager to become valid
Recommendation
Keep that in mind before using
setManager() -
I-04 Informational Unused Code Superfluous Code Resolved
Description
There is unused code in multiple parts of the system.
Imports:
OwnableandISapienceStructsin thePredictionMarketcontractIPredictionStructsin thePassiveLiquidityVaultcontract
Errors:
PassiveLiquidityVaultOnlyOwnerInvalidIndexProcessingInProgressInvalidCaller
PredictionMarketUmaResolverMarketNotDisputedMarketNotOpenInvalidCallerMarketAlreadyWrappedMarketNotSettled
Recommendation
Consider to use or remove unused code.
-
I-05 Informational PassiveLiquidityVault Prediction Exposure Risk Warning Acknowledged
Description
The
PassiveLiquidityVaultacts as something similar to a market maker in the sense that it takes on the opposite side of a bet.As the quotes for the vault (market price) will always be a bit better than for the user and as the vault has open positions on both sides of many different predictions. It should sum up in the end and the vault should make profit under normal conditions.
However if the vault freely allows users to open bets with any liquidity amount the vault has available than there is a big risk of having too much exposure on a single bet or just a few bets.
For example if the vault owns $
150k and someone creates a big bet that locks up$100k and the bet is lost than the vault lost a significant share of it's liquidity.Recommendation
Be aware of this risk and make sure that the amount of liquidity the
PassiveLiquidityVaultprovides to to a single bet is limited based on the amount of liquidity the vault owns.Exposure always needs to be distributed enough or otherwise the vault could quickly lose a lot of it's funds with just a bit of bad luck.
-
I-06 Informational The Vault Doesn't Support Any Token Informational Acknowledged
Description
During the kickoff call, it was mentioned that the vault is expected to work with any ERC20 token, but the current implementation doesn't support:
- tokens with
decimals != 18because ofMIN_DEPOSIT - tokens with fee on transfer features
Recommendation
Keep that in mind before deploying.
- tokens with
-
I-07 Informational
MIN_DEPOSITIs Inacurate Name Best Practices ResolvedDescription
The
MIN_DEPOSITconstant inPassiveLiquidityVaultis a minimum amount of tokens that has to be requested during deposits and withdrawals. Currently the name suggests it will be only used on deposits.Recommendation
Consider changing the constant to
MIN_REQUEST_AMOUNT -
I-08 Informational Utilization Rate Calculation Can Be In
WADRounding ResolvedDescription
Currently the utilization rate is represented in
BASIS_POINTS = 1e4. In result, there is a precision loss of up to0.01%happening.Recommendation
You can use
WADinstead ofBASIS_POINTSfor the utilization calculation to reduce the precision loss experienced. -
I-09 Informational Unnecessary
makerWonCondition Best Practices ResolvedDescription
At the end of
PredictionMarketUmaResolver.resolvePrediction()it's checked if there are any unsettled markets.if (isValid && hasUnsettledMarkets && makerWon) { // No decisive loss found, but at least one market is unsettled isValid = false; error = Error.MARKET_NOT_SETTLED; }The
makerWonis unnecessary - it's always gonna be true because the only code path that sets it tofalsereturns afterwards.Recommendation
You can remove
makerWonfrom the if condition. -
I-10 Informational Repeated Storage Reads In
PassiveLiquidityVaultGas Optimization ResolvedDescription
The
PassiveLiquidityVault.requestWithdrawal()function andPassiveLiquidityVault.requestDeposit()read the same storage-backed values multiple times within a single execution path. Specifically, pendingRequests[msg.sender] is accessed twice to read fields in both functions andbalanceOf(msg.sender)is used three times inrequestWithdrawal(). Each access incurs an SLOAD. Since these values do not change within the function prior to use, caching them in local variables would reduce gas usage.Recommendation
Cache storage reads into local variables and reuse them. For example:
- uint256 bal = balanceOf(msg.sender); (for
requestWithdrawal) - PendingRequest storage req = pendingRequests[msg.sender]; (for both functions)
Then use bal and req consistently throughout the function.
- uint256 bal = balanceOf(msg.sender); (for
-
I-11 Informational Redundant Assignments Gas Optimization Resolved
Description
Several state variables are initialized with default values at declaration and then set again in the constructor to identical values. This causes unnecessary SSTOREs and gas usage without changing behavior.
uint256 public maxUtilizationRate = 8000; // 80% uint256 public interactionDelay = 1 days; ... constructor(...) { ... manager = _manager; maxUtilizationRate = DEFAULT_MAX_UTILIZATION_RATE; // same as 8000 interactionDelay = DEFAULT_INTERACTION_DELAY; // same as 1 days }Recommendation
Remove the constructor assignments for maxUtilizationRate and interactionDelay if they already equal the
DEFAULT_*values. -
I-12 Informational Unused Custom Error Best Practices Resolved
Description
The custom error
OnlyOwner(address caller, address owner)is declared inPassiveLiquidityVault, but never used in any revert paths. Unused custom errors add noise and bloat the bytecode.Recommendation
Remove the unused error.
-
I-13 Informational Unused
OwnableImport Best Practices ResolvedDescription
The
PredictionMarket.solfile imports OpenZeppelin'sOwnable, but the contract does not inherit from it nor reference any symbols from that file. Unused imports increase code noise and can mislead reviewers into assuming ownership controls exist.Recommendation
Remove the unused import.
-
I-14 Informational Arbitrary Resolvers Allowed Warning Acknowledged
Description
PredictionMarket._createPrediction()can be executed with an arbitraryresolver. This allows the usage of malicious resolvers which may revert on resolution or control who wins the prediction.Recommendation
Make sure the takers, especially the vault, check the resolver before taking an order.
-
I-15 Informational An Already Settled Market Can Be Used Validation Acknowledged
Description
PredictionMarketUmaResolver.validatePredictionMarkets()doesn't check if the given market of an outcome has already been settled. This allows the creation of parleys with an already known outcome.Recommendation
Consider validating if the market is settled
Remediation Review
12 findings · October 15 to 24, 2025-
M-01 Medium Missing Manager Compensation Logical Error Acknowledged
Description
The
managerhas to pay gas to process requests but is not compensated to do so.Recommendation
Consider to let users pay a gas fee to the manager.
-
L-01 Low Wrong Util Ratio Check Validation Resolved
Description
The
approveFundsUsagefunction tries to check if the utilization ratio after this approval exceeds the configuredmaxUtilizationRate.However the calculation is only based on the total approvals to markets and excludes the already deployed liquidity (
getUserCollateralDeposits).Recommendation
Include the funds from the
getUserCollateralDepositscalls in this calculation. -
L-02 Low Approval Not Adjusted On Withdraws Validation Resolved
Description
Approvals of the vault to the given prediction markets needs to be configured manually with the
approveFundsUsage. As it is not adjusted automatically when users exit the system it is possible that unconfirmed assets are utilized.For example:
- eve requests a deposits of 1000 assets
- the deposit is processed
- 80% so 800 assets are approved to the prediction market
- alice requests a deposit of 500 assets
- eve front runs the transaction and requests a withdraw of 999 assets
- alice deposit request goes through
- eve’s withdraw request is processed
- eve creates a prediction and 501 assets are pulled from the vault (possible as the approval still exists from when eve had 1000 assets in the vault)
- now all of alice funds are used but she has no shares in the vault which means if her deposit fails all of her funds were stolen / belong to eve now as she owns 1 share in the system
Recommendation
Consider to automatically decrease the utilization rate after a withdrawal if the available assets < the max utilization rate.
-
L-03 Low Potential Risks Because Of
_verifyTransfer()Unexpected Behavior ResolvedDescription
When the
PredictionMarketNFTs are transferred between accounts, the_update()function callsto.supportsInterface()if thetoaddress has code.if (addr.code.length == 0) { return false; } // Use ERC-165 standard interface detection try IERC165(addr).supportsInterface(type(IPassiveLiquidityVault).interfaceId) returns (bool supported) { return supported; } catch { return false; }This allows any account to reject transfers, even if they were executed via
transferFrom()and notsafeTransferFrom(). There is a check for the size of the code of the account, however since EIP-7702, EOAs can have code associated with them to be executed when they are called. Therefore, any EOA or a contract can reject a transfer.The
supportsInterface()also gives the control flow to thetoaddress even whentransferFrom()is used, which may be unexpected, since most NFTs are not doing it.Furthermore,
_verifyTransfer()is executed before all changes inPredictionMarket._update()are applied. This can lead to loss of funds for users in some very specific edge cases. For example:- Alice and Bob have 5000 collateral each
- they are respectively a maker and taker for 1000 collateral
- Prediction is settled and taker wins
- Alice transfers the maker NFT to Bob
- Bob uses the hook to call
burn() burn()will reduce both of their collaterals with 1000- the rest of the
_update()function will reduce Alice's collateral with 1000 more and will add it to Bob
In result Alice lost her collateral twice.
Recommendation
Follow the CEI pattern in the
_update()function and be very explicit in the documentation thattransferFrom()gives the control flow to the recipient, so third party integrators are prepared for any related risks. As an additional safety measure, you can have the gas forwarded for thesupportsInterfacecall capped. -
I-01 Informational Unused Code Superfluous Code Resolved
Description
There are unused errors in the
PredictionMarketUmaResolver:MarketNotDisputedMarketNotOpenInvalidCallerMarketAlreadyWrappedMarketNotSettled
Recommendation
Consider to remove the unused errors.
-
I-02 Informational CEI Not Followed In
PredictionMarket.mint()Best Practices ResolvedDescription
The
CEIpattern is not followed in thePredictionMarket.mint()function. It first checks the nonce, then callsisValidSignature()and finally updates the maker's nonce. While this should be generally safe, becauseisValidSignature()is called on the taker, it increases the risk of signature replays if some taker has non-standard implementation.Recommendation
Consider updating the nonce immediately after checking it.
- if (mintPredictionRequestData.makerNonce != nonces[mintPredictionRequestData.maker]) { + if (mintPredictionRequestData.makerNonce != nonces[mintPredictionRequestData.maker]++) { revert InvalidMakerNonce(); } -
I-03 Informational Already Settled Market Can Be Matched Warning Acknowledged
Description
As a fix for M-06 orders are now cancellable at any point in time, not only after they expire.
While this protects users from having their orders executed unexpectedly, the
mint()function is still susceptible to that.Recommendation
Acknowledge or add a fix for
mint()as well. -
I-04 Informational
IERC721ReceiverIs Not Considered Supported Informational ResolvedDescription
When
PassiveLiquidityVault.supportsInterface()is called for theIERC721Receiverinterface, the result will befalsebecause that interface is not included in the function, even though the vault implements it.Recommendation
Consider including the interface in the
supportsInterface()function. -
I-05 Informational Inaccurate Comment Best Practices Resolved
Description
The comment above
PassiveLiquidityVault.decimals()says that an offset is added to the underlying asset's decimals, but that is not true as the function returns_underlyingDecimalsunchanged.* @dev Decimals are computed by adding the decimal offset on top of the underlying asset's decimals. This * "original" value is cached during construction of the vault contract. If this read operation fails (e.g., the * asset has not been created yet), a default of 18 is used to represent the underlying asset's decimals. * * See {IERC20Metadata-decimals}. */ function decimals() public view virtual override returns (uint8) { return _underlyingDecimals; }Recommendation
Consider changing that part of the comment.
-
I-06 Informational Ineffective Optimization Gas Optimization Resolved
Description
The code was updated in a way that when a deposit or withdrawal request is performed in the
PassiveLiquidityVault, each field ofpendingRequestis updated separately.request.user = msg.sender; request.isDeposit = true; request.shares = expectedShares; request.assets = assets; request.timestamp = block.timestamp; request.processed = false;This version of the code uses 5
SSTOREcompared to using only 1 with the previous one.Recommendation
Consider storing everything at once.
-
I-07 Informational User Balance May Drop Below Their Locked Shares Informational Acknowledged
Description
The
_update()function inPassiveLiquidityVaultwas overriden to prevent transfers of locked shares. However, there is a way for the user balance to drop below these locked shares during emergencies. For example:- User has 1000 shares
- They request a withdrawal of 500 shares
- Emergency mode activates and they withdraw their whole balance
- Now their balance is 0, but the locked shares are still 500
The user would have to clear their withdrawal request manually when the contract is not paused.
Recommendation
No code changes needed, but would be good if it's documented.
-
I-08 Informational Ambiguous Event Emission Events Resolved
Description
The
UtilizationRateUpdatedevent is emitted whensetMaxUtilizationRatechanges the maximum utilization or whenapproveFundsUsage()is called. These are two different use cases, but the event emitted is the same.For example:
- Admin sets the utilization to 80%, therefore
UtilizationRateUpdated(0, 80e18)is emitted - 600 of 1000 funds are approved, so
UtilizationRateUpdated(0, 60e18)is emitted
Notice that for the last emission, the second parameter is the projected utilization, not the actual one.
Recommendation
Consider separating the two use cases to emit different events.
- Admin sets the utilization to 80%, therefore
No findings match.
More from Sapience
All 6 reports-
LayerZero Composer
7 findings 7 findings: 5 low, 2 informational -
Sapience
87 findings8 high 87 findings: 8 high, 18 medium, 30 low, 31 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.
