Skip to content

Runbook: verifying USDT live on TRON Nile

USDT cannot run on regtest. There is no TRON equivalent of bitcoind -regtest, so every USDT path in this project — the deposit matcher, the withdrawal verifier, the gas monitor, the confirmation depth — is tested against a fake that imitates TronGrid rather than against TronGrid.

That fake is good. It runs the real parser over payloads shaped like real ones, so a parser bug still fails a test. What it cannot do is notice that TronGrid sends a field the fake never heard of, or that the contract address shipped as a default is not the contract anyone deployed.

One live session on the Nile testnet closes that gap. The first one ran on 2026-08-11 and is recorded in the live verification log; the payloads it captured are committed under tests/fixtures/tron/, so the suite now parses real TronGrid bytes as well as invented ones. That corpus ages, though — it only knows the TronGrid of the day it was recorded — which is why the session is repeatable and why this page exists. It is the hard gate before the v0.2.0 tag, and it happens by hand, with real testnet money. This page is the half you do yourself. The other half is scripts/verify_nile.py, which walks you through the rest in six numbered stages.

Budget two to three hours, most of it waiting on BTCPay to restart and on faucets to answer. None of it is difficult.


What the session proves

Claim How it is proved
USDT_CONTRACT_NILE is the USDT contract the contract answers symbol() = USDT and decimals() = 6
USDT_CONTRACT_MAINNET is too the same two reads against api.trongrid.io, read-only, no funds touched
a real Nile deposit is credited exactly one payment, matched, credited to the micro-USDT
the withdrawal verifier works on real data the full-tuple check runs against a transaction you actually sent
TRON_CONFIRMATIONS=19 means what it says the withdrawal confirms 19 blocks deep and not before
one transaction settles one withdrawal the same txid against a second withdrawal is refused
the fake matches reality every captured payload is diffed against tests/fake_tron.py

Before you start: the checklist

Tick these off in order. Each line links to the section that explains it.


1. TronGrid API key (10 minutes)

Register at www.trongrid.io and create a key. The free tier is 100,000 requests a day at 15 QPS, which is far more than a session uses.

The key is not optional. Keyless TronGrid is throttled unpredictably, and the thing that gets throttled is the check that a withdrawal really happened. The service refuses to start with a TRON hot wallet configured and no key.

The same key works for Nile and mainnet, which matters: the preflight reads the mainnet USDT contract too.

2. Two Nile wallets (10 minutes)

Install TronLink (browser extension or mobile), then switch the network to TRON Nile Testnet in the network dropdown. It is not the default and it is easy to miss — a mainnet address looks identical.

Create two accounts:

Role What it does in the session
hot wallet sends the withdrawal. TRON_HOT_WALLET_ADDRESS is this one
user wallet pays the deposit, and receives the withdrawal

Both addresses start with T and are 34 characters of base58 — for example TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t. There is no separate testnet address format: a Nile address and a mainnet address are indistinguishable by eye, which is exactly why the script refuses to run unless every part of the configuration says Nile.

Keep the two accounts separate. The verifier compares the sender against the hot wallet and the recipient against the destination, so a withdrawal sent from the wrong account is correctly rejected — and you will have spent a faucet claim proving it.

3. Faucet: TRX and test USDT (15 minutes)

Both wallets need TRX (TRC-20 transfers cost energy and bandwidth, not USDT) and both need test USDT.

Option A — the official Nile faucet. nileex.io/join/getJoinPage. Paste an address, solve the reCAPTCHA, click Obtain. It gives 2,000 TRX per address per day and also hands out test USDT. No signup, no mainnet balance check. It needs a real browser: the page is behind bot protection and will not answer a script.

Option B — TronFAQBot, on Telegram or Discord. Join the TRON developers Telegram group or the TRON Discord and message the bot:

Command Gives
!nile YOUR_ADDRESS up to 5,000 TRX per 24 hours
!nile_usdt YOUR_ADDRESS up to 5,000 test USDT per 24 hours

Two options are listed because testnet faucets go down, and finding that out mid-session costs you the day. Both are documented by TRON itself in Getting testnet tokens.

Fund the hot wallet with USDT directly. This is the non-obvious part. The deposit you make in stage 2 lands on a pool address, not on the hot wallet, and nothing in this system sweeps it across — USDT custody is the operator's job. So the hot wallet needs its own faucet USDT to send the withdrawal from. About 10 USDT and 200 TRX in each wallet is comfortable.

Note the contract address the faucet's USDT actually uses. The preflight checks whatever you configure, so if the faucet hands out a different token than the default, this is where you find out.

4. Bring the stack up (10 minutes)

