EVM ↔ Substrate Mapping
Orbinum runs both a Substrate runtime and an EVM execution environment in the same chain. A single ORB balance can be accessed from either side. This document explains how the relationship between an Ethereum H160 address and a Substrate AccountId32 works — both by default (structural derivation) and explicitly (stateful mapping via pallet-account-mapping).
How the two address spaces work
| Property | Substrate side | EVM side |
|---|---|---|
| Address format | AccountId32 (32 bytes, SS58 encoded) | H160 (20 bytes, EIP-55 checksum) |
| Key scheme | Sr25519 / Ed25519 / Secp256k1 | Secp256k1 |
| Wallet | Talisman, SubWallet | MetaMask, Trust Wallet |
| Signs | Substrate extrinsics | Ethereum transactions |
Default relationship: structural derivation (no setup required)
For any wallet using a Secp256k1 keypair (standard Ethereum wallet), no configuration or on-chain call is needed. The runtime derives the AccountId32 from the H160 automatically on every transaction using a fixed encoding:
AccountId32 = H160 (20 bytes) ++ 0x00 x 12
For example:
H160: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
AccountId32: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 000000000000000000000000
The 12 trailing zero bytes act as a marker (EVM_ACCOUNT_MARKER) that lets the runtime detect whether an AccountId32 corresponds to an EVM address. Both the H160 and the derived AccountId32 resolve to the same slot in pallet_balances — there is no synchronization, copy, or lock. They are the same balance entry.
This means a user with a standard Ethereum wallet can:
- Receive ORB at their
H160address from any EVM-compatible tool. - Spend that same balance via a Substrate extrinsic signed with the same private key.
- No
map_accountcall is needed or expected.
If you generate a Substrate keypair using a Secp256k1 derivation path (e.g. via MetaMask or any Ethereum wallet that can export a raw private key), the resulting H160 is the standard Ethereum address for that key and the derived AccountId32 is the corresponding Substrate account — the same key controls both.
Runtime enforcement: OrbinumSignature
The structural derivation described above is enforced at the signature-verification layer by OrbinumSignature, a custom Substrate signature type that replaces the standard MultiSignature in the Orbinum runtime.
For the Ecdsa variant, OrbinumSignature derives the AccountId32 from the recovered secp256k1 public key using the same EVM-suffix encoding used by EeSuffixAddressMapping:
- Verify the signature over
blake2_256(payload)usingsecp256k1_ecdsa_recover. - Decompress the recovered 33-byte public key to its 65-byte uncompressed form.
- Compute
keccak256of the 64-byte body (bytes 1–64, excluding the0x04prefix). - Take the last 20 bytes → this is the
H160address. - Build
AccountId32 = [H160 (20 bytes) | 0x00×12].
The result is identical to what EeSuffixAddressMapping produces for the same key when processing an EVM transaction:
secp256k1 private key
├── EVM path → H160 = keccak256(pubkey_64b)[12..]
│ AccountId32 = EeSuffixAddressMapping(H160) = [H160 | 0x00×12]
└── Substrate path → OrbinumSignature::Ecdsa recovery
H160 = keccak256(pubkey_64b)[12..]
AccountId32 = [H160 | 0x00×12] ← same result
Ed25519 and Sr25519 variants behave identically to the standard MultiSignature — their AccountId32 derivation is unaffected.
template/runtime/src/orbinum_signature.rs — OrbinumSigner::Ecdsa::into_account() and OrbinumSignature::Ecdsa::verify().
Stateful mapping: map_account
With OrbinumSignature, all secp256k1 accounts have an implicit mapping — the AccountId32 = [H160 | 0x00×12] pattern makes the relationship structurally derivable without any on-chain storage entry.
As of the Account Mapping Alignment Plan (Fase 1), map_account for secp256k1 accounts is event-only: it emits AccountMapped { account, address } and returns without writing to OriginalAccounts or MappedAccounts storage. Balance unification is guaranteed by structural derivation regardless of whether this call is made.
map_account can only be called by secp256k1 accounts. Accounts with a pure Sr25519 or Ed25519 AccountId32 (bytes 20–31 are not all zero) cannot derive an H160 and are rejected with NativeAccountCannotBeMapped.
Calling map_account from MetaMask or any Ethereum wallet is optional and has no effect on balance access. Its purpose is to let indexers and off-chain tools detect the association via an on-chain event without recomputing the structural derivation locally.
The AddressMapping implementation used by the EVM pallet (EeSuffixAddressMapping) checks the legacy explicit registry first and falls back to structural derivation if no entry is found:
fn into_account_id(address: H160) -> AccountId {
// 1. Explicit registry entry?
if let Some(mapped) = pallet_account_mapping::mapped_account(address) {
return mapped;
}
// 2. Structural fallback: H160 ++ [0x00; 12]
evm_h160_to_account_id_bytes(address).into()
}
Registering an explicit mapping
Call map_account from the Substrate side. No arguments are required — the runtime derives the H160 from the caller's AccountId32 by reading back the first 20 bytes (only valid for ECDSA-derived accounts).
pallet: AccountMapping (index 14)
call: map_account (index 0)
origin: Signed (any account whose AccountId32 has the EVM_ACCOUNT_MARKER suffix)
Constraints checked by the runtime:
- For secp256k1 accounts (bytes 20–31 are all zero): the call always succeeds — only an
AccountMappedevent is emitted, no storage is written. - Accounts whose
AccountId32bytes 20–31 are not all zero cannot derive a validH160and are rejected withNativeAccountCannotBeMapped. This covers pure Sr25519/Ed25519 accounts.
On success, emits:
AccountMapped { account: AccountId32, address: H160 }
Removing a mapping
Call unmap_account from the mapped Substrate account.
pallet: AccountMapping (index 14)
call: unmap_account (index 1)
origin: Signed (the account that owns the mapping)
Emits:
AccountUnmapped { account: AccountId32, address: H160 }
After this call, the H160 address and the AccountId32 are independent again. Any ORB balance held at the time of unmapping remains accessible from both sides (the balance is shared by derivation, not locked by the mapping).
If a secp256k1 account has a legacy storage entry from a runtime version predating the Alignment Plan, unmap_account detects and removes it before emitting AccountUnmapped. If no legacy entry exists, the event is still emitted (for indexer compatibility) and the call succeeds with no state change.
Address derivation reference
The encoding and its reverse are defined in template/runtime/src/account_mapping_runtime.rs:
// H160 → AccountId32
pub const EVM_ACCOUNT_MARKER: [u8; 12] = [0x00u8; 12];
pub fn evm_bytes_to_account_id_bytes(eth_address: [u8; 20]) -> [u8; 32] {
let mut bytes = [0u8; 32];
bytes[..20].copy_from_slice(ð_address); // H160 in bytes 0–19
bytes[20..].copy_from_slice(&EVM_ACCOUNT_MARKER); // 12 zero bytes
bytes
}
// AccountId32 → H160 (only valid if marker matches)
pub fn try_evm_h160_from_account_id(account_id: &AccountId) -> Option<H160> {
let bytes: &[u8; 32] = account_id.as_ref();
if bytes[20..] == EVM_ACCOUNT_MARKER {
Some(H160::from_slice(&bytes[0..20]))
} else {
None // pure Substrate account, not EVM-derivable
}
}
This convention is compatible with Frontier and with any EVM wallet that imports a raw private key.
The OrbinumSigner::Ecdsa derivation in orbinum_signature.rs mirrors the same result starting from the compressed public key:
// orbinum_signature.rs — OrbinumSigner::Ecdsa AccountId derivation
fn compressed_ecdsa_pub_to_eth_addr(compressed: &[u8; 33]) -> Option<[u8; 20]> {
let pk = libsecp256k1::PublicKey::parse_slice(
compressed,
Some(libsecp256k1::PublicKeyFormat::Compressed),
)
.ok()?;
let uncompressed = pk.serialize(); // [u8; 65] — 0x04 prefix + 32 + 32
let keccak = sp_io::hashing::keccak_256(&uncompressed[1..]); // hash 64 bytes
keccak[12..].try_into().ok() // last 20 bytes → H160
}
// AccountId32 = [H160 | 0x00×12] — same as evm_bytes_to_account_id_bytes(H160)
Querying the mapping
Two JSON-RPC methods are available:
accountMapping_getMappedAccount(H160_hex) → AccountId32_ss58 | null
accountMapping_getAccountAddresses(AccountId32_ss58) → { mapped: H160_hex | null, fallback: H160_hex | null }
accountMapping_getMappedAccount queries only the explicit on-chain registry written by map_account. It returns null if the address has never registered a mapping.
accountMapping_getAccountAddresses returns both fields:
mapped— the explicit registry entry, if any.fallback— the structural derivation (bytes 0–19 of theAccountId32as anH160);nullif bytes 20–31 are not theEVM_ACCOUNT_MARKER(i.e. a pure Sr25519/Ed25519 account).
Relationship to the alias system
An EVM mapping is independent of the alias system. An account can:
- Have an EVM mapping and no alias.
- Have an alias and no EVM mapping.
- Have both.
The alias system (see Aliases) works at the Substrate layer and does not depend on whether the account has an EVM mapping.
Known limitations
- One-to-one only: a single
AccountId32cannot map to multipleH160addresses. - Remapping requires
unmap_accountfollowed by a newmap_account. - Shielded pool operations (
shield,unshield,private_transfer) can be submitted as Ethereum transactions via theShieldedPoolPrecompileat address0x0000…0801. EVM wallets (MetaMask, Trust Wallet) can call the precompile directly without any Substrate tooling. Alternatively, a Secp256k1 keypair can sign a Substrate extrinsic directly — the runtime accepts and verifies it viaOrbinumSignature::Ecdsaand derives the senderAccountId32asH160 ++ [0x00; 12], so the shielded balance belongs to the same identity as the EVM account. See ShieldedPool precompile for selector details.