Skip to main content

EVM ↔ Substrate Mapping

Orbinum runs a Substrate runtime and an EVM in the same chain, over one set of balances. This page explains how an Ethereum H160 address relates to a Substrate AccountId32: a structural derivation the runtime computes on every transaction, with no registration step and no stored mapping. It also owns the runtime's signature type, OrbinumSignature, and how each key type signs an extrinsic.


The two address spaces​

PropertySubstrate sideEVM side
Address formatAccountId32 (32 bytes, SS58 encoded)H160 (20 bytes, EIP-55 checksum)
Key schemesr25519 / ed25519 / secp256k1secp256k1
WalletTalisman, SubWallet; Solana wallets (ed25519)MetaMask, Trust Wallet
SignsSubstrate extrinsicsEthereum transactions

Structural derivation​

An H160 maps to an AccountId32 by appending twelve zero bytes:

AccountId32 = H160 (20 bytes) ++ 0x00 × 12
H160:        0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
AccountId32: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 000000000000000000000000

The twelve zero bytes are the marker (EVM_ACCOUNT_MARKER) of an EVM-derived account. Both forms index the same entry in pallet_balances — there is no copy, synchronization or lock. A user with a standard Ethereum wallet can receive ORB at the H160 from any EVM tool and spend the same balance with a Substrate extrinsic signed by the same key, with no setup call.

The runtime maps only H160 → AccountId32. Reading the H160 back from an AccountId32 is done off-chain from its first 20 bytes — see Converting between address forms.


Substrate side: OrbinumSignature​

The runtime's signature type is OrbinumSignature, which replaces Substrate's standard MultiSignature. Indices 0x00–0x02 keep MultiSignature's wire format; ed25519 keys sign through two variants of their own.

IndexVariantSigned messageAccount
0x00Ed25519Never verifies—
0x01Sr25519The signing payload (its blake2_256 above 256 bytes)The public key
0x02Ecdsablake2_256 of that message[H160 | 0x00×12]
0x03Ed25519TextThe transaction textThe public key
0x04Ed25519WrappedTextThe transaction text inside <Bytes>…</Bytes>The public key

Ed25519 stays at index 0 only because polkadot-api builds no extrinsic for a signature enum without Ed25519, Sr25519 and Ecdsa. A signature under it fails with BadProof.

ECDSA: the Ethereum account​

For the Ecdsa variant the runtime derives the signer's account the Ethereum way:

  1. Hash the payload with blake2_256 and recover the 64-byte public key with secp256k1_ecdsa_recover.
  2. Take keccak256 of those 64 bytes; the last 20 bytes are the H160.
  3. Compare the signer against AccountId32 = [H160 | 0x00×12].

Ed25519 signs the transaction text​

An ed25519 key signs a UTF-8 text that names an Orbinum transaction, never raw bytes:

Orbinum transaction
chain: 0x{genesis hash}
action: {Pallet}.{call}
to: 0x{account} balance transfers
amount: {n} ORB balance transfers, ORB shields and fee claims
tip: {n} ORB a non-zero tip
payload: 0x{blake2_256(signing payload)}

Hex is lowercase. The runtime renders the text again from the whole signing payload — at any size, never its hash, through fp-self-contained's VerifyFullPayload — and verifies the signature against it:

  • What the wallet shows is what executes. A text showing another amount, recipient or tip does not match, and the extrinsic fails with BadProof. A site cannot show one transaction and get another signed; the payload: line binds every remaining byte.
  • A raw ed25519 signature authorises nothing. Solana wallets sign arbitrary bytes through signMessage, so a "login nonce" signed on another site cannot be replayed as a transaction.
WalletSignsVariant
Solana wallets (Phantom, Solflare, Backpack)The text, bare, with solana:signMessage0x03 Ed25519Text
Substrate extensions holding an ed25519 account (Talisman, SubWallet, polkadot-js)The text with signRaw, which wraps it in <Bytes>…</Bytes>0x04 Ed25519WrappedText

