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: it is a structural derivation, computed by the runtime on every transaction, with no on-chain registration step.


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.
  • Do all of this with no registration or setup call of any kind.
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().


EVM-side resolution: AddressMapping

The AddressMapping implementation used by the EVM pallet (EeSuffixAddressMapping) applies the same structural derivation, with no storage lookup involved:

fn into_account_id(address: H160) -> AccountId {
// H160 ++ [0x00; 12]
evm_h160_to_account_id_bytes(address).into()
}

Every secp256k1 account therefore has an implicit mapping: the AccountId32 = [H160 | 0x00×12] pattern makes the relationship derivable from the address alone. There is no registration extrinsic, no registry to write, and no state that can fall out of sync.

Accounts with a pure Sr25519 or Ed25519 AccountId32 (bytes 20–31 are not all zero) have no H160 counterpart. They are reachable from Solidity only through the Balances precompile, which accepts a raw AccountId32.


Address derivation reference

The encoding and its reverse are defined in template/runtime/src/evm_account.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)

Converting between address forms

Both directions are pure functions — no RPC round-trip is needed. @orbinum/protocol exposes them directly:

import { evmToSubstrate, substrateToEvm, isImplicitEvmAccount } from '@orbinum/protocol';

substrateToEvm returns null for a pure Sr25519/Ed25519 account, where bytes 20–31 are not the EVM_ACCOUNT_MARKER. isImplicitEvmAccount tests that marker directly.


Known limitations

Known constraints
  • One-to-one only: the derivation is a bijection, so an AccountId32 has exactly one H160 and vice versa. It cannot be reassigned.
  • Pure Sr25519/Ed25519 accounts have no H160 form and cannot be reached by a plain EVM transfer.
  • 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.