Organization
- @Eco-Inc
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Low Risk
2 findings
2 fixed
0 acknowledged
Informational
1 findings
1 fixed
0 acknowledged
Gas Optimizations
1 findings
1 fixed
0 acknowledged
Medium Risk1 finding
Solver can grief refunds by setting a bad claimant
State
- Fixed
PR #360
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: High Submitted by
Rikard Hjort
Summary
The solver can opt to forego rewards in exchange for also blocking refunds, keeping funds stuck in a Vault indefinitely.
Finding Description
In
Vault.sol, part of the withdrawal process is sending native tokens to the claimant viacall. This can fail if the solver has chosen a contract as claimant (or delegated the receiving address to a contract via a EIP-7702 delegation) and that contract reverts. The solver could do so maliciously. The result is that withdrawals will always fail, and thus, so will refunds, because refunds depends on the intent either being unfulfilled or withdrawn.There seems to have been an assumption that solvers cannot block withdrawals because
withdraw()is callable by anyone. But this assumption fails to take griefing into account.Impact Explanation
The creator can not receive any refunds on an intent. They will, however, have the intent fulfilled. Thus the impact is limited to any additional funds that have been submitted to the Vault.
Likelihood Explanation
This attack works on any intent which specifies a non-zero native reward amount. It is profitable or meaningful only for an Vault with an oversupply of rewards. The solver does not benefit from the attack, they only lose by having to perform the complete fulfillment of the intent and foregoing the reward. However, the attack is technically easy to perform and can be triggered even after fulfillment, for any number of Vaults simultaneously, by choosing a claimant address which they have a private key for and EIP7702-delegating, or by having it be a contract address which is already deployed or which they deploy at a later time, which reverts for any reason.
Recommendation
Use Solady
forceSafeTransferETH()or similar to prevent the distribution of ETH to fail.If the chain does not support
selfdestructas needed byforceSafeTransferETH(), simply remove the reverting condition. If the call fails, leave the native tokens in the contract.
Low Risk2 findings
Duplicate tokens in route causes flashFulfill() to fail due to overwritten approvals
Description
In
LocalProver.flashFulfill(), the contract approves the portal (_PORTAL) to spend tokens before calling_PORTAL.fulfill(). The approval is done in a loop iterating overroute.tokens:for (uint256 i = 0; i < tokensLength; ++i) { IERC20(route.tokens[i].token).approve( address(_PORTAL), route.tokens[i].amount );}The root cause is that
approve()sets an absolute allowance rather than incrementing it. If the same token address appears multiple times inroute.tokens[], each subsequentapprove()call overwrites the previous approval amount instead of accumulating them.When
_PORTAL.fulfill()is subsequently called, theInbox._fulfill()function executessafeTransferFrom()for each token entry. Since the approval only reflects the last occurrence's amount rather than the cumulative total, the transfer will revert with insufficient allowance when attempting to transfer duplicate token entries.Note that this issue only affects
flashFulfill(). In the normalfulfill()case where the solver directly calls the portal, tokens are transferred directly frommsg.senderwithout intermediate approvals, so duplicate tokens are handled correctly.Recommendation
Replace
approve()withsafeIncreaseAllowance()from OpenZeppelin's SafeERC20 library to accumulate allowances for duplicate token addresses.reward.prover can be different from the currently executing LocalProver
State
- Fixed
PR #359
Severity
- Severity: Low
Submitted by
Rikard Hjort
Description
It is possible to call
flashFulfill()with an intent specifying a different prover than theLocalProver. In that case,provenIntents()will be called on some other contract, creating a different flow. The user can set this to be any contract of their choosing, including a malicious one which can return anything on aprovenIntents()call. This creates a hard-to-reason-about scenario and a potential footgun. However, there are no known vulnerabilities caused by this.Recommendation
Add a check to ensure the only flow in
flashFulfill()is the expected one, with a callback to the prover itself:require(reward.prover == address(this));
Informational1 finding
Incorrect comment
State
- Fixed
PR #357
Severity
- Severity: Informational
Submitted by
Rikard Hjort
Description
The linked comment marks tokens transfers as "effects" in the "Checks, Effects, Interactions" pattern. They are interactions, not effects. However, they are still in the correct location in the code -- after all the effects.
Recommendation
- // EFFECTS - Transfer remaining funds to claimant+ // INTERACTIONS - Transfer remaining funds to claimant
Gas Optimizations1 finding
_flashFulfillInProgress can be made transient
State
- Fixed
PR #356
Severity
- Severity: Gas optimization
Submitted by
Rikard Hjort
Description
_flashFulfillInProgressis set to the currently flash-fulfilling intent hash in order to create special behavior inprovenIntents(). The same intent can not be fulfilled twice, andflashFullfill()cannot be reentered, so the same intent will not haveprovenIntents()called on it twice by the actual portal. Therefore the storage variable does not strictly need to be cleared after each transaction.However, leaving it set to a valid intent hash slightly complicates reasoning about attack patterns, and it would be recommended to reset it to some safe default value after each transaction, e.g.
0or1, which will not be valid intent hashes.This is an excellent use case for transient storage, which is cheaper to write to than regular storage, and is reset after each transaction.
Recommendation
Verify that all chains you are using support transient storage.
(If they do not, clear
_flashFulfillInProgressat the end of each transaction, for example to a simply chosen but non-zero value likebytes32(1), to avoid code smell.)Then:
In
LocalProver.sol:// SPDX-License-Identifier: MIT-pragma solidity ^0.8.13;+pragma solidity ^0.8.28; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";// ... * @dev Used to enable withdrawal during flashFulfill execution (before Portal.claimants is set) * Only one flashFulfill can execute at a time due to nonReentrant modifier */- bytes32 private _flashFulfillInProgress;+ bytes32 transient private _flashFulfillInProgress; constructor(address portal) { _PORTAL = IPortal(portal);In
foundry.toml:[profile.default]-solc_version="0.8.27"+solc_version="0.8.28" src = "contracts" out = "out" fs_permissions = [{ access = "read-write", path = "./out"}]# ... optimizer_runs = 1000000 via_ir = true retries = 2-evm_version = "paris"+evm_version = "cancun" cbor_metadata = false bytecode_hash = "none"