A client that signs for an ed25519 account with plain signPayload produces 0x00, which is refused. sr25519 and secp256k1 accounts sign the payload as with MultiSignature.

One encoding per signature​

Each variant refuses what the host primitive would accept as a forgery or an alias:

VariantRefused
Ed25519Text, Ed25519WrappedTextSmall-order keys, in every encoding: under ZIP-215, R = identity, s = 0 verifies any message for them
Sr25519The identity key 0x00…00, for which R = 0, s = 0 verifies any message
Ecdsas > n/2, and recovery ids other than raw 0–3, so one signed extrinsic has one encoding and one hash

The signer​

The signer type OrbinumSigner derives the ECDSA account from a compressed public key:

// template/runtime/src/orbinum_signature/signer.rs — OrbinumSigner::into_account, Ecdsa arm
Self::Ecdsa(k) => {
use libsecp256k1::{PublicKey, PublicKeyFormat};
match PublicKey::parse_slice(k.as_ref(), Some(PublicKeyFormat::Compressed)) {
Ok(pk) => {
let mut pub64 = [0u8; 64];
pub64.copy_from_slice(&pk.serialize()[1..]);
super::ecdsa::account_from_pubkey(&pub64) // [keccak256(pub64)[12..] | 0x00×12]
}
// No ECDSA signature recovers an invalid key, so this account never verifies.
Err(_) => MultiSigner::Ecdsa(k).into_account(),
}
}

A key that does not parse as a compressed secp256k1 point falls back to the standard MultiSigner derivation (blake2_256 of the key), which has no H160 form and no signature that verifies for it. The ed25519 and sr25519 arms return the public key itself.


EVM side: EeSuffixAddressMapping​

The EVM pallet's AddressMapping applies the same encoding, with no storage read:

// template/runtime/src/evm_account.rs
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);
bytes[20..].copy_from_slice(&EVM_ACCOUNT_MARKER);
bytes
}

impl<T> pallet_evm::AddressMapping<T::AccountId> for EeSuffixAddressMapping<T>
where
T: pallet_evm::Config,
T::AccountId: From<[u8; 32]>,
{
fn into_account_id(address: H160) -> T::AccountId {
T::AccountId::from(evm_h160_to_account_id_bytes(address))
}
}

So one secp256k1 key reaches the same account from both paths:

secp256k1 key
├── EVM path → H160 = keccak256(pub64)[12..]
│ AccountId32 = EeSuffixAddressMapping(H160) = [H160 | 0x00×12]
└── Substrate path → OrbinumSignature::Ecdsa recovery
H160 = keccak256(pub64)[12..]
AccountId32 = [H160 | 0x00×12] ← same account

The EVM pallet's call and withdraw origins use EnsureAddressMatches, which accepts a signed origin only when it equals the account mapped from the H160. The convention is compatible with Frontier and with any EVM wallet that imports a raw private key.


Converting between address forms​

Both directions are pure functions, with no RPC call. @orbinum/protocol exposes them:

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

substrateToEvm returns null for an sr25519 or ed25519 account, whose bytes 20–31 are not the EVM_ACCOUNT_MARKER; isImplicitEvmAccount tests that marker directly.


Limits​

Known constraints
  • One way only. The runtime maps each H160 to exactly one AccountId32, and the mapping cannot be reassigned. There is no reverse mapping on-chain; the H160 of an EVM-derived account is read off-chain from its first 20 bytes.
  • sr25519 and ed25519 accounts have no H160. A plain EVM transfer cannot reach them; Solidity pays them through the Balances precompile, which accepts a raw AccountId32.
  • Only shield has an EVM entry point. EVM wallets call it on the shielded-pool precompile at 0x0000…0801; a secp256k1 key can also sign the Substrate shield extrinsic, verified through OrbinumSignature::Ecdsa. private_transfer and unshield are signed by no account: they are accepted only unsigned, authorised by their zero-knowledge proof.