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
| Name | Address | Kusama Gas | Ethereum Gas | Use Case |
|---|---|---|---|---|
| ecRecover | 0x01 | 991 | 3,000 | Signature verification |
| SHA-256 | 0x02 | 991 | 60 + 12/word | General hashing |
| RIPEMD-160 | 0x03 | 991 | 600 + 120/word | Bitcoin-style hashing |
| Identity | 0x04 | 991 | 15 + 3/word | Data copying |
| ModExp | 0x05 | 991 | Variable | RSA, modular arithmetic |
| ecAdd (BN254) | 0x06 | 991 | 150 | BN254 point addition |
| ecMul (BN254) | 0x07 | 991 | 6,000 | BN254 scalar multiplication |
| ecPairing | 0x08 | 1,511+ | 113,000+ | ZK proof verification |
| Blake2f | 0x09 | 991 | 4 | Blake2 compression |
| P256Verify | 0x100 | Callable | N/A | WebAuthn/passkey |
Not Available
| Precompile | Address | Status |
|---|---|---|
| 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:
| Pairs | Kusama Gas | Ethereum Gas | Ratio |
|---|---|---|---|
| 2 | 1,511 | 113,000 | 75x cheaper |
| 4 | 2,062 | 181,000 | 88x cheaper |
| 6 | ~2,613 | ~249,000 | 95x 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:
| Asset | ID | ERC20 Address |
|---|---|---|
| USDT | 1984 | 0x000007C000000000000000000000000001200000 |
| USDC | 1337 | 0x0000053900000000000000000000000001200000 |
| DOT | 100 | 0x0000006400000000000000000000000001200000 |
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