Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(contracts-rfq): arbitrary calls without additional native value [SLT-233] #3215

Merged
merged 16 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 68 additions & 13 deletions packages/contracts-rfq/contracts/FastBridgeV2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
pragma solidity 0.8.24;

import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";

import {UniversalTokenLib} from "./libs/UniversalToken.sol";

import {Admin} from "./Admin.sol";
import {IFastBridge} from "./interfaces/IFastBridge.sol";
import {IFastBridgeV2} from "./interfaces/IFastBridgeV2.sol";
import {IFastBridgeV2Errors} from "./interfaces/IFastBridgeV2Errors.sol";
import {IFastBridgeRecipient} from "./interfaces/IFastBridgeRecipient.sol";

/// @notice FastBridgeV2 is a contract for bridging tokens across chains.
contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors {
Expand All @@ -24,6 +26,9 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors {
/// @notice Minimum deadline period to relay a requested bridge transaction
uint256 public constant MIN_DEADLINE_PERIOD = 30 minutes;

/// @notice Maximum length of accepted callParams
uint256 public constant MAX_CALL_PARAMS_LENGTH = 2 ** 16 - 1;

/// @notice Status of the bridge tx on origin chain
mapping(bytes32 => BridgeTxDetails) public bridgeTxDetails;
/// @notice Relay details on destination chain
Expand All @@ -45,7 +50,12 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors {
function bridge(BridgeParams memory params) external payable {
bridge({
params: params,
paramsV2: BridgeParamsV2({quoteRelayer: address(0), quoteExclusivitySeconds: 0, quoteId: bytes("")})
paramsV2: BridgeParamsV2({
quoteRelayer: address(0),
quoteExclusivitySeconds: 0,
quoteId: bytes(""),
callParams: bytes("")
})
});
}

Expand Down Expand Up @@ -117,6 +127,9 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors {

/// @inheritdoc IFastBridge
function getBridgeTransaction(bytes memory request) external pure returns (BridgeTransaction memory) {
// TODO: the note below isn't true anymore with the BridgeTransactionV2 struct
// since the variable length `callParams` was added. This needs to be fixed/acknowledged.

Comment on lines +130 to +132
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Address TODO comment regarding BridgeTransactionV2 struct decoding

The TODO comment indicates that the previous explanation about decoding BridgeTransactionV2 is no longer accurate due to the addition of the variable-length callParams field. This needs to be addressed to ensure correct decoding behavior.

Please update the comment and verify the decoding behavior of the getBridgeTransaction function to account for the new callParams field in BridgeTransactionV2.

// Note: when passing V2 request, this will decode the V1 fields correctly since the new fields were
// added as the last fields of the struct and hence the ABI decoder will simply ignore the extra data.
return abi.decode(request, (BridgeTransaction));
Expand All @@ -132,6 +145,7 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors {
if (params.sender == address(0) || params.to == address(0)) revert ZeroAddress();
if (params.originToken == address(0) || params.destToken == address(0)) revert ZeroAddress();
if (params.deadline < block.timestamp + MIN_DEADLINE_PERIOD) revert DeadlineTooShort();
if (paramsV2.callParams.length > MAX_CALL_PARAMS_LENGTH) revert CallParamsLengthAboveMax();
int256 exclusivityEndTime = int256(block.timestamp) + paramsV2.quoteExclusivitySeconds;
// exclusivityEndTime must be in range (0 .. params.deadline]
if (exclusivityEndTime <= 0 || exclusivityEndTime > int256(params.deadline)) {
Comment on lines +148 to 151
Copy link
Contributor

@coderabbitai coderabbitai bot Oct 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

LGTM with suggestion: New checks added for callParams and exclusivity period.

The addition of checks for callParams length and exclusivity period is good. However, there's a potential issue with the exclusivityEndTime calculation.

The casting of block.timestamp to int256 could cause overflow issues in the far future. Consider using uint256 instead:

-int256 exclusivityEndTime = int256(block.timestamp) + paramsV2.quoteExclusivitySeconds;
-if (exclusivityEndTime <= 0 || exclusivityEndTime > int256(params.deadline)) {
+uint256 exclusivityEndTime = block.timestamp + paramsV2.quoteExclusivitySeconds;
+if (exclusivityEndTime > params.deadline) {
    revert ExclusivityParamsIncorrect();
}

This change eliminates the need for the <= 0 check and prevents potential overflow issues.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (paramsV2.callParams.length > MAX_CALL_PARAMS_LENGTH) revert CallParamsLengthAboveMax();
int256 exclusivityEndTime = int256(block.timestamp) + paramsV2.quoteExclusivitySeconds;
// exclusivityEndTime must be in range (0 .. params.deadline]
if (exclusivityEndTime <= 0 || exclusivityEndTime > int256(params.deadline)) {
if (paramsV2.callParams.length > MAX_CALL_PARAMS_LENGTH) revert CallParamsLengthAboveMax();
uint256 exclusivityEndTime = block.timestamp + paramsV2.quoteExclusivitySeconds;
// exclusivityEndTime must be in range (0 .. params.deadline]
if (exclusivityEndTime > params.deadline) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solidity ^0.8 prevents overflows by trigering hard revert.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@trajan0x, you're correct. Since Solidity 0.8, integer overflows and underflows cause a revert, so the explicit checks may not be necessary. Thank you for the clarification.

(・・?)


✏️ Learnings added
Learnt from: trajan0x
PR: synapsecns/sanguine#3215
File: packages/contracts-rfq/contracts/FastBridgeV2.sol:145-148
Timestamp: 2024-10-03T16:44:38.362Z
Learning: In Solidity 0.8 and above, integer overflows and underflows cause a hard revert, so explicit overflow checks are unnecessary.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Expand Down Expand Up @@ -163,7 +177,8 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors {
nonce: senderNonces[params.sender]++, // increment nonce on every bridge
exclusivityRelayer: paramsV2.quoteRelayer,
// We checked exclusivityEndTime to be in range (0 .. params.deadline] above, so can safely cast
exclusivityEndTime: uint256(exclusivityEndTime)
exclusivityEndTime: uint256(exclusivityEndTime),
callParams: paramsV2.callParams
})
);
bytes32 transactionId = keccak256(request);
Expand Down Expand Up @@ -214,18 +229,32 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors {
address token = transaction.destToken;
uint256 amount = transaction.destAmount;

uint256 rebate = chainGasAmount;
if (!transaction.sendChainGas) {
// forward erc20
rebate = 0;
// All state changes have been done at this point, can proceed to the external calls.
// This follows the checks-effects-interactions pattern to mitigate potential reentrancy attacks.
if (transaction.callParams.length == 0) {
// No arbitrary call requested, so we just pull the tokens from the Relayer to the recipient,
// or transfer ETH to the recipient (if token is ETH_ADDRESS)
_pullToken(to, token, amount);
} else if (token == UniversalTokenLib.ETH_ADDRESS) {
// lump in gas rebate into amount in native gas token
_pullToken(to, token, amount + rebate);
} else {
// forward erc20 then forward gas rebate in native gas token
} else if (token != UniversalTokenLib.ETH_ADDRESS) {
// Arbitrary call requested with ERC20: pull the tokens from the Relayer to the recipient first
_pullToken(to, token, amount);
_pullToken(to, UniversalTokenLib.ETH_ADDRESS, rebate);
// Follow up with the hook function call
_checkedCallRecipient({
recipient: to,
msgValue: 0,
token: token,
amount: amount,
callParams: transaction.callParams
});
} else {
// Arbitrary call requested with ETH: combine the ETH transfer with the call
_checkedCallRecipient({
recipient: to,
msgValue: amount,
token: token,
amount: amount,
callParams: transaction.callParams
});
Comment on lines +232 to +257
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

LGTM with suggestion: New logic for handling callParams implemented

The implementation of the arbitrary call functionality is well-structured and follows the checks-effects-interactions pattern. However, there's a potential issue with the ERC20 token transfer.

When handling ERC20 tokens with callParams, calling _pullToken(to, token, amount); may incorrectly attempt to transfer tokens from the relayer to the recipient, rather than from the contract to the recipient. Consider using universalTransfer instead:

} else if (token != UniversalTokenLib.ETH_ADDRESS) {
    // Arbitrary call requested with ERC20: transfer the tokens first
-   _pullToken(to, token, amount);
+   token.universalTransfer(to, amount);

This change ensures that the tokens are transferred from the contract to the recipient before making the arbitrary call.

Committable suggestion was skipped due to low confidence.

}

emit BridgeRelayed(
Expand All @@ -237,7 +266,8 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors {
transaction.destToken,
transaction.originAmount,
transaction.destAmount,
rebate
// chainGasAmount is 0 since the gas rebate function is deprecated
0
);
}

Expand Down Expand Up @@ -327,6 +357,31 @@ contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors {
}
}

/// @notice Calls the Recipient's hook function with the specified callParams and performs
/// all the necessary checks for the returned value.
function _checkedCallRecipient(
address recipient,
uint256 msgValue,
address token,
uint256 amount,
bytes memory callParams
)
internal
{
bytes memory hookData =
abi.encodeCall(IFastBridgeRecipient.fastBridgeTransferReceived, (token, amount, callParams));
// This will bubble any revert messages from the hook function
bytes memory returnData = Address.functionCallWithValue({target: recipient, data: hookData, value: msgValue});
// Explicit revert if no return data at all
if (returnData.length == 0) revert RecipientNoReturnValue();
// Check that exactly a single return value was returned
if (returnData.length != 32) revert RecipientIncorrectReturnValue();
// Return value should be abi-encoded hook function selector
if (bytes32(returnData) != bytes32(IFastBridgeRecipient.fastBridgeTransferReceived.selector)) {
revert RecipientIncorrectReturnValue();
}
}
Comment on lines +360 to +383
Copy link
Contributor

@coderabbitai coderabbitai bot Oct 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider adding reentrancy protection to external calls

The _checkedCallRecipient function performs an external call to recipient using Address.functionCallWithValue, which could introduce reentrancy vulnerabilities. Although tokens are transferred before this call, adding reentrancy protection enhances security.

Consider adding the nonReentrant modifier by inheriting from OpenZeppelin's ReentrancyGuard and applying it to the relay function.

Apply this diff to add reentrancy protection:

+import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

-contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors {
+contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors, ReentrancyGuard {
    ...

    function relay(bytes memory request, address relayer) public payable
+       nonReentrant
    {
        ...
    }

Committable suggestion was skipped due to low confidence.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also a bit surprised this isn't non-reenrant. Can you explain why?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@trajan0x, the relay function is not marked as nonReentrant because it follows the checks-effects-interactions pattern to mitigate reentrancy risks. All state changes are performed before any external calls are made. Specifically, before calling _checkedCallRecipient, the contract updates the bridgeRelayDetails mapping, ensuring that reentrancy vulnerabilities are minimized.

Here's the relevant part of the code:

// Mark bridge transaction as relayed
bridgeRelayDetails[transactionId] = BridgeRelay({
    blockNumber: uint48(block.number),
    blockTimestamp: uint48(block.timestamp),
    relayer: relayer
});

// All state changes have been done at this point, can proceed to the external calls.
// This follows the checks-effects-interactions pattern to mitigate potential reentrancy attacks.
if (transaction.callParams.length == 0) {
    // No arbitrary call requested, so we just pull the tokens from the Relayer to the recipient,
    // or transfer ETH to the recipient (if token is ETH_ADDRESS)
    _pullToken(to, token, amount);
} else if (token != UniversalTokenLib.ETH_ADDRESS) {
    // Arbitrary call requested with ERC20: pull the tokens from the Relayer to the recipient first
    _pullToken(to, token, amount);
    // Follow up with the hook function call
    _checkedCallRecipient({
        recipient: to,
        msgValue: 0,
        token: token,
        amount: amount,
        callParams: transaction.callParams
    });
} else {
    // Arbitrary call requested with ETH: combine the ETH transfer with the call
    _checkedCallRecipient({
        recipient: to,
        msgValue: amount,
        token: token,
        amount: amount,
        callParams: transaction.callParams
    });
}

By updating the bridgeRelayDetails before any external calls, the contract ensures that any attempted reentrancy attacks cannot manipulate critical state variables.

However, adding the nonReentrant modifier to the relay function would provide an additional layer of security. It acts as a safeguard against any unforeseen reentrancy vulnerabilities that might arise from future changes or edge cases not covered by the current implementation.

To add reentrancy protection, you can inherit from OpenZeppelin's ReentrancyGuard and apply the nonReentrant modifier to the relay function:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract FastBridgeV2 is Admin, IFastBridgeV2, IFastBridgeV2Errors, ReentrancyGuard {
    ...

    function relay(bytes memory request, address relayer) public payable
        nonReentrant
    {
        ...
    }
}

Implementing this change would enhance the contract's security by enforcing non-reentrancy at the function level, ensuring the contract remains robust against potential reentrancy attacks.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@trajan0x this PR doesn't add any additional risk vectors: legacy ETH transfers are also done through the recipient call. Therefore no additional reenrancy protection was introduced here. I suggest to leave it out of this PR as being a potentially more of a global thing we might or might not want to deal with.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


/// @notice Calculates time since proof submitted
/// @dev proof.timestamp stores casted uint40(block.timestamp) block timestamps for gas optimization
/// _timeSince(proof) can accomodate rollover case when block.timestamp > type(uint40).max but
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
Dismissed Show dismissed Hide dismissed
ChiTimesChi marked this conversation as resolved.
Show resolved Hide resolved

interface IFastBridgeRecipient {
function fastBridgeTransferReceived(
address token,
uint256 amount,
bytes memory callParams
)
external
payable
returns (bytes4);
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ interface IFastBridgeV2 is IFastBridge {
/// @param quoteRelayer Relayer that provided the quote for the transaction
/// @param quoteExclusivitySeconds Period of time the quote relayer is guaranteed exclusivity after user's deposit
/// @param quoteId Unique quote identifier used for tracking the quote
/// @param callParams Parameters for the arbitrary call to the destination recipient (if any)
struct BridgeParamsV2 {
address quoteRelayer;
int256 quoteExclusivitySeconds;
bytes quoteId;
bytes callParams;
}

/// @notice Updated bridge transaction struct to include parameters introduced in FastBridgeV2.
Expand All @@ -57,6 +59,7 @@ interface IFastBridgeV2 is IFastBridge {
uint256 nonce;
address exclusivityRelayer;
uint256 exclusivityEndTime;
bytes callParams;
}

event BridgeQuoteDetails(bytes32 indexed transactionId, bytes quoteId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ pragma solidity ^0.8.0;

interface IFastBridgeV2Errors {
error AmountIncorrect();
error CallParamsLengthAboveMax();
error ChainIncorrect();
error ExclusivityParamsIncorrect();
error MsgValueIncorrect();
error SenderIncorrect();
error StatusIncorrect();
error ZeroAddress();

error RecipientIncorrectReturnValue();
error RecipientNoReturnValue();

error DeadlineExceeded();
error DeadlineNotExceeded();
error DeadlineTooShort();
Expand Down
Loading
Loading