Skip to main content

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

PropertySubstrate sideEVM side
Address formatAccountId32 (32 bytes, SS58 encoded)H160 (20 bytes, EIP-55 checksum)
Key schemeSr25519 / Ed25519 / Secp256k1Secp256k1
WalletTalisman, SubWalletMetaMask, Trust Wallet
SignsSubstrate extrinsicsEthereum 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 H160 address from any EVM-compatible tool.
  • Spend that same balance via a Substrate extrinsic signed with the same private key.
  • No map_account call is needed or expected.
info

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:

  1. Verify the signature over blake2_256(payload) using secp256k1_ecdsa_recover.
  2. Decompress the recovered 33-byte public key to its 65-byte uncompressed form.
  3. Compute keccak256 of the 64-byte body (bytes 1–64, excluding the 0x04 prefix).
  4. Take the last 20 bytes → this is the H160 address.
  5. 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.

Implementation reference

template/runtime/src/orbinum_signature.rsOrbinumSigner::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.

info

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 AccountMapped event is emitted, no storage is written.
  • Accounts whose AccountId32 bytes 20–31 are not all zero cannot derive a valid H160 and are rejected with NativeAccountCannotBeMapped. 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).

note

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(&eth_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 the AccountId32 as an H160); null if bytes 20–31 are not the EVM_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

Current limitations (MVP)
  • One-to-one only: a single AccountId32 cannot map to multiple H160 addresses.
  • Remapping requires unmap_account followed by a new map_account.
  • Shielded pool operations (shield, unshield, private_transfer) can be submitted as Ethereum transactions via the ShieldedPoolPrecompile at address 0x0000…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 via OrbinumSignature::Ecdsa and derives the sender AccountId32 as H160 ++ [0x00; 12], so the shielded balance belongs to the same identity as the EVM account. See ShieldedPool precompile for selector details.