Skip to main content
The Executor is an isolated contract that executes intent calls on the destination chain. It provides security isolation by separating arbitrary call execution from the Portal’s storage and balance.

Purpose

The Executor serves as a security boundary for intent execution: Without Executor:
Portal (with storage) → executes arbitrary calls
❌ Risk: Malicious calls can manipulate Portal state
With Executor:
Portal (with storage) → Executor (stateless) → executes arbitrary calls
✅ Portal state protected from execution context

Architecture

Portal (Inbox)
    ↓ calls
Executor (single instance)
    ↓ executes
User-specified calls (swaps, transfers, etc.)
Key Properties:
  • One executor per Portal deployment
  • Stateless (no storage between transactions)
  • Only Portal can trigger execution
  • Receives tokens from solver before execution
  • Returns unused tokens/ETH to solver after execution

Access Control

modifier onlyPortal()
The executor can only be called by the Portal that deployed it. This prevents:
  • Direct execution of arbitrary calls
  • Bypassing intent validation
  • Unauthorized token access

Execution Flow

Call Execution

function execute(Call[] calldata calls) 
    external payable onlyPortal returns (bytes[] memory)
Process:
  1. Portal validates intent and transfers tokens to executor
  2. Portal calls executor with intent’s call array
  3. Executor validates and executes each call sequentially
  4. Executor returns results to Portal
  5. Portal refunds unused ETH to solver

Single Call

function execute(Call calldata call) internal returns (bytes memory)
Each call is executed with:
  • Target: Contract address to call
  • Value: Native token amount to send
  • Data: Encoded function call data
Validation:
  • Checks if call targets EOA with calldata (security check)
  • Uses low-level call() for execution
  • Reverts entire transaction if any call fails

Security Features

EOA Protection

function _isCallToEoa(Call calldata call) internal view returns (bool)
Prevents calls to externally owned accounts (EOAs) when calldata is present:
if (target.code.length == 0 && calldata.length > 0) {
    revert CallToEOA(target);
}
Why this matters:
  • EOAs cannot execute code, so calldata is meaningless
  • Calldata to EOA might indicate misconfiguration
  • Prevents potential phishing where calldata appears to be a function call
Allowed:
  • Native token transfers to EOAs (zero calldata)
  • Calls to contracts (non-zero code length)
Blocked:
  • Calls to EOAs with calldata (suspicious pattern)

Storage Isolation

The Executor has no state variables except immutable portal:
  • Cannot be manipulated through reentrancy
  • No persistent state to corrupt
  • Fresh execution context for each intent

Atomic Execution

All calls execute atomically:
  • If any call fails, entire transaction reverts
  • No partial execution of intent
  • Solver either succeeds completely or loses only gas

Token Handling

Token Flow

Solver (approves tokens) 

Portal (validates and transfers)

Executor (receives tokens)

Executor (executes calls using tokens)

Destination contracts (swaps, transfers, etc.)

Native Tokens

The Executor accepts ETH via:
receive() external payable {}
Portal transfers route.nativeAmount to Executor before execution:
executor.execute{value: route.nativeAmount}(route.calls);

ERC20 Tokens

Portal transfers ERC20s to Executor before execution:
for (TokenAmount memory token : route.tokens) {
    IERC20(token.token).safeTransferFrom(
        solver,
        address(executor),
        token.amount
    );
}
Executor then uses these tokens during call execution.

Call Structure

struct Call {
    address target;   // Contract to call
    uint256 value;    // Native tokens to send
    bytes data;       // Encoded function call
}

Example Calls

Token Swap:
Call({
    target: uniswapRouter,
    value: 0,
    data: abi.encodeWithSelector(
        IUniswapRouter.swapExactTokensForTokens.selector,
        amountIn,
        amountOutMin,
        path,
        recipient,
        deadline
    )
})
Native Token Transfer:
Call({
    target: recipientAddress,
    value: 1 ether,
    data: ""  // Empty for simple transfer
})
Contract Interaction:
Call({
    target: dappContract,
    value: 0.1 ether,
    data: abi.encodeWithSelector(
        IDapp.deposit.selector,
        amount,
        params
    )
})

Error Handling

CallToEOA

error CallToEOA(address target)
Thrown when attempting to call an EOA with calldata. Resolution:
  • Remove calldata for simple transfers
  • Use correct contract address if calling contract
  • Verify target address is not an EOA

CallFailed

error CallFailed(Call call, bytes result)
Thrown when any call execution fails. Contains:
  • Full call data (target, value, data)
  • Revert reason from failed call
Common Causes:
  • Insufficient token balance in executor
  • Reverted function on target contract
  • Out of gas
  • Invalid function selector

NonPortalCaller

error NonPortalCaller(address caller)
Thrown when non-Portal address attempts execution. Resolution:
  • Only Portal can call executor
  • Use Portal’s fulfill methods instead of direct executor calls

Execution Patterns

Simple Transfer

Intent that sends tokens to recipient:
Call[] memory calls = new Call[](1);
calls[0] = Call({
    target: recipientAddress,
    value: 0,
    data: ""
});
Executor transfers tokens that were sent to it by Portal.

