Organization
- @definitive-finance
Engagement Type
Cantina Reviews
Period
-
Researchers
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Low Risk
6 findings
5 fixed
1 acknowledged
Informational
6 findings
4 fixed
2 acknowledged
Medium Risk1 finding
Client Admin can pull tokens through vault reentrancy
State
- Fixed
PR #1413
Severity
- Severity: Medium
Submitted by
Valerian Callens
Description
The contract
DefinitiveFlashAllowanceis expected to prevent any unexpected token pulls that would not be used to perform a flash swap, and does so with:- a modifier
onlyPerformerallowing calls only whentx.origin == performer; - a verification that the value of
msg.sendermatches the value of the fieldorder.vaultin the order object (FlashOrderorQuickOrder) signed by the owner of the funds;
Still, it is possible for a Client Admin (owner of the integrator vault) to request the contract
DefinitiveFlashAllowanceto pull tokens directly, without using it for a swap, through reentrancy triggered when receiving tokens, because the functionBaseAccountAbstraction::executeBatch()does not reject reentrant calls. Here are the steps:- As first precondition, a flash order signed by Alice (for example: 10 WETH) should be partially filled (for example: 1 WETH), so the order and the associated signatures are visible on-chain;
- As second precondition, an address called Bob with the Client Admin role for a given integrator vault needs to set a function
attack()with a custom logic that will execute the functionBaseAccountAbstraction::executeBatch(). The Client Admin can do so in at least three ways:
- grant the
DEFAULT_ADMIN_ROLEto a contract with that functionattack(); - as upgradeable contract, he can update the contract to another implementation including the function
attack(); - as EOA, he can use an EIP-7702 delegation to a delegated contract with that function
attack();
- Bob signs a
FlashOrderwith an output token being either native asset, or an ERC-777 token (supporting hooks on transfers) to be sent to Bob as swap recipient (or any intermediary address further forwarding the call to Bob) that will react when receiving tokens with a call toBob.attack(); - Bob's order is processed by a performer who submits it via the function
BaseFlashSwap::flashSwap()of Bob's integrator vault. Since the function is protected by the modifiernonReentrant, the reentrancy flag is now enabled on the vault. - During the swap execution, the recipient set by Bob with a custom logic on token reception triggers the logic, which executes the function
Bob.attack(); - The function
Bob.attack(), executed with Bob asmsg.sender, meaning with the Client Admin role on Bob's integrator vault will call the functionBaseAccountAbstraction::executeBatch()which is not using the modifiernonReentrant, with the batch of calls:[Call_1 - DefinitiveAllowance::pullFlashAllowance(order: orderOfAlice, signature: signatureOfAlice, pullAmount: 9 WETH), Call_2 - WETH:transfer(Bob, 9 WETH)]
That scenario is possible, for the following reasons:
Call_1is allowed because the functionBaseAccountAbstraction::executeBatch()allows any method to be executed by Client Admins, and does not use the modifiernonReentrant;Call_2transfers the pulled amount outside of the integrator's vault.- If executed as part of a legit performer call, the modifier
onlyPerformerwill pass in the functionpullFlashAllowance(). In that case,msg.senderwill be Bob's vault, which matches what was allowed in Alice's order. - And what was not yet swapped for Alice's order is transferred to Bob's vault.
As impact, it would mean that any partially filled order would be at risk of being drained by the allowed but malicious integrator.
Note that the likelihood here depends on an integrator vault admin getting compromised, and its capability to introduce a custom logic able to leverage the reentrancy attack vector. Still, such attacks would be hard for the performer to detect before the fact, which could justify a Medium severity.
Recommendation
Consider adding the modifier
nonReentrantto the functionsBaseAccountAbstraction::execute()andBaseAccountAbstraction::executeBatch().Definitive Finance
Fixed in PR 1413 (commit
3fbd9929dce33a823a47776c2fa966f77d42dbea).Cantina Managed
Fixes verified. The functions
BaseAccountAbstraction::execute()andBaseAccountAbstraction::executeBatch()are now using the modifiernonReentrant.
Low Risk6 findings
Temporary DOS of pullFlashERC2612() function is possible
State
- Fixed
PR #1399
Severity
- Severity: Low
Submitted by
Chinmay Farkya
Description
In
DefinitiveFlashAllowance.sol, there are multiple ways to pull funds from theorder.swapper(user who placed the order). One of these functions uses ERC-2612 permit logic to directly execute a permit signature fetched from user on theinputTokencontract:IERC20Permit(order.fromToken).permit( order.swapper, address(this), auth.value, auth.deadline, auth.v, auth.r, auth.s );The issue here is that anyone can just use the signature parameters, and front-run a legitimate transaction executing the function
flashSwap()with the optionpullFlashERC2612(), by directly executing the functionfromToken.permit()on the token contract.This will consume the permit signature and lead to a failure of the actual call to
flashSwap()pushed on-chain by Definitive performer address.Ethereum Mainnet is in scope, so such an attack is possible by an attacker, to temporarily delay flash swap execution.
Recommendation
Consider using a try-catch approach for this logic, such that even if the permit call fails due to a permit signature already being used, the logic in the function
pullFlashERC2612()can continue the execution.Definitive Finance
Fixed in PR 1399 (commit
8d1c1fd6c71e5b1503207475b4f7e7b4be74ce0d).Cantina Managed
Fixes verified. The function
pullFlashERC2612()uses now a try-catch block.Inconsistent usage of _fetchFeeAccount() for the management of input fees
State
- Fixed
PR #1402
Severity
- Severity: Low
Submitted by
Valerian Callens
Description
In the functions
BaseTransfersNativeInitiable::withdrawToV2()andBaseFlashSwap::_executeFlashSwap(), the code uses the function_fetchFeeAccount()for theinputFees.However, it is not the case in the function
BaseSimpleSwapInitiable::swapV2()that directly usesFEE_ACCOUNT()instead of_fetchFeeAccount(). As a result, that flow will not treat differently the case when the input token is the native asset and the value ofmsg.senderis not theEntrypoint;Recommendation
Consider aligning the behavior of these three functions.
Definitive Finance
Addressed in PR 1402 (commit
0c7d7c22d297486b08cd632b5bc06344227a6439).Cantina Managed
Fixes verified. The behavior of the function
swapV2()regarding the fee account is now aligned with what is done in the functionswithdrawToV2()and_executeFlashSwap().Function isValidSignature() not gated by modifier stopGuarded
State
- Fixed
PR #1406
Severity
- Severity: Low
Submitted by
Valerian Callens
Description
Deployed trading vaults externally expose the functions defined in
BaseRecoverSignerInitiable. The filedocs/eip-1271.mdexplicitly describes the functionisValidSignature()as the on-chain authorization gate for CoW/Permit2/EIP-3009-style integrations.Also, a hard-stop for fund-moving actions exists, and is enforced through the modifier
stopGuarded, which reverts when either the globalSTOP_GUARDIANkey is disabled, or the local stop boolean is set. Every direct value-moving vault entry-point (flashSwap,withdraw*,validateUserOp,execute,executeBatch) uses that modifier. However, the vault’s ERC-1271 authorization path does not, which makes the functionBaseRecoverSignerInitiable::isValidSignature()externally callable without a check by the modifierstopGuarded.That leaves an economically equivalent fund-moving path triggered by an external actor with active valid allowance live under halt. A halted vault therefore continues returning the ERC-1271 magic value (
0x1626ba7e) for those external settlement requests, even while every other vault method is temporarily stopped.Recommendation
Consider adding the modifier
stopGuardedto the functionisValidSignature().Definitive Finance
Fixed in PR 1406 (commit
40fb1c88152377107f37d63ffa4e70bafb187af4).Cantina Managed
Fixes verified. The function
isValidSignature()is now using the modifierstopGuarded.Full consumption of inputAmount not checked in delegate swap mode
Severity
- Severity: Low
Submitted by
Valerian Callens
Description
The function
flashSwap()pullspullAmountinto the vault, but the swap path never verifies thatpayload.amountwas actually consumed.In particular, part of the delegate swap mode, the vault allows an external spender from the vault context, but the vault does not verify if
inputAmounthas been fully consumed. As a result, any route under-consuming the input token leaves:- a persistent allowance usable later by the external spender via ERC-20
transferFromwithout calling the vault, hence bypassing the checksstopGuarded,tradingEnabled, andonlyDefinitive; - these user-approved funds, becoming indistinguishable from integrator fees, accumulated in the vault, and that can be withdrawn by a Client Admin;
The root cause is that the vault only verifies output-side deltas and does not require full input consumption, so it does not reset any potential unspent allowances.
Note that the likelihood of that scenario is limited for two reasons:
- It requires swaps to under-consume the input amount;
- The Performer is trusted, and expected to only call trusted swap routers;
Recommendation
Consider a mechanism in the vault that will verify the full consumption of the input amount, and react with the following options: revert, reset the open allowance or isolate in the vault what should be considered as unspent user funds or integrator fees.
Definitive Finance
Fixed in PRs 1407 (commit
1f54c7cf69130360f2dd3bd9f9b5d802099a85eb), 1415 (13691897896691ab4bd3e1fe0873edd839e44e19) and 1417 (97c6e15b0e2ff8b0145610e6f0fc85acdadc1796).Cantina Managed
Fixes verified. The amount of input token consumed by the swap is now verified in the function
_executeFlashSwap(). Any unspent amount is sent back to the owner of the funds. Any potential open allowance remaining after the swap when not all input amount has been consumed is manually reset to0using the function_tryResetAllowanceToZero()with best effort, silently ignoring if reseting the approval reverts.Full consumption of inputAmount not checked in non-delegate swap mode
State
- Fixed
PR #1408
Severity
- Severity: Low
Submitted by
Valerian Callens
Description
After performing a swap in non-delegate mode, the function
swapDelegate()lets anyone sweep any unspent input owned byMetaSwapHandlerV2.In the non-delegate swap mode, the function
CoreSwapHandlerV1.swapCall()transfers the full declared input amount to the handler before the downstream route runs, but no check verifies that the route actually consumes that input or refunds any unused remainder. It means that in that mode, any unused portion of the user's pulled tokens will remain owned by the contractMetaSwapHandlerV2. After that, any external account can directly call the handler's unrestricted functionswapDelegate()to trigger a transfer of the handler's balance to that external caller.This breaks the flash-swap safety envelope, since user-approved funds that were supposed to be atomically swapped can be left in a public contract, and taken by arbitrary third parties.
Here is an example illustrating that scenario:
- An authorized performer executes the function
flashSwap()withpayload.isDelegate == false, so the vault goes through the handler's non-delegate swap mode path. - The function
_prepareAssetsForNonDelegateHandlerCall()approves the handler for the fullpayload.amount, andCoreSwapHandlerV1._swapCall()immediately pulls that amount from the vault. - After the handler execution, the function only checks the output-token delta, and only reports the observed input-token delta back to the vault. There is no verification that
params.inputAmountwas fully consumed, and no refund of any remainder to the vault, or user. - If the route spends less than the declared maximum input, which could happen for multiple reasons, the unused input remains owned by the contract
MetaSwapHandlerV2. - Any external address can then directly call the function
MetaSwapHandlerV2.swapDelegate(). That path has no access control and only requiresinputAmount != 0. When called directly, the functionMetaSwapHandlerV2._performSwap()executes the arbitrarycalldatausing the handler's own balances. - The external address calling can set the value of
underlyingSwapRouterAddressto the stranded token contract andswapDatatotransfer(caller, leftover). The handler will transfer these unused input tokens to the caller, and the call will not revert because the value ofparams.minOutputAmountcan be set to0.
Recommendation
Consider introducing a mechanism at handler's level that automatically reacts if any input amount is still owned by the handler after a non-delegate swap call.
Definitive Finance
Fixed in PR 1408 (commit
9b804d82c9bf7fe35b25a8edbaba3df470f087c6). We've removed the non-delegated swapCall functionality as it was deprecated functionality.Cantina Managed
Fixes verified. The contract
CoreSwapHandlerV1now only supports the delegate swap mode. The functions part of the non-delegate swap mode were removed, andSwapPayloadobjects withisDelegate == falseare now rejected by the function_processSwap().Client Admin can pull tokens through UserOp
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Valerian Callens
Description
A valid performer is expected and allowed to submit bundles of UserOps. In that case, any EVM call from that bundle will have
tx.origin == performer.The contract
DefinitiveFlashAllowanceis expected to prevent any unexpected token pulls that would not be used to perform a flash swap, and does so with:- a modifier
onlyPerformerallowing calls only whentx.origin == performer; - a verification that the value of
msg.sendermatches the value of the fieldorder.vaultin the order object (FlashOrderorQuickOrder) signed by the owner of the funds;
Still, it is possible for a Client Admin (owner of the integrator vault) to request the contract
DefinitiveFlashAllowanceto pull tokens directly, without using it for a swap, by submitting a malicious UserOp. Here are the steps:- Victim address Alice signs a valid
flashOrderof 10 WETH through the malicious integrator vault of Bob; - Valid performer submits a flash swap for a partial fill of 1 WETH using the function
pullFlashAllowance(). An open allowance of 9 WETH remains open. - Bob sees the valid order above with its associated signature, and submits a
UserOpthat callsBaseAccountAbstraction::executeBatch()with the batch of calls:[Call_1 - DefinitiveAllowance::pullFlashAllowance(order: orderOfAlice, signature: signatureOfAlice, pullAmount: 9 WETH), Call_2 - WETH:transfer(Bob, 9 WETH)]
That scenario is possible, for the following reasons:
Call_1is allowed because the functionBaseAccountAbstraction::validateUserOp()allows any method to be executed by Client Admins.Call_2transfers the pulled amount outside of the integrator's vault.- If executed within a bundler submitted by a legit performer as
tx.origin, the modifieronlyPerformerwill pass in the functionpullFlashAllowance(). In that case,msg.senderwill be Bob's vault, which matches what was allowed in Alice's order. - And what was not yet swapped for Alice's order is transferred to Bob's vault.
As impact, it would mean that any partially filled order would be at risk of being drained by the allowed but malicious integrator.
Note that the likelihood of that scenario depends on the off-chain controls performed by the performer when filtering UserOps that are added to the bundles.
Recommendation
Consider making sure that a performer rejects these particular UserOps when building bundlers.
Definitive Finance
Acknowledged with comment: We will never be processing arbitrary client actions via performers. We have dedicated non-performer relayers to do these.
Cantina Managed
Acknowledged.
Informational9 findings
Function _executeFlashSwap() returns an output amount before fees
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Valerian Callens
Description
The function
_executeFlashSwap()currently returns the output amount post swap (outputAmount). However, that value does not exactly reflect what is sent to the recipient, as output fees can be deducted fromoutputAmountbefore what remains (recipientAmount) is transferred to the recipient.Recommendation
Consider assessing whether the function
_executeFlashSwap()should return the value ofoutputAmountorrecipientAmount, and update the code accordingly.Definitive Finance
Acknowledged with comment: We intentionally view all values from the perspective of the trading vault, not what the end actor actually receives.
Cantina Managed
Acknowledged.
Consolidated suggestions to improve the documentation
State
- Fixed
PR #1401
Severity
- Severity: Informational
Submitted by
Valerian Callens
Description
Here are multiple items where what the documentation says could be clarified:
- In
DefinitiveFlashAllowance.sol, an inline comment could explain that the variableFlashAllowanceStorage.signatureBypassAllowed.solis only used for off-chain simulations, so that successful simulations for swaps can happen without needing to collect signatures beforehand. - In
BaseAccessControlInitiable.sol, a comment enumerates "Modifiers inherited from CoreAccessControl". The modifieronlyAdminsis not in the list. - In
DefinitiveFlashAllowance, the NatSpec says "This contract is used to allow a user to pull funds from a user account for a flash swap", which is misleading because only a performer address is allowed to originate flash swaps and only the vault address can pull funds.
Recommendation
Consider clarifying all the items above.
Definitive Finance
Fixed in PRs 1395 (commit
5aa3069233a3426e1fe52aef7832ae09fb1bc3b4), 1397 (commitf2b5b37e5135a38612cb90ecf4ee7af20c949cf6) and 1401 (commit23b2bfd512b0525b77c649cbc0c607a65cbe3151).Cantina Managed
Fixes verified. Documentation updated as suggested.
No event emitted when the allowance contract is updated in GlobalGuardian
State
- Fixed
PR #1403
Severity
- Severity: Informational
Submitted by
Valerian Callens
Description
The function
setAllowanceContract()defined in the contractGlobalGuardianlets the owner of the contract update the active allowance contract. However, there is no event emission when it happens, so off-chain observers cannot easily monitor these updates to react accordingly.Recommendation
Consider emitting an event when the function
setAllowanceContract()is successfully executed.Definitive Finance
Function removed in PR 1403 (commit
2fda95575ff4b6a12f7ed1db897255c182e7cecc).Cantina Managed
Fixes verified.
Function validateSwapPayloadSigner() declared but not used
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Valerian Callens
Description
The function
validateSwapPayloadSigner()is declared but not used in the codebase. It is also the case for the fieldSwapPayload.signature. As a result, the signature of theSwapPayloadobjects submitted to the system to perform swaps is currently not verified on-chain.Recommendation
Consider assessing if the function
validateSwapPayloadSigner()should be used on-chain when swaps are processed, or documenting the fact that this is currently verified off-chain.Definitive Finance
Acknowledged with comment: This is something not yet supported in trading vaults and we will add in a future contract update.
Cantina Managed
Acknowledged.
Ownership of DefinitiveFlashAllowance contract can be renounced
State
- Fixed
PR #1398
Severity
- Severity: Informational
Submitted by
Chinmay Farkya
Description
The
DefinitiveFlashAllowancecontract inherits from OpenZeppelin'sOwnableUpgradeablelibrary.This library has a function called
renounceOwnership()that allows the current contract owner to give up contract ownership permanently.If this function is called accidentally, it could lead to permanent loss of admin privileges over this contract, and the contract will never be upgradeable as
_authorizeUpgrade()is protected by theonlyOwnermodifier.Also, the current setup lacks a two-step ownership transfer, which is recommended over a single-step process.
Recommendation
Consider using OpenZeppelin's
Ownable2StepUpgradeablelibrary and overriding the functionrenounceOwnership()to always revert.Definitive Finance
Fixed in PR 1398 (commit
c2a4b978482ca4880251f6345b248291ae6ce0de).Cantina Managed
Fixes verified. The contract imports now the contract
Ownable2StepUpgradeable, and the functionrenounceOwnership()always reverts.Test suite does not cover flash swaps where the output token is the native token
State
- Fixed
PR #1404
Severity
- Severity: Informational
Submitted by
Valerian Callens
Description
The system support flash swaps with the
outputTokenbeing the native token. However, the test suite does not cover that case, even if it involves a specific logic. As a result, the test suite may not catch potential breaking changes in the future impacting flash swaps with theoutputTokenbeing the native token.Recommendation
Consider adding that scenario to the test suite.
Definitive Finance
Fixed in PR 1404 (commit
9c90071224b62a293344c0e2e18682797862226b).Cantina Managed
Fixes verified. Four test cases with native assets as output token were added to the test suite.
Security Review Statement
State
- New
Severity
- Severity: Informational
Submitted by
Valerian Callens
Security Review Statement
Definitive Finance engaged Cantina to conduct a security review of the Definitive Flash feature and its integration with existing vault permissions, swap handling, fees, and guardian controls. We would like to thank the Definitive Finance team for their responsiveness and constructive engagement throughout the review process. No significant issues were identified during the assessment, and the protocol is expected to operate as intended.
Code Overview
State
- New
Severity
- Severity: Informational
Submitted by
Valerian Callens
Scope
Cantina reviewed the DefinitiveCo/contracts repository at branch
master, commit c515674. The following files were in scope:contracts/├── base/BaseFlashSwap/BaseFlashSwap.sol├── base/BaseFlashSwap/IBaseFlashSwap.sol├── tools/FlashAllowance/DefinitiveFlashAllowance.sol└── tools/FlashAllowance/IDefinitiveFlashAllowance.solIn addition, the integration of the Flash feature with the existing system was in scope, which involved in particular the following files:
contracts/├── base/BaseAccessControlInitiable.sol├── base/BaseAccountAbstraction.sol├── strategies/Trading/v2/TradingVaultImplementation.sol└── tools/GlobalGuardian/GlobalGuardian.solCode Overview
The Definitive Flash feature lets integrators place orders on Definitive Finance on behalf of users: funds stay in the user’s wallet until the swap occurs, and output is sent back to the user’s wallet or another user-defined recipient. Orders are submitted by trusted actors (the performers), and can take multiple forms like multi-fill (e.g., TWAPs), and can also be filled at a later date (e.g., limit orders). The review mainly focused on the contracts
DefinitiveFlashAllowance(a singleton contract acting as an allowance gateway and supporting multiple allowance methods) andBaseFlashSwap(a capability added to the existing integrator trading vaults). The vaults also have other capabilities defined in other contracts, such as swap execution, fee management, account abstraction, access control and guardian protection. Four roles are involved in that system: the users, the performers, the integrator vault admins, and the Definitive admins.Trust Assumptions
State
- New
Severity
- Severity: Informational
Submitted by
Valerian Callens
Trust Assumptions
- Performers are assumed to be trusted. They can theoretically extract value by forcing bad trades or taking 100% native-asset fees. The user accepts and trusts the performers to make honest trades.
- It is assumed that no address will have at the same time the roles of performer and vault admins (also called client admins).
- Contracts are upgradeable, and the Definitive team uses a multisig for upgrades with extensive validations to prevent malicious changes.