Перейти к содержимому

Адреса Tron и Ethereum в 2D

Адрес Ethereum выглядит так: 0xf39Fd6…. Адрес Tron: TR7NHq…. Разные алфавиты, разные контрольные суммы, разные экосистемы. На первый взгляд, это две совершенно разные вещи.

На самом деле это не так. Под обеими строками скрывается один и тот же 20-байтный аккаунт, полученный одинаковым образом из открытого ключа secp256k1. Всё, что их различает: префикс 0x или T, чередование заглавных и строчных букв, а также алфавит Base58. Все эти отличия существуют лишь на уровне кодирования поверх базовых 20 байт.

Сеть 2D хранит аккаунты в едином 20-байтном формате и предоставляет их вовне в любом из двух представлений. Перевод, отправленный из TronLink, поступает на тот же аккаунт, который MetaMask затем видит через вызов eth_getBalance. В этой статье мы разберем: как формируется сам 20-байтный адрес, две формы его записи (EIP-55 hex и Base58Check) и где именно в коде (lib/chain/) сеть определяет, какой из форматов был передан на вход.

Как в Ethereum, так и в Tron адрес аккаунта генерируется из открытого ключа secp256k1:

account = keccak256(uncompressed_pubkey_without_prefix)[-20..]

Двадцать байт. Одна и та же формула, одинаковое распределение битов. Единственное различие между 0x… и T… заключается в выборе формата записи поверх этих двадцати байт.

В 2D это реализовано в функции Chain.Crypto.recover_tron_sender/2: подпись Tron восстанавливается в те же самые 20 байт, что и подпись Ethereum.

Адрес Ethereum представляет собой 20 байт в шестнадцатеричном (hex) формате с контрольной суммой, закодированной в регистре символов.

  • Внешний вид: префикс 0x и 40 hex-символов.
  • Стандарт EIP-55 использует регистр каждого hex-символа в качестве одного бита контрольной суммы: если соответствующий полубайт (ниббл) в хеше keccak256(lowercase_hex) ≥ 8, символ переводится в верхний регистр.

В этом формате больше ничего нет: ни байта версии, ни отдельных байт контрольной суммы. Проверка сводится к следующему: длина строки должна составлять 42 символа, а чередование заглавных и строчных букв должно совпадать с результатом keccak256(lowercase). См. реализацию в 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

Tron унаследовал формат Base58Check от Bitcoin, добавив собственный префикс:

  1. К 20-байтной полезной нагрузке (payload) слева добавляется байт версии 0x41 — метка основной сети (mainnet) Tron (в Bitcoin на этой позиции находится 0x00). В результате получается 21 байт: 0x41 || addr.
  2. Вычисляется 4-байтная контрольная сумма: sha256(sha256(payload))[:4]. Используется именно двойное хеширование SHA-256, как в Bitcoin.
  3. Итоговая строка payload || checksum длиной 25 байт кодируется в формат Base58.

Поскольку первый байт полезной нагрузки всегда равен 0x41, после кодирования в Base58 любой адрес Tron начинается с буквы T. Отсюда и происходит формат T….

См. реализацию в Chain.Tron.Address.encode/1 и 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

Проверка (функция validate_check/1) выполняется в обратном порядке: декодировать Base58 → отсечь контрольную сумму → заново вычислить двойной SHA-256 от полезной нагрузки → сравнить полученные 4 байта.

ХарактеристикаEthereum (EIP-55)Tron (Base58Check)
Хеш-функцияkeccak256 (от строчной hex-строки)sha256(sha256(…)) (от сырых байт)
Размер контрольной суммы0 байт отдельно (закодирована в регистре символов)4 байта, добавляются перед кодированием в Base58
Алфавит0-9a-fA-FBase58: 123456789ABCDEFGHJKLMN… (исключены символы 0, O, I, l)
Механизм выявления опечатокБит регистра для каждого hex-символаЛюбое изменение байта нарушает контрольную сумму
Длина при передаче (on-wire)42 символа (0x + 40)34 символа (обычно)

Несмотря на разные подходы, цель у них одна: предотвратить подписание транзакции с ошибочно введенным адресом.

HTTP API /wallet/* в сети 2D принимает четыре формата адресов на сетевом уровне, и все они преобразуются в один и тот же 20-байтный аккаунт:

ФорматПримерПроцесс декодирования в 2D
Tron Base58CheckTR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6tBase58.decode → проверка validate_check → удаление префикса 0x41 → 20 байт
21-байтный hex с 0x0x41… (42 hex-символа / 44 символа всего)удаление 0x → декодирование hex → проверка первого байта 0x41 → извлечение последних 20 байт
Сырой 21-байтный hex41… (42 hex-символа / 42 символа всего)декодирование hex → проверка первого байта 0x41 → извлечение последних 20 байт
Ethereum 20-байтный hex0xf39Fd6… (40 hex-символов / 42 символа всего)удаление 0x → декодирование hex → 20 байт

Вся логика маршрутизации реализована в функции 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

Условие «префикс 0x и длина 42» позволяет сразу отфильтровать адреса Ethereum на входе. Все остальные форматы (T…, 41…, 0x41…) передаются в функцию Chain.Tron.Address.decode/1, которая единообразно обрабатывает три оставшихся представления Tron.

приватный ключ secp256k1
открытый ключ (64 байта)
keccak256(pubkey)[-20..] ─── 20-байтный аккаунт
│ │
│ │
▼ ▼
┌──────────────┐ ┌────────────────────┐
│ Ethereum │ │ Tron │
│ │ │ │
│ 0x + hex │ │ префикс 0x41 │
│ + EIP-55 │ │ + sha256·sha256 │
│ регистр │ │ + Base58 │
└──────┬───────┘ └──────────┬─────────┘
│ │
▼ ▼
0xf39Fd6e5… TR7NHqjeKQ…

Сеть хранит 20-байтный ключ в единственном экземпляре. Каждый RPC-обработчик выполняет декодирование на входе и кодирование на выходе. Кошельки TronLink и MetaMask не знают о существовании друг друга, однако оба отображают один и тот же баланс, единый nonce и видят транзакции, отправленные из любой экосистемы.