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.
- [ ] TronGrid account and API key — free tier
- [ ] Two Nile wallets, hot and user, both
T…addresses - [ ] Faucet claims: TRX in both, test USDT in both
- [ ] The Nile stack is up
- [ ] The USDt plugin is installed and configured for Nile
- [ ] An address pool is pasted into the store
- [ ]
apiandworkerrestarted,USDT_TRC20enabled - [ ] The environment file has all five TRON values
- [ ]
python scripts/verify_nile.pyrun to the end - [ ] The results committed
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 file — deploy/, 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:
- Server Settings > Plugins — install USDt, then restart BTCPay. The restart is slow; this is most of the 30 minutes.
- 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. - The address pool — section 6 below.
- Restart
apiandworker— section 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¶
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:
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:
- 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.
- Downgrade any caveat the entry lists as downgraded. The first green
session, on 2026-08-11, took
USDT_CONTRACT_NILEfrom "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.mdand the Nile override. A later session re-reads the same contract, so what it changes is the date; if the answer ever stops beingUSDT/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. - Fix
tests/fake_tron.pyagainst anything stage 5 found. The 2026-08-11 session found 20 shape differences and closed them, and the payloads it captured are committed undertests/fixtures/tron/withtests/unit/test_tron_payload_corpus.pyholding 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.