Skip to main content

Overview

Opcode: 0xfa Introduced: Byzantium (EIP-214) STATICCALL executes code from another account with state modification restrictions enforced. Any attempt to modify state (SSTORE, CREATE, CALL with value, SELFDESTRUCT, LOG, etc.) reverts the entire call. This enables secure read-only operations and implements Solidity’s view and pure function semantics.

Specification

Stack Input:
gas       (max gas to forward)
address   (target account to call)
inOffset  (calldata memory offset)
inLength  (calldata size)
outOffset (returndata memory offset)
outLength (returndata size)
Stack Output:
success   (1 if call succeeded, 0 if failed/reverted)
Gas Cost: 700 + cold_access + memory_expansion Operation:
calldata = memory[inOffset:inOffset+inLength]
success = static_call(address, calldata, gas * 63/64)  // Sets is_static flag
memory[outOffset:outOffset+outLength] = returndata[0:min(outLength, returndata.length)]
push(success)

Behavior

STATICCALL performs a read-only external call with state protection:
  1. Pop 6 stack arguments (no value parameter)
  2. Calculate gas cost:
    • Base: 700 gas (Tangerine Whistle+)
    • Cold access: +2,600 gas for first access (Berlin+)
    • Memory expansion for input and output regions
  3. Read calldata from memory
  4. Forward gas: Up to 63/64 of remaining gas (EIP-150)
  5. Execute in callee context with static flag:
    • msg.sender = caller address
    • msg.value = 0 (always zero!)
    • Storage = callee’s storage (READ-ONLY)
    • Code = callee’s code
    • is_static = true (inherited by child calls)
  6. Enforce read-only restrictions:
    • SSTORE reverts (storage modification)
    • CREATE/CREATE2 revert (contract creation)
    • CALL with value > 0 reverts (ETH transfer)
    • SELFDESTRUCT reverts (account deletion)
    • LOG0-LOG4 revert (event emission)
  7. Copy returndata to memory
  8. Set return_data buffer
  9. Push success flag
  10. Refund unused gas
Key characteristics:
  • No value transfer (msg.value always 0)
  • State modifications forbidden (enforced recursively)
  • Safe for untrusted code execution
  • Foundation for view/pure functions

Examples

Basic Static Call

import { STATICCALL } from '@tevm/voltaire/evm/system';
import { Address } from '@tevm/voltaire/primitives';

const frame = createFrame({
  gasRemaining: 1000000n,
  address: Address("0x1234..."),
});

// Call view function: balanceOf(address)
const calldata = new Uint8Array([
  0x70, 0xa0, 0x82, 0x31,  // balanceOf(address) selector
  // ... ABI-encoded address parameter
]);

// Write calldata to memory
for (let i = 0; i < calldata.length; i++) {
  frame.memory.set(i, calldata[i]);
}

// Stack: [gas=100000, address, inOffset=0, inLength=36, outOffset=0, outLength=32]
frame.stack.push(32n);                              // outLength
frame.stack.push(0n);                               // outOffset
frame.stack.push(36n);                              // inLength
frame.stack.push(0n);                               // inOffset
frame.stack.push(BigInt("0x742d35Cc..."));          // target address
frame.stack.push(100000n);                          // gas

const err = STATICCALL(frame);

console.log(frame.stack[0]);     // 1n if success, 0n if failed
console.log(frame.return_data);  // Balance (uint256)

// In callee:
// - msg.value = 0 (always)
// - Any SSTORE, CREATE, LOG, etc. will revert
// - is_static flag set (inherited by nested calls)

View Function Call

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
    function totalSupply() external view returns (uint256);
}

contract Reader {
    // Safe read-only call to ERC20
    function getBalance(
        address token,
        address account
    ) external view returns (uint256) {
        // Solidity automatically uses STATICCALL for view functions
        return IERC20(token).balanceOf(account);
    }

    // Manual static call with assembly
    function getBalanceAssembly(
        address token,
        address account
    ) external view returns (uint256) {
        bytes memory callData = abi.encodeWithSelector(
            IERC20.balanceOf.selector,
            account
        );

        uint256 balance;
        bool success;

        assembly {
            // STATICCALL(gas, address, inOffset, inLength, outOffset, outLength)
            success := staticcall(
                gas(),
                token,
                add(callData, 0x20),
                mload(callData),
                0,
                32
            )

            if success {
                balance := mload(0)
            }
        }

        require(success, "Static call failed");
        return balance;
    }
}

