EVM Precompiles Guide

Precompiles are native runtime functions exposed at fixed addresses, providing optimized implementations of cryptographic and utility operations. This guide covers using precompiles in ZK applications on Kusama Asset Hub.

What are Precompiles?

Precompiles run at the runtime level rather than as on-chain PVM contracts. When your contract calls a precompile address, the PVM detects it and executes optimized native code directly.

┌─────────────┐
│   Your      │
│  Contract   │
│  (Solidity) │
└──────┬──────┘
       │ Call 0x08 (ecPairing)
       ▼
┌─────────────────────────────────┐
│  PVM Runtime                    │
│  ┌─────────────────────────┐    │
│  │  Precompile 0x08        │    │
│  │  (Native BN254 pairing) │    │
│  └─────────────────────────┘    │
└─────────────────────────────────┘

Available Precompiles on Kusama

NameAddressKusama GasEthereum GasUse Case
ecRecover0x019913,000Signature verification
SHA-2560x0299160 + 12/wordGeneral hashing
RIPEMD-1600x03991600 + 120/wordBitcoin-style hashing
Identity0x0499115 + 3/wordData copying
ModExp0x05991VariableRSA, modular arithmetic
ecAdd (BN254)0x06991150BN254 point addition
ecMul (BN254)0x079916,000BN254 scalar multiplication
ecPairing0x081,511+113,000+ZK proof verification
Blake2f0x099914Blake2 compression
P256Verify0x100CallableN/AWebAuthn/passkey

Not Available

PrecompileAddressStatus
PointEvaluation (EIP-4844)0x0A❌ Not enabled
BLS12-381 (EIP-2537)0x0B-0x13❌ Not enabled

BN254 Precompiles for ZK

ecPairing (0x08) - The Most Important for ZK

The pairing check precompile is the core of Groth16 verification.

Gas Cost:

Base (floor):     991 gas
Per pair:        ~276 gas
Formula:    991 + 276 × pairs

Benchmark Results:

PairsKusama GasEthereum GasRatio
21,511113,00075x cheaper
42,062181,00088x cheaper
6~2,613~249,00095x cheaper

Solidity Interface:

interface IBn254Pairing {
    /// @param input Encoded pairing points
    /// @return result 1 if pairing check passes, 0 otherwise
    function pairingCheck(bytes calldata input) 
        external view returns (bool result);
}

// Input format: [(a_x, a_y, b_x1, b_x2, b_y1, b_y2), ...]
// Each tuple is 192 bytes (6 × 32 bytes)

Example Usage (from Groth16 Verifier):

function verifyProof(
    uint256[2] calldata _pA,
    uint256[2][2] calldata _pB,
    uint256[2] calldata _pC,
    uint256[3] calldata _pubSignals
) public view returns (bool) {
    assembly {
        // Prepare pairing input (6 pairs for Groth16)
        let mIn := mload(0x40)
        
        // -A (negated for verification)
        mstore(mIn, calldataload(_pA))
        mstore(add(mIn, 32), mod(sub(q, calldataload(add(_pA, 32))), q))
        
        // B
        mstore(add(mIn, 64), calldataload(_pB))
        mstore(add(mIn, 96), calldataload(add(_pB, 32)))
        mstore(add(mIn, 128), calldataload(add(_pB, 64)))
        mstore(add(mIn, 160), calldataload(add(_pB, 96)))
        
        // ... (alpha, beta, gamma, delta points)
        
        // Call precompile 0x08
        let success := staticcall(
            sub(gas(), 2000), 
            0x08, 
            mIn, 
            768,  // 6 pairs × 128 bytes
            mIn, 
            0x20
        )
        
        let isValid := and(success, mload(mIn))
        mstore(0, isValid)
        return(0, 0x20)
    }
}

ecMul (0x07) - BN254 Scalar Multiplication

Used in Groth16 for linear combination of verification key points.

Gas Cost: 991 gas (hits floor, actual compute is cheap)

Solidity Interface:

interface IBn254Mul {
    /// @param input Encoded point (x, y) and scalar (s)
    /// @return result Scaled point (x', y')
    function ecMul(bytes calldata input) 
        external view returns (uint256 x, uint256 y);
}

// Input: 96 bytes = [x (32), y (32), s (32)]
// Output: 64 bytes = [x' (32), y' (32)]

Example:

function multiplyPoint(uint256 x, uint256 y, uint256 scalar) 
    public view returns (uint256 rx, uint256 ry) 
{
    bytes memory input = abi.encodePacked(x, y, scalar);
    (bool success, bytes memory result) = 
        address(0x07).staticcall(input);
    require(success, "ecMul failed");
    return abi.decode(result, (uint256, uint256));
}

ecAdd (0x06) - BN254 Point Addition

Gas Cost: 991 gas (gas floor)

Solidity Interface:

interface IBn254Add {
    /// @param input Encoded two points
    /// @return result Sum of points
    function ecAdd(bytes calldata input) 
        external view returns (uint256 x, uint256 y);
}

