Skip to main content

Overview

Eco Routes Protocol implements a comprehensive security model with multiple layers of protection:
  • Vault escrow system for isolated fund management
  • Executor safety checks to prevent malicious calls
  • Authorization controls limiting contract interactions
  • Lifecycle-based state management preventing double-spends

Vault Security

Isolated Escrow

Each intent gets its own dedicated vault contract for fund isolation:
// From IntentSource.sol:886-902
function _getOrDeployVault(bytes32 intentHash) internal returns (address) {
    address vault = _getVault(intentHash);
    
    return vault.code.length > 0
        ? vault
        : VAULT_IMPLEMENTATION.clone(intentHash);
}

function _getVault(bytes32 intentHash) internal view returns (address) {
    return VAULT_IMPLEMENTATION.predict(intentHash, CREATE2_PREFIX);
}
Key properties:
  • One vault per intent: Funds cannot be mixed between intents
  • Deterministic addresses: Vault address computed from intentHash
  • Minimal proxies: Gas-efficient clones of implementation contract
  • Immutable portal: Each vault only accepts calls from its deployer

Portal Authorization

Critical: Only the portal contract that deployed a vault can call its functions.
// From Vault.sol:19-44
contract Vault is IVault {
    /// @notice Address of the portal contract that can call this vault
    address private immutable portal;
    
    constructor() {
        portal = msg.sender;  // Set to deployer (IntentSource)
    }
    
    modifier onlyPortal() {
        if (msg.sender != portal) {
            revert NotPortalCaller(msg.sender);
        }
        _;
    }
    
    function fundFor(/* ... */) external payable onlyPortal { /* ... */ }
    function withdraw(/* ... */) external onlyPortal { /* ... */ }
    function refund(/* ... */) external onlyPortal { /* ... */ }
    function recover(/* ... */) external onlyPortal { /* ... */ }
}
Security guarantees:
  • Users cannot withdraw directly from vaults
  • Vaults cannot be drained by unauthorized contracts
  • Portal enforces all business logic and validation

Fund Management

Funding

// From Vault.sol:46-74
function fundFor(
    Reward calldata reward,
    address funder,
    IPermit permit
) external payable onlyPortal returns (bool fullyFunded) {
    fullyFunded = address(this).balance >= reward.nativeAmount;
    
    uint256 rewardsLength = reward.tokens.length;
    for (uint256 i; i < rewardsLength; ++i) {
        IERC20 token = IERC20(reward.tokens[i].token);
        
        uint256 remaining = _fundFromPermit(
            funder,
            token,
            reward.tokens[i].amount,
            permit
        );
        remaining = _fundFrom(funder, token, remaining);
        
        fullyFunded = fullyFunded && remaining == 0;
    }
}
Features:
  • Partial funding support: Returns whether fully funded
  • Multiple funding methods: Standard approvals and permit-based
  • Safe transfers: Uses OpenZeppelin’s SafeERC20
  • Balance checks: Verifies existing balance before transferring

Withdrawal

// From Vault.sol:76-106
function withdraw(
    Reward calldata reward,
    address claimant
) external onlyPortal {
    uint256 rewardsLength = reward.tokens.length;
    for (uint256 i; i < rewardsLength; ++i) {
        IERC20 token = IERC20(reward.tokens[i].token);
        uint256 amount = reward.tokens[i].amount.min(
            token.balanceOf(address(this))
        );
        
        if (amount > 0) {
            token.safeTransfer(claimant, amount);
        }
    }
    
    uint256 nativeAmount = address(this).balance.min(reward.nativeAmount);
    if (nativeAmount == 0) return;
    
    (bool success, ) = claimant.call{value: nativeAmount}("");
    if (!success) {
        revert NativeTransferFailed(claimant, nativeAmount);
    }
}
Safety features:
  • Actual balance check: Uses min() to prevent over-withdrawal
  • Graceful degradation: Transfers available amount even if underfunded
  • Native ETH handling: Safely transfers ETH with success check

Refund

// From Vault.sol:108-133
function refund(Reward calldata reward, address refundee) external onlyPortal {
    uint256 rewardsLength = reward.tokens.length;
    for (uint256 i; i < rewardsLength; ++i) {
        IERC20 token = IERC20(reward.tokens[i].token);
        uint256 amount = token.balanceOf(address(this));
        
        if (amount > 0) {
            token.safeTransfer(refundee, amount);
        }
    }
    
    uint256 nativeAmount = address(this).balance;
    if (nativeAmount == 0) return;
    
    (bool success, ) = refundee.call{value: nativeAmount}("");
    if (!success) {
        revert NativeTransferFailed(refundee, nativeAmount);
    }
}
Key difference from withdrawal:
  • Full balance: Transfers entire balance, not just reward amount
  • Emergency recovery: Can recover funds even if partially funded