Do section 8 first. The Nile override refuses to start without TRON_HOT_WALLET_ADDRESS — deliberately, because a stack booted with a placeholder hot wallet would verify withdrawals against an address nobody owns. So the .env values must exist before up can work, which means you need your wallets (section 2) before this section.

The Nile override adds the TRON settings to the regtest stack — and moves the BTC side of it to a peerless testnet. That second part is forced: the USDt plugin maps BTCPay's Bitcoin chain to a TRON network (mainnet→mainnet, testnet→Nile) and crashes at load on anything else, so a regtest BTCPay cannot run it at all (plugin 0.6.1.0, crash observed live 2026-08-11). The testnet bitcoind runs with no peers — no DNS seeds, no listening, no outbound — so it sits at the genesis block and never syncs anything. The BTC half stays entirely offline either way; only the USDT half needs the internet. BTC drills do not run in this mode; they belong to the plain regtest stack.

Because the override points BTCPay at testnet-named databases, the first up in this mode starts from a fresh BTCPay: bootstrap_btcpay.py re-creates the admin user and store (new password in .env.regtest.generated), and the USDt plugin has to be installed once in this BTCPay too.

Before the bootstrap, mine one block. NBXplorer refuses to serve wallets while the newest block is older than five months — its own rule, on top of bitcoind's two IBD gates the override already disarms — and the testnet genesis block is from 2011. Testnet allows a minimum-difficulty block when none arrived for 20 minutes, and minimum difficulty is CPU-mineable (a few billion hashes; the RPC caps one call at 2 of them, hence the loop — seconds to minutes per call, ~40% chance each):

docker exec cpapi-regtest-bitcoind-1 sh -c '
  bitcoin-cli -datadir=/data createwallet miner 2>/dev/null;
  ADDR=$(bitcoin-cli -datadir=/data -rpcwallet=miner getnewaddress);
  while [ "$(bitcoin-cli -datadir=/data getblockcount)" -lt 1 ]; do
    bitcoin-cli -datadir=/data -rpcclienttimeout=0 generatetoaddress 1 "$ADDR" 2000000000;
  done; bitcoin-cli -datadir=/data getblockcount'

Wait for docker compose ps to show BTCPay up and curl http://127.0.0.1:14142/api/v1/health to answer {"synchronized":true} before running the bootstrap.

docker compose --env-file .env \
               -f deploy/docker-compose.regtest.yml \
               -f deploy/docker-compose.nile.override.yml up -d
python scripts/bootstrap_btcpay.py

--env-file .env is not optional. Compose looks for a default .env next to the first compose filedeploy/, where there is none — not in the repository root where section 8 puts it. Without the flag, up fails with required variable TRON_HOT_WALLET_ADDRESS is missing a value even though the value is right there in .env.

bootstrap_btcpay.py writes .env.regtest.generated, which the verification script reads to talk to BTCPay. It is idempotent.

5. The USDt plugin (30 minutes, mostly restarts)

Greenfield has no plugin management API and the plugin's own settings are not in its schema either, so this part is BTCPay's UI. The four steps are documented in deploy/docker-compose.nile.override.yml next to the settings they belong to, and in full in BTCPay Server setup. In short:

  1. Server Settings > Plugins — install USDt, then restart BTCPay. The restart is slow; this is most of the 30 minutes.
  2. Server Settings > USDt — set the TRON JSON-RPC endpoint to https://nile.trongrid.io/jsonrpc, paste the TronGrid key, and set the USDT contract address for Nile.
  3. The address pool — section 6 below.
  4. Restart api and workersection 7.

The contract you set here and this service's USDT_CONTRACT_ADDRESS must be the same string. BTCPay watches for transfers of that token; the withdrawal verifier refuses transfers of any other one. The script's preflight refuses to continue if it and the api container disagree.

6. The address pool (10 minutes)

Store > Settings > USDt: paste TRON addresses, one per line. The reasoning — pool size is the maximum number of USDT deposits that can be open at once, and addresses are reused across users over time — is in the pool section of the BTCPay setup page and is not repeated here.

For this session the pool needs to be big enough that a deposit can be created at all. Production advice is at least 20; the drill opens one deposit at a time, so five is enough to finish. If this stack is going to live longer than the session, do the 20 now.

Use addresses from accounts you control in TronLink. The test USDT you deposit lands there and stays there — nothing sweeps it — so addresses you cannot spend from make the deposit unrecoverable. On a testnet that costs nothing but a faucet claim; the habit is still worth keeping.

7. Turn the asset on (5 minutes)

docker compose --env-file .env \
               -f deploy/docker-compose.regtest.yml \
               -f deploy/docker-compose.nile.override.yml \
               up -d --force-recreate api worker