// Input: 128 bytes = [x1 (32), y1 (32), x2 (32), y2 (32)]
// Output: 64 bytes = [x' (32), y' (32)]

Hash Precompiles

SHA-256 (0x02)

Gas Cost: 991 gas flat (vs Ethereum's 60 + 12/word)

interface ISha256 {
    function sha256(bytes calldata input) 
        external view returns (bytes32 hash);
}

// Usage
bytes memory data = "Hello World";
(bool success, bytes memory result) = 
    address(0x02).staticcall(data);
bytes32 hash = abi.decode(result, (bytes32));

Blake2f (0x09)

Blake2 compression function F, used in some ZK constructions.

Gas Cost: 991 gas flat

interface IBlake2f {
    /// @param input 213 bytes: rounds(4), h[8], m[16], t(2), f(1)
    function blake2f(bytes calldata input) 
        external view returns (bytes32[8] memory h);
}

System Precompile (0x0000000000000000000000000000000000000900)

Polkadot-specific precompile for runtime integration.

Key Functions for ZK

interface ISystem {
    // BLAKE2 hashing (Polkadot native)
    function hashBlake256(bytes memory input) 
        external pure returns (bytes32);
    
    function hashBlake128(bytes memory input) 
        external pure returns (bytes32);
    
    // Sr25519 verification (Polkadot signatures)
    function sr25519Verify(
        uint8[64] calldata signature,
        bytes calldata message,
        bytes32 publicKey
    ) external view returns (bool);
    
    // Weight tracking
    function weightLeft() 
        external view returns (uint64 refTime, uint64 proofSize);
    
    // ECDSA utilities
    function ecdsaToEthAddress(uint8[33] calldata publicKey) 
        external view returns (bytes20);
}

Usage Example

contract ZKVerifier {
    ISystem constant SYSTEM = ISystem(
        0x0000000000000000000000000000000000000900
    );
    
    function verifyWithWeightCheck(
        uint256[2] calldata proofA,
        // ... other proof data
    ) external {
        // Check remaining weight before expensive operation
        (uint64 refTime, ) = SYSTEM.weightLeft();
        require(refTime > 1e12, "Insufficient weight");
        
        // Use Blake2 for non-ZK hashing (cheaper than Poseidon)
        bytes32 dataHash = SYSTEM.hashBlake256(
            abi.encodePacked(msg.sender, block.number)
        );
        
        // Verify Groth16 proof
        _verifyGroth16(proofA, /* ... */);
    }
}

Storage Precompile (0x0000000000000000000000000000000000000901)

Low-level storage access for advanced patterns.

interface IStorage {
    // Full value read/write
    function get(bytes32 key) external view returns (bytes memory);
    function set(bytes32 key, bytes memory value) external;
    
    // Partial reads (optimize gas for large values)
    function get_range(
        bytes32 key, 
        uint32 offset, 
        uint32 length
    ) external view returns (bytes memory);
    
    function get_prefix(bytes32 key, uint32 max_length) 
        external view returns (bytes memory);
    
    // Inspection
    function has_key(bytes32 key) external view returns (bool);
    function length(bytes32 key) external view returns (uint32);
    
    // Cleanup
    function remove(bytes32 key) external;
}

Use Case: Efficient Merkle Tree Storage

contract MerkleTree {
    IStorage constant STORAGE = IStorage(
        0x0000000000000000000000000000000000000901
    );
    
    // Store leaf at sequential key
    function insertLeaf(uint256 leaf, uint256 index) internal {
        bytes32 key = keccak256(abi.encodePacked("leaf", index));
        STORAGE.set(key, abi.encode(leaf));
    }
    
    // Read specific leaf
    function getLeaf(uint256 index) 
        public view returns (uint256) 
    {
        bytes32 key = keccak256(abi.encodePacked("leaf", index));
        bytes memory data = STORAGE.get(key);
        return abi.decode(data, (uint256));
    }
    
    // Check existence without reading
    function leafExists(uint256 index) public view returns (bool) {
        bytes32 key = keccak256(abi.encodePacked("leaf", index));
        return STORAGE.has_key(key);
    }
}

ERC20 Precompile (Asset-Specific Addresses)

Each Assets pallet token has a mapped ERC20 precompile address.

Address Calculation

ERC20 Address = 0x[assetId (8 hex)] + [24 zeros] + [prefix (8 hex)]

Example Asset IDs:

AssetIDERC20 Address
USDT19840x000007C000000000000000000000000001200000
USDC13370x0000053900000000000000000000000001200000
DOT1000x0000006400000000000000000000000001200000

Usage in ZK Applications

interface IERC20 {
    function transfer(address to, uint256 amount) 
        external returns (bool);
    function balanceOf(address account) 
        external view returns (uint256);
    function approve(address spender, uint256 amount) 
        external returns (bool);
}

contract ShieldedPool {
    IERC20 constant USDT = IERC20(
        0x000007C000000000000000000000000001200000
    );
    
    function deposit(uint256 amount, uint256 commitment) external {
        // Transfer USDT from user to pool
        require(
            USDT.transferFrom(msg.sender, address(this), amount),
            "Transfer failed"
        );
        
        // Insert commitment into Merkle tree
        _insertCommitment(commitment);
    }
    
    function withdraw(
        uint256 amount,
        address recipient,
        // ... proof data
    ) external {
        // Verify proof
        _verifyProof(/* ... */);
        
        // Transfer USDT to recipient
        require(
            USDT.transfer(recipient, amount),
            "Withdrawal failed"
        );
    }
}

P256Verify (0x100) - WebAuthn Support

EIP-7212 precompile for WebAuthn/passkey verification.

interface IP256Verify {
    /// @param messageHash Hash of signed message
    /// @param r Signature component
    /// @param s Signature component  
    /// @param x Public key x-coordinate
    /// @param y Public key y-coordinate
    /// @return success true if signature valid
    function verify(
        bytes32 messageHash,
        uint256 r,
        uint256 s,
        uint256 x,
        uint256 y
    ) external view returns (bool success);
}

// Usage for passkey-based ZK proof submission
contract PasskeyVerifier {
    function verifyPasskeyProof(
        bytes32 messageHash,
        uint256[4] calldata signature,
        uint256[2] calldata publicKey,
        // ... ZK proof
    ) external view {
        bool valid = IP256Verify(0x100).verify(
            messageHash,
            signature[0],
            signature[1],
            publicKey[0],
            publicKey[1]
        );
        require(valid, "Invalid passkey signature");
        
        // Proceed with ZK verification
    }
}

Gas Optimization Strategies

1. Batch Precompile Calls

// ❌ Expensive: Multiple separate calls
for (uint i = 0; i < hashes.length; i++) {
    hash[i] = ISha256(0x02).sha256(data[i]);
}

// ✅ Better: Batch in single call where possible
bytes memory batched = abi.encodePacked(data[0], data[1], /* ... */);
bytes32 combined = ISha256(0x02).sha256(batched);

2. Use Rust for Heavy Crypto

// ❌ Solidity Poseidon (hits block limit)
for (uint i = 0; i < 20; i++) {
    node = IPoseidon(POSEIDON_ADDR).hash([left, right]);
} // REVERTS - exceeds block ref_time

// ✅ Rust PVM (fits easily)
for (uint i = 0; i < 20; i++) {
    node = IRustPoseidon(RUST_POSEIDON).hash([left, right]);
} // SUCCESS - 37,956 gas total

3. Check Weight Before Expensive Ops

function expensiveOperation() external {
    (uint64 refTime, ) = ISystem(SYSTEM_ADDR).weightLeft();
    
    // Need ~1T ref_time for 20 Poseidon hashes
    require(refTime > 1e12, "Insufficient weight");
    
    // Proceed with operation
    _computeMerklePath();
}

Testing Precompiles

Using Foundry

// test/PrecompileTest.t.sol
pragma solidity ^0.8.28;

import "forge-std/Test.sol";

contract PrecompileTest is Test {
    function testEcPairing() public {
        // Test vectors from EIP-197
        bytes memory input = hex"..."; // 192 bytes per pair
        (bool success, bytes memory result) = 
            address(0x08).staticcall(input);
        
        assertTrue(success);
        assertEq(abi.decode(result, (uint256)), 1);
    }
    
    function testSha256() public {
        bytes memory input = "test";
        (bool success, bytes memory result) = 
            address(0x02).staticcall(input);
        
        bytes32 expected = 0x9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08;
        assertEq(abi.decode(result, (bytes32)), expected);
    }
}

Using cast

# Test SHA-256
cast call 0x0000000000000000000000000000000000000002 \
  "$(cast calldata 'sha256(bytes)' $(cast to-bytes "test"))" \
  --rpc-url https://testnet-passet-hub-eth-rpc.polkadot.io

# Test ecPairing
cast call 0x0000000000000000000000000000000000000008 \
  "<pairing-input>" \
  --rpc-url https://testnet-passet-hub-eth-rpc.polkadot.io

Common Issues

"UnsupportedPrecompileAddress"

Some precompiles are not enabled in the runtime:

  • PointEvaluation (0x0A) - EIP-4844 not available
  • BLS12-381 (0x0B-0x13) - Not implemented

Solution: Use Groth16/BN254 instead of KZG/BLS-based schemes.

Gas Floor Confusion

Simple precompiles (0x01-0x05) all show 991 gas, but actual compute cost varies.

Solution: Check ref_time in transaction errors for true cost.

ecPairing Scaling

Gas increases linearly with pairs, but not at Ethereum rates.

Kusama: 991 + 276 × pairs
Ethereum: 45,000 + 34,000 × pairs

Resources


Previous: Poseidon Hash | Next: Asset Hub Integration