Skip to content

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/.

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" <> checksummed
end

Packaging #2 — Tron (version-prefixed Base58Check)

Section titled “Packaging #2 — Tron (version-prefixed Base58Check)”

Tron inherits the Bitcoin Base58Check address format with specific customizations:

  1. Prefix the 20-byte payload with the version byte 0x41 — Tron mainnet’s equivalent of Bitcoin’s 0x00. The payload becomes 21 bytes: 0x41 || addr.
  2. Compute a 4-byte checksum: sha256(sha256(payload))[:4]. That’s double SHA-256, same as Bitcoin.
  3. 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)
end

Validation (validate_check/1) reverses the process: Base58-decode → strip checksum → double-SHA the payload → compare.

Ethereum (EIP-55)Tron (Base58Check)
Hash usedkeccak256 (of the lowercase hex string!)sha256(sha256(…)) (of the raw bytes)
Checksum size0 bytes on the wire (encoded in case)4 bytes appended before Base58
Alphabet0-9a-fA-FBase58 — 123456789ABCDEFGHJKLMN… (no 0, no O, no I, no l)
Typo detectionPer-character case bitAny single-byte change fails the 4-byte check
Total on-wire length42 chars (0x + 40)34 chars (typical)

Different families, same goal — catch mistyped addresses before you sign a transaction to them.

2D’s /wallet/* surface accepts four on-wire address forms, all resolving to the same 20-byte key:

FormExampleHow 2D decodes it
Tron Base58CheckTYBNgWfhGuNzdLtjKtxXTfskAhTbMcqbaGBase58.decode → validate_check → drop 0x41 → 20 bytes
0x-prefixed 21-byte hex0x41… (42 hex chars / 44 chars total)Strip 0x → decode hex → assert first byte is 0x41 → keep last 20 bytes
Raw 21-byte hex41… (42 hex chars / 42 chars total)Decode hex directly → assert 0x41 prefix → keep last 20 bytes
Ethereum 20-byte hex0xf39Fd6… (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
end
end

The 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.

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.