Robinhood MainnetChain ID: 4663
Contract detail

BridgeInFacet

BridgeInFacet is a verified Robinhood Chain smart contract at 0x9db03579…88aaf179 with 0 indexed transactions.

Address type
Smart contract
Transactions
0
History coverage
No indexed activity found
Verification
Verified
Compiler
v0.8.24+commit.e11b9ed9
Verification match
partial
Creation transaction
0x0275adb7…62e2b1ba
Created at block
#28614873
Language
solidity
EVM version
cancun
License
none
Compiler optimization
Enabled · 200 runs
Verified contract

Contract source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
import {ECDSA} from "solady/utils/ECDSA.sol";
import {EfficientHashLib} from "solady/utils/EfficientHashLib.sol";

import {
    AppStorage,
    LibAppStorage,
    BridgeInRequest,
    SignerSet,
    TokenConfig,
    TokenMode,
    TokenNotConfigured,
    PendingBridgeIn,
    MessageStatus,
    TimelockReason,
    OperatingMode,
    AddressPolicy,
    AddressStatus,
    DeadlineExpired,
    MAX_DECIMALS,
    DEFAULT_QUEUE_DELAY
} from "../libs/LibAppStorage.sol";
import {LibPausable} from "../libs/LibPausable.sol";
import {LibReentrancyGuard} from "../libs/LibReentrancyGuard.sol";
import {LibRateLimiter} from "../libs/LibRateLimiter.sol";
import {LibBridgeIn} from "../libs/LibBridgeIn.sol";

// ═══════════════════════════════════════════════════════════════════════════════
// ERRORS
// ═══════════════════════════════════════════════════════════════════════════════

error ZeroAmount();
error ZeroRecipient();
error InvalidDecimals(uint8 decimals, uint8 maxDecimals);
error AmountTooSmallAfterConversion();
error BridgeInAlreadyProcessed(bytes32 messageId);
error NoValidSignerSet();
error ChainIdMismatch(uint256 expected, uint256 actual);

// Routing errors
error RecipientBlocked(address recipient);
error QuantityRateLimitExceeded(address token, uint256 available, uint256 requested, uint256 retryAfter);
error GlobalValueRateLimitExceeded(uint256 available, uint256 requested, uint256 retryAfter);
error AddressRateLimitExceeded(address recipient, uint256 available, uint256 requested, uint256 retryAfter);

// ═══════════════════════════════════════════════════════════════════════════════
// EVENTS
// ═══════════════════════════════════════════════════════════════════════════════

/// @notice Emitted when a bridge-in is queued for timelock
/// @param messageId Unique message identifier
/// @param recipient Recipient address
/// @param amount Original amount from request (source decimals)
/// @param reason Reason for queueing
event BridgeInQueued(
    bytes32 indexed messageId, address indexed recipient, uint256 amount, TimelockReason reason, uint256 executeAfter
);

// ═══════════════════════════════════════════════════════════════════════════════
// FACET
// ═══════════════════════════════════════════════════════════════════════════════

