Guide
Networks and amounts
EVM and Tron side by side, address formats, decimals, and amounts without floating point.
Two families
Every network belongs to one of two families, and the family decides the address format and a
few details of signing. Network ids are lower-case strings such as eth,
bsc and tron; which ones are switched on is set per merchant. The
balance response and the network_select step list what you
actually have.
EVM (eth, bsc, …) | Tron (tron) | |
|---|---|---|
| Address | 0x + 40 hex chars | Base58 T…, 34 chars |
| Token standard | ERC-20 (BEP-20 on BSC) | TRC-20 |
| Redirect, iframe, self-hosted deposits | yes | no |
| Wallet address deposits | yes, when switched on | yes, when switched on |
| Payouts and batch payouts | yes | yes |
| Payout signature | EIP-712, chain's own chainId | the same EIP-712 payload, chainId 728126428 (Nile testnet 3448148188) |
| Payout allowance | signed transaction via the API, or from any wallet | signed transaction id via the API, or from TronLink |
| Fees on the payout transactions | paid by us | energy paid by us |
| Fees on your allowance | gas from your payout wallet | TRX from your payout wallet, fee limit 50 TRX |
| Balance endpoint | yes | no |
Amounts
Every amount you send is a decimal string in whole token units: "125.50"
means 125.50 USDT. Never a JSON number and never a float — 0.1 + 0.2 is not a
thing you want near a ledger. Amounts you receive come in two forms:
| Where | Form | Example |
|---|---|---|
Request bodies, amount in responses and webhooks, credited, fee, error details | whole units, decimal string | "125.5" |
typed_data.message.amount, items[].amount | base units, integer string | "125500000" at 6 decimals |
amount_raw in deposit.confirmed, raw in balance | base units, integer string | "49500000" |
Decimals differ by network
The same coin has different decimals on different chains: USDT is 6 on Ethereum and Tron and 18
on BSC. Do not hard-code them; the balance response carries decimals per token, and
the typed data you sign is already in base units. Converting base units to a decimal string
without floating point:
func ToAmount(minor int64, decimals int) string {
r := new(big.Rat).SetFrac(big.NewInt(minor), new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil))
return r.FloatString(decimals)
}function toAmount(string $minor, int $decimals): string
{
return bcdiv($minor, bcpow('10', (string) $decimals), $decimals);
}function toAmount(minor, decimals) {
const value = BigInt(minor);
const scale = 10n ** BigInt(decimals);
const whole = value / scale;
const fraction = (value % scale).toString().padStart(decimals, "0");
return decimals === 0 ? whole.toString() : `${whole}.${fraction}`;
}from decimal import Decimal
def to_amount(minor, decimals):
return format(Decimal(minor).scaleb(-decimals).quantize(Decimal(1).scaleb(-decimals)), "f")function toAmount(minor, decimals) {
const value = BigInt(minor);
const scale = 10n ** BigInt(decimals);
const whole = value / scale;
const fraction = (value % scale).toString().padStart(decimals, "0");
return decimals === 0 ? whole.toString() : `${whole}.${fraction}`;
}typed_data against your own record converted the same way — not the
other way round through a float.
Tron addresses in signed data
Everywhere in the API a Tron address is the familiar T… form. The one exception is
what you sign: EIP-712 has no type for a Base58 address, so a Tron payout's
typed_data carries every address — merchant, token,
recipient, verifyingContract — as its 20-byte 0x form. It is
the same address: Base58-decode the T… string, drop the 0x41 prefix and
the 4-byte checksum.
You need this to do the one check that matters before signing a Tron payout: that each recipient
in typed_data is the address in your own withdrawal record.
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"strings"
)
const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
func TronToEVM(address string) (string, error) {
n := new(big.Int)
for _, c := range address {
i := strings.IndexRune(base58Alphabet, c)
if i < 0 {
return "", fmt.Errorf("not a Tron address: %q", address)
}
n.Mul(n, big.NewInt(58)).Add(n, big.NewInt(int64(i)))
}
if n.BitLen() > 200 {
return "", fmt.Errorf("not a Tron address: %q", address)
}
raw := n.FillBytes(make([]byte, 25))
first := sha256.Sum256(raw[:21])
check := sha256.Sum256(first[:])
if raw[0] != 0x41 || !bytes.Equal(check[:4], raw[21:]) {
return "", fmt.Errorf("not a Tron address: %q", address)
}
return "0x" + hex.EncodeToString(raw[1:21]), nil
}function tronToEvm(string $address): string
{
$alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
$n = gmp_init(0);
foreach (str_split($address) as $c) {
$i = strpos($alphabet, $c);
if ($i === false) {
throw new InvalidArgumentException("not a Tron address: $address");
}
$n = gmp_add(gmp_mul($n, 58), $i);
}
$raw = str_pad(gmp_export($n), 25, "\0", STR_PAD_LEFT);
$check = substr(hash('sha256', hash('sha256', substr($raw, 0, 21), true), true), 0, 4);
if (strlen($raw) !== 25 || $raw[0] !== "\x41" || $check !== substr($raw, 21)) {
throw new InvalidArgumentException("not a Tron address: $address");
}
return '0x' . bin2hex(substr($raw, 1, 20));
}const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
export async function tronToEvm(address) {
let n = 0n;
for (const c of address) {
const i = BASE58.indexOf(c);
if (i < 0) throw new Error(`not a Tron address: ${address}`);
n = n * 58n + BigInt(i);
}
const hex = n.toString(16).padStart(50, "0");
const raw = Uint8Array.from(hex.match(/../g), (b) => parseInt(b, 16));
const first = await crypto.subtle.digest("SHA-256", raw.slice(0, 21));
const check = new Uint8Array(await crypto.subtle.digest("SHA-256", first));
if (hex.length !== 50 || raw[0] !== 0x41 || check.slice(0, 4).some((b, i) => b !== raw[21 + i])) {
throw new Error(`not a Tron address: ${address}`);
}
return "0x" + hex.slice(2, 42);
}import hashlib
BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def tron_to_evm(address):
n = 0
for c in address:
i = BASE58.find(c)
if i < 0:
raise ValueError(f"not a Tron address: {address}")
n = n * 58 + i
if n.bit_length() > 200:
raise ValueError(f"not a Tron address: {address}")
raw = n.to_bytes(25, "big")
check = hashlib.sha256(hashlib.sha256(raw[:21]).digest()).digest()[:4]
if raw[0] != 0x41 or check != raw[21:]:
raise ValueError(f"not a Tron address: {address}")
return "0x" + raw[1:21].hex()import { createHash } from "node:crypto";
const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const sha256 = (bytes) => createHash("sha256").update(bytes).digest();
export function tronToEvm(address) {
let n = 0n;
for (const c of address) {
const i = BASE58.indexOf(c);
if (i < 0) throw new Error(`not a Tron address: ${address}`);
n = n * 58n + BigInt(i);
}
const raw = Buffer.from(n.toString(16).padStart(50, "0"), "hex");
const check = sha256(sha256(raw.subarray(0, 21))).subarray(0, 4);
if (raw.length !== 25 || raw[0] !== 0x41 || !check.equals(raw.subarray(21))) {
throw new Error(`not a Tron address: ${address}`);
}
return "0x" + raw.subarray(1, 21).toString("hex");
}TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t → 0xa614f803b6fd780986a42c78ec9c7f77e6ded13c (USDT-TRC20)
Compare case-insensitively: typed_data uses checksummed (mixed-case) hex.
Confirmations and finality
A deposit is credited only once it is final: confirmation depth is amount-based on EVM networks — larger deposits wait for more blocks — and on Tron a transfer counts once the block containing it is solidified. You do not track any of this yourself; the webhook is sent only when the money can no longer disappear.