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
| 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; Solana wallets (ed25519) | MetaMask, Trust Wallet |
| Signs | Substrate extrinsics | Ethereum 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.
| Index | Variant | Signed message | Account |
|---|---|---|---|
0x00 | Ed25519 | Never verifies | — |
0x01 | Sr25519 | The signing payload (its blake2_256 above 256 bytes) | The public key |
0x02 | Ecdsa | blake2_256 of that message | [H160 | 0x00×12] |
0x03 | Ed25519Text | The transaction text | The public key |
0x04 | Ed25519WrappedText | The 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:
- Hash the payload with
blake2_256and recover the 64-byte public key withsecp256k1_ecdsa_recover. - Take
keccak256of those 64 bytes; the last 20 bytes are theH160. - 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; thepayload: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.
| Wallet | Signs | Variant |
|---|---|---|
| Solana wallets (Phantom, Solflare, Backpack) | The text, bare, with solana:signMessage | 0x03 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:
| Variant | Refused |
|---|---|
Ed25519Text, Ed25519WrappedText | Small-order keys, in every encoding: under ZIP-215, R = identity, s = 0 verifies any message for them |
Sr25519 | The identity key 0x00…00, for which R = 0, s = 0 verifies any message |
Ecdsa | s > 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(ð_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
- One way only. The runtime maps each
H160to exactly oneAccountId32, and the mapping cannot be reassigned. There is no reverse mapping on-chain; theH160of 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 rawAccountId32. - Only
shieldhas an EVM entry point. EVM wallets call it on the shielded-pool precompile at0x0000…0801; a secp256k1 key can also sign the Substrateshieldextrinsic, verified throughOrbinumSignature::Ecdsa.private_transferandunshieldare signed by no account: they are accepted only unsigned, authorised by their zero-knowledge proof.
Related
- Accounts & Identity — the account model and the shielded pool
- Balances precompile — paying any
AccountId32from Solidity - Shielded-pool precompile — the EVM
shieldentry point - EVM Compatibility — how the EVM runs inside the chain