synapsecns/sanguine

View on GitHub
packages/contracts-rfq/contracts/FastBridgeV2.sol

Summary

Maintainability
Test Coverage
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

// ════════════════════════════════════════════════ INTERFACES ═════════════════════════════════════════════════════

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

// ═════════════════════════════════════════════ INTERNAL IMPORTS ══════════════════════════════════════════════════

import {AdminV2} from "./AdminV2.sol";
import {BridgeTransactionV2Lib} from "./libs/BridgeTransactionV2.sol";
import {MulticallTarget} from "./utils/MulticallTarget.sol";

// ═════════════════════════════════════════════ EXTERNAL IMPORTS ══════════════════════════════════════════════════

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

/// @title FastBridgeV2
/// @notice Core component of the SynapseRFQ protocol, enabling Relayers (Solvers) to fulfill bridge requests.
/// Supports ERC20 and native gas tokens, along with the Zap feature for executing actions on the destination chain.
/// Users interact with the off-chain Quoter API to obtain a current quote for a bridge transaction.
/// They then submit the bridge request with the quote to this contract, depositing their assets in escrow.
/// Relayers can fulfill requests by relaying them to the destination chain and must prove fulfillment to claim funds.
/// Guards monitor proofs and can dispute discrepancies.
/// Users can reclaim funds by cancelling their requests if it has not been fulfilled within the specified deadline.
contract FastBridgeV2 is AdminV2, MulticallTarget, IFastBridgeV2, IFastBridgeV2Errors {
    using BridgeTransactionV2Lib for bytes;
    using SafeERC20 for IERC20;

    /// @notice The duration of the dispute period for relayed transactions.
    uint256 public constant DISPUTE_PERIOD = 30 minutes;

    /// @notice The minimum required time between transaction request and deadline.
    uint256 public constant MIN_DEADLINE_PERIOD = 30 minutes;

    /// @notice The maximum allowed length for zapData.
    uint256 public constant MAX_ZAP_DATA_LENGTH = 2 ** 16 - 1;

    /// @notice Maps transaction IDs to bridge details (status, destination chain ID, proof timestamp, and relayer).
    /// Note: this is only stored for transactions having local chain as the origin chain.
    mapping(bytes32 => BridgeTxDetails) public bridgeTxDetails;
    /// @notice Maps transaction IDs to relay details (block number, block timestamp, and relayer).
    /// Note: this is only stored for transactions having local chain as the destination chain.
    mapping(bytes32 => BridgeRelay) public bridgeRelayDetails;
    /// @notice Maps sender addresses to their unique bridge nonce.
    mapping(address => uint256) public senderNonces;

    /// @notice This variable is deprecated and should not be used.
    /// @dev Replaced by senderNonces.
    uint256 public immutable nonce = 0;

    /// @notice Initializes the FastBridgeV2 contract with the provided default admin,
    /// sets the default cancel delay, and records the deploy block number.
    constructor(address defaultAdmin) AdminV2(defaultAdmin) {}

    // ══════════════════════════════════════ EXTERNAL MUTABLE (USER FACING) ═══════════════════════════════════════════

    /// @inheritdoc IFastBridge
    function bridge(BridgeParams memory params) external payable {
        bridgeV2({
            params: params,
            paramsV2: BridgeParamsV2({
                quoteRelayer: address(0),
                quoteExclusivitySeconds: 0,
                quoteId: bytes(""),
                zapNative: 0,
                zapData: bytes("")
            })
        });
    }

    /// Note: this function is deprecated and will be removed in a future version.
    /// @dev Replaced by `cancel`.
    /// @inheritdoc IFastBridge
    function refund(bytes calldata request) external {
        cancelV2(request);
    }

    // ══════════════════════════════════════ EXTERNAL MUTABLE (AGENT FACING) ══════════════════════════════════════════

    /// @inheritdoc IFastBridge
    function relay(bytes calldata request) external payable {
        // `relay` override will validate the request.
        relayV2({request: request, relayer: msg.sender});
    }

    /// @inheritdoc IFastBridge
    function prove(bytes calldata request, bytes32 destTxHash) external {
        request.validateV2();
        proveV2({transactionId: keccak256(request), destTxHash: destTxHash, relayer: msg.sender});
    }

    /// @inheritdoc IFastBridgeV2
    function claimV2(bytes calldata request) external {
        // `claim` override will validate the request.
        claim({request: request, to: address(0)});
    }

    /// @inheritdoc IFastBridge
    function dispute(bytes32 transactionId) external onlyRole(GUARD_ROLE) {
        // Aggregate the read operations from the same storage slot.
        BridgeTxDetails storage $ = bridgeTxDetails[transactionId];
        uint16 proverID = $.proverID;
        address disputedRelayer = $.proofRelayer;
        BridgeStatus status = $.status;
        uint40 proofBlockTimestamp = $.proofBlockTimestamp;

        // Can only dispute a RELAYER_PROVED transaction within the dispute period.
        if (status != BridgeStatus.RELAYER_PROVED) revert StatusIncorrect();
        if (_timeSince(proofBlockTimestamp) > DISPUTE_PERIOD) {
            revert DisputePeriodPassed();
        }

        // Apply the timeout penalty to the prover that submitted the proof.
        // Note: this is a no-op if the prover has already been removed.
        _applyDisputePenaltyTime(proverID);

        // Update status to REQUESTED and delete the disputed proof details.
        // Note: these are storage writes.
        $.status = BridgeStatus.REQUESTED;
        $.proverID = 0;
        $.proofRelayer = address(0);
        $.proofBlockTimestamp = 0;

        emit BridgeProofDisputed(transactionId, disputedRelayer);
    }

    // ══════════════════════════════════════════════ EXTERNAL VIEWS ═══════════════════════════════════════════════════

    /// @inheritdoc IFastBridge
    function canClaim(bytes32 transactionId, address relayer) external view returns (bool) {
        // The correct relayer can only claim a RELAYER_PROVED transaction after the dispute period.
        BridgeTxDetails storage $ = bridgeTxDetails[transactionId];
        if ($.status != BridgeStatus.RELAYER_PROVED) revert StatusIncorrect();
        if ($.proofRelayer != relayer) revert SenderIncorrect();

        return _timeSince($.proofBlockTimestamp) > DISPUTE_PERIOD;
    }

    /// @inheritdoc IFastBridge
    /// @dev This method is added to achieve backwards compatibility with decoding requests into V1 structs:
    /// - `zapNative` is partially reported as a zero/non-zero flag
    /// - `zapData` is ignored
    /// In order to process all kinds of requests use getBridgeTransactionV2 instead.
    function getBridgeTransaction(bytes calldata request) external view returns (BridgeTransaction memory) {
        // Try decoding into V2 struct first. This will revert if V1 struct is passed.
        try this.getBridgeTransactionV2(request) returns (BridgeTransactionV2 memory txV2) {
            // Note: we entirely ignore the zapData field, as it was not present in V1.
            return BridgeTransaction({
                originChainId: txV2.originChainId,
                destChainId: txV2.destChainId,
                originSender: txV2.originSender,
                destRecipient: txV2.destRecipient,
                originToken: txV2.originToken,
                destToken: txV2.destToken,
                originAmount: txV2.originAmount,
                destAmount: txV2.destAmount,
                originFeeAmount: txV2.originFeeAmount,
                sendChainGas: txV2.zapNative != 0,
                deadline: txV2.deadline,
                nonce: txV2.nonce
            });
        } catch {
            // Fallback to V1 struct.
            return abi.decode(request, (BridgeTransaction));
        }
    }

    /// @inheritdoc IFastBridgeV2
    function getBridgeTransactionV2(bytes calldata request) external pure returns (BridgeTransactionV2 memory) {
        request.validateV2();
        return BridgeTransactionV2Lib.decodeV2(request);
    }

    // ═══════════════════════════════════════ PUBLIC MUTABLE (USER FACING) ════════════════════════════════════════════

    /// @inheritdoc IFastBridgeV2
    function bridgeV2(BridgeParams memory params, BridgeParamsV2 memory paramsV2) public payable {
        // If relayer exclusivity is not intended for this bridge, set exclusivityEndTime to static zero.
        // Otherwise, set exclusivity to expire at the current block ts offset by quoteExclusivitySeconds.
        int256 exclusivityEndTime = 0;
        if (paramsV2.quoteRelayer != address(0)) {
            exclusivityEndTime = int256(block.timestamp) + paramsV2.quoteExclusivitySeconds;
        }
        _validateBridgeParams(params, paramsV2, exclusivityEndTime);

        // Transfer tokens to bridge contract. We use the actual transferred amount in case of transfer fees.
        uint256 originAmount = _takeBridgedUserAsset(params.originToken, params.originAmount);

        // Track the amount of origin token owed to protocol.
        uint256 originFeeAmount = 0;
        if (protocolFeeRate > 0) {
            originFeeAmount = (originAmount * protocolFeeRate) / FEE_BPS;
            // The Relayer filling this request will be paid the originAmount after fees.
            // Note: the protocol fees will be accumulated only when the Relayer claims the origin collateral.
            originAmount -= originFeeAmount;
        }

        // Hash the bridge request and set the initial status to REQUESTED.
        bytes memory request = BridgeTransactionV2Lib.encodeV2(
            BridgeTransactionV2({
                originChainId: uint32(block.chainid),
                destChainId: params.dstChainId,
                originSender: params.sender,
                destRecipient: params.to,
                originToken: params.originToken,
                destToken: params.destToken,
                originAmount: originAmount,
                destAmount: params.destAmount,
                originFeeAmount: originFeeAmount,
                deadline: params.deadline,
                // Increment the sender's nonce on every bridge.
                nonce: senderNonces[params.sender]++,
                exclusivityRelayer: paramsV2.quoteRelayer,
                // We checked exclusivityEndTime to be in range [0 .. params.deadline] above, so can safely cast.
                exclusivityEndTime: uint256(exclusivityEndTime),
                zapNative: paramsV2.zapNative,
                zapData: paramsV2.zapData
            })
        );
        bytes32 transactionId = keccak256(request);
        // Note: the tx status will be updated throughout the tx lifecycle, while destChainId is set once here.
        bridgeTxDetails[transactionId].status = BridgeStatus.REQUESTED;
        bridgeTxDetails[transactionId].destChainId = params.dstChainId;

        emit BridgeRequested({
            transactionId: transactionId,
            sender: params.sender,
            request: request,
            destChainId: params.dstChainId,
            originToken: params.originToken,
            destToken: params.destToken,
            originAmount: originAmount,
            destAmount: params.destAmount,
            sendChainGas: paramsV2.zapNative != 0
        });
        emit BridgeQuoteDetails(transactionId, paramsV2.quoteId);
    }

    /// @inheritdoc IFastBridgeV2
    function cancelV2(bytes calldata request) public {
        // Decode the request and check that it could be cancelled.
        request.validateV2();
        bytes32 transactionId = keccak256(request);

        // Can only cancel a REQUESTED transaction after its deadline expires.
        BridgeTxDetails storage $ = bridgeTxDetails[transactionId];
        if ($.status != BridgeStatus.REQUESTED) revert StatusIncorrect();

        // Permissionless cancel is only allowed after `cancelDelay` on top of the deadline.
        uint256 deadline = request.deadline();
        if (!hasRole(CANCELER_ROLE, msg.sender)) deadline += cancelDelay;
        if (block.timestamp <= deadline) revert DeadlineNotExceeded();

        // Update status to REFUNDED.
        // Note: this is a storage write.
        $.status = BridgeStatus.REFUNDED;

        // Return the full amount (collateral + protocol fees) to the original sender.
        // The protocol fees are only accumulated when the transaction is claimed, so we don't need to update them here.
        address to = request.originSender();
        address token = request.originToken();
        uint256 amount = request.originAmount() + request.originFeeAmount();

        // Emit the event before any external calls.
        emit BridgeDepositRefunded(transactionId, to, token, amount);

        // Return the funds to the original sender as last transaction action.
        if (token == NATIVE_GAS_TOKEN) {
            Address.sendValue(payable(to), amount);
        } else {
            IERC20(token).safeTransfer(to, amount);
        }
    }

    // ═══════════════════════════════════════ PUBLIC MUTABLE (AGENT FACING) ═══════════════════════════════════════════

    /// @inheritdoc IFastBridgeV2
    function relayV2(bytes calldata request, address relayer) public payable {
        // Decode the request and check that it could be relayed.
        request.validateV2();
        bytes32 transactionId = keccak256(request);
        _validateRelayParams(request, transactionId, relayer);

        // Mark the bridge request as relayed by saving the relayer and the block details.
        bridgeRelayDetails[transactionId].blockNumber = uint48(block.number);
        bridgeRelayDetails[transactionId].blockTimestamp = uint48(block.timestamp);
        bridgeRelayDetails[transactionId].relayer = relayer;

        // Transfer tokens to recipient on destination chain and trigger Zap if requested.
        address to = request.destRecipient();
        address token = request.destToken();
        uint256 amount = request.destAmount();
        uint256 zapNative = request.zapNative();

        // Emit the event before any external calls.
        emit BridgeRelayed({
            transactionId: transactionId,
            relayer: relayer,
            to: to,
            originChainId: request.originChainId(),
            originToken: request.originToken(),
            destToken: token,
            originAmount: request.originAmount(),
            destAmount: amount,
            chainGasAmount: zapNative
        });

        // 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 (token == NATIVE_GAS_TOKEN) {
            // For the native gas token, additional zapNative is not allowed.
            if (zapNative != 0) revert ZapNativeNotSupported();
            // Check that the correct msg.value was sent.
            if (msg.value != amount) revert MsgValueIncorrect();
            // Don't do a native transfer yet: we will handle it alongside the Zap below.
        } else {
            // For ERC20s, we check that the correct msg.value was sent.
            if (msg.value != zapNative) revert MsgValueIncorrect();
            // We need to transfer the tokens from the Relayer to the recipient first before performing an
            // optional post-transfer Zap.
            IERC20(token).safeTransferFrom(msg.sender, to, amount);
        }
        // At this point we have done:
        // - Transferred the requested amount of ERC20 tokens to the recipient.
        // At this point we have confirmed:
        // - For ERC20s: msg.value matches the requested zapNative amount.
        // - For the native gas token: msg.value matches the requested destAmount.
        // Remaining optional things to do:
        // - Forward the full msg.value to the recipient (if non-zero).
        // - Trigger a Zap (if zapData is present).
        bytes calldata zapData = request.zapData();
        if (zapData.length != 0) {
            // Zap Data is present: Zap has been requested by the recipient. Trigger it forwarding the full msg.value.
            _triggerZapWithChecks({recipient: to, token: token, amount: amount, zapData: zapData});
            // Note: if token has a fee on transfers, the recipient will have received less than `amount`.
            // This is a very niche edge case and should be handled by the recipient contract.
        } else if (msg.value != 0) {
            // Zap Data is missing, but msg.value was sent. This could happen in two different cases:
            // - Relay with the native gas token is happening.
            // - Relay with ERC20 is happening, with a `zapNative > 0` request.
            // In both cases, we need to transfer the full msg.value to the recipient.
            Address.sendValue(payable(to), msg.value);
        }
    }

    /// @inheritdoc IFastBridgeV2
    function proveV2(bytes32 transactionId, bytes32 destTxHash, address relayer) public {
        uint16 proverID = getActiveProverID(msg.sender);
        if (proverID == 0) revert ProverNotActive();
        // Can only prove a REQUESTED transaction.
        BridgeTxDetails storage $ = bridgeTxDetails[transactionId];
        if ($.status != BridgeStatus.REQUESTED) revert StatusIncorrect();

        // Update status to RELAYER_PROVED and store the proof details.
        // Note: these are storage writes.
        $.status = BridgeStatus.RELAYER_PROVED;
        $.proverID = proverID;
        $.proofBlockTimestamp = uint40(block.timestamp);
        $.proofRelayer = relayer;

        emit BridgeProofProvided(transactionId, relayer, destTxHash);
    }

    /// @inheritdoc IFastBridge
    function claim(bytes calldata request, address to) public {
        // Decode the request and check that it could be claimed.
        request.validateV2();
        bytes32 transactionId = keccak256(request);

        // Aggregate the read operations from the same storage slot.
        BridgeTxDetails storage $ = bridgeTxDetails[transactionId];
        address proofRelayer = $.proofRelayer;
        BridgeStatus status = $.status;
        uint40 proofBlockTimestamp = $.proofBlockTimestamp;

        // Can only claim a RELAYER_PROVED transaction after the dispute period.
        if (status != BridgeStatus.RELAYER_PROVED) revert StatusIncorrect();
        if (_timeSince(proofBlockTimestamp) <= DISPUTE_PERIOD) revert DisputePeriodNotPassed();
        if (to == address(0)) {
            // Anyone could claim the funds to the proven relayer on their behalf.
            to = proofRelayer;
        } else if (proofRelayer != msg.sender) {
            // Only the proven relayer could specify an address to claim the funds to.
            revert SenderIncorrect();
        }

        // Update status to RELAYER_CLAIMED and transfer the origin collateral to the specified claim address.
        // Note: this is a storage write.
        $.status = BridgeStatus.RELAYER_CLAIMED;

        // Accumulate protocol fees if origin fee amount exists.
        address token = request.originToken();
        uint256 amount = request.originAmount();
        uint256 originFeeAmount = request.originFeeAmount();
        if (originFeeAmount > 0) protocolFees[token] += originFeeAmount;

        // Emit the event before any external calls.
        emit BridgeDepositClaimed(transactionId, proofRelayer, to, token, amount);

        // Complete the relayer claim as the last transaction action.
        if (token == NATIVE_GAS_TOKEN) {
            Address.sendValue(payable(to), amount);
        } else {
            IERC20(token).safeTransfer(to, amount);
        }
    }

    // ═══════════════════════════════════════════════ PUBLIC VIEWS ════════════════════════════════════════════════════

    /// @inheritdoc IFastBridgeV2
    function bridgeStatuses(bytes32 transactionId) public view returns (BridgeStatus status) {
        return bridgeTxDetails[transactionId].status;
    }

    /// @inheritdoc IFastBridgeV2
    function bridgeProofs(bytes32 transactionId) public view returns (uint96 timestamp, address relayer) {
        BridgeTxDetails storage $ = bridgeTxDetails[transactionId];
        timestamp = $.proofBlockTimestamp;
        relayer = $.proofRelayer;
    }

    /// @inheritdoc IFastBridgeV2
    function bridgeRelays(bytes32 transactionId) public view returns (bool) {
        // This transaction has been relayed if the relayer address is recorded.
        return bridgeRelayDetails[transactionId].relayer != address(0);
    }

    // ═════════════════════════════════════════════ INTERNAL METHODS ══════════════════════════════════════════════════

    /// @notice Takes the bridged asset from the user into FastBridgeV2 custody. The asset will later be
    /// claimed by the relayer who completed the relay on the destination chain, or returned to the user
    /// via the cancel function if no relay is completed.
    function _takeBridgedUserAsset(address token, uint256 amount) internal returns (uint256 amountTaken) {
        if (token == NATIVE_GAS_TOKEN) {
            // For the native gas token, we just need to check that the supplied msg.value is correct.
            // Supplied `msg.value` is already in FastBridgeV2 custody.
            if (amount != msg.value) revert MsgValueIncorrect();
            amountTaken = msg.value;
        } else {
            // For ERC20s, token is explicitly transferred from the user to FastBridgeV2.
            // We don't allow non-zero `msg.value` to avoid extra funds from being stuck in FastBridgeV2.
            if (msg.value != 0) revert MsgValueIncorrect();
            // Throw an explicit error if the provided token address is not a contract.
            if (token.code.length == 0) revert TokenNotContract();

            // Use the balance difference as the amount taken in case of fee on transfer tokens.
            amountTaken = IERC20(token).balanceOf(address(this));
            IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
            amountTaken = IERC20(token).balanceOf(address(this)) - amountTaken;
        }
    }

    /// @notice Calls the recipient's hook function with the specified zapData and validates
    /// the returned value.
    function _triggerZapWithChecks(address recipient, address token, uint256 amount, bytes calldata zapData) internal {
        // Call the recipient's hook function with the specified zapData, bubbling any revert messages.
        bytes memory returnData = Address.functionCallWithValue({
            target: recipient,
            data: abi.encodeCall(IZapRecipient.zap, (token, amount, zapData)),
            // Note: see `relay()` for reasoning behind passing msg.value.
            value: msg.value
        });

        // 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(IZapRecipient.zap.selector)) {
            revert RecipientIncorrectReturnValue();
        }
    }

    /// @notice Calculates the time elapsed since a proof was submitted.
    /// @dev The proof.timestamp stores block timestamps as uint40 for gas optimization.
    /// _timeSince(proof) handles timestamp rollover when block.timestamp > type(uint40).max but
    /// proof.timestamp < type(uint40).max via an unchecked statement.
    /// @param proofBlockTimestamp The block timestamp when the proof was submitted.
    /// @return delta The time elapsed since proof submission.
    function _timeSince(uint40 proofBlockTimestamp) internal view returns (uint256 delta) {
        unchecked {
            delta = uint40(block.timestamp) - proofBlockTimestamp;
        }
    }

    /// @notice Validates all parameters required for a bridge transaction.
    /// @dev This function's complexity cannot be reduced due to the number of required checks,
    /// so we disable the code-complexity rule.
    // solhint-disable-next-line code-complexity
    function _validateBridgeParams(
        BridgeParams memory params,
        BridgeParamsV2 memory paramsV2,
        int256 exclusivityEndTime
    )
        internal
        view
    {
        // Check V1 (legacy) params.
        if (params.dstChainId == block.chainid) revert ChainIncorrect();
        if (params.originAmount == 0 || params.destAmount == 0) revert AmountIncorrect();
        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();

        // Check V2 params.
        if (paramsV2.zapData.length > MAX_ZAP_DATA_LENGTH) revert ZapDataLengthAboveMax();
        if (paramsV2.zapNative != 0 && params.destToken == NATIVE_GAS_TOKEN) {
            revert ZapNativeNotSupported();
        }

        // exclusivityEndTime must be in range [0 .. params.deadline].
        if (exclusivityEndTime < 0 || exclusivityEndTime > int256(params.deadline)) {
            revert ExclusivityParamsIncorrect();
        }
    }

    /// @notice Validates all parameters required for a relay transaction.
    function _validateRelayParams(bytes calldata request, bytes32 transactionId, address relayer) internal view {
        if (relayer == address(0)) revert ZeroAddress();
        // Check that the transaction has not been relayed yet and is for the current chain.
        if (bridgeRelays(transactionId)) revert TransactionRelayed();
        if (request.destChainId() != block.chainid) revert ChainIncorrect();
        // Check that the deadline for relay to happen has not passed yet.
        if (block.timestamp > request.deadline()) revert DeadlineExceeded();
        // Check the exclusivity period, if it was specified and is still ongoing.
        address exclRelayer = request.exclusivityRelayer();
        if (exclRelayer != address(0) && exclRelayer != relayer && block.timestamp <= request.exclusivityEndTime()) {
            revert ExclusivityPeriodNotPassed();
        }
    }
}