Multi-token Reader

contract TokenReader {
    struct TokenInfo {
        uint256 totalSupply;
        uint256 userBalance;
        string name;
        string symbol;
        uint8 decimals;
    }

    // Read multiple token properties in one call
    function getTokenInfo(
        address token,
        address user
    ) external view returns (TokenInfo memory info) {
        // All calls are STATICCALL (view context)
        info.totalSupply = IERC20(token).totalSupply();
        info.userBalance = IERC20(token).balanceOf(user);
        info.name = IERC20Metadata(token).name();
        info.symbol = IERC20Metadata(token).symbol();
        info.decimals = IERC20Metadata(token).decimals();
    }

    // Batch read multiple tokens
    function batchGetBalances(
        address[] calldata tokens,
        address user
    ) external view returns (uint256[] memory balances) {
        balances = new uint256[](tokens.length);

        for (uint256 i = 0; i < tokens.length; i++) {
            balances[i] = IERC20(tokens[i]).balanceOf(user);
        }
    }
}

Safe Untrusted Call

contract SafeReader {
    // Call untrusted contract safely (no state changes possible)
    function safeQuery(
        address untrusted,
        bytes memory data
    ) external view returns (bool success, bytes memory result) {
        assembly {
            // Allocate memory for return data
            let ptr := mload(0x40)

            // STATICCALL to untrusted contract
            success := staticcall(
                gas(),
                untrusted,
                add(data, 0x20),
                mload(data),
                0,
                0
            )

            // Copy return data
            let size := returndatasize()
            result := ptr
            mstore(result, size)
            returndatacopy(add(result, 0x20), 0, size)
            mstore(0x40, add(add(result, 0x20), size))
        }

        // Even if untrusted contract is malicious:
        // - Cannot modify state
        // - Cannot transfer ETH
        // - Cannot emit events
        // Worst case: returns wrong data or reverts
    }
}

Price Oracle Reader

interface IPriceOracle {
    function getPrice(address token) external view returns (uint256);
}

contract PriceAggregator {
    address[] public oracles;

    // Get median price from multiple oracles (all static calls)
    function getMedianPrice(address token) external view returns (uint256) {
        require(oracles.length > 0, "No oracles");

        uint256[] memory prices = new uint256[](oracles.length);
        uint256 validPrices = 0;

        for (uint256 i = 0; i < oracles.length; i++) {
            try IPriceOracle(oracles[i]).getPrice(token) returns (uint256 price) {
                prices[validPrices] = price;
                validPrices++;
            } catch {
                // Oracle failed, skip
            }
        }

        require(validPrices > 0, "No valid prices");

        // Sort and return median
        return _median(prices, validPrices);
    }

    function _median(uint256[] memory prices, uint256 length) internal pure returns (uint256) {
        // Sort and return median
        // Implementation omitted for brevity
    }
}

Gas Cost

Total cost: 700 + cold_access + memory_expansion + forwarded_gas

Base Cost: 700 gas (Tangerine Whistle+)

Pre-Tangerine Whistle: STATICCALL didn’t exist (introduced Byzantium).

No Value Transfer Cost

STATICCALL never transfers value:
// No CallValueTransferGas (msg.value always 0)
// No CallNewAccountGas

Cold Access: +2,600 gas (Berlin+)

EIP-2929 (Berlin+): First access to target address:
if (firstAccess(address)) {
  cost += 2600  // ColdAccountAccess
} else {
  cost += 100   // WarmStorageRead
}
Pre-Berlin: No access list costs.

Memory Expansion

Same as CALL - charges for both input and output regions:
const inEnd = inOffset + inLength;
const outEnd = outOffset + outLength;
const maxEnd = Math.max(inEnd, outEnd);

const words = Math.ceil(maxEnd / 32);
const expansionCost = words ** 2 / 512 + 3 * words;

Gas Forwarding (EIP-150)

