settlement-contracts
Cantina Security Report
Organization
- @t3rn
Engagement Type
Cantina Reviews
Period
-
Repositories
N/A
Researchers
Findings
High Risk
2 findings
2 fixed
0 acknowledged
Medium Risk
5 findings
4 fixed
1 acknowledged
Low Risk
11 findings
11 fixed
0 acknowledged
Informational
1 findings
1 fixed
0 acknowledged
High Risk2 findings
DOS on refunds
Severity
- Severity: High
≈
Likelihood: High×
Impact: High Submitted by
0xWeiss
Description
When calling
claimRefundV2
if there is a reward claimable for certain beneficiary, it will enter the following if statement:if (claimer.isClaimable(_batchPayloadHash, _batchPayload, orderId, _beneficiary, maxReward, 1)) {escrowGMP.twoifyPayloadHash(orderId);ro.settlePayoutWithFeesCall(maxReward, rewardAsset, _beneficiary, address(ro), 1, orderId);} else {emit NonRefundable(orderId, escrowGMP.getRemotePaymentPayloadHash(orderId), _batchPayloadHash);}After, if forwards the call to the
settlePayoutWithFeesCall
function inside the remote order contract where it tries to send funds viasettleNativeOrToken
:function settleNativeOrToken(uint256 amount,address asset,address beneficiary,address sender) internal nonReentrant returns (bool) {if (amount == 0) return true;if (beneficiary == address(0)) return false;if (asset == address(0)) {(bool sent, ) = beneficiary.call{value: amount}("");return sent;}IERC20(asset).safeTransferFrom(sender, beneficiary, amount);return true;}Notice both of the key parameters,
sender
andbeneficiary
. According to the first call in theclaimRefundV2
functions, thesender
was specified to be the remote order contractaddress(ro)
, which in this case it does act likeaddress(this)
.safeTransferFrom
requires a pre-approval for the funds to be transferred and when calling it from the same contract it will revert.Recommendation
If the sender address is meant to be
address(this)
(the remote order contract) use safeTransfer instead than safeTransferFromNullify Order ID status after confirmation in confirmOrderV3
Severity
- Severity: High
≈
Likelihood: Medium×
Impact: High Submitted by
VÃctor MartÃnez
Description
In the
confirmOrderV3
function, the order ID is not nullified after confirmation, which allows the same order to potentially be confirmed by two different relayers if the transactions are executed in the same block. This could lead to a scenario where only one of the two relayers that fulfilled and confirmed the order would be able to claim the payout on the source chain.A hash of amount, asset, target, and orderId can be used as a status key to ensure a unique mapping for the oder status.
Recommendation
For the status mapping a hash of amount, asset, target and orderId can be used
- Add a check at the beginning of the function to verify if the order has already been confirmed by checking the confirmation status using a hash of
amount
,asset
,target
, andorderId
. - After successfully confirming the order, mark it as confirmed in storage by setting a true value in the confirmation status mapping (using the hash as the key). This ensures that once an order (and parameters) is confirmed, it cannot be re-confirmed by another relayer, preventing the second relayer from losing the funds.
Medium Risk5 findings
Operators can't be removed in case they act maliciously
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: High Submitted by
0xWeiss
Description
Currently, the owner can whitelist certain operators which are granted certain permissions to call multiple functions:
function setOperator(address _operator) external onlyOwner {operators[_operator] = true;}This lacks support to remove operators in case they act maliciously or even a mistake has been made when adding them as the boolean is hardcoded to be
true
Recommendation
Add the ability to remove operators.
Withdrawals can silently fail
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: High Submitted by
0xWeiss
Description
When calling
emergencyWithdraw
for a certainbeneficiary
it does make an external call trying to send ETH to the beneficiary specified:function emergencyWithdraw(address asset, uint256 amount, address beneficiary) external onlyOwner {settleNativeOrToken(amount, asset, beneficiary, address(this));Inside this call, if there was an unsuccessful transfer, it would return false, which it is not checked for after.
if (asset == address(0)) {(bool sent, ) = beneficiary.call{value: amount}("");return sent;}Recommendation
Check that the return was not false, if so, revert
function emergencyWithdraw(address asset, uint256 amount, address beneficiary) external onlyOwner {- settleNativeOrToken(amount, asset, beneficiary, address(this));+ require(settleNativeOrToken(amount, asset, beneficiary, address(this));,"transfer failed");Beneficiaries can grief senders on ETH transfers
State
Severity
- Severity: Medium
Submitted by
0xWeiss
Description
Functions such as
confirmOrderV3
allow a sender to the send funds to a beneficiary via a external call in case of the asset being ETH.require(settleNativeOrToken(amount, asset, target, msg.sender), "RO#2");The function makes an external call to the beneficiary where they can basically spend the remaining gas of the transaction and make the sender pay for a huge increase on gas fees:
if (asset == address(0)) {(bool sent, ) = beneficiary.call{value: amount}("");return sent;}Recommendation
As limiting the gas being forwarded does not prevent a return bomb attack, the only choice is to use a low level call to avoid loading the returned data into memory:
assembly {success := call(gasLimit, receiver, amount, 0, 0, 0, 0)}Bonuses can't be applied
Severity
- Severity: Medium
Submitted by
0xWeiss
Description
The
BonusesL3
contract expects to hold ETH in its balance to later distribute it but it lacks a receive function:function distribute(address to, uint256 amount) internal nonReentrant returns (bool) {if (to == address(0)) {emit DistributionAttempt(to, amount, false, "Invalid address");return false;}if (amount == 0) {emit DistributionAttempt(to, amount, false, "Invalid amount");return false;}if (address(this).balance < amount) {emit DistributionAttempt(to, amount, false, "Insufficient balance");return false;}(bool success, ) = to.call{value: amount}("");emit DistributionAttempt(to, amount, success, success ? "Success" : "Payment failed");return success;}Recommendation
Add a receive function inside the
BonusesL3
contract.Same second orders will fail given a timestamp nonce usage
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
0xWeiss
Description
The nonce for orders is calculated using both the timestamp and the address of the msg.sender:
function orderMemoryData(bytes memory input) public payable isOn returns (bytes32) {uint32 nonce = uint32(block.timestamp);bytes32 id = generateId(msg.sender, nonce);Nonces should not be based on timestamp, they should be their own variable that it is incremented by 1 every time the function gets called. If called twice during the same second, in this case, it would revert inside
storeRemoteOrderPayload
:require(escrowGMP.storeRemoteOrderPayload(id, keccak256(abi.encode(rewardAsset, maxReward, block.timestamp))),"RO#0");Notice how even if it would go through there would be no distinction in the event emission, as no real nonce is used here, and the same event would be emitted:
emit RemoteOrderCreated(id, nonce, msg.sender, block.timestamp);
Recommendation
A nonce should be used as an incrementable variable each time a user calls that function, not based on timestamp
Low Risk11 findings
GMPExpectedPayloadNotMatched reflects the wrong beneficiary
Severity
- Severity: Low
Submitted by
0xWeiss
Description
When the payload hash is not found in the
commitEscrowBeneficiaryPayload
function, then theGMPExpectedPayloadNotMatched
event is emitted to reflect such case:// Update the payment payload (hash of the payload)bytes32 currentHash = escrowOrdersPayloadHash[sfxId];if (currentHash == bytes32(0)) {emit GMPExpectedPayloadNotMatched(sfxId, address(0), currentHash);return (false);}The address of the beneficiary is hardcoded to 0 while it should be the actual beneficiary passed as a function argument, same as done in the
commitRemoteBeneficiaryPayload
function.Recommendation
Update the address to reflect the beneficiary instead of
address(0)
:- emit GMPExpectedPayloadNotMatched(sfxId, address(0), currentHash);+ emit GMPExpectedPayloadNotMatched(sfxId, beneficiary, currentHash);Use pull over push method for handling protocol fees
Severity
- Severity: Low
Submitted by
0xWeiss
Description
Currently, a push system is used when paying the protocol fee as the fee is sent in each transaction to the protocol address.
This causes multiple problems:
- For every transaction the gas costs increase significally as it will process the extra logic for sending the fee to the fee address every time.
- There is a DOS scenario where the protocol could block the users from claiming refunds as they could make the call that sends the fee to their address revert.
- Similarly, if the keys get leaked the protocol fee address can be taken over and willingly DOS refunds
function settlePayoutWithFees(uint256 amount, address asset, address beneficiary, address sender) internal {uint256 fees = currentProtocolFee > 0 ? calcProtocolFee(amount) : 0;require(settleNativeOrToken(fees, asset, protocolFeesCollector, sender) &&settleNativeOrToken(amount - fees, asset, beneficiary, sender),"RO#16");}Recommendation
Add a protocolFee variable, increment it every time that there is a fee that the protocol should claim and add a separate function that allows the protocol owner to withdraw fee whenever they want.
checkIsRefundable returns true when protocol is halted
Severity
- Severity: Low
Submitted by
0xWeiss
Description
checkIsRefundable
is a public function and it is allowed to be called by any external system that wants to integrate with t3rns infrastructure. ThecheckIsRefundable
function checkes whether an order is refundable for certain user:function checkIsRefundable(bytes32 orderId,uint256 orderTimestamp,address rewardAsset,uint256 maxReward) public view returns (bool) {Though it fails to check whether the contract is on a halted state, were refunds are effectively paused:
function claimRefundV2(uint32 orderNonce,address rewardAsset,uint256 maxReward,uint256 orderTimestamp,bytes32 _batchPayloadHash,bytes memory _batchPayload) public payable isOn {Therefore, it will return the wrong boolean as in fact the order can't be refunded while
isOn
is trueRecommendation
Add a check so that if
isOn
is set to true, thencheckIsRefundable
will return falseForcefully bypassed event emission on empty payloads
Severity
- Severity: Low
Submitted by
0xWeiss
Description
Inside the
commitEscrowBeneficiaryPayload
function, the result ofescrowOrdersPayloadHash[sfxId]
is checked to not be 0 twice, the first time is just returns false and the second one (which won't be triggered), emits the correct event and returns false:function commitEscrowBeneficiaryPayload(bytes32 sfxId, address beneficiary) external onlyAttesters returns (bool) {// Check if the payload exists and return false if it doesn'tif (escrowOrdersPayloadHash[sfxId] == 0) {return (false);}// Update the payment payload (hash of the payload)bytes32 currentHash = escrowOrdersPayloadHash[sfxId];if (currentHash == bytes32(0)) {emit GMPExpectedPayloadNotMatched(sfxId, address(0), currentHash);return (false);}Recommendation
Remove the first check so that the event can be emitted for empty hashes:
function commitEscrowBeneficiaryPayload(bytes32 sfxId, address beneficiary) external onlyAttesters returns (bool) {- // Check if the payload exists and return false if it doesn't- if (escrowOrdersPayloadHash[sfxId] == 0) {- return (false);- }// Update the payment payload (hash of the payload)bytes32 currentHash = escrowOrdersPayloadHash[sfxId];if (currentHash == bytes32(0)) {emit GMPExpectedPayloadNotMatched(sfxId, address(0), currentHash);return (false);}msg.value can be forwarded when the reward token is not ETH
Severity
- Severity: Low
Submitted by
0xWeiss
Description
When calling
orderMemoryData
it checks for msg.value or it transfers the erc20 tokens from the sender. Both cases are handled, but there is a case missing to check for and it is when the rewardAsset is not ETH, but msg.value is being sent:if (rewardAsset == address(0)) {require(msg.value == maxReward, "RO#7");require(amount < maxReward, "RO#7");} else {IERC20(rewardAsset).safeTransferFrom(msg.sender, address(this), maxReward);}Recommendation
Add a check when
rewardAsset
is not ETH for msg.value to be 0.Incorrect Rounding Direction in Fee Calculation
State
Severity
- Severity: Low
Submitted by
VÃctor MartÃnez
Context:
Description:
The protocol fee calculation uses incorrect rounding, causing the Protocol Fee Amount to be rounded down.
As a general rule, protocol fee calculations are usually rounded up on DeFi protocols.
Recommendation:
Adjust the rounding logic to ensure protocol fees are rounded up, aligning with industry best practices.
Proposed Fix:
function calcProtocolFee(uint256 amount) public view returns (uint256) {return (amount * currentProtocolFee + 1e6 - 1) / 1e6;}Consider moving _disableInitializers to Constructor for Consistency
Severity
- Severity: Low
Submitted by
VÃctor MartÃnez
Description
The
_disableInitializers
function, inherited fromInitializable
, is currently callable viadisableInitializers
. To streamline initialization security, consider moving this call directly to the constructor, eliminating the need for an external function. This approach aligns with the pattern used in other contracts in the codebase, such asremoteOrder
.Recommendation
Invoke
_disableInitializers
within the constructor instead of requiring a separate function call, ensuring consistency and preventing unnecessary external interactions.claimerGMPV2 generateId Function Does Not Include sourceId
State
Severity
- Severity: Low
Submitted by
VÃctor MartÃnez
Description
The
generateId
function currently hashes only therequester
andnonce
, but it does not incorporatesourceId
. This results in incorrect getter outputs.Recommendation
Modify the function to include sourceId in the hash computation.
Improve Pause Mechanism for More Granular Control
Severity
- Severity: Low
Submitted by
VÃctor MartÃnez
Description
The current pause mechanism on
remoteOrder
is binary, meaning the entire contract is either fully operational or completely halted. There is no selective pausing for specific functions (e.g., allowing claims while preventing new orders). This lack of flexibility may limit the contract’s ability to respond to different situations effectively.Recommendation
Consider implementing the pause mechanism with a more granular approach.
Ensure executionCutOff is always less than orderTimeout across chains
Severity
- Severity: Low
Submitted by
VÃctor MartÃnez
Description
The
setExecutionCutOff
function allows the owner to set the executionCutOff value. However, sinceexecutionCutOff
andorderTimeout
exist on different chains, there is a potential situation whereexecutionCutOff
might be set higher thanorderTimeout
on the source chain, leading to unintended executions or inconsistencies across chains.Recommendation
Ensure that, during every deployment or configuration update,
executionCutOff
is always set to a value smaller thanorderTimeout
. This can be enforced through:- Off-chain validation and deployment scripts.
- Explicit checks in setter functions (setExecutionCutOff, setOrderTimeout) by enforcing min/max value constraints at the contract level.
Missing cap in fees
State
Severity
- Severity: Low
Submitted by
0xWeiss
Description
The function
setCurrentProtocolFee
is missing a cap so that it can not be set to over a certain amount. Usually, as a placeholder, the max amount can be set to 100%:function setCurrentProtocolFee(uint256 _protocolFee) external onlyOwner {currentProtocolFee = _protocolFee;}Recommendation
Add a maximum protocol fee
Informational1 finding
Minor improvements to code and comments
State
Severity
- Severity: Informational
Submitted by
VÃctor MartÃnez
Context: See below.
Description/Recommendation:
-
escrowGMP.sol#L4-L7 - The imported OpenZeppelin contracts (ReentrancyGuard, OwnableUpgradeable, and Initializable) are not used in the code.
-
escrowGMP.sol#L15-L16 - Deprecated mappings (
escrowCallsPayments
andescrowCallExists
) are still present in the codebase. Consider removing them if they are no longer needed. -
escrowGMP.sol#L100-L104 - The
onlyOwner
modifier should be applied directly to the external functions rather than being enforced within an internal function. This would improve readability and make access control clearer at the function level. -
escrowGMP.sol#L255 - Consider defining constants for bytes32(0), bytes32(1), and bytes32(2) to improve code maintainability, readability, and reduce redundancy.
-
avpBatchSubmitter.sol#L4-L8 - The imported OpenZeppelin contracts (MerkleProof, OwnableUpgradeable, and Initializable) are not used in the code.
-
avpBatchSubmitter.sol#L40 - The
initialize
function inavpBatchSubmitter
lacks address validation, whereas remoteOrder includes it. Consider adding validation to initialize for consistency and security. -
avpBatchSubmitter.sol#L57 - Consider adding events and address validation to all setter functions to enhance transparency, traceability, and input safety.
-
avpBatchSubmitter.sol#L126 - For improved readability, consider moving the BatchData struct to the beginning of the contract.
-
avpBatchSubmitter.sol#L396, remoteOrder.sol#L251, remoteOrder.sol#L497-L526 - Remove comment.
-
remoteOrder.sol#L4-L10 - The imported OpenZeppelin contracts (Context, OwnableUpgradeable, and Initializable) are not used in the code.
-
remoteOrder.sol#L65-L68 - Multiple fields, such as
nonce
, that use uint types may not need to be indexed. Consider removing the indexed keyword for these fields to optimize gas usage -
remoteOrder.sol#L120 - Consider adding events and address validation to all setter functions to enhance transparency, traceability, and input safety. Additionally, no event is emitted when isHalted changes via turnOnHalt/turnOffHalt.
-
remoteOrder.sol#L195 - The
isOn
modifier is currently duplicated when called via theorder
function. Consider either removing it from order or removing it fromorderMemoryData
and making the function internal to reduce redundancy and improve code quality. -
remoteOrder.sol#L223 - Emits
RemoteOrderCreated
with limited data, while the wrapper order function emitsOrderCreated
with full details. -
remoteOrder.sol#L229 - Consider replacing magic numbers with constants throughout the codebase. This will enhance readability, maintainability, and reduce the risk of errors.
-
remoteOrder.sol#L402 - The
isOn
modifier is currently duplicated when called via theclaimRefund
function. Consider either removing it fromclaimRefund
. -
remoteOrder.sol#L342-L359 - The check if (address(claimerGMP) == address(0)) return false; is missing.