Payment-method discovery runs at startup and re-enables USDT_TRC20 once the store reports a matching method. Until then the asset stays disabled and USDT deposit requests answer 503 — deliberately, because an enabled asset with no payment method would mint invoices for an address nobody is watching.

Confirm with GET /v1/assets that USDT_TRC20 is enabled. If it is not, go back to step 5: the plugin is installed but not configured, or BTCPay was not restarted after installing it.

8. The environment (5 minutes)

Both docker compose and the verification script are pointed at .env in the repository root, so one file configures both. The verification script finds it on its own; docker compose does not — it only looks next to the first compose file, which is why every compose command in this runbook carries --env-file .env. The file needs five values:

TRON_NETWORK=nile
TRONGRID_API_KEY=your-trongrid-key
USDT_CONTRACT_ADDRESS=the-contract-the-plugin-is-pointed-at
TRON_HOT_WALLET_ADDRESS=T...your-hot-wallet
TRON_CONFIRMATIONS=19

.env is gitignored. Do not put the TronGrid key anywhere else.

The script never prints the key, and never prints the admin API key either. It reads CPAPI_ADMIN_KEY if you export one, and mints a throwaway admin key inside the api container if you do not.

9. Run the session

python scripts/verify_nile.py

It runs six stages. Each one prints what it is about to do, what you must do by hand, and what it collected.

Stage What it does What it asks of you
1 preflight reads both USDT contracts, the hot wallet's balances and BTCPay's payment methods nothing — no funds move
2 deposit creates a USDT deposit and waits send exactly 5.000000 USDT from the user wallet to the address it prints, then paste the transaction id
3 withdrawal requests 2 USDT, approves it, verifies your transaction, waits for 19 confirmations give it a destination address, send exactly the net amount from the hot wallet, paste the transaction id
4 duplicate submits the stage-3 transaction id against a second withdrawal nothing — it must be refused with a 409
5 payloads diffs every captured payload against tests/fake_tron.py nothing
6 report writes the verification-log entry nothing

If it stops, fix what it complained about and resume:

python scripts/verify_nile.py --stage 3

State lives in spike-evidence-nile/ (gitignored), so a resumed run picks up the deposit and withdrawal the earlier run created. You do not need a second faucet claim.

What can go wrong, and what it means

Symptom Cause
preflight: the contract answers something other than USDT/6 USDT_CONTRACT_ADDRESS is not the USDT contract. Check it against the plugin's setting and against the faucet's token
preflight: the script and the api container disagree .env changed after the containers started. Recreate api and worker
preflight: no enabled USDT payment method step 5 is incomplete, or BTCPay was not restarted after installing the plugin
deposit goes to review instead of settling the amount sent was not the amount asked for. That is the attribution guard working — see attributing a USDT deposit
mark-broadcast refused with 422 the transaction is not a transfer of that amount from the hot wallet to that destination. The message names the part that did not match. Do not send again before reading it
the withdrawal never confirms the transfer ran out of energy. It is in a block and moved nothing. Top up TRX and send again

The 422 is worth dwelling on: a refusal there is the system working, and the script gives you three attempts precisely because the ordinary cause is a mis-pasted transaction id.


Afterwards

Stage 6 prints a filled verification-log entry and writes it to spike-evidence-nile/verification-log-<date>.md. Three things follow from a green session:

  1. Paste the entry into the live verification log and commit it. That page is the adopter-facing evidence that any of this was ever run for real, and every entry on it is one stage-6 report pasted verbatim.
  2. Downgrade any caveat the entry lists as downgraded. The first green session, on 2026-08-11, took USDT_CONTRACT_NILE from "format-verified only, NOT confirmed against a live Nile node" to confirmed in all five places that said it — gateway/trongrid.py, config.py, .env.example, btcpay-setup.md and the Nile override. A later session re-reads the same contract, so what it changes is the date; if the answer ever stops being USDT / 6, that is the news and the wording goes back. Keep the advice to check the address against your own plugin configuration: that stays true regardless.
  3. Fix tests/fake_tron.py against anything stage 5 found. The 2026-08-11 session found 20 shape differences and closed them, and the payloads it captured are committed under tests/fixtures/tron/ with tests/unit/test_tron_payload_corpus.py holding the fake to them. A stage 5 that still reports differences means TronGrid has moved since; fix the fake, recapture the corpus, and read the diff rather than accepting it.

The raw payloads under spike-evidence-nile/ are not committed. They hold nothing secret — the API key travels in a header and is never captured — but they are one session's scratch, and the assertions are the part worth keeping.