63/64 rule applies:
remaining_after_charge = gas_remaining - base_cost
max_forwarded = remaining_after_charge - (remaining_after_charge / 64)
actual_forwarded = min(gas_parameter, max_forwarded)

Example Calculation

// STATICCALL to read function, cold access
const gasRemaining = 100000n;
const inLength = 36;   // balanceOf(address)
const outLength = 32;  // uint256 return

// Base cost
let cost = 700n;  // Byzantium+

// No value transfer cost

// Cold access (Berlin+, first access)
cost += 2600n;  // ColdAccountAccess

// Memory expansion (clean memory)
const maxEnd = Math.max(36, 32);  // 36 bytes
const words = Math.ceil(36 / 32);  // 2 words
const memCost = Math.floor(words ** 2 / 512) + 3 * words;  // 6 gas
cost += BigInt(memCost);

// Total charged: 3,306 gas

// Gas forwarding
const afterCharge = gasRemaining - cost;  // 96,694 gas
const maxForward = afterCharge - afterCharge / 64n;  // 95,184 gas

// Total consumed: 3,306 + gas_used_by_callee

Common Usage

Safe Contract Query

contract QueryHelper {
    // Query arbitrary contract safely
    function query(
        address target,
        bytes memory data
    ) external view returns (bytes memory) {
        (bool success, bytes memory result) = target.staticcall(data);
        require(success, "Query failed");
        return result;
    }

    // Query with try/catch
    function tryQuery(
        address target,
        bytes memory data
    ) external view returns (bool success, bytes memory result) {
        (success, result) = target.staticcall(data);
    }
}

View Function Multicall

contract Multicall {
    struct Call {
        address target;
        bytes callData;
    }

    // Execute multiple view calls in one transaction
    function aggregate(
        Call[] memory calls
    ) external view returns (bytes[] memory results) {
        results = new bytes[](calls.length);

        for (uint256 i = 0; i < calls.length; i++) {
            (bool success, bytes memory result) = calls[i].target.staticcall(
                calls[i].callData
            );
            require(success, "Call failed");
            results[i] = result;
        }
    }

    // Aggregate with failure tolerance
    function tryAggregate(
        Call[] memory calls
    ) external view returns (bool[] memory successes, bytes[] memory results) {
        successes = new bool[](calls.length);
        results = new bytes[](calls.length);

        for (uint256 i = 0; i < calls.length; i++) {
            (successes[i], results[i]) = calls[i].target.staticcall(
                calls[i].callData
            );
        }
    }
}

Contract Existence Check

contract ExistenceChecker {
    // Check if contract exists and has code
    function exists(address target) external view returns (bool) {
        uint256 size;
        assembly {
            size := extcodesize(target)
        }
        return size > 0;
    }

    // Check if contract implements interface (ERC165)
    function supportsInterface(
        address target,
        bytes4 interfaceId
    ) external view returns (bool) {
        bytes memory data = abi.encodeWithSelector(
            IERC165.supportsInterface.selector,
            interfaceId
        );

        (bool success, bytes memory result) = target.staticcall(data);

        return success && result.length == 32 && abi.decode(result, (bool));
    }
}

Security

State Modification Prevention

STATICCALL enforces read-only semantics:
contract Malicious {
    uint256 public value;

    function maliciousView() external view returns (uint256) {
        // This compiles but REVERTS at runtime when called via STATICCALL
        value = 42;  // SSTORE in static context!
        return value;
    }
}

contract Caller {
    function callView(address target) external view returns (uint256) {
        // Calls Malicious.maliciousView() via STATICCALL
        // Reverts because maliciousView() attempts SSTORE
        return Malicious(target).maliciousView();
    }
}
Protected operations (revert in static context):
  • SSTORE (storage write)
  • CREATE/CREATE2 (contract creation)
  • CALL with value > 0 (ETH transfer)
  • SELFDESTRUCT (account deletion)
  • LOG0-LOG4 (event emission)

Read-Only Guarantee

STATICCALL guarantees no state changes:
// SAFE: State cannot be modified via static call
contract SafeReader {
    function readUntrusted(address target) external view returns (bytes memory) {
        (bool success, bytes memory data) = target.staticcall("");

        // Even if target is malicious:
        // - Cannot modify storage
        // - Cannot create contracts
        // - Cannot transfer ETH
        // - Cannot emit events
        // Worst case: returns bad data or reverts

        require(success, "Call failed");
        return data;
    }
}

