Commit:
7f1a9bc— Make it an Ethereum chain: the full Frontier EVMReading time: ~15 min.
Where we are. Lesson 7 swapped the account model to H160 +
EthereumSignature. Users already sign with secp256k1, addresses are already 20 bytes, and an account's address already matches its MetaMask address. What MetaMask can't do yet is talk to the chain at all: there's no EVM, so no Ethereum transactions to sign and no way to deploy or call contracts. This lesson plugs Frontier in:pallet-ethereumfor Ethereum-shaped extrinsics,pallet-evmfor execution, the standard precompile set, EIP-1559 fees viapallet-base-fee, the fulleth_*/net_*/web3_*/debug_*/eth_filter_*/eth_pubsub_*JSON-RPC surface, and the secondary database that maps substrate blocks to Ethereum block hashes. Spec version 106 → 107.
Two layers land together:
- Runtime side: an EVM that is the chain. Not a sidecar, not a hashed-mapping bridge —
pallet-evmkeys directly on H160 (EnsureAccountId20 + IdentityAddressMapping), shares the substrateBalancesledger, and reads gas pricing frompallet-base-fee.pallet-ethereumwraps the EVM as a "self-contained" extrinsic so Ethereum-signed transactions dispatch through substrate's executive layer without a substrate signature. The runtime exposesEthereumRuntimeRPCApiandConvertTransactionRuntimeApifor the node to query. - Node side: full Ethereum JSON-RPC. The node serves the entire
eth_*namespace on the same port substrate RPC binds to (9944). AMappingSyncWorkertask indexes substrate blocks into a secondary database keyed by Ethereum block hash, soeth_getBlockByHashandeth_getTransactionByHashwork even when the canonical substrate hash differs. AFrontierBlockImportwraps the GRANDPA block import inside the BABE import chain so EVM state is tracked on every imported block. ABabeConsensusDataProviderfabricates a BABE pre-digest for the pending block soeth_callagainst"pending"can simulate execution against a non-zero coinbase.
After this lesson, deploying a Solidity contract is one forge create, hardhat run, or truffle migrate away. MetaMask connects to http://127.0.0.1:9944 and recognises chain id 1337. A Hardhat or Foundry suite runs against the node binary unmodified.
Frontier is the polkadot-evm/frontier project — a set of FRAME pallets and substrate-client crates that bolt Ethereum compatibility onto a substrate chain. It's structured as two layers: runtime pallets (pallet-evm, pallet-ethereum, pallet-evm-chain-id, pallet-base-fee, pallet-dynamic-fee, plus runtime primitives fp-account, fp-evm, fp-ethereum, fp-rpc, fp-self-contained) and node-side client crates (fc-db, fc-mapping-sync, fc-rpc, fc-storage, fc-consensus). Both layers pin to the same upstream substrate release (stable2603 for us). The runtime layer is consensus-neutral — pallets don't care whether BABE or AURA produces the block — but the node layer needs to know enough about the consensus engine to author a pending block for simulation. That's where BabeConsensusDataProvider (this lesson) and AuraConsensusDataProvider (shipped by Frontier) diverge.
The runtime pallets are additions, not replacements. Substrate consensus, substrate-signed extrinsics, substrate state — all still work. An H160 user can either submit a substrate-signed Balances::transfer or an Ethereum-signed Ethereum::transact carrying a CALL transaction. Both route the funds; the second also fires EVM events that eth_getLogs can subscribe to.
pallet-ethereum exposes exactly one user-facing extrinsic: transact(transaction: Transaction). The Transaction type (an alias for Frontier's TransactionV3) is the standard Ethereum transaction envelope — Legacy / EIP-2930 / EIP-1559 / EIP-7702, RLP-encoded, signed with a secp256k1 signature covering the canonical Ethereum signing payload. The pallet's job:
- Decode the signed transaction, recover the sender's H160 from the signature.
- Convert the EVM-style nonce / gas / value fields into
pallet-evminput parameters. - Dispatch through
pallet-evm::Pallet::callorpallet-evm::Pallet::createdepending on whether the transaction has atoaddress. - Record the result (status, logs, gas used) into per-block storage keyed by transaction hash — that's the data
eth_getTransactionReceiptreads later. - At the end of the block, finalise a synthetic Ethereum block (header + transactions root + receipts root) and store it under the substrate block's hash. That's what
eth_getBlockByNumberandeth_getBlockByHashreturn.
The pallet doesn't have its own balance or storage abstraction — it's a thin adapter between Ethereum's transaction shape and pallet-evm's execution interface.
A normal substrate extrinsic carries a (Address, Signature, TxExtension) — the signer, their substrate signature, and a set of "extensions" (nonce, mortality, tip, weight). The runtime's Checkable::check verifies the signature against the payload and the extensions enforce their per-call invariants.
An Ethereum-signed transaction has a different shape: it carries a secp256k1 signature over an RLP payload that already includes the nonce and gas. It has no notion of substrate-style mortality or tips. Pretending it's a substrate-signed extrinsic and faking the substrate signature doesn't work — the signature wouldn't verify, and the wallet wouldn't know what to sign.
Frontier solves this with fp_self_contained::UncheckedExtrinsic and the SelfContainedCall trait. A self-contained call is one that:
- Carries its own signature inside the call payload (in our case, inside the
pallet_ethereum::Call::transactargument). - Implements
SelfContainedCall::check_self_contained(&self) -> Option<Result<H160, TxValidityError>>— verifies the embedded signature, returns the recovered H160. - Implements
SelfContainedCall::apply_self_contained(self, info: SignedInfo) -> Option<DispatchResultWithPostInfo>— dispatches with the recovered identity, skipping substrate's signature/extension checks.
The runtime's UncheckedExtrinsic becomes fp_self_contained::UncheckedExtrinsic<Address, RuntimeCall, Signature, TxExtension> (instead of the standard generic::UncheckedExtrinsic). When a transaction arrives, the executive layer asks RuntimeCall::is_self_contained(). For pallet_ethereum::Call::transact { transaction } it's true — the executive routes through apply_self_contained, which calls into pallet-ethereum, which dispatches into pallet-evm. For every other call (Balances::transfer, Sudo::sudo, etc.) it's false — normal substrate signature path.
This is what lets a single chain accept both substrate-signed and Ethereum-signed extrinsics on the same RPC endpoint with no protocol-level distinction.
Inside the EVM, block.coinbase (Solidity) / COINBASE opcode (yul) yields the block author. Substrate chains track the author via pallet-authorship, which reads from a runtime-provided FindAuthor trait. BABE provides one: pallet_babe::FindAuthor reads the BABE pre-digest from the block header, extracts the authority index, looks up the authority's sr25519 public key, and returns that.
But the EVM expects 20 bytes, and a sr25519 public key is 32. Frontier ships FindAuthorTruncated<F> — wrap a substrate FindAuthor<u32> and return H160::from_slice(&pubkey[4..24]) for the EVM. Truncation is deterministic, but it isn't reversible — the H160 doesn't correspond to a real Ethereum private key. That's fine: block.coinbase is used by contracts for accounting (split a fee with the validator, prove that block N was authored by address X) and doesn't need to be spendable.
Substrate Tutorial's FindAuthorTruncated<Babe> resolves to bytes [4..24] of the BABE-elected sr25519 key. For Alith (whose session keys are bound to Alice — see lesson 7), the resolved coinbase is 0x15fdd31c61141abd04a99fd6822c8558854ccde3 — bytes [4..24] of Alice's sr25519 pubkey 0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d.
Ethereum's EIP-1559 fee market has a per-block base fee that adjusts up or down based on the previous block's gas usage relative to the target. The base fee is burned (not paid to the miner); the user adds a priority tip on top. Total fee = (base_fee + tip) × gas_used.
pallet-base-fee implements this adjustment. Three parameters control the target:
Threshold::lower()— base fee floor (gwei).Threshold::ideal()— the target gas usage in PPM (parts-per-million of the block gas limit). The fee adjusts up if the previous block used more than this, down if less.Threshold::upper()— base fee ceiling.
Substrate Tutorial's BaseFeeThreshold returns (0, 500_000, 1_000_000) — base fee can go to zero (chain is happy to be cheap when underloaded), target gas usage is 50% of the block (= 37.5M gas given a 75M block gas limit), ceiling is 1M PPM. The starting base fee is 1 gwei (1_000_000_000 wei), which eth_gasPrice reports.
pallet-dynamic-fee is a sibling pallet that lets the block author hint at a minimum gas price via an inherent. We include it because Frontier's RPC layer expects it to be present, but the inherent value isn't consequential for a single-author dev chain — the base fee dominates.
When a substrate block imports, it has a substrate hash (32 bytes, BLAKE2b-256-derived). The same block, viewed as an Ethereum block, has a different hash (32 bytes, keccak256-derived from the Ethereum block header). eth_getBlockByHash(eth_hash) needs to find the substrate block — but neither hash is derivable from the other without recomputing.
fc-db is the secondary database that maintains the mapping. It has two backends:
KeyValue— a small RocksDB / ParityDB store next to the substrate database, keyed by Ethereum block hash, valued by substrate block hash. Read on everyeth_getBlockByHash/eth_getTransactionByHash. Written by theMappingSyncWorkerbackground task on every imported block.Sql— a SQLite database that indexes logs in addition to the hash mapping. Required for high-volumeeth_getLogsworkloads. Heavier; not enabled by default.
We use KeyValue. The MappingSyncWorker runs as a substrate task, listening to the block-import notification stream, reading each imported block's Ethereum view from runtime state (via EthereumRuntimeRPCApi), and writing the hash entry. It can also catch up from a non-zero starting block on restart, governed by state_pruning_blocks so it doesn't try to index pruned ancestors.
eth_call(... , "pending") and eth_estimateGas(... , "pending") simulate execution against the next block — the one that hasn't been authored yet. To do that, Frontier's RPC layer needs to fabricate a header for that next block and run the runtime's initialize_block against it. The header must satisfy whatever invariants pallet-babe's on_initialize checks: chiefly, a BABE pre-digest identifying the slot and authority.
fc_rpc::pending::ConsensusDataProvider<B> is the trait Frontier defines for "fabricate a digest for a pending block." Frontier ships AuraConsensusDataProvider for AURA chains. It doesn't ship a BABE one — cumulus-chain typically calls relay-chain consensus, parachains don't author standalone, and the BABE solochain variety is rare enough that Frontier leaves it to the integrator.
We implement it in node/src/rpc/babe_pending.rs. The provider:
- On construction, reads the BABE configuration from the runtime (
sc_consensus_babe::configuration(client)) — needs the slot duration. - On
create_digest(parent, inherent_data): a. Pulls the timestamp from the inherent data (BABE depends on a timestamp inherent for slot derivation). b. Converts timestamp → slot using the slot duration. c. Constructs aPreDigest::SecondaryPlain { authority_index: 0, slot }— secondary-plain is BABE's fallback authoring mode when the VRF-based primary slot doesn't elect anyone. We use it for simulation because we have no VRF private key handy in the RPC handler, and pending blocks don't have to be valid for production — just consistent enough thaton_initializedoesn't panic. d. Wraps that into aDigestItemviaCompatibleDigestItem::babe_pre_digest. e. Returns aDigestcontaining the single item.
Without this, pallet_babe::on_initialize can't determine the slot, pallet_authorship::FindAuthor returns None, and block.coinbase simulates as the zero address. Which is exactly the symptom we don't want — a contract that splits a fee with the coinbase would silently send the funds to 0x0000…0000.
The provider is purely an RPC-side construct. Real block authoring still goes through the full BABE flow (VRF, key from keystore, signed digest). The simulation path just needs a syntactically valid pre-digest to make on_initialize happy.
The runtime is compiled to wasm. When the runtime calls a host function (e.g. ext_storage_get, ext_hashing_blake2_256), the wasm executor looks up that function in a table provided by the substrate client. The client's WasmExecutor<HostFunctions> is parameterised on which functions are available.
pallet-evm imports ext_storage_proof_size_storage_proof_size_version_1 — a function from cumulus-primitives-proof-size-hostfunction that reports the size of the current state proof being constructed. The function originates in cumulus (parachains care about proof size because it caps how much state can change per block under PoV limits) but pallet-evm adopts it for EVM gas accounting: the EVM's storage-touching opcodes (SSTORE, SLOAD, etc.) charge gas partly based on proof size impact.
Solochains don't have a PoV budget — they're not parachains. But pallet-evm doesn't know that, and the wasm runtime declares the import regardless. If the host doesn't expose the function, the runtime fails to load with runtime requires function imports which are not present on the host. We add the host function side, return what the function would naturally return (zero or near-zero on a solochain), and the EVM is happy.
This is a runtime ↔ host contract mismatch that bites everyone who integrates Frontier on a non-parachain. The fix is one tuple addition to the executor's HostFunctions type.
Single-account-model Frontier (no HashedAddressMapping). Because lesson 7 already moved Substrate Tutorial to H160-native accounts, pallet-evm keys directly on the substrate AccountId. No translation layer. EVM and substrate share one balance ledger, one nonce per account, one storage space.
pallet-base-fee for gas pricing, not a fixed FixedGasPrice. Real EIP-1559 dynamics, starting at 1 gwei, adjusting based on per-block load. Cheap when underloaded, expensive when saturated.
Standard Ethereum precompile set + SHA3FIPS. Addresses 1–5 are ECRecover / SHA256 / RIPEMD160 / Identity / ModExp — the original set from Frontier Homestead/Byzantium. SHA3FIPS-256 at 0x400 is the FIPS-202 standard SHA3-256 — distinct from Ethereum's non-standard Keccak-256 (which the EVM already exposes as the KECCAK256 opcode; the two use different padding and produce different digests for the same input). The precompile exists so contracts can compute FIPS-compliant SHA3, useful for cross-chain schemes. No exotic extras (no BLS12-381 pairings, no zk-friendly hashes). Contracts that need those can be added later as a custom precompile or via a wasm-native pallet.
KeyValue database backend, not SQL. RocksDB / ParityDB are already in the substrate dependency tree; SQLite would add rusqlite and a separate config. We don't expect heavy eth_getLogs traffic on a learning chain. If we ever do, the swap is one config field.
FrontierBlockImport wraps GRANDPA's import inside BABE. The import chain is BABE → Frontier → GRANDPA → Client. Frontier sits below BABE (so it sees blocks after BABE-level validation) but above GRANDPA (so its mapping-DB writes happen before finality-related work). This is the order frontier-template uses.
Full eth RPC namespace, not just eth_*. We expose Eth, EthFilter, EthPubSub, Net, Web3, Debug, and TxPool (the last gated behind a txpool feature, off by default). That's the surface MetaMask, Hardhat, foundry, and viem expect. The Debug namespace adds debug_traceTransaction and friends — useful for debugging contract failures.
BabeConsensusDataProvider for pending simulation, even though no real validation depends on it. Without it, eth_call("pending") would return wrong-looking results for contracts that read block.coinbase. The cost is ~60 lines of code in the node. Worth it.
Frontier and polkadot-sdk pinned to matching releases (stable2603 both). Mismatched pins are the most common source of "everything compiles, but the runtime panics at boot." Same-release pins guarantee the same primitive types (Slot, DigestItem, Header, …) flow consistently between layers.
The diff is large (≈ 2400 added lines), so we walk it in dependency order: workspace deps → runtime types → runtime pallets → runtime APIs → node DB and RPC → consensus provider.
A new section near the existing # --- Polkadot SDK --- blocks:
# --- Frontier (polkadot-evm) ---
fp-account = { git = "https://github.com/polkadot-evm/frontier", branch = "stable2603" }
fp-ethereum = { git = "...", branch = "stable2603" }
fp-evm = { ... }
fp-rpc = { ... }
fp-self-contained = { ... }
pallet-base-fee = { ... }
pallet-dynamic-fee = { ... }
pallet-ethereum = { ... }
pallet-evm = { ... }
pallet-evm-chain-id = { ... }
pallet-evm-precompile-modexp = { ... }
pallet-evm-precompile-sha3fips = { ... }
pallet-evm-precompile-simple = { ... }
fc-api = { ... }
fc-consensus = { ... }
fc-db = { ... }
fc-mapping-sync = { ... }
fc-rpc = { ... }
fc-rpc-core = { ... }
fc-storage = { ... }Plus one cumulus dep for the host function:
# --- Polkadot SDK — Cumulus (used by Frontier for storage-proof-size host fn) ---
cumulus-primitives-proof-size-hostfunction = { ..., branch = "stable2603" }And one workspace utility:
hex-literal = "0.4.1"The node/Cargo.toml pulls in the fc-* set; runtime/Cargo.toml pulls in the fp-* and pallet-* set plus the cumulus host fn. The runtime side disables default features (wasm no_std), the node side enables them.
fc-db needs an explicit features = ["rocksdb"] on the node side — Frontier's default features for fc-db are ["sql"] (the SQL backend), and the KeyValue backend's RocksDb arm is #[cfg(feature = "rocksdb")]. Without enabling it, fc_db::kv::Backend::open returns the famously confusing error "Supported db sources: \auto` | `rocksdb` | `paritydb`"— the substrate database *is* RocksDb, the catch-all just didn't match because therocksdb` arm wasn't compiled in. One feature flag, one boot blocker resolved.
UncheckedExtrinsic changes shape:
// Before (standard):
pub type UncheckedExtrinsic =
generic::UncheckedExtrinsic<Address, RuntimeCall, Signature, TxExtension>;
// After (Frontier):
pub type UncheckedExtrinsic =
fp_self_contained::UncheckedExtrinsic<Address, RuntimeCall, Signature, TxExtension>;
pub type CheckedExtrinsic =
fp_self_contained::CheckedExtrinsic<AccountId, RuntimeCall, TxExtension, H160>;The fp_self_contained variants add the routing logic: an extrinsic is self-contained if call.is_self_contained() == true, and self-contained extrinsics flow through apply_self_contained instead of normal Checkable::check + Applyable::apply.
The RuntimeCall impl of SelfContainedCall:
impl fp_self_contained::SelfContainedCall for RuntimeCall {
type SignedInfo = H160;
fn is_self_contained(&self) -> bool {
matches!(self, RuntimeCall::Ethereum(pallet_ethereum::Call::transact { .. }))
}
fn check_self_contained(&self) -> Option<Result<H160, TransactionValidityError>> {
if let RuntimeCall::Ethereum(call) = self { call.check_self_contained() } else { None }
}
fn pre_dispatch_self_contained(&self, info: &H160, dispatch_info: &..., len: usize)
-> Option<Result<(), TransactionValidityError>>
{
if let RuntimeCall::Ethereum(call) = self {
call.pre_dispatch_self_contained(info, dispatch_info, len)
} else { None }
}
fn apply_self_contained(self, info: Self::SignedInfo)
-> Option<sp_runtime::DispatchResultWithInfo<...>>
{
match self {
call @ RuntimeCall::Ethereum(pallet_ethereum::Call::transact { .. }) =>
Some(call.dispatch(RuntimeOrigin::from(pallet_ethereum::RawOrigin::EthereumTransaction(info)))),
_ => None,
}
}
}pallet_ethereum::Call::transact is the only self-contained variant. Every other call routes through the substrate signature path unchanged.
FindAuthorTruncated<F> lives in this file too — a wrapper that turns a substrate FindAuthor<u32> (returns an authority index) into a FindAuthor<H160> (returns the truncated authority pubkey):
pub struct FindAuthorTruncated<F>(PhantomData<F>);
impl<F: FindAuthor<u32>> FindAuthor<H160> for FindAuthorTruncated<F> {
fn find_author<'a, I>(digests: I) -> Option<H160>
where I: 'a + IntoIterator<Item = (ConsensusEngineId, &'a [u8])>
{
let author_index = F::find_author(digests)?;
let authority = pallet_babe::Authorities::<Runtime>::get().get(author_index as usize)?.0.clone();
let pubkey: sp_core::sr25519::Public = authority.into();
Some(H160::from_slice(&pubkey.to_raw_vec()[4..24]))
}
}Spec version bumps 106 → 107. The encoding of an extrinsic didn't technically change shape (fp_self_contained::UncheckedExtrinsic is wire-compatible with generic::UncheckedExtrinsic for non-self-contained calls), but a new pallet set and the chain-id change warrant a bump.
Five pallet configs land at the end of the file. Each is small but worth a look.
pallet_evm_chain_id — trivial:
impl pallet_evm_chain_id::Config for Runtime {}Empty config; chain id is stored in EVMChainId storage, seeded at genesis (see below). Changing it post-launch requires a runtime upgrade and a migration; we set 1337 and leave it.
pallet_evm — the meaty one:
impl pallet_evm::Config for Runtime {
type FeeCalculator = BaseFee;
type GasWeightMapping = pallet_evm::FixedGasWeightMapping<Self>;
type WeightPerGas = WeightPerGas;
type BlockHashMapping = pallet_ethereum::EthereumBlockHashMapping<Self>;
type CallOrigin = EnsureAccountId20;
type WithdrawOrigin = EnsureAccountId20;
type AddressMapping = IdentityAddressMapping;
type Currency = Balances;
type PrecompilesType = FrontierPrecompiles<Self>;
type PrecompilesValue = PrecompilesValue;
type ChainId = EVMChainId;
type BlockGasLimit = BlockGasLimit;
type Runner = pallet_evm::runner::stack::Runner<Self>;
type OnChargeTransaction = ();
type OnCreate = ();
type FindAuthor = FindAuthorTruncated<Babe>;
type GasLimitPovSizeRatio = GasLimitPovSizeRatio; // BLOCK_GAS_LIMIT / MAX_POV_SIZE
type GasLimitStorageGrowthRatio = GasLimitStorageGrowthRatio; // BLOCK_GAS_LIMIT / MAX_STORAGE_GROWTH
type Timestamp = Timestamp;
type WeightInfo = pallet_evm::weights::SubstrateWeight<Self>;
// ... a few more associated types from upstream defaults
}The interesting choices:
FeeCalculator = BaseFee— gas price comes frompallet-base-fee, not a constant.EnsureAccountId20for bothCallOriginandWithdrawOrigin— only H160 callers, no other origin types.AddressMapping = IdentityAddressMapping— H160 is the AccountId. No translation. (This is only valid because lesson 7 already moved the chain to AccountId20.)Currency = Balances— EVM balances live in the substrateBalancespallet. A user's H160 has one balance; both substrate transfers and EVMCALL value:...move the same number.FindAuthor = FindAuthorTruncated<Babe>— the coinbase wiring described above.OnChargeTransaction = ()— Frontier's default fee handler runs. Burns the base fee, pays tips to the block author. Could be customised to redirect base fees to treasury (we don't, for clarity).BlockGasLimit = 75_000_000(constantBLOCK_GAS_LIMIT). High enough for non-trivial contracts; low enough that a single block can be produced in well under the slot duration.
pallet_ethereum — wraps the EVM:
impl pallet_ethereum::Config for Runtime {
type StateRoot = pallet_ethereum::IntermediateStateRoot<Self::Version>;
type PostLogContent = PostBlockAndTxnHashes;
type ExtraDataLength = ConstU32<30>;
type AllowUnprotectedTxs = AllowUnprotectedTxs;
}StateRoot::IntermediateStateRoot re-derives an Ethereum-compatible state root from substrate state on every block (used in the synthetic Ethereum block header). PostLogContent = BlockAndTxnHashes includes both substrate and Ethereum hashes in the post-execution log entry (consumed by indexers).
Note that pallet_ethereum::Config doesn't declare a RuntimeEvent associated type — it doesn't emit events directly; events flow from pallet-evm (where they originate at execution) into the storage that pallet-ethereum exposes via runtime API.
pallet_base_fee — EIP-1559:
pub struct BaseFeeThreshold;
impl pallet_base_fee::BaseFeeThreshold for BaseFeeThreshold {
fn lower() -> Permill { Permill::zero() }
fn ideal() -> Permill { Permill::from_parts(500_000) } // 50%
fn upper() -> Permill { Permill::from_parts(1_000_000) } // 100%
}
impl pallet_base_fee::Config for Runtime {
type Threshold = BaseFeeThreshold;
type DefaultBaseFeePerGas = DefaultBaseFeePerGas; // 1 gwei
type DefaultElasticity = DefaultElasticity; // 0.125 (1/8)
}Threshold parts are in PPM (parts-per-million). (0, 500_000, 1_000_000) means: floor at zero, target gas usage at 50% of the block, ceiling at 100%. Default elasticity 1/8 is Ethereum mainnet's value — that's the maximum proportional change per block.
pallet_dynamic_fee — block-author hint:
impl pallet_dynamic_fee::Config for Runtime {
type MinGasPriceBoundDivisor = BoundDivision; // parameter_types! U256 = 1024
}The bound divisor limits how much the author-provided gas-price hint can swing the actual base fee. 1024 is what Frontier-template uses; we follow it. The author's hint is provided via an inherent (see FpDynamicFeeInherentDataProvider on the node side).
A new file, ≈ 70 lines:
use pallet_evm_precompile_modexp::Modexp;
use pallet_evm_precompile_sha3fips::Sha3FIPS256;
use pallet_evm_precompile_simple::{ECRecover, Identity, Ripemd160, Sha256};
pub struct FrontierPrecompiles<R>(PhantomData<R>);
impl<R: pallet_evm::Config> FrontierPrecompiles<R> {
pub fn used_addresses() -> [H160; 6] {
[H160::from_low_u64_be(1), // ECRecover
H160::from_low_u64_be(2), // SHA256
H160::from_low_u64_be(3), // RIPEMD160
H160::from_low_u64_be(4), // Identity
H160::from_low_u64_be(5), // ModExp
H160::from_low_u64_be(1024)] // SHA3FIPS-256 (0x400)
}
}The PrecompileSet impl matches the call address against this set and dispatches to the corresponding precompile struct. The first five mirror Ethereum mainnet. Sha3FIPS256 at 0x400 is the FIPS-202 standard SHA3-256 — not the same as Ethereum's Keccak-256 (KECCAK256 opcode); it exists so a contract can compute FIPS-compliant SHA3 (handy for cross-chain schemes) without a wasm-native equivalent.
Address 0x401 (ECRecoverPublicKey) and the BN128 pairing precompiles (0x06–0x08) are intentionally omitted. They can be added later by extending used_addresses() and the dispatch match.
Frontier defines two runtime APIs the node-side RPC handlers call into. apis.rs gains both impls.
fp_rpc::EthereumRuntimeRPCApi<Block> — the bulk of the work. Fifteen-ish methods, each a thin shim over pallet-ethereum / pallet-evm storage:
impl fp_rpc::EthereumRuntimeRPCApi<Block> for Runtime {
fn chain_id() -> u64 { <Runtime as pallet_evm::Config>::ChainId::get() }
fn account_basic(address: H160) -> EVMAccount { pallet_evm::Pallet::<Runtime>::account_basic(&address).0 }
fn gas_price() -> U256 { <Runtime as pallet_evm::Config>::FeeCalculator::min_gas_price().0 }
fn account_code_at(address: H160) -> Vec<u8> { pallet_evm::AccountCodes::<Runtime>::get(address) }
fn author() -> H160 { <pallet_evm::Pallet<Runtime>>::find_author() }
fn storage_at(address: H160, index: U256) -> H256 { ... }
fn call(/* args */) -> Result<pallet_evm::CallInfo, sp_runtime::DispatchError> {
// dispatches into pallet_evm::Runner::call for `eth_call`
}
fn create(/* args */) -> Result<pallet_evm::CreateInfo, sp_runtime::DispatchError> { ... }
fn current_transaction_statuses() -> Option<Vec<TransactionStatus>> { ... }
fn current_block() -> Option<pallet_ethereum::Block> { ... }
fn current_receipts() -> Option<Vec<pallet_ethereum::Receipt>> { ... }
fn current_all() -> (Option<...>, Option<...>, Option<...>) { ... }
fn extrinsic_filter(xts: Vec<UncheckedExtrinsic>) -> Vec<EthereumTransaction> { ... }
fn elasticity() -> Option<Permill> { Some(pallet_base_fee::Elasticity::<Runtime>::get()) }
fn gas_limit_multiplier_support() {}
fn pending_block(xts: Vec<UncheckedExtrinsic>) -> (Option<...>, Option<Vec<...>>) { ... }
fn initialize_pending_block(header: &<Block as BlockT>::Header) { Executive::initialize_block(header) }
}pending_block and initialize_pending_block are the methods Frontier's RPC layer calls during pending-block simulation — initialize_pending_block runs Executive::initialize_block on the fabricated header (which is where our BABE pre-digest matters), then pending_block returns the result.
fp_rpc::ConvertTransactionRuntimeApi<Block> — one method:
impl fp_rpc::ConvertTransactionRuntimeApi<Block> for Runtime {
fn convert_transaction(transaction: EthereumTransaction) -> <Block as BlockT>::Extrinsic {
UncheckedExtrinsic::new_bare(
pallet_ethereum::Call::<Runtime>::transact { transaction }.into(),
)
}
}This is what eth_sendRawTransaction calls: take the Ethereum-signed transaction the user submitted, wrap it in an UncheckedExtrinsic carrying pallet_ethereum::Call::transact, and submit that into substrate's transaction pool. The transaction pool sees a self-contained extrinsic, validates it via SelfContainedCall::check_self_contained (which recovers the H160 from the secp256k1 signature), and routes it through apply_self_contained on block construction.
One line per preset:
evm_chain_id: EVMChainIdConfig { chain_id: 1337 },Chain id 1337 is the Hardhat / Ganache / Foundry default — every Ethereum tooling chain assumes 1337 for local dev. Production deployments would pick a registered chain id (e.g. via chainlist.org). For a tutorial, 1337 is right.
EVM storage starts empty. Balances come from the existing H160 endowment (lesson 7). No contract is pre-deployed.
Roughly 165 lines. Two purposes:
-
EthConfiguration— a clap-derived struct flattened into the main CLI:#[derive(Debug, Clone, clap::Parser)] pub struct EthConfiguration { #[arg(long, default_value = "10000")] pub max_past_logs: u32, #[arg(long, default_value = "1024")] pub max_block_range: u32, #[arg(long, default_value = "2048")] pub fee_history_limit: u64, #[arg(long, default_value = "false")] pub enable_dev_signer: bool, #[arg(long, default_value = "1")] pub target_gas_price: u64, #[arg(long, default_value = "10")] pub execute_gas_limit_multiplier: u64, #[arg(long, default_value = "false")] pub rpc_allow_unprotected_txs: bool, // ... a few more cache / pubsub knobs }
Defaults match frontier-template. None of these are consensus-critical — they all tune RPC behaviour (cache sizes, retention windows, log-query limits).
-
spawn_frontier_tasks— three substrate background tasks:- mapping-sync-worker — the indexer described above. Started with a 6 s read-notification timeout and 3 retry attempts, the parameters frontier-template uses.
- fee-history-cache-task — maintains the sliding window for
eth_feeHistory. - filter-pool-task — evicts stale entries from the
EthFilterpool every 100 blocks. (The pool itself is created innew_frontier_partial.)
Plus
set_max_pending_notifications_per_subscriberis called globally (it's a setter on a static — Frontier's idiom — not a per-pool config).
The biggest single change. Three additions:
Host functions tuple:
pub type HostFunctions = (
sp_io::SubstrateHostFunctions,
cumulus_primitives_proof_size_hostfunction::storage_proof_size::HostFunctions,
);
pub(crate) type FullClient =
sc_service::TFullClient<Block, RuntimeApi, sc_executor::WasmExecutor<HostFunctions>>;This is the bit that fixes runtime requires function imports which are not present on the host. Without the cumulus host function the runtime won't load.
Block-import chain:
type GrandpaBlockImport =
sc_consensus_grandpa::GrandpaBlockImport<FullBackend, Block, FullClient, FullSelectChain>;
type FrontierBlockImportT = FrontierBlockImport<Block, GrandpaBlockImport, FullClient>;
type BabeBlockImportT = BabeBlockImport<
Block, FullClient, FrontierBlockImportT,
BabeCreateInherentDataProviders<Block>, FullSelectChain,
>;Substitute Frontier into the middle of the import stack. The chain is BABE → Frontier → GRANDPA → Client. Every block flows through Frontier's import, which writes the substrate-block → Ethereum-block hash mapping into the Frontier DB.
Frontier DB and tasks wired in new_full:
let frontier_backend = FrontierBackend::KeyValue(Arc::new(
fc_db::kv::Backend::open(Arc::clone(&client), &config.database, &db_config_dir(config))?
));
let FrontierPartialComponents { filter_pool, fee_history_cache, fee_history_cache_limit } =
new_frontier_partial(ð_config)?;
let pubsub_notification_sinks: fc_mapping_sync::EthereumBlockNotificationSinks<...> = Default::default();
let pubsub_notification_sinks = Arc::new(pubsub_notification_sinks);
fc_mapping_sync::set_max_pending_notifications_per_subscriber(eth_config.pubsub_max_pending_notifications);
spawn_frontier_tasks(
&task_manager, client.clone(), backend.clone(),
frontier_backend.clone(), filter_pool.clone(),
storage_override.clone(), fee_history_cache.clone(), fee_history_cache_limit,
state_pruning_blocks,
sync_service.clone(),
pubsub_notification_sinks.clone(),
);The RPC builder constructs an EthDeps (≈ 20 fields), populates crate::rpc::FullDeps { ..., eth: eth_deps }, and calls crate::rpc::create_full(deps, subscription_executor, pubsub_notification_sinks.clone()).
Holds EthDeps<B, C, P, CT, CIDP> (the bundle of Ethereum-side state the RPC handlers need) and create_eth(io, deps, ...) — the function that merges every eth_* handler into the existing substrate RPC module:
io.merge(Eth::new(/* 17 args including the BabeConsensusDataProvider */).into_rpc())?;
io.merge(EthFilter::new(...).into_rpc())?;
io.merge(EthPubSub::new(...).into_rpc())?;
io.merge(Net::new(client.clone(), network, true).into_rpc())?;
io.merge(Web3::new(client.clone()).into_rpc())?;
io.merge(Debug::new(...).into_rpc())?;
#[cfg(feature = "txpool")]
io.merge(TxPool::new(client, pool).into_rpc())?;The Eth::new call's last argument is the Option<Box<dyn ConsensusDataProvider<B>>> — that's where our BABE provider gets passed:
Some(Box::new(crate::rpc::BabeConsensusDataProvider::new(client.clone())))The where-clauses on create_eth include C::Api: BabeApi<B> (needed by BabeConsensusDataProvider::new, which reads BABE config from runtime).
About sixty lines including comments. Already described in the Background section. The whole file:
pub struct BabeConsensusDataProvider<B, C> {
slot_duration: SlotDuration,
_phantom: PhantomData<(B, C)>,
}
impl<B, C> BabeConsensusDataProvider<B, C>
where B: BlockT, C: AuxStore + ProvideRuntimeApi<B> + UsageProvider<B>, C::Api: BabeApi<B>
{
pub fn new(client: Arc<C>) -> Self {
let babe_config = sc_consensus_babe::configuration(&*client)
.expect("BABE config is always present at startup; qed");
Self { slot_duration: babe_config.slot_duration(), _phantom: PhantomData }
}
}
impl<B: BlockT, C: Send + Sync> ConsensusDataProvider<B> for BabeConsensusDataProvider<B, C> {
fn create_digest(&self, _parent: &B::Header, data: &InherentData)
-> Result<Digest, sp_inherents::Error>
{
let timestamp = data.timestamp_inherent_data()?
.expect("Timestamp inherent is always present; qed");
let slot = Slot::from_timestamp(timestamp, self.slot_duration);
let pre_digest = PreDigest::SecondaryPlain(SecondaryPlainPreDigest {
authority_index: 0,
slot,
});
let digest_item = <DigestItem as CompatibleDigestItem>::babe_pre_digest(pre_digest);
Ok(Digest { logs: vec![digest_item] })
}
}SecondaryPlain (rather than Primary or SecondaryVRF) is the no-VRF fallback variant — we can't sign a VRF output in the RPC context, but pending-block simulation doesn't need a valid signature. authority_index: 0 is arbitrary; for a real consensus block it'd be derived from the BABE election, for simulation any in-range value works because the consumer only reads the resulting H160 via FindAuthorTruncated.
Two non-EVM contract layers exist in substrate. pallet-contracts runs WebAssembly contracts compiled from ink! (a Rust DSL). pallet-revive is the newer successor that runs PolkaVM contracts (a different VM aimed at deterministic performance and parachain-PoV friendliness).
Pros (revive/contracts): native to substrate, no host-function juggling, no chain-id, no fp_self_contained machinery. Wasm/PolkaVM is provably terminating in ways the EVM isn't. Type-safe contract APIs.
Cons: orthogonal to the Ethereum tooling ecosystem. No MetaMask. No Solidity. No Hardhat. No Foundry. The contract authoring story is "learn ink!" or "use Solang's experimental backend." For a chain that wants the existing Ethereum ecosystem to apply, this is the wrong direction.
For a complementary layer — running both EVM and PolkaVM contracts on the same chain — see Asset Hub Westend, which does this. Not done here for simplicity.
Frontier ships a HashedAddressMapping<H> that takes an H160, hashes it with a salt to derive a substrate AccountId32, and uses that for storage. Most non-H160-native substrate chains use it: their substrate accounts are AccountId32; their EVM accounts are AccountId32-via-hash-of-H160. Two parallel account spaces, one balance ledger per account.
Pros: works without a runtime account-model migration. Existing substrate accounts unaffected.
Cons: confusing UX. The same human user has two addresses depending on which API they speak through. Their Polkadot.js Apps balance doesn't match their MetaMask balance unless they manually translate. Wallets like Talisman that try to bridge the two universes have to maintain explicit mapping tables.
We did the H160 migration in lesson 7 specifically to avoid this. IdentityAddressMapping is the cleaner choice for a chain that committed to H160-native accounts.
Frontier ships FixedGasPrice<P> — a constant gas price, configured at Config-impl time. pallet-base-fee is optional.
Pros: simpler. No per-block adjustment, no inherent. Predictable transaction cost.
Cons: no congestion control. Under load the chain has no mechanism to price block space — extrinsics queue and wait. Fee markets are the entire point of EIP-1559.
For a learning chain it's a defensible choice. We went with the real model because the dev-experience parity with Ethereum mainnet is closer.
fc-db has two backends — KeyValue (we picked) and Sql. The SQL backend indexes log topics, addresses, and block numbers in addition to the hash mapping. eth_getLogs queries against the SQL backend can use indexed lookups; against the KeyValue backend they scan storage.
Pros: dramatically faster eth_getLogs for chains with many events.
Cons: a separate sqlite database, an extra dependency, more disk usage, slower writes. Most learning chains don't care about log-query performance.
We use KeyValue. Production chains with indexer workloads switch to SQL.
A substrate-flavoured pattern: implement a custom precompile that, when called, dispatches into a substrate pallet. E.g. a precompile at 0x800 that lets a contract call Balances::transfer directly — letting EVM contracts manipulate substrate state without going through a separate transaction.
Pros: lets contracts orchestrate substrate-native functionality (governance votes, NPoS bonding, identity claims, …).
Cons: significant complexity. Origin handling has to be carefully designed (does the precompile dispatch as the contract's H160? as the original sender? as root?). Gas accounting for substrate calls is fiddly. Security review of every dispatch path.
This is what Moonbeam's substrate precompiles do. We don't add them yet — the EVM-only surface is already a lot. The where-to-next page mentions this as a forward direction.
Frontier's pallets have a runtime-benchmarks feature like every FRAME pallet. When enabled, benchmark targets exercise each pallet's expensive paths to generate weight files.
Pros: real, measured weights for production deployments.
Cons: another feature flag in the build matrix, slower wasm builds, an extra step in the release process.
We don't enable benchmarks anywhere in this tutorial — no tests, no benchmarks yet; it's a learning chain. The default pallet_evm::weights::SubstrateWeight<Self> weights are good enough for a dev chain.
-
Chain id 1337 collides with every other local dev chain. Hardhat node, Ganache, Anvil, and most local Substrate-with-Frontier dev chains all use 1337. MetaMask will warn ("network already exists") if you've configured another local chain previously. Workaround: pick a non-1337 id for distinct deployments. Production deployments must pick a unique registered id.
-
block.coinbaseis not a real Ethereum address. It's the truncation of an sr25519 public key, not a secp256k1-derived H160. A contract that sends value toblock.coinbasesends it to an address no one controls. This is consistent with Moonbeam and other H160-substrate chains, but contracts that rely on the coinbase being spendable will lose funds. -
block.difficultyandblock.prevrandaoare zero. Both come frompallet-evm's defaults. BABE has a VRF output that could be plumbed intoprevrandao, but Frontier doesn't do that by default and we don't either. Solidity contracts that readblock.prevrandaofor randomness get zero — which is a known footgun. Use chainlink VRF or a substrate-native randomness pallet via precompile if you actually need on-chain randomness. -
The transaction pool sees self-contained and substrate-signed extrinsics in one queue. This is fine, but mempool-monitoring tools that expect a pure Ethereum mempool (
txpool_content) will see substrate extrinsics too.txpool_statusreports both kinds. Thetxpoolnamespace is feature-gated; if you ship a public RPC node you might want to keep it off. -
The Frontier DB grows with the substrate database, no pruning. Even when substrate prunes a block from its KV store, the Frontier DB keeps the hash mapping entry. Over time this is bounded by the substrate height (one entry per finalised block) but isn't zero. Production deployments with finality-pruning still keep mapping entries; periodic compaction is not automatic.
-
eth_subscribeis wired but rate-limited. Per-subscriber pending-notification count is capped by--pubsub-max-pending-notifications(default 512 in ourEthConfiguration). A slow consumer falls behind by dropping the oldest notifications. For high-throughput indexers this is too low — bump it. -
The Frontier
rocksdbfeature must be enabled.fc-db's default features are["sql"], not["rocksdb"]. Substrate's default database is RocksDb; iffc-db'srocksdbarm isn't compiled in, opening the Frontier KV backend errors at boot with"Supported db sources: \auto` | `rocksdb` | `paritydb`". We enable it explicitly innode/Cargo.toml`. Mention this whenever someone says "Frontier compiles but won't boot." -
SelfContainedCall::apply_self_containedreturningNonefor non-Ethereum calls is intentional. It means "I am not self-contained; route me through normal substrate signature check." A bug in this match arm (e.g. accidentally accepting another call as self-contained) would let an attacker submit unsigned substrate transactions. The current implementation is exhaustive — onlypallet_ethereum::Call::transactmatches. -
pallet-evm'sOnChargeTransaction = ()uses Frontier's default — burn base fee, pay tip to author. A natural extension ispallet_evm::EVMCurrencyAdapterplus a customOnUnbalancedthat routes burned base fees to the treasury (matching the substrate-side fee-split from lesson 5). Worth doing for a production chain; not done here to keep the lesson focused on getting the EVM working. -
Spec version 106 → 107. Storage layout for new pallets, new chain-id, new extrinsic type. Existing dev databases must be purged. The on-wire encoding of an Ethereum-side extrinsic is different from a normal substrate extrinsic (the call is
pallet_ethereum::Call::transact, the rest is empty), butfp_self_contained::UncheckedExtrinsicis wire-compatible for non-self-contained calls, so substrate-signed extrinsics keep working without re-encoding clients.
After Frontier, Substrate Tutorial is a fully Ethereum-compatible substrate chain with substrate-native NPoS, OpenGov, treasury, and identity. The combination is what Moonbeam, Astar, and Acala chose to ship. From here the design space forks:
- Custom precompiles that dispatch into substrate pallets — let EVM contracts vote in referenda, bond as a validator, register identities. The Moonbeam pattern.
- Solidity testing infrastructure — Hardhat or Foundry test suite against the running node.
- Sealing modes — instant-seal (one block per extrinsic) or manual-seal (RPC-triggered) for deterministic test environments. Frontier ships sealing helpers but we don't wire them; integration test suites usually prefer instant-seal for predictability.
- Off-chain message commitment / bridges — Snowbridge, Hyperbridge, XCM with a parachain — to give substrate state cross-chain reachability.
See 99-where-to-next.md for the broader roadmap.
- Frontier source —
polkadot-evm/frontier. Readtemplate/node/src/service.rsandtemplate/node/src/rpc/eth.rsas canonical references for node-side wiring; the Substrate Tutorial code closely mirrors them. fp_self_containedsource —UncheckedExtrinsic,CheckedExtrinsic, theSelfContainedCalltrait.pallet-ethereumsource —transactextrinsic, block construction, log indexing.pallet-evmsource — execution, precompile interface, gas accounting.- EIP-1559 specification — the dynamic fee model
pallet-base-feeimplements. - Moonbeam runtime — production-grade reference for an H160-native substrate chain with EVM. Their precompile crates show the pattern for custom substrate-dispatching precompiles.
- BABE pre-digest spec — the digest format
BabeConsensusDataProviderproduces. Section 5.3 covers SecondaryPlain. - cumulus
storage_proof_sizehost function — what we add to the executor'sHostFunctionstuple.
Next up: 99 — Where to next.