This page is a placeholder. All examples on this page are currently AI-generated and are not correct. This documentation will be completed in the future with accurate, tested examples.
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:
- Pop 6 stack arguments (no value parameter)
- Calculate gas cost:
- Base: 700 gas (Tangerine Whistle+)
- Cold access: +2,600 gas for first access (Berlin+)
- Memory expansion for input and output regions
- Read calldata from memory
- Forward gas: Up to 63/64 of remaining gas (EIP-150)
- 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)
- 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)
- Copy returndata to memory
- Set return_data buffer
- Push success flag
- 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