Gas Limit Attacks

Callee still controls gas consumption:
// VULNERABLE: Unbounded gas consumption
function dangerousQuery(address target, bytes memory data) external view returns (bytes memory) {
    // Forwards 63/64 of all remaining gas
    (bool success, bytes memory result) = target.staticcall(data);
    require(success);
    return result;  // Callee could consume all gas
}

// BETTER: Limit forwarded gas
function limitedQuery(address target, bytes memory data) external view returns (bytes memory) {
    (bool success, bytes memory result) = target.staticcall{gas: 100000}(data);
    require(success);
    return result;
}

Returndata Bomb

Large returndata can cause OOG when copying:
// VULNERABLE: Unbounded returndata
function unsafeQuery(address target) external view returns (bytes memory) {
    (, bytes memory data) = target.staticcall("");
    return data;  // Might copy gigabytes!
}

// SAFE: Limit returndata size
function safeQuery(address target) external view returns (bytes memory) {
    (bool success, ) = target.staticcall("");
    require(success);

    // Manual copy with size limit
    uint256 size = min(returndatasize(), 1024);
    bytes memory data = new bytes(size);
    assembly {
        returndatacopy(add(data, 32), 0, size)
    }
    return data;
}

Reentrancy (Still Possible!)

Read-only reentrancy is possible:
// VULNERABLE: Read-only reentrancy
contract Vault {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];

        // VULNERABILITY: External call before state update
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);

        balances[msg.sender] = 0;  // Too late!
    }

    function getBalance(address user) external view returns (uint256) {
        return balances[user];  // Can be called during reentrancy!
    }
}

contract Attacker {
    Vault vault;
    Oracle oracle;

    receive() external payable {
        // During withdraw, balance not yet updated
        uint256 balance = vault.getBalance(address(this));  // STATICCALL
        // Oracle sees inflated balance!
        oracle.updatePrice(balance);
    }
}
Mitigation: Checks-Effects-Interactions pattern.

Implementation

/**
 * STATICCALL opcode (0xfa)
 * Read-only message call with state protection
 */
export function staticcall(frame: FrameType): EvmError | null {
  // Pop 6 arguments (no value)
  const gas = popStack(frame);
  const address = popStack(frame);
  const inOffset = popStack(frame);
  const inLength = popStack(frame);
  const outOffset = popStack(frame);
  const outLength = popStack(frame);

  // Calculate gas cost
  let gasCost = 700n;  // Base

  // No value transfer cost

  // Cold access cost (Berlin+)
  const accessCost = getAccessCost(address);
  gasCost += accessCost;

  // Memory expansion
  const inEnd = inLength > 0 ? inOffset + inLength : 0;
  const outEnd = outLength > 0 ? outOffset + outLength : 0;
  const maxEnd = Math.max(inEnd, outEnd);

  if (maxEnd > 0) {
    gasCost += memoryExpansionCost(frame, maxEnd);
    updateMemorySize(frame, maxEnd);
  }

  // Calculate forwarded gas (63/64 rule)
  const afterCharge = frame.gasRemaining - gasCost;
  const maxForward = afterCharge - afterCharge / 64n;
  const forwardedGas = min(gas, maxForward);

  // Charge total cost
  consumeGas(frame, gasCost + forwardedGas);

  // Read calldata
  const calldata = readMemory(frame, inOffset, inLength);

  // Execute static call (set is_static flag!)
  const result = executeStaticCall({
    target: address,
    data: calldata,
    gas: forwardedGas,
    isStatic: true  // Enforces read-only
  });

  // Refund unused gas
  frame.gasRemaining += result.gasLeft;

  // Copy returndata
  const copySize = min(outLength, result.returnData.length);
  writeMemory(frame, outOffset, result.returnData.slice(0, copySize));

  // Set return_data buffer
  frame.returnData = result.returnData;

  // Push success flag
  pushStack(frame, result.success ? 1n : 0n);

  frame.pc += 1;
  return null;
}

References