Executor Security

Purpose

The Executor contract safely executes arbitrary calls on behalf of intents:
// From Executor.sol:8-15
/**
 * @title Executor
 * @notice Contract for secure batch execution of intent calls
 * @dev Implements IExecutor with comprehensive safety checks and authorization controls
 * - Only the portal contract can execute calls (onlyPortal modifier)
 * - Prevents malicious calls through EOA validation
 * - Supports batch execution for multiple calls in a single transaction
 */
contract Executor is IExecutor {

Portal-Only Execution

// From Executor.sol:17-40
contract Executor is IExecutor {
    /// @notice Address of the portal contract authorized to call execute
    address private immutable portal;
    
    constructor() {
        portal = msg.sender;  // Set during deployment by portal
    }
    
    modifier onlyPortal() {
        if (msg.sender != portal) {
            revert NonPortalCaller(msg.sender);
        }
        _;
    }
    
    function execute(
        Call[] calldata calls
    ) external payable override onlyPortal returns (bytes[] memory) {
        // ...
    }
}
Authorization Model: The executor is deployed by the portal contract and only accepts calls from it. This prevents unauthorized execution of calls.

EOA Protection

One of the most important security features prevents calls to Externally Owned Accounts (EOAs) with calldata:
// From Executor.sol:79-88
/**
 * @notice Checks if a call is targeting an EOA with calldata
 * @dev Returns true if target has no code but calldata is provided
 * This prevents potential phishing attacks where calldata might be misinterpreted
 * @param call The call to validate
 * @return bool True if this is a potentially unsafe call to an EOA
 */
function _isCallToEoa(Call calldata call) internal view returns (bool) {
    return call.target.code.length == 0 && call.data.length > 0;
}
Why this matters:
// ❌ BLOCKED: Call to EOA with calldata
Call memory maliciousCall = Call({
    target: 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,  // EOA (no code)
    value: 1 ether,
    data: hex"1234abcd"  // Some calldata
});
// Executor.execute([maliciousCall]) → Reverts with CallToEOA

// ✅ ALLOWED: Simple ETH transfer (no calldata)
Call memory ethTransfer = Call({
    target: 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb,  // EOA
    value: 1 ether,
    data: ""  // No calldata
});
// Executor.execute([ethTransfer]) → Success

// ✅ ALLOWED: Call to contract
Call memory contractCall = Call({
    target: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,  // USDC contract
    value: 0,
    data: abi.encodeWithSignature("transfer(address,uint256)", recipient, amount)
});
// Executor.execute([contractCall]) → Success

Call Execution

// From Executor.sol:50-77
function execute(
    Call[] calldata calls
) external payable override onlyPortal returns (bytes[] memory) {
    uint256 callsLength = calls.length;
    bytes[] memory results = new bytes[](callsLength);
    
    for (uint256 i = 0; i < callsLength; i++) {
        results[i] = execute(calls[i]);
    }
    
    return results;
}

function execute(Call calldata call) internal returns (bytes memory) {
    if (_isCallToEoa(call)) {
        revert CallToEOA(call.target);
    }
    
    (bool success, bytes memory result) = call.target.call{
        value: call.value
    }(call.data);
    
    if (!success) {
        revert CallFailed(call, result);
    }
    
    return result;
}
Security features:
  • Batch execution: All calls in a batch must succeed or entire transaction reverts
  • Return data: Captures and returns results from each call
  • Detailed errors: Includes failed call details in revert messages

Lifecycle State Management

Intent Status

enum Status {
    Initial,    // Intent created but not funded
    Funded,     // Intent fully funded
    Withdrawn,  // Rewards withdrawn by solver
    Refunded    // Rewards refunded to creator
}

State Transitions

Validation Logic

Funding Validation

// From IntentSource.sol:62-79
modifier onlyFundable(bytes32 intentHash) {
    Status status = rewardStatuses[intentHash];
    
    if (status == Status.Withdrawn || status == Status.Refunded) {
        revert InvalidStatusForFunding(status);
    }
    
    if (status == Status.Funded) {
        return;  // Allow re-funding (for partial funding)
    }
    
    _;
}
Partial Funding: Intents can be funded multiple times until fully funded, but cannot be funded after withdrawal or refund.

Withdrawal Validation

// From IntentSource.sol:816-835
function _validateWithdraw(
    bytes32 intentHash,
    address claimant
) internal view {
    Status status = rewardStatuses[intentHash];
    
    if (status != Status.Initial && status != Status.Funded) {
        revert InvalidStatusForWithdrawal(status);
    }
    
    if (claimant == address(0)) {
        revert InvalidClaimant();
    }
}
Requirements:
  • Intent must be in Initial or Funded state
  • Claimant must be proven by the prover contract
  • Cannot withdraw after refund

Refund Validation

// From IntentSource.sol:781-814
function _validateRefund(
    bytes32 intentHash,
    uint64 destination,
    Reward calldata reward
) internal view {
    Status status = rewardStatuses[intentHash];
    IProver.ProofData memory proof = IProver(reward.prover).provenIntents(
        intentHash
    );
    
    // If proof is incorrect or no proof
    if (proof.destination != destination || proof.claimant == address(0)) {
        if (block.timestamp < reward.deadline) {
            revert InvalidStatusForRefund(
                status,
                block.timestamp,
                reward.deadline
            );
        }
        
        return;
    }
    
    if (status == Status.Initial || status == Status.Funded) {
        revert IntentNotClaimed(intentHash);
    }
}
Refund conditions:
  • Deadline must have passed
  • Intent must not have been proven on the correct destination
  • OR intent has been withdrawn/refunded already

Best Practices

For Intent Creators

Set Reasonable Deadlines: Too short and legitimate solvers may not have time to fulfill. Too long and your funds are locked unnecessarily.
// ✅ Good: 1 hour for cross-chain bridge
Reward memory reward = Reward({
    deadline: uint64(block.timestamp + 1 hours),
    // ...
});

// ❌ Bad: 30 seconds (too short)
Reward memory reward = Reward({
    deadline: uint64(block.timestamp + 30),
    // ...
});

// ⚠️ Risky: 7 days (funds locked too long)
Reward memory reward = Reward({
    deadline: uint64(block.timestamp + 7 days),
    // ...
});

For Solvers

Verify Proof Before Withdrawing: Always ensure your fulfillment was correctly proven before attempting withdrawal.
// Check proof status first
IProver.ProofData memory proof = prover.provenIntents(intentHash);

require(proof.claimant == address(this), "Not proven as claimant");
require(proof.destination == expectedDestination, "Wrong destination");

// Then withdraw
portal.withdraw(destination, routeHash, reward);

For Integrators

Token Approvals: Only approve the exact amount needed for each intent. Never give unlimited approvals.
// ✅ Good: Approve exact amount
token.approve(portal, intentAmount);
portal.publishAndFund(intent, false);

// ❌ Bad: Unlimited approval
token.approve(portal, type(uint256).max);

Attack Vectors & Mitigations

Double-Spend Prevention

Attack: Attempt to withdraw the same intent twice. Mitigation: State transitions prevent this:
// First withdrawal
portal.withdraw(destination, routeHash, reward);
// Status: Initial → Withdrawn

// Second withdrawal attempt
portal.withdraw(destination, routeHash, reward);
// ❌ Reverts: InvalidStatusForWithdrawal(Withdrawn)

Vault Draining

Attack: Try to withdraw funds from vault directly. Mitigation: onlyPortal modifier:
vault.withdraw(reward, attacker);
// ❌ Reverts: NotPortalCaller(attacker)

Malicious Calls

Attack: Include malicious calldata targeting EOAs. Mitigation: EOA validation:
Call memory attack = Call({
    target: victimEOA,
    value: 0,
    data: maliciousCalldata
});

executor.execute([attack]);
// ❌ Reverts: CallToEOA(victimEOA)

Front-Running

Attack: See a profitable intent in mempool and try to fulfill it first. Mitigation: This is actually desirable behavior in the protocol! Solvers compete to fulfill intents quickly, benefiting users.
No. Vaults are isolated and can only be accessed by the portal contract. The portal enforces all business logic including proof verification before allowing withdrawals.
You can call refund() to recover your funds from the vault. The intent status will change to Refunded and you’ll receive all deposited tokens back.
The executor can only be called by the portal, and it validates that calls don’t target EOAs with calldata. However, the intent creator defines what calls are made, so choose trusted routes.
The withdraw function uses min() to transfer available balance:
uint256 amount = reward.tokens[i].amount.min(
    token.balanceOf(address(this))
);
Solvers receive whatever is available, but may choose not to fulfill underfunded intents.

Security Audits

Coming Soon: Professional security audits will be conducted before mainnet launch. Check the documentation for updates.

Next Steps

Deterministic Addresses

Learn how CREATE2 enables secure deterministic deployments

Deposit Addresses

See how security applies to deposit address system

Build docs developers (and LLMs) love