/// @title BridgeInFacet
/// @notice Handles incoming bridge messages (GalaChain -> EVM) with routing
/// @dev Uses EIP-712 threshold signature verification from authorized signer set.
///      Routes transactions through instant path or timelock based on operating mode,
///      trust status, and rate limits.
contract BridgeInFacet {
    using EnumerableSet for EnumerableSet.Bytes32Set;
    using LibRateLimiter for LibRateLimiter.Bucket;

    // =========================================================================
    // Constants
    // =========================================================================

    /// @dev BridgeInRequest typehash for EIP-712 structured data
    bytes32 public constant BRIDGE_IN_TYPEHASH = keccak256(
        "BridgeInRequest(string sourceChain,string sourceToken,string sender,uint256 nonce,address token,address recipient,uint256 sourceAmount,uint8 sourceDecimals,uint256 deadline,uint256 attestedUsdValue)"
    );

    // =========================================================================
    // Storage
    // =========================================================================

    /// @dev AppStorage at slot 0 - MUST be the only state variable
    AppStorage internal s;

    // =========================================================================
    // External Functions
    // =========================================================================

    /// @notice Bridge tokens from GalaChain to EVM with automatic routing
    /// @dev Verifies threshold signatures, consumes rate limits, and routes based on
    ///      operating mode and trust status. Instant path available for trusted recipients
    ///      within limits in Normal mode. Everything queued in Guarded mode.
    /// @param request The bridge in request containing token, amount, recipient, etc.
    /// @param signatures Array of signatures, sorted by signer address ascending
    function bridgeIn(BridgeInRequest calldata request, bytes[] calldata signatures) external {
        LibPausable.requireNotPaused();
        LibReentrancyGuard.lock();

        // === FAIL-FAST: Cheapest checks first ===

        // 1. Zero checks (calldata reads only)
        if (request.sourceAmount == 0) revert ZeroAmount();
        if (request.recipient == address(0)) revert ZeroRecipient();

        // 2. Deadline check
        if (request.deadline != 0 && block.timestamp > request.deadline) {
            revert DeadlineExpired(request.deadline, block.timestamp);
        }

        // 3. Blocked address check (before expensive operations)
        if (s.addressPolicies[request.recipient].status == AddressStatus.Blocked) {
            revert RecipientBlocked(request.recipient);
        }

        // === EXPENSIVE OPERATIONS ===

        // 4. Verify signatures (expensive but must happen before state changes)
        _verifySignatures(request, signatures);

        // 5. Compute message ID
        bytes32 messageId = _computeMessageId(request.sourceChain, request.sender, request.nonce);

        // 6. Replay check using MessageStatus
        if (s.messageStatus[messageId] != MessageStatus.Unknown) {
            revert BridgeInAlreadyProcessed(messageId);
        }

        // 7. Token config check
        TokenConfig storage config = s.tokenConfigs[request.token];
        if (config.mode == TokenMode.NotConfigured) {
            revert TokenNotConfigured(request.token);
        }

        // 8. Normalize amount from source to destination decimals
        uint256 normalizedAmount = _normalizeAmount(request.sourceAmount, request.sourceDecimals, config.decimals);
        if (normalizedAmount == 0) {
            revert AmountTooSmallAfterConversion();
        }

        // === CONSUME RATE LIMITS (reverts if exceeded) ===
        _consumeRateLimits(request.token, request.recipient, normalizedAmount, request.attestedUsdValue);

        // === ROUTING DECISION ===
        if (s.operatingMode == OperatingMode.Guarded) {
            // Guarded mode: everything goes through timelock
            s.messageStatus[messageId] = MessageStatus.Pending;
            _queueBridgeIn(messageId, request, normalizedAmount, config.mode, TimelockReason.GuardedMode);
        } else {
            // Normal mode: check if eligible for instant execution
            (bool eligible, TimelockReason reason) =
                _checkInstantEligibility(request.token, request.recipient, request.attestedUsdValue);

            if (eligible) {
                // Mark as executed and process immediately
                s.messageStatus[messageId] = MessageStatus.Executed;
                LibBridgeIn.executeInstant(
                    messageId, request.token, request.recipient, normalizedAmount, config.mode, s.escrowAddress
                );
            } else {
                // Not eligible for instant - queue with reason
                s.messageStatus[messageId] = MessageStatus.Pending;
                _queueBridgeIn(messageId, request, normalizedAmount, config.mode, reason);
            }
        }

        LibReentrancyGuard.unlock();
    }

    /// @notice Check if a bridge-in message has been processed
    /// @param messageId The message ID to check
    /// @return True if the message has been processed (executed or reverted)
    function isBridgeInProcessed(bytes32 messageId) external view returns (bool) {
        MessageStatus status = s.messageStatus[messageId];
        return status == MessageStatus.Executed || status == MessageStatus.Reverted;
    }

    /// @notice Compute message ID for a bridge-in request
    /// @dev Useful for off-chain systems to pre-compute message IDs
    /// @param sourceChain Source chain identifier
    /// @param sender Sender identifier on source chain
    /// @param nonce User-provided nonce
    /// @return The message ID
    function computeMessageId(string calldata sourceChain, string calldata sender, uint256 nonce)
        external
        pure
        returns (bytes32)
    {
        return _computeMessageId(sourceChain, sender, nonce);
    }

    /// @notice Get the EIP-712 domain separator
    /// @dev Useful for off-chain systems to construct valid signatures
    /// @return The domain separator
    function getDomainSeparator() external view returns (bytes32) {
        return _domainSeparator();
    }

    // =========================================================================
    // Private Functions - Rate Limit Consumption
    // =========================================================================

    /// @notice Consume all rate limit buckets with contextual error messages
    /// @dev Reverts with specific error identifying which bucket was exceeded.
    ///      All buckets are consumed before routing decision.
    /// @param token Token address (for quantity bucket)
    /// @param recipient Recipient address (for address bucket)
    /// @param normalizedAmount Amount in destination decimals
    /// @param attestedUsdValue USD value from signed message (18 decimals)
    function _consumeRateLimits(address token, address recipient, uint256 normalizedAmount, uint256 attestedUsdValue)
        private
    {
        // 1. Quantity bucket (per-token)
        _consumeQuantityBucketChecked(token, normalizedAmount);

        // 2. Global value bucket (bridge-wide USD)
        _consumeGlobalValueBucketChecked(attestedUsdValue);

        // 3. Address bucket (per-recipient USD, no-op if not enabled or no attestation)
        _consumeAddressBucketChecked(recipient, attestedUsdValue);
    }

    /// @notice Consume quantity bucket with contextual error
    /// @param token Token address
    /// @param amount Amount to consume
    function _consumeQuantityBucketChecked(address token, uint256 amount) private {
        LibRateLimiter.Bucket storage bucket = s.quantityBuckets[token];

        // Check isEnabled from storage BEFORE expensive withAccumulatedRefill()
        if (!bucket.isEnabled) {
            revert QuantityRateLimitExceeded(token, 0, amount, type(uint256).max);
        }

        LibRateLimiter.Bucket memory current = bucket.withAccumulatedRefill();

        if (amount > current.tokens) {
            uint256 retryAfter = amount > current.capacity
                ? type(uint256).max
                : block.timestamp + _ceilDiv(amount - current.tokens, current.refillRate);
            revert QuantityRateLimitExceeded(token, current.tokens, amount, retryAfter);
        }

        // Consume (will succeed since we pre-checked)
        bucket.consume(amount);
    }

    /// @notice Consume global value bucket with contextual error
    /// @param valueAmount USD value to consume (18 decimals)
    function _consumeGlobalValueBucketChecked(uint256 valueAmount) private {
        if (valueAmount == 0) return;

        LibRateLimiter.Bucket storage bucket = LibAppStorage.globalValueBucket();

        if (!bucket.isEnabled) return; // Disabled bucket = no limit

        LibRateLimiter.Bucket memory current = bucket.withAccumulatedRefill();

        if (valueAmount > current.tokens) {
            uint256 retryAfter = valueAmount > current.capacity
                ? type(uint256).max
                : block.timestamp + _ceilDiv(valueAmount - current.tokens, current.refillRate);
            revert GlobalValueRateLimitExceeded(current.tokens, valueAmount, retryAfter);
        }

        // Consume (will succeed since we pre-checked)
        bucket.consume(valueAmount);
    }

    /// @notice Consume address bucket with contextual error
    /// @dev No-op if usdValue is 0 (no USD attestation provided)
    /// @dev No-op if bucket is not enabled (untrusted addresses)
    /// @param recipient Recipient address
    /// @param usdValue USD value to consume (18 decimals)
    function _consumeAddressBucketChecked(address recipient, uint256 usdValue) private {
        if (usdValue == 0) return; // No USD attestation = skip bucket consumption

        AddressPolicy storage policy = s.addressPolicies[recipient];

        if (!policy.bucket.isEnabled) return; // Not enabled = no limit

        LibRateLimiter.Bucket memory current = policy.bucket.withAccumulatedRefill();

        if (usdValue > current.tokens) {
            uint256 retryAfter = usdValue > current.capacity
                ? type(uint256).max
                : block.timestamp + _ceilDiv(usdValue - current.tokens, current.refillRate);
            revert AddressRateLimitExceeded(recipient, current.tokens, usdValue, retryAfter);
        }

        // Consume (will succeed since we pre-checked)
        policy.bucket.consume(usdValue);
    }

    /// @notice Ceiling division: ceil(a / b)
    /// @dev Used for wait time calculations
    function _ceilDiv(uint256 a, uint256 b) private pure returns (uint256) {
        return (a + b - 1) / b;
    }

    // =========================================================================
    // Private Functions - Instant Eligibility
    // =========================================================================

    /// @notice Check if transaction is eligible for instant execution
    /// @dev Checks instant thresholds only. Bucket capacity is enforced via consume() which reverts.
    ///      Order: token instant limit -> trusted status -> per-address instant limit
    ///      All instant limits are USD-based (18 decimals). If a limit is configured but
    ///      attestedUsdValue is 0 (not attested), the transaction goes to timelock.
    /// @param token The token being bridged
    /// @param recipient The recipient address
    /// @param attestedUsdValue The attested USD value (18 decimals), 0 if not attested
    /// @return eligible True if eligible for instant path
    /// @return reason If not eligible, the reason for timelock
    function _checkInstantEligibility(address token, address recipient, uint256 attestedUsdValue)
        private
        view
        returns (bool eligible, TimelockReason reason)
    {
        // 1. Check token instant value limit (USD-based)
        uint256 tokenLimit = s.instantValueLimits[token];
        if (tokenLimit > 0) {
            // Attestation enforced only when the limit is configured
            if (attestedUsdValue == 0) {
                return (false, TimelockReason.MissingAttestation);
            }
            if (attestedUsdValue > tokenLimit) {
                return (false, TimelockReason.TokenInstantLimitExceeded);
            }
        }

        // 2. Check recipient is trusted
        AddressPolicy storage policy = s.addressPolicies[recipient];
        if (policy.status != AddressStatus.Trusted) {
            return (false, TimelockReason.UntrustedRecipient);
        }

        // 3. Check per-address instant limit (USD-based)
        if (policy.instantValueLimit > 0) {
            // Attestation enforced only when the limit is configured
            if (attestedUsdValue == 0) {
                return (false, TimelockReason.MissingAttestation);
            }
            if (attestedUsdValue > policy.instantValueLimit) {
                return (false, TimelockReason.PolicyInstantLimitExceeded);
            }
        }

        return (true, TimelockReason.UntrustedRecipient); // reason ignored when eligible
    }

    // =========================================================================
    // Private Functions - Queue Integration
    // =========================================================================

    /// @notice Queue a bridge-in request for delayed execution
    /// @dev Pre-computes normalized amount and snapshots mode for deterministic execution.
    ///      Full request details emitted in event for off-chain indexing.
    /// @param messageId The unique message identifier
    /// @param request The bridge-in request (for event emission)
    /// @param normalizedAmount Pre-computed amount in destination decimals
    /// @param mode Execution mode snapshot (Lock or Burn)
    /// @param reason The reason for queuing
    function _queueBridgeIn(
        bytes32 messageId,
        BridgeInRequest calldata request,
        uint256 normalizedAmount,
        TokenMode mode,
        TimelockReason reason
    ) private {
        uint256 delay = s.queueDelay;
        if (delay == 0) {
            delay = DEFAULT_QUEUE_DELAY;
        }

        // Store minimal execution data with snapshots
        s.pendingMessages[messageId] = PendingBridgeIn({
            token: request.token,
            recipient: request.recipient,
            normalizedAmount: normalizedAmount,
            mode: mode,
            reason: reason,
            executeAfter: block.timestamp + delay
        });

        // Add to queue
        s.queuedIds.add(messageId);

        // Emit full request for off-chain systems (indexers, UI, audit trail)
        emit BridgeInQueued(messageId, request.recipient, request.sourceAmount, reason, block.timestamp + delay);
    }

    // =========================================================================
    // Private Functions - Signature Verification
    // =========================================================================

    /// @notice Verify signatures for a bridge-in request
    /// @dev Tries current signer set first, then previous set if still active
    function _verifySignatures(BridgeInRequest calldata request, bytes[] calldata signatures) private view {
        bytes32 structHash = _hashRequest(request);
        bytes32 digest = _toTypedDataHash(_domainSeparator(), structHash);

        // Try current signer set first
        if (LibAppStorage.currentSignerSet().signers.length > 0) {
            if (_tryVerifyWithSet(digest, signatures, LibAppStorage.currentSignerSet())) {
                return;
            }
        }

        // Try previous signer set if still active
        if (
            LibAppStorage.previousSignerSet().signers.length > 0
                && (LibAppStorage.previousSignerSet().activeUntilBlock == 0
                    || block.number <= LibAppStorage.previousSignerSet().activeUntilBlock)
        ) {
            if (_tryVerifyWithSet(digest, signatures, LibAppStorage.previousSignerSet())) {
                return;
            }
        }

        revert NoValidSignerSet();
    }

    /// @notice Try to verify signatures against a signer set
    function _tryVerifyWithSet(bytes32 digest, bytes[] calldata signatures, SignerSet storage set)
        private
        view
        returns (bool)
    {
        uint256 quorum = set.quorum;

        if (signatures.length < quorum) {
            return false;
        }

        address lastSigner = address(0);

        for (uint256 i = 0; i < quorum; i++) {
            address signer = ECDSA.tryRecoverCalldata(digest, signatures[i]);

            // Solady returns address(0) on recovery failure
            if (signer == address(0)) {
                return false;
            }

            if (!_isSignerInSet(set, signer)) {
                return false;
            }

            if (signer <= lastSigner) {
                return false;
            }

            lastSigner = signer;
        }

        return true;
    }

    /// @notice Check if an address is in the signer set
    function _isSignerInSet(SignerSet storage set, address signer) private view returns (bool) {
        address[] storage signers = set.signers;
        uint256 length = signers.length;

        for (uint256 i = 0; i < length; i++) {
            if (signers[i] == signer) {
                return true;
            }
        }

        return false;
    }

    /// @notice Compute the EIP-712 struct hash for a BridgeInRequest
    function _hashRequest(BridgeInRequest calldata request) private pure returns (bytes32) {
        return EfficientHashLib.hash(
            BRIDGE_IN_TYPEHASH,
            keccak256(bytes(request.sourceChain)),
            keccak256(bytes(request.sourceToken)),
            keccak256(bytes(request.sender)),
            bytes32(request.nonce),
            bytes32(uint256(uint160(request.token))),
            bytes32(uint256(uint160(request.recipient))),
            bytes32(request.sourceAmount),
            bytes32(uint256(request.sourceDecimals)),
            bytes32(request.deadline),
            bytes32(request.attestedUsdValue)
        );
    }

    /// @notice Get the EIP-712 domain separator
    /// @dev Returns the domain separator computed and stored at initialization.
    ///      Reverts with ChainIdMismatch if chain ID has changed.
    ///      This is intentional - on a fork, the bridge should stop until reinitialized.
    function _domainSeparator() private view returns (bytes32) {
        if (block.chainid != s.domainChainId) {
            revert ChainIdMismatch(s.domainChainId, block.chainid);
        }
        return s.domainSeparator;
    }

    /// @notice Compute EIP-712 typed data hash efficiently
    /// @dev Equivalent to keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash))
    function _toTypedDataHash(bytes32 domainSeparator, bytes32 structHash) private pure returns (bytes32 digest) {
        /// @solidity memory-safe-assembly
        assembly {
            let m := mload(0x40) // Cache free memory pointer
            // Store "\x19\x01" - value is right-aligned, so 0x19 ends up at 0x1e, 0x01 at 0x1f
            mstore(0x00, 0x1901)
            mstore(0x20, domainSeparator)
            mstore(0x40, structHash)
            digest := keccak256(0x1e, 0x42) // Hash 66 bytes starting at 0x1e
            mstore(0x40, m) // Restore free memory pointer
        }
    }

    // =========================================================================
    // Private Functions - Message Processing
    // =========================================================================

    /// @notice Compute the chain-agnostic message ID for replay protection
    /// @dev Only uses (sourceChain, sender, nonce) - identifies which message from whom
    function _computeMessageId(string calldata sourceChain, string calldata sender, uint256 nonce)
        private
        pure
        returns (bytes32)
    {
        return keccak256(abi.encode(keccak256(bytes(sourceChain)), keccak256(bytes(sender)), nonce));
    }

    /// @notice Normalize amount from source decimals to destination decimals
    /// @param amount Raw amount in source decimals
    /// @param sourceDecimals Token decimals on source chain
    /// @param destDecimals Token decimals on destination chain
    /// @return Normalized amount in destination decimals
    function _normalizeAmount(uint256 amount, uint8 sourceDecimals, uint8 destDecimals) private pure returns (uint256) {
        // Validate decimals are within bounds
        if (sourceDecimals > MAX_DECIMALS) {
            revert InvalidDecimals(sourceDecimals, MAX_DECIMALS);
        }
        if (destDecimals > MAX_DECIMALS) {
            revert InvalidDecimals(destDecimals, MAX_DECIMALS);
        }

        if (sourceDecimals == destDecimals) {
            return amount;
        }

        if (sourceDecimals > destDecimals) {
            // Scale down (precision loss - floor division)
            return amount / (10 ** (sourceDecimals - destDecimals));
        }

        // Scale up
        return amount * (10 ** (destDecimals - sourceDecimals));
    }
}