Swap and Transfer

Intent that swaps tokens and sends result:
Call[] memory calls = new Call[](2);

// 1. Swap on DEX
calls[0] = Call({
    target: dexRouter,
    value: 0,
    data: abi.encodeWithSelector(
        IRouter.swap.selector,
        tokenIn,
        tokenOut,
        amountIn,
        address(executor)  // Swap result to executor
    )
});

// 2. Transfer swapped tokens
calls[1] = Call({
    target: tokenOut,
    value: 0,
    data: abi.encodeWithSelector(
        IERC20.transfer.selector,
        finalRecipient,
        amountOut
    )
});

Multi-Step DeFi

Intent executing complex DeFi operations:
Call[] memory calls = new Call[](4);

// 1. Approve DEX
calls[0] = Call({
    target: token,
    value: 0,
    data: abi.encodeWithSelector(
        IERC20.approve.selector,
        dexRouter,
        amount
    )
});

// 2. Swap
calls[1] = Call({
    target: dexRouter,
    value: 0,
    data: /* swap calldata */
});

// 3. Add liquidity
calls[2] = Call({
    target: liquidityPool,
    value: 0,
    data: /* addLiquidity calldata */
});

// 4. Transfer LP tokens
calls[3] = Call({
    target: lpToken,
    value: 0,
    data: /* transfer calldata */
});

Gas Considerations

Per-Call Overhead

Each call incurs:
  • Call validation: ~2,000 gas (EOA check)
  • Low-level call: ~2,600 gas base + execution
  • Result handling: ~1,000 gas

Batch Optimization

Multiple calls in one intent are more efficient than separate intents:
  • Shared validation costs
  • Single token transfer from solver
  • Atomic execution reduces failure risk

Failed Call Costs

When a call fails:
  • Gas consumed up to failure point
  • Full transaction reverts (no state changes)
  • Solver pays gas but receives no reward

Integration Patterns

For Intent Creators

Encoding Calls:
// Create calls for route
Call[] memory calls = new Call[](2);

calls[0] = Call({
    target: tokenAddress,
    value: 0,
    data: abi.encodeWithSelector(
        IERC20.transfer.selector,
        recipient,
        amount
    )
});

// Include in intent
Intent memory intent = Intent({
    // ...
    route: Route({
        // ...
        calls: calls
    })
});
Testing Calls:
// Test call execution off-chain
(bool success, bytes memory result) = target.call{value: value}(data);
require(success, "Call would fail");

For Solvers

Pre-Execution Validation:
// Before fulfilling, validate calls won't fail
for (Call memory call : route.calls) {
    // Simulate each call
    (bool success, ) = call.target.call{value: call.value}(call.data);
    require(success, "Call simulation failed");
}

// Now fulfill intent
portal.fulfill(intentHash, route, rewardHash, claimant);
Token Preparation:
// Approve tokens for Portal before fulfillment
for (TokenAmount memory token : route.tokens) {
    IERC20(token.token).approve(
        address(portal),
        token.amount
    );
}

Security Best Practices

For Intent Creators

  1. Test Thoroughly: Simulate all calls before publishing intent
  2. Minimal Privileges: Only include necessary calls in route
  3. Verify Targets: Ensure all target addresses are correct contracts
  4. Amount Validation: Verify all token amounts and values are correct
  5. Deadline Safety: Set appropriate route deadline for execution window

For Solvers

  1. Validate Before Fulfilling: Simulate execution to avoid wasted gas
  2. Check Token Balances: Ensure sufficient tokens to provide route amounts
  3. Gas Estimation: Estimate execution costs accurately
  4. Slippage Protection: Account for price changes in DEX calls
  5. Revert Analysis: Understand why calls might fail before executing

For Protocol Integrators

  1. Immutable Executor: Executor address never changes per Portal
  2. No Direct Calls: Never call executor directly, always through Portal
  3. Result Handling: Process execution results appropriately
  4. Error Recovery: Handle call failures gracefully in UI/SDK

Limitations

No Persistent State

Executor cannot:
  • Store data between transactions
  • Accumulate tokens across intents
  • Maintain allowances or permissions
Each execution starts fresh.

No Token Retention

After execution:
  • All tokens should be transferred to final destinations
  • Executor should have zero balance
  • Any remaining tokens are stuck until next execution
Best Practice: Include cleanup call to sweep any dust.

Sequential Execution

Calls execute in order:
  • Cannot parallelize
  • Later calls depend on earlier ones
  • Any failure reverts all
Design Consideration: Order calls carefully for dependencies.

Comparison to Alternatives

Direct Portal Execution

❌ Portal executes calls directly
Risk: Malicious calls access Portal storage

Executor Pattern (Current)

✅ Executor executes calls in isolation
Benefit: Portal storage protected

External Executor

❌ Separate executor deployed per intent
Cost: Higher gas for deployment
Complexity: Address management
The single stateless executor provides optimal balance of security, gas efficiency, and simplicity.
I