Tron & Ethereum addresses in 2D
The same account address can be encoded in Ethereum or Tron format. For example, 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 and TYBNgWfhGuNzdLtjKtxXTfskAhTbMcqbaG encode the same 20 bytes.
POA stores one account identifier and converts it at the RPC boundary. Wallets connected to POA with the same key access the same balance. This does not combine balances on the independent Ethereum and Tron networks or move funds between them. These addresses are encoding examples only; do not send funds to them.
This article covers address derivation, EIP-55 and Base58Check, and parsing in lib/chain/.
The shared foundation
Section titled “The shared foundation”Ethereum and Tron both derive an account from a secp256k1 public key:
account = keccak256(uncompressed_pubkey_without_prefix)[-20..]The result is twenty bytes with the same derivation and bit layout. Every downstream difference between 0x… and T… is merely a packaging choice on top of this foundation.
In 2D, this is handled by Chain.Crypto.recover_tron_sender/2: a Tron signature recovers to the exact same 20 bytes as an Ethereum signature.
Packaging #1 — Ethereum (EIP-55 checksummed hex)
Section titled “Packaging #1 — Ethereum (EIP-55 checksummed hex)”Ethereum addresses are just hex-encoded 20 bytes with a case-based checksum layered on top:
- The display form is
0x+ 40 hex characters. - EIP-55 uses the case of each hex character as a per-character checksum bit: if the corresponding nibble of
keccak256(lowercase_hex)is≥ 8, the character is uppercased.
This constitutes the entire format: no version byte and no separate checksum bytes. Validation requires the length to be 42 and the case pattern to match keccak256(lowercase). See Chain.Crypto.encode_address/1:
def encode_address(<<address::binary-20>>) do hex = Base.encode16(address, case: :lower) hash = keccak256(hex) |> Base.encode16(case: :lower)
checksummed = hex |> String.graphemes() |> Enum.zip(String.graphemes(hash)) |> Enum.map(fn {char, h} -> {h_int, _} = Integer.parse(h, 16) if h_int >= 8, do: String.upcase(char), else: char end) |> Enum.join()
"0x" <> checksummedendPackaging #2 — Tron (version-prefixed Base58Check)
Section titled “Packaging #2 — Tron (version-prefixed Base58Check)”Tron inherits the Bitcoin Base58Check address format with specific customizations:
- Prefix the 20-byte payload with the version byte
0x41— Tron mainnet’s equivalent of Bitcoin’s0x00. The payload becomes 21 bytes:0x41 || addr. - Compute a 4-byte checksum:
sha256(sha256(payload))[:4]. That’s double SHA-256, same as Bitcoin. - Base58-encode the 25-byte string
payload || checksum.
Because 0x41 is always the first byte of the payload, the result always starts with T when Base58-encoded. That’s where T… comes from.
See Chain.Tron.Address.encode/1 and Base58.encode/1:
def encode(<<_::binary-20>> = address) do payload = <<@mainnet_version>> <> address checksum = :binary.part(:crypto.hash(:sha256, :crypto.hash(:sha256, payload)), 0, 4) Base58.encode(payload <> checksum)endValidation (validate_check/1) reverses the process: Base58-decode → strip checksum → double-SHA the payload → compare.
The two checksums side by side
Section titled “The two checksums side by side”| Ethereum (EIP-55) | Tron (Base58Check) | |
|---|---|---|
| Hash used | keccak256 (of the lowercase hex string!) | sha256(sha256(…)) (of the raw bytes) |
| Checksum size | 0 bytes on the wire (encoded in case) | 4 bytes appended before Base58 |
| Alphabet | 0-9a-fA-F | Base58 — 123456789ABCDEFGHJKLMN… (no 0, no O, no I, no l) |
| Typo detection | Per-character case bit | Any single-byte change fails the 4-byte check |
| Total on-wire length | 42 chars (0x + 40) | 34 chars (typical) |
Different families, same goal — catch mistyped addresses before you sign a transaction to them.
How 2D accepts both
Section titled “How 2D accepts both”2D’s /wallet/* surface accepts four on-wire address forms, all resolving to the same 20-byte key:
| Form | Example | How 2D decodes it |
|---|---|---|
| Tron Base58Check | TYBNgWfhGuNzdLtjKtxXTfskAhTbMcqbaG | Base58.decode → validate_check → drop 0x41 → 20 bytes |
| 0x-prefixed 21-byte hex | 0x41… (42 hex chars / 44 chars total) | Strip 0x → decode hex → assert first byte is 0x41 → keep last 20 bytes |
| Raw 21-byte hex | 41… (42 hex chars / 42 chars total) | Decode hex directly → assert 0x41 prefix → keep last 20 bytes |
| Ethereum 20-byte hex | 0xf39Fd6… (40 hex chars / 42 chars total) | Strip 0x → decode hex → keep all 20 bytes |
The dispatcher is Chain.Tron.Wallet.parse_address_param/2:
defp parse_address_param(params, key) do case Map.get(params, key) do nil -> {:error, "missing #{key}"}
addr when is_binary(addr) -> cond do String.starts_with?(addr, "0x") and byte_size(addr) == 42 -> case Crypto.decode_address_safe(addr) do {:ok, a} -> {:ok, a} {:error, _} -> {:error, "invalid #{key}"} end
true -> case Address.decode(addr) do {:ok, a} -> {:ok, a} {:error, _} -> {:error, "invalid #{key}"} end end endendThe 0x… + length-42 branch peels off Ethereum addresses early. Everything else — T…, 41…, 0x41… — falls through to Chain.Tron.Address.decode/1, which handles all three Tron forms uniformly.
One key, two worlds
Section titled “One key, two worlds” secp256k1 private key │ ▼ uncompressed public key (64 bytes) │ ▼ keccak256(pubkey)[-20..] ─── the 20-byte account │ │ │ │ ▼ ▼ ┌──────────────┐ ┌────────────────────┐ │ Ethereum │ │ Tron │ │ │ │ │ │ 0x + hex │ │ prefix 0x41 │ │ + EIP-55 │ │ + sha256·sha256 │ │ case-check │ │ + Base58 │ └──────┬───────┘ └──────────┬─────────┘ │ │ ▼ ▼ 0xf39Fd6e5… TYBNgWfhGu…The chain stores the 20-byte account identifier once. Every RPC handler decodes on the way in, encodes on the way out. Neither TronLink nor MetaMask notice the other exists — but both see the same balance, the same nonce, and each other’s transactions.
Further reading
Section titled “Further reading”- EIP-55 — Mixed-case checksum address encoding
- Tron protocol — Tron Address Format
- Satoshi’s original Base58Check definition in Bitcoin Core
- 2D source:
lib/chain/tron/address.ex,lib/chain/tron/base58.ex,lib/chain/crypto.ex