Precompiles: extending 2D without an EVM
A precompile is a built-in network program at a fixed address. POA implements these programs as Elixir modules with the Chain.Precompile interface and addresses in the 0x2D00… range. Operators deploy them with the node software. The network does not execute arbitrary EVM bytecode.
A fixed handler set makes the executable code easier to inspect, but does not guarantee safety. Handler bugs, access control, and node upgrades remain part of the threat model.
How a transaction finds a precompile
Section titled “How a transaction finds a precompile”Every transaction has a to address. The block producer walks the happy path step by step. Decode the signed tx, check the nonce, then ask the precompile registry whether to belongs to a registered handler. A hit dispatches to the handler’s execute/3. A miss falls through to the native USDC transfer. Plain account-to-account USDC transfers do not require a precompile.
Abbreviated dispatch logic from lib/chain/block_executor.ex:
defp charge_and_execute(pending, tx, from, to, value, fee, block_number, tx_index, ts) do case to && Chain.Precompiles.Registry.lookup(to) do {:ok, handler} -> cond do Decimal.compare(value, Decimal.new(0)) != :eq -> {:error, :precompile_value_not_supported}
true -> with {:ok, input} <- Crypto.decode_hex_safe(tx.input), true <- byte_size(input) >= 4 do <<selector::binary-4, args::binary>> = input run_precompile_execute(handler, selector, args, input, from, value, ...) else _ -> {:error, :invalid_calldata} end end
_ -> # native USDC transfer: debit sender, credit receiver, deduct fee. ... endendTwo branches, one decision. Registered? Call the handler. Unregistered? Move USDC. Nothing else counts as custom execution; everything custom goes through a precompile.
The @behaviour Chain.Precompile
Section titled “The @behaviour Chain.Precompile”Each precompile implements three callbacks (lib/chain/precompiles/precompile.ex):
defmodule Chain.Precompile do @callback address() :: binary()
@callback execute(selector :: binary(), args :: binary(), context :: Chain.Precompile.Context.t()) :: {:ok, result :: binary(), logs :: list()} | {:revert, reason :: binary()}
@callback read(selector :: binary(), args :: binary()) :: {:ok, abi_encoded :: binary()} | {:revert, reason :: binary()}endaddress/0. Where this precompile lives. Hardcoded in the0x2D00…range so the set is easy to enumerate.execute/3. State-changing entry point. Called inside the block’s transaction. Returns a result plus a log list that flows into the tx receipt, or{:revert, reason}to abort cleanly.read/2. View call. Used byeth_callandtriggerconstantcontract. Never mutates state.
The context struct passed to execute/3 carries what Solidity calls msg.sender, msg.value, plus block height, block timestamp, and tx index. That’s everything a handler needs to reason about who it’s acting for. Keeping this narrow is deliberate: an execute/3 that can only do explicit account and table writes through local modules is a much smaller thing to review than a whole EVM.
The registry
Section titled “The registry”The registry (lib/chain/precompiles/registry.ex) is an ETS table keyed by address:
def lookup(address) when is_binary(address) do case :ets.lookup(@table, address) do [{^address, module}] -> {:ok, module} [] -> :not_found endend
def register(address, handler_module) do GenServer.call(__MODULE__, {:register, address, handler_module})endAt startup the node loads enabled precompiles. Dispatch uses an ETS address lookup. Adding a handler requires registration and a coordinated node upgrade; users cannot install it by submitting bytecode to the mempool.
Address namespace matters. All system precompiles live at 0x2D00…; the top byte 0x2D (ASCII -, and the project’s namesake) is a reserved prefix. eth_getCode returns 0x01 for any address in this range that resolves, so wallets that branch on “is there code here?” get the right answer without needing a full EVM.
The HTLC precompile
Section titled “The HTLC precompile”Talking about precompiles in the abstract only gets you so far. The HTLC precompile at 0x2D00…0001 is live stateful code on the chain (lib/chain/precompiles/htlc.ex).
An HTLC (hashed time-locked contract) locks funds under a secret hash and a deadline. For an ordinary lock, the sender specifies a receiver. Anyone knowing P such that sha256(P) = H may submit claim before the deadline, but funds always go to that receiver. If the lock is not claimed in time, refund returns the previously debited funds to the sender.
A lock created by bridge_lock has different refund semantics: on POA, refund closes the row without crediting funds. The original USDC is refunded on Ethereum under its HTLC rules. Paired locks require matching recipients, coordinated deadlines, and monitoring of both chains; an HTLC alone does not secure an entire bridge.
defmodule Chain.Precompiles.HTLC do @moduledoc """ Hashed time-locked contract: atomic swaps without a trusted bridge. """ @behaviour Chain.Precompile
@address <<0x2D, 0::144, 0x01>> # 0x2D00…0001
# 4-byte Keccak-256 selectors @lock_selector selector("lock(bytes32,address,uint256,uint256)") @claim_selector selector("claim(bytes32,bytes32)") @refund_selector selector("refund(bytes32)") @get_swap_selector selector("getSwap(bytes32)")
defp selector(signature), do: :erlang.binary_part(ExKeccak.hash_256(signature), 0, 4)
@impl true def address, do: @address
@impl true def execute( @lock_selector, <<hash::binary-32, _pad::binary-12, receiver::binary-20, amount::256, deadline_ms::256>>, ctx ) do do_lock(hash, receiver, amount, deadline_ms, ctx) end
def execute(@claim_selector, <<hash::binary-32, preimage::binary-32>>, ctx) do do_claim(hash, preimage, ctx) end
def execute(@refund_selector, <<hash::binary-32>>, ctx) do do_refund(hash, ctx) end
def read(@get_swap_selector, <<hash::binary-32>>), do: do_get_swap(hash)endThe handler stores state in state.htlc_swaps, checks each operation, and changes balances inside a SERIALIZABLE database transaction. Those checks and verifier replay require independent review.
The bridge uses the same precompile model at 0x2D00…0003: BridgeRefillMint exposes only bridge_lock(...), atomically inserts bridge_mints plus the receiver-bound HTLC row, and leaves the cross-chain proof to the verifier. The bridge article covers that flow in detail.
NativeTransferAuth at 0x2D00…0006 is the EIP-3009-shaped method for native USDC: transferWithAuthorization against state.accounts, not an ERC-20. That is the asset x402 advertises on eip155:77. Permit2 and Ethereum mainnet USDC are rejected.
HTLC security boundaries
Section titled “HTLC security boundaries”The hash, receiver, deadline, and lock status constrain claim and refund. They do not remove trust in the implementation, node upgrades, cross-chain verification, or Ethereum asset custody. A shared handler or bridge-governance failure can affect multiple locks or the entire backing pool; loss cannot be assumed to stop at one swap.
See the bridge article for the trust model, verifier limitations, and OperatorVault authority.
Trade-offs against an EVM
Section titled “Trade-offs against an EVM”- Programs ship with node releases. Users cannot add a handler by transaction. Operators coordinate code and activation with verifiers.
- Explicit composition. Interactions between precompiles are implemented in node code; arbitrary composition of user contracts is not supported.
- Specific integrations. Applications call each precompile’s ABI. Address and partial RPC compatibility do not imply compatibility with the full Solidity toolchain.
- Node-level resource control. Removing the EVM and per-opcode gas metering does not remove the need for input-size, execution-time, and database-load limits.
Security strategy
Section titled “Security strategy”Changes require checks for authorization, balance preservation, replay, deadlines, and transaction rollback. Property tests, type analysis, and protocol models can complement review; the @behaviour interface alone does not prove these properties.
Before release, coordinate handler versions, database schema, activation height, and state-root rules across producer and verifiers. Verify historical replay and rejection of malformed inputs. Publish evidence for the specific release separately from plans for future formal verification.
Where this leads
Section titled “Where this leads”HTLC, BridgeRefillMint, and NativeTransferAuth use this model for locks, bridge deposits, and x402 payments. Each program requires its own checks and limits.