Chains, Currencies & Confirmations
Which networks and assets Havala settles, how many confirmations each needs, and how amounts are represented on the wire.
Havala settles on eleven chains. Seven are EVM and behave the same way as each other; four are not, and each is its own thing. Which one a payment runs on decides the deposit address format, the number of confirmations before the money counts, and how long that takes in wall-clock time.
Everything on this page is read from the chain registry the API uses at payment creation time, so a value here is the value your payment row will carry.
The eleven chains
EVM โ Ethereum, Polygon, BNB Smart Chain, Arbitrum One, Optimism, Base,
Avalanche C-Chain. Same address format (0xโฆ, 40 hex characters), same
signature scheme, same tooling. An address is valid on all seven, which is
exactly why a one-time deposit address is leased per payment: it is the only
thing that disambiguates which invoice a transfer belongs to.
Non-EVM โ Bitcoin, TRON, Solana, TON. Different address formats, different finality models, different block times by three orders of magnitude.
The chain is named by a lowercase slug everywhere it appears in a request or a payment row:
ethereum, polygon, bsc, arbitrum, optimism, base, avalanche,
bitcoin, tron, solana, ton
The uppercase PaymentBlockchainType enum (ETHEREUM, POLYGON, โฆ) exists in
the schema, but the payments.blockchain column is a plain VARCHAR carrying
the slug. A ?blockchain= filter that sends ETHEREUM matches nothing. The one
place the uppercase names are used is the merchant-level blockchain settings
document, which is stored as free-form JSON.
Tokens and decimals
| Chain | Native | Tokens | Decimals |
|---|---|---|---|
ethereum | ETH | ETH, USDT, USDC | 18, 6, 6 |
polygon | POL | POL, USDT, USDC | 18, 6, 6 |
bsc | BNB | BNB, USDT, USDC | 18, 18, 18 |
arbitrum | ETH | ETH, USDT, USDC | 18, 6, 6 |
optimism | ETH | ETH, USDT, USDC | 18, 6, 6 |
base | ETH | ETH, USDC | 18, 6 |
avalanche | AVAX | AVAX, USDT, USDC | 18, 6, 6 |
bitcoin | BTC | BTC | 8 |
tron | TRX | TRX, USDT | 6, 6 |
solana | SOL | SOL, USDC | 9, 6 |
ton | TON | TON, USDT | 9, 6 |
USDT and USDC on BNB Smart Chain have 18 decimals, not the 6 they have
everywhere else. Any code that hardcodes "stablecoins are 6 decimals" is off by
a factor of a trillion on BSC. Read decimals off the checkout response rather
than assuming:
// decimals comes back on the public checkout payment object. Use it, and use
// BigInt for the base-unit conversion โ Number loses precision above 2^53,
// which an 18-decimal token crosses at about 9 units.
function toBaseUnits(amount: string, decimals: number): bigint {
const [whole, frac = ''] = amount.split('.');
if (frac.length > decimals) throw new Error(`${amount} has more precision than ${decimals}`);
return BigInt(whole + frac.padEnd(decimals, '0'));
}
toBaseUnits('149.5', 6); // 149500000n โ USDC on Ethereum
toBaseUnits('149.5', 18); // 149500000000000000000n โ USDT on BSCSending a token that a chain does not carry โ USDT on Base, SOL on Ethereum โ is
rejected at checkout with INVALID_CURRENCY.
Confirmations
requiredConfirmations is not one global number. It is resolved per chain, and
separately for mainnet and testnet, when the payment row is created โ and it is
then frozen on that row. Compare a payment's confirmations against the
requiredConfirmations the payment itself reports, never against a table you
keep in your own code.
| Chain | Mainnet | Testnet | Block time | โ time to settle on mainnet |
|---|---|---|---|---|
ethereum | 12 | 6 | 12 s | ~2.5 min |
polygon | 128 | 32 | 2 s | ~4 min |
bsc | 15 | 6 | 3 s | ~45 s |
arbitrum | 64 | 12 | 0.25 s | ~15 s |
optimism | 64 | 12 | 2 s | ~2 min |
base | 64 | 12 | 2 s | ~2 min |
avalanche | 12 | 6 | 2 s | ~25 s |
bitcoin | 3 | 1 | 10 min | ~30 min |
tron | 19 | 6 | 3 s | ~1 min |
solana | 32 | 12 | 0.4 s | ~15 s |
ton | 12 | 6 | 5 s | ~1 min |
The settle column is arithmetic โ required confirmations times block time โ not a guarantee. Real chains reorganise, congest and stall.
Two defaults sit behind that table and are worth knowing because they show up in
odd rows. If the chain slug is not in the registry at all, the lookup falls back
to 12. And the payments.requiredConfirmations column has a database
default of 10, which the invoice-checkout path always overwrites with the
per-chain value โ a row still carrying 10 was not created through checkout.
The confirmation tracker re-checks on a delay derived from the chain's block
time, with a five-second floor. That is why a Bitcoin payment legitimately sits
in CONFIRMING for half an hour while an Arbitrum one clears while the buyer is
still looking at the page. Set the buyer's expectation from the chain they
chose.
A merchant can raise the bar from the merchant dashboard, in the blockchain
settings. That group carries a confirmationOverrides map โ chain slug to a
confirmation count โ and it is written together with the enabled blockchains and
enabled currencies rather than one field at a time. The map is stored verbatim
and is not checked against the chain registry, so a mistyped slug is accepted
and then silently never matches. Keep it to chains you actually use, and
remember that every extra confirmation is block time the buyer waits through.
How amounts are represented
There are two amount representations in the API and they are not the same thing.
Invoice amount is a BigInt of minor units in the invoice's fiat
currency, carried as a JSON string. "12500" with "currency": "USD" is
$125.00. It is a string because a BigInt does not survive JSON.parse as a
number safely, not because it is a decimal.
Payment amount, expectedAmount and receivedAmount are VARCHAR(80)
decimal strings in the coin's own unit โ "0.04217", "149.500000". They
are strings so that an 18-decimal value survives the round trip without a
float's rounding. Parse them with a decimal type. receivedAmount starts at
"0" and stays there until a deposit is observed.
One quirk to hold onto: on the invoice-checkout path the payment is opened with
paymentAmount and expectedAmount copied verbatim from the invoice's fiat
minor units, because fiat-to-crypto conversion is not applied at that step. Read
amount alongside cryptoCurrency and decimals rather than assuming a
conversion happened, and do your own received-versus-expected comparison in the
webhook handler.
Testnets
Each chain has one testnet in the registry:
| Chain | Testnet | Chain id |
|---|---|---|
ethereum | Sepolia | 11155111 |
polygon | Amoy | 80002 |
bsc | BSC Testnet | 97 |
arbitrum | Arbitrum Sepolia | 421614 |
optimism | OP Sepolia | 11155420 |
base | Base Sepolia | 84532 |
avalanche | Fuji | 43113 |
bitcoin | testnet | โ |
tron | Nile | โ |
solana | devnet | โ |
ton | testnet | โ |
Mainnet chain ids, for the EVM seven: Ethereum 1, Optimism 10, BNB Smart Chain 56, Polygon 137, Base 8453, Arbitrum One 42161, Avalanche C-Chain 43114.
The payment row records which network it ran on in a NetworkEnum column:
MAINNET, SEPOLIA, GOERLI, HOLESKY, AMOY, MUMBAI, BSC_TESTNET
(stored as bsc-testnet), FUJI, NILE, TESTNET, DEVNET,
TESTNET_GENERIC (stored as testnet-generic).
The network field on
POST /api/v1/invoices/{id}/checkout
is written straight into that column. It has one sharp edge:
Omit
networkentirely for mainnet. TheisTestnetflag is derived as "was anything other than the lowercase stringmainnetsupplied", so passing the uppercase"MAINNET"marks the payment as testnet โ a live-looking payment carrying testnet confirmation counts. And passing the lowercase"mainnet"is not a valid enum value, so that write fails outright.
For a testnet, pass the exact uppercase enum name (SEPOLIA, AMOY,
BSC_TESTNET, โฆ). See Testing on Testnets.
Smart-contract payments
The seven EVM chains also support a contract path, where the buyer's wallet
calls a Havala payment contract instead of transferring to a leased address. The
payment is paymentType: SMART_CONTRACT and carries contractAddress,
externalId, treasuryWallet, tokenAddress (null for the chain's native
coin) and chainId instead of a paymentAddress.
Bitcoin, TRON, Solana and TON are direct-transfer only; asking for the contract
path there fails with BLOCKCHAIN_NOT_SUPPORTED. The mechanics are in
The Hosted Checkout Page.
| Next | What it covers |
|---|---|
| Accepting Crypto Payments | Choosing a chain at checkout and watching it confirm |
| Testing on Testnets | Faucets and a scripted end-to-end run |
| The Hosted Checkout Page | Letting the buyer pick from this matrix |