ej-research / cryptovoucher
Encode private keys into Base62 vouchers and restore them
Package info
github.com/EJ-Research/CryptoVoucher
Language:Python
pkg:composer/ej-research/cryptovoucher
Requires
- php: >=7.4
- ext-bcmath: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
CryptoVoucher turns a wallet private key into a short voucher that can be sold, printed or sent as text, and redeemed like a gift card. The voucher also says which network and coin it holds, for example USDT on TRON or BTC on Bitcoin. It is a small library for Go, Node.js, Python and PHP.
The idea
Getting someone started with crypto usually means walking them through a wallet app, a backup phrase, networks and fees. A voucher removes that step. The issuer creates a fresh wallet, funds it and gives the buyer a 45-character code. The buyer does not need a wallet at all. To spend the voucher, they give the code to a merchant, who turns it back into the private key, checks the balance of the address and moves the funds to their own wallet.
The voucher is the private key itself, written in a shorter alphabet, plus a network code and a check character. Nothing is encrypted and no server is involved. Anyone who holds the full voucher controls the funds, the same way anyone holding a banknote can spend it. This is why redeeming has to follow a fixed order (see Redeeming a voucher and The race window).
One key has an address on many networks, and all EVM chains even share the same address. The network code tells the merchant which chain, which coin and which address type to check.
The whole flow:
- The issuer generates a new private key and funds its address on one network.
- The issuer calls
create_voucherwith the key and the network ID, and gives the voucher to the buyer. - The buyer passes the voucher to a merchant.
- The merchant calls
restore_private_key, which returns the key and the network ID, derives the address for that network and checks the balance. - The merchant moves the full balance to their own wallet, waits until that transfer is final, and only then delivers.
Voucher format
A private key is a 256-bit number, normally written as 64 hex characters. To build a voucher, the library:
- checks that the key is in the range
1 <= key < n, wherenis the order of the secp256k1 curve, - writes the network code (one character, see Network codes),
- writes the key in Base62 (
0-9,A-Z,a-z), left-padded with0to 43 characters, - appends one check character,
- splits the 45 characters into a voucher key and a voucher code.
| Part | Length | Content |
|---|---|---|
| Voucher key | 29 | Network code, then the first 28 Base62 characters |
| Voucher code | 16 | Last 15 Base62 characters, then the check character |
43 is the shortest Base62 length that fits every 256-bit value (62^43 is just above 2^256), so the key part cannot get any shorter without losing information. The padding zeros have no effect on the value, and the decoder treats them like any other digit.
The check character works like the ISBN-10 check digit. The Base62 value of each of the first 44 characters (0-9 = 0 to 9, A-Z = 10 to 35, a-z = 36 to 61), that is the network code and the 43 key digits, is multiplied by its position (1 to 44). The products are added up, and the sum modulo 61 picks the check character (0 to y, so z never appears in that position). Because 61 is prime, this catches:
- every mistyped character, except
0typed aszor the other way round, - every swap of two characters, whether they are next to each other or further apart, again except
0withz.
A typo in the network code is always caught, because 0 and z are never used as network codes. Other kinds of damage get through about once in 61 tries. The one fixed blind spot is a doubled character in the first two places of the voucher code (positions 30 and 31) that turns into a different doubled character, such as 44 typed as 77.
restore_private_key also checks the length of each part, so entering the voucher key and the voucher code in each other's fields is always caught. If your form takes the whole voucher in a single field, a reversed paste can only be caught by the check character. A voucher that fails any of these checks is rejected instead of silently decoding to some other valid key.
Both parts are needed to restore the key, and both must be kept secret. Do not print or display the voucher key as if it were a public card number. It holds about two thirds of the private key, and on secp256k1 networks, once the voucher address has sent any transaction (which puts its public key on chain), the voucher key alone is enough to recover the rest with modest hardware.
Vouchers are case sensitive: a and A are different characters.
Network codes
The first character of every voucher is one of these codes. The network ID is what create_voucher takes and restore_private_key returns. The same table with chain and asset IDs is in networks.json.
| Code | Network ID | Network | Asset | Token contract | Decimals |
|---|---|---|---|---|---|
1 |
BITCOIN_BTC |
Bitcoin | BTC | native | 8 |
2 |
ETHEREUM_ETH |
Ethereum | ETH | native | 18 |
3 |
ETHEREUM_USDT |
Ethereum | USDT | 0xdAC17F958D2ee523a2206206994597C13D831ec7 |
6 |
4 |
ETHEREUM_USDC |
Ethereum | USDC | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 |
6 |
5 |
TRON_TRX |
TRON | TRX | native | 6 |
6 |
TRON_USDT |
TRON | USDT | TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t |
6 |
7 |
BSC_BNB |
BNB Smart Chain | BNB | native | 18 |
8 |
BSC_USDT |
BNB Smart Chain | USDT (Binance-Peg) | 0x55d398326f99059fF775485246999027B3197955 |
18 |
9 |
BSC_USDC |
BNB Smart Chain | USDC (Binance-Peg) | 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d |
18 |
A |
POLYGON_POL |
Polygon PoS | POL | native | 18 |
B |
POLYGON_USDT |
Polygon PoS | USDT (USDT0) | 0xc2132D05D31c914a87C6611C10748AEb04B58e8F |
6 |
C |
POLYGON_USDC |
Polygon PoS | USDC | 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 |
6 |
D |
SOLANA_SOL |
Solana | SOL | native | 9 |
E |
SOLANA_USDT |
Solana | USDT | Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB |
6 |
F |
SOLANA_USDC |
Solana | USDC | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v |
6 |
G |
TON_TON |
TON | TON | native | 9 |
H |
TON_USDT |
TON | USDT | EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs |
6 |
J |
ARBITRUM_ETH |
Arbitrum One | ETH | native | 18 |
K |
ARBITRUM_USDT |
Arbitrum One | USDT (USDT0) | 0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9 |
6 |
L |
ARBITRUM_USDC |
Arbitrum One | USDC | 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 |
6 |
M |
OPTIMISM_ETH |
OP Mainnet | ETH | native | 18 |
N |
OPTIMISM_USDT |
OP Mainnet | USDT (bridged) | 0x94b008aA00579c1307B0EF2c499aD98a8ce58e58 |
6 |
P |
OPTIMISM_USDC |
OP Mainnet | USDC | 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85 |
6 |
Q |
BASE_ETH |
Base | ETH | native | 18 |
R |
BASE_USDC |
Base | USDC | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
6 |
S |
AVALANCHE_AVAX |
Avalanche C-Chain | AVAX | native | 18 |
T |
AVALANCHE_USDT |
Avalanche C-Chain | USDT | 0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7 |
6 |
U |
AVALANCHE_USDC |
Avalanche C-Chain | USDC | 0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E |
6 |
V |
LITECOIN_LTC |
Litecoin | LTC | native | 8 |
W |
DOGECOIN_DOGE |
Dogecoin | DOGE | native | 8 |
X |
BITCOINCASH_BCH |
Bitcoin Cash | BCH | native | 8 |
Y |
XRPL_XRP |
XRP Ledger | XRP | native | 6 |
a |
ETHEREUM_DAI |
Ethereum | DAI | 0x6B175474E89094C44Da98b954EedeAC495271d0F |
18 |
b |
ETHEREUM_PYUSD |
Ethereum | PYUSD | 0x6c3ea9036406852006290770BEdFcAbA0e23A0e8 |
6 |
A voucher holds exactly the asset its code names, at exactly the contract listed. Other versions of the same coin are different tokens and do not count, for example USDC.e on Polygon or Arbitrum, USDT.e on Avalanche, or USDT0 on OP Mainnet (0x01bFF41798a0BcF287b996046Ca68b395DbC1071). Every token contract above was cross-checked in at least two independent token registries, but still check them against the token issuer or the network's block explorer before going live. USDT and USDC on BNB Smart Chain use 18 decimals, not 6.
How the network turns the voucher's number into an address:
| Networks | Key | Address |
|---|---|---|
| Ethereum (chain ID 1), BNB Smart Chain (56), Polygon PoS (137), Arbitrum One (42161), OP Mainnet (10), Base (8453), Avalanche C-Chain (43114) | secp256k1 private key | EVM address 0x..., the same on every EVM chain |
| TRON | secp256k1 private key | The EVM address bytes with a 0x41 prefix, in Base58Check: T... |
| Bitcoin | secp256k1, compressed public key | Native SegWit P2WPKH: bc1q... |
| Litecoin | secp256k1, compressed public key | Native SegWit P2WPKH: ltc1q... |
| Dogecoin | secp256k1, compressed public key | P2PKH: D... |
| Bitcoin Cash | secp256k1, compressed public key | P2PKH in CashAddr: bitcoincash:q... |
| XRP Ledger | secp256k1, compressed public key | Classic address: r... |
| Solana | Ed25519 seed | Base58 public key; tokens sit in the associated token account |
| TON | Ed25519 seed | W5 wallet (v5r1) on workchain 0 with the default wallet ID 2147483409 (0x7FFFFF11), non-bounceable form UQ... |
On Solana and TON the 32 bytes are used as the Ed25519 seed. The voucher still requires 1 <= key < n; a random seed falls outside that range with a probability of about 2^-128, and if it ever happens the issuer generates another one.
The table follows three rules:
- An assigned code is permanent. It is never changed or reused, and new networks only get codes that have never been used.
0andzare never assigned, since the check character cannot tell them apart.I,O,landoare left out because they are easy to misread.- The library rejects any code that is not in the table. There is no "other" or "unspecified" code.
networks.json
networks.json is the machine-readable version of the table and the only place where codes are edited. The tables in the four libraries and in this README are generated from it. Each entry has these fields:
| Field | Meaning |
|---|---|
code, id |
Voucher code and network ID |
network, asset, variant |
Display names. variant is set when the issuer does not mint the token on that chain itself: Binance-Peg, USDT0 or bridged |
decimals, contract |
Token decimals and contract address. contract is null for native coins |
caip2 |
CAIP-2 chain ID, such as eip155:1 or tron:728126428 |
caip19 |
CAIP-19 asset ID, such as eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7 |
slip44 |
SLIP-44 coin type of a native coin, null for tokens |
key |
secp256k1 or ed25519-seed |
address |
Address format from the table above: evm, tron, bitcoin-p2wpkh, litecoin-p2wpkh, dogecoin-p2pkh, bitcoincash-cashaddr, xrpl-classic, solana or ton-w5 |
With the CAIP IDs, a wallet or payment system can map a voucher code to a chain and asset it already knows instead of keeping its own table. Native coins use slip44:<coin type> as the asset part. EVM tokens use erc20:<address>, BNB Smart Chain included, and Solana tokens use token:<mint>. The TRON namespace has no token profile yet, so TRON tokens use trc20:<address>, the form MetaMask uses. TON jettons have no CAIP-19 form at all, so caip19 is null for TON_USDT. Where SLIP-44 lists more than one coin type, the file uses the one MetaMask uses: 714 for BNB on BNB Smart Chain and 9005 for AVAX on the C-Chain.
Sample data
This key is public. Do not send funds to its addresses.
| Field | Value |
|---|---|
| Private key | 0B6BF630452AABF9C57A2755DD4B3DD570A4047181C8A3A44239AD50E9F7D06B |
| Network | TRON_USDT |
| Voucher key | 62hvFlb6W2LlmFns9bG3NdCO6l85G |
| Voucher code | VdTISeuq2iftf7zh |
Addresses of the same key on each network family:
| Network | Address |
|---|---|
| EVM chains | 0x560b70C1F4Bd994037911858E29281909EcAAA14 |
| TRON | THpApTFkvxbvHThDKix1mwe7KDYxfVtRhP |
| Bitcoin | bc1qk5m5qsegkvgvl8a0s85wqm4fedgv7ezcj44fyv |
| Litecoin | ltc1qk5m5qsegkvgvl8a0s85wqm4fedgv7ezckf0duu |
| Dogecoin | DMfH2tUKF29NiYrLh3J9m8v8M3x7w9xxvJ |
| Bitcoin Cash | bitcoincash:qz6nwszr9ze3pnul47q73crw4894pnmytq5jp48w4h |
| XRP Ledger | rHXBVdXCAcEaBYCjxTJbD4kXTvDFcDtg9j |
| Solana | Fru5TeY998yrMjQPiroZQvwH62196kTFZDYtaB35YQ1C |
| Solana, USDC token account | 9qSJDRQgrhsUXtDpQJ9UBpt9jho6AK5ETu9uXsL9ftBY |
| Solana, USDT token account | 6cfJYfWqMMy3GTyT3t4bKST4hZFp9qV5SiwfKw5ZBWMu |
| TON | UQDnNYjgOR9KMfFq_2z7ugFLOmsrIOdbSj_wkIE1MJCR0eb9 |
Edge cases, handy for testing a port:
| Private key | Network | Voucher key | Voucher code |
|---|---|---|---|
0000000000000000000000000000000000000000000000000000000000000001 |
BITCOIN_BTC |
10000000000000000000000000000 |
000000000000001j |
fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140 (n - 1) |
ETHEREUM_PYUSD |
byhjskwdA6OZ1AL1YmHWZWcETkvPc |
IqwQly7v5TWNN687 |
Inputs that must be rejected (messages from the Python version):
| Input | Result |
|---|---|
62hvFlb6W2LlmFns9bG3NdCO6l85G + VdTISXuq2iftf7zh (one character changed) |
Invalid voucher check character! |
62hvblF6W2LlmFns9bG3NdCO6l85G + VdTISeuq2iftf7zh (two characters swapped) |
Invalid voucher check character! |
52hvFlb6W2LlmFns9bG3NdCO6l85G + VdTISeuq2iftf7zh (network code mistyped) |
Invalid voucher check character! |
02hvFlb6W2LlmFns9bG3NdCO6l85G + VdTISeuq2iftf7zh (code not in the table) |
Unknown network code! |
VdTISeuq2iftf7zh + 62hvFlb6W2LlmFns9bG3NdCO6l85G (parts in the wrong order) |
Voucher key must be 29 characters long! |
2hvFlb6W2LlmFns9bG3NdCO6l85G + VdTISeuq2iftf7z (first release format, no network code) |
Voucher key must be 29 characters long! |
Network ID TRON when creating a voucher |
Unknown network! |
| Private key of 64 zeros | Private key is out of secp256k1 range! |
The full set is in vectors.json: 39 valid vouchers, at least one per network, and 28 inputs that must be rejected. All four libraries run it in CI, and a port should pass it too. Error messages there are compared without regard to case, since the Go version starts them in lower case.
Installation
Go 1.16 or newer:
go get github.com/EJ-Research/CryptoVoucher/Golang@v1.0.0
Node.js 14 or newer:
npm install crypto-voucher
Python 3.8 or newer:
pip install cryptovoucher
PHP 7.4 or newer with the BCMath extension:
composer require ej-research/cryptovoucher
The packages are published from this repository under exactly these names: crypto-voucher on npm, cryptovoucher on PyPI and ej-research/cryptovoucher on Packagist. A package with a similar name from another publisher is not this library. None of them has any dependencies.
The Elixir and Ruby versions still exist but are deprecated, see below.
Usage
All four versions expose the same two operations. Private keys are accepted in upper or lower case, and restored keys always come back as 64 lowercase hex characters. Network IDs are exactly the ones in the table.
Go
import cryptovoucher "github.com/EJ-Research/CryptoVoucher/Golang" cv := cryptovoucher.NewCryptoVoucher() voucherKey, voucherCode, err := cv.CreateVoucher(privateKey, "TRON_USDT") if err != nil { // invalid private key or unknown network } restoredKey, network, err := cv.RestorePrivateKey(voucherKey, voucherCode) if err != nil { // wrong length, unknown network code, bad character, failed check or out of range }
Node.js
const { CryptoVoucher } = require("crypto-voucher"); const cv = new CryptoVoucher(); const voucher = cv.createVoucher(privateKey, "TRON_USDT"); // { success: true, voucherKey: "...", voucherCode: "..." } // { success: false, message: "..." } const restored = cv.restorePrivateKey(voucher.voucherKey, voucher.voucherCode); // { success: true, data: "0b6bf6...", network: "TRON_USDT" }
Python
from CryptoVoucher import CryptoVoucher cv = CryptoVoucher() voucher_key, voucher_code, error = cv.create_voucher(private_key, "TRON_USDT") restored_key, network, error = cv.restore_private_key(voucher_key, voucher_code)
error is None on success and a message otherwise.
PHP
require 'vendor/autoload.php'; $cv = new CryptoVoucher(); $voucher = $cv->createVoucher($privateKey, 'TRON_USDT'); // ['status' => 'success', 'voucher_key' => '...', 'voucher_code' => '...'] $restored = $cv->restorePrivateKey($voucher['voucher_key'], $voucher['voucher_code']); // ['status' => 'success', 'data' => '0b6bf6...', 'network' => 'TRON_USDT'] // ['status' => 'error', 'message' => '...']
Running the Node.js, Python or PHP file directly prints the sample voucher above, and go test runs the same example in Go. Importing the library has no side effects.
Redeeming a voucher
1. Decode it
Call restore_private_key with both parts. If your form takes the voucher in a single field, split it after the 29th character. It fails when:
- the voucher key is not 29 characters or the voucher code is not 16,
- the first character is not a code from the network table,
- a character is outside
0-9A-Za-z, - the check character does not match, which almost always means a typo,
- the decoded number is outside
1 <= key < n.
On failure, ask the customer to type the voucher again. Never try to "fix" it by guessing characters. Show the decoded network (for example "USDT on TRON") before going on, so the customer can confirm it.
2. Get the address
Use the network ID to pick the address rule from the table above. For EVM chains and TRON, in Node.js:
const { Wallet } = require("ethers"); // ethers v6 const { TronWeb } = require("tronweb"); // tronweb v6 const evmAddress = new Wallet("0x" + privateKey).address; // Ethereum, BSC, Polygon, Arbitrum, OP, Base, Avalanche const tronAddress = TronWeb.address.fromPrivateKey(privateKey); // TRON
In Python:
from eth_account import Account from tronpy.keys import PrivateKey evm_address = Account.from_key("0x" + private_key).address tron_address = PrivateKey(bytes.fromhex(private_key)).public_key.to_base58check_address()
A TRON address holds the same 20 bytes as the EVM address, prefixed with 0x41 and written in Base58Check. For the sample key, 0x560b70C1...EcAAA14 on Ethereum is THpApTFk...fVtRhP on TRON.
The other networks, in Node.js:
const bitcoin = require("bitcoinjs-lib"); const ecc = require("tiny-secp256k1"); const { ECPairFactory } = require("ecpair"); const xrpl = require("xrpl"); const { Keypair, PublicKey } = require("@solana/web3.js"); const { getAssociatedTokenAddressSync } = require("@solana/spl-token"); const { keyPairFromSeed } = require("@ton/crypto"); const { WalletContractV5R1 } = require("@ton/ton"); const secret = Buffer.from(privateKey, "hex"); // Bitcoin family and XRP Ledger: compressed secp256k1 public key const pubkey = Buffer.from(ECPairFactory(ecc).fromPrivateKey(secret, { compressed: true }).publicKey); const btcAddress = bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin }).address; const xrpAddress = xrpl.deriveAddress(pubkey.toString("hex").toUpperCase()); // Solana: the owner address, and the token account that holds USDC const owner = Keypair.fromSeed(secret).publicKey; const usdcAccount = getAssociatedTokenAddressSync(new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), owner); // TON: W5 wallet on workchain 0 const tonKeys = keyPairFromSeed(secret); const tonAddress = WalletContractV5R1.create({ workchain: 0, publicKey: tonKeys.publicKey }).address.toString({ bounceable: false });
Litecoin uses the same P2WPKH script with the ltc bech32 prefix, Dogecoin uses P2PKH with version byte 0x1e, and Bitcoin Cash uses P2PKH written in CashAddr. Compare your results with the sample addresses above before going live.
3. Check the balance
Always read balances from confirmed or finalized state, not from the latest block. A balance that only exists in a recent block can still disappear. Token contracts and decimals are in the network table.
TRON
TronGrid returns the account, including its TRX and TRC-20 balances:
curl -s "https://api.trongrid.io/v1/accounts/THpApTFkvxbvHThDKix1mwe7KDYxfVtRhP?only_confirmed=true"
data[0].balance is the TRX balance in sun (1 TRX = 1,000,000 sun) and is missing when it is zero. data[0].trc20 is an array of one-entry objects, [{ "<contract>": "<amount>" }].
An empty data array only means the account has not been activated. It can still hold tokens, because receiving TRC-20 tokens does not activate a TRON account. Before treating a voucher as empty, ask the token contract directly:
# balanceOf(address) on USDT, read from confirmed state; addresses in hex (0x41 prefix) curl -s -X POST "https://api.trongrid.io/walletsolidity/triggerconstantcontract" -H "Content-Type: application/json" \ -d '{"owner_address":"41560b70c1f4bd994037911858e29281909ecaaa14","contract_address":"41a614f803b6fd780986a42c78ec9c7f77e6ded13c","function_selector":"balanceOf(address)","parameter":"000000000000000000000000560b70c1f4bd994037911858e29281909ecaaa14"}'
constant_result[0] is the balance as a 32-byte hex number (6 decimals for USDT). 41a614f8...ded13c is TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t in hex. For production traffic, get a TronGrid API key and send it in the TRON-PRO-API-KEY header.
With tronweb:
const tronWeb = new TronWeb({ fullHost: "https://api.trongrid.io", privateKey }); const sun = await tronWeb.trx.getBalance(tronAddress); // confirmed balance const usdt = await tronWeb.contract().at("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); const units = await usdt.balanceOf(tronAddress).call({ confirmed: true }); // 6 decimals
trx.getBalance already reads from the solidity node. For contract calls, { confirmed: true } does the same; without it, the call reads the latest block.
EVM chains
Use a JSON-RPC endpoint for the chain the voucher names. Nodes of all seven EVM chains in the table support the finalized block tag; on Avalanche it returns the last accepted block.
# native coin (ETH, BNB, POL, AVAX), result in wei as hex curl -s -X POST "$RPC_URL" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBalance","params":["0x560b70C1F4Bd994037911858E29281909EcAAA14","finalized"]}' # token balance: balanceOf(address), selector 0x70a08231 + address padded to 32 bytes curl -s -X POST "$RPC_URL" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0xdAC17F958D2ee523a2206206994597C13D831ec7","data":"0x70a08231000000000000000000000000560b70c1f4bd994037911858e29281909ecaaa14"},"finalized"]}'
With ethers:
const { JsonRpcProvider, Contract } = require("ethers"); const provider = new JsonRpcProvider(RPC_URL); const wei = await provider.getBalance(evmAddress, "finalized"); const usdt = new Contract(USDT_ADDRESS, ["function balanceOf(address) view returns (uint256)"], provider); const units = await usdt.balanceOf(evmAddress, { blockTag: "finalized" });
Other networks
- Solana: read SOL with
getBalanceand tokens withgetTokenAccountBalanceon the associated token account, both with thefinalizedcommitment. If the associated token account does not exist, the call returns an error; treat that as zero. Tokens can also sit in other token accounts of the same owner, sogetTokenAccountsByOwnerwith a mint filter finds everything. - TON: read TON from the wallet address. For USDT, ask the USDT contract for the owner's jetton wallet with
get_wallet_address, then read that wallet's balance withget_wallet_data. A jetton wallet that is not deployed yet holds nothing. - Bitcoin, Litecoin, Dogecoin, Bitcoin Cash: add up the confirmed unspent outputs of the address, using your own node, an Electrum server or an indexer.
- XRP Ledger: call
account_infoagainst thevalidatedledger. The balance is in drops (1 XRP = 1,000,000 drops), and part of it is locked as the account reserve.
4. Move the funds before you deliver
Seeing a balance does not make the funds yours; only a confirmed transfer to your own wallet does. After checking the balance, send everything to your own address at once, wait for that transfer to become final, and only then hand over the product. The next section explains why.
How the transfer is paid for depends on what the voucher holds.
Native coin vouchers
The fee is paid from the voucher balance itself.
- On Ethereum, BSC and Polygon, a plain transfer to an address without contract code uses 21,000 gas. Send
balance - 21000 * maxFeePerGas(or21000 * gasPricefor a legacy transaction). The actual fee is usually a bit lower and the difference stays on the voucher address as dust. On Avalanche the same rule applies. Rollups add an L1 part to the fee: on Arbitrum,eth_estimateGasalready includes it, while on OP Mainnet and Base you also have to subtract the L1 data fee, whichgetL1Feeon the GasPriceOracle contract (0x420000000000000000000000000000000000000F) returns. - On TRON a TRX transfer uses bandwidth. It is normally covered by the free daily bandwidth every activated account gets; if not, a small amount of TRX is burned.
- On Bitcoin, Litecoin, Dogecoin and Bitcoin Cash, spend every unspent output in one transaction and take the fee out of the amount.
- On Solana the fee comes out of the SOL balance. Send the balance minus the fee exactly; leaving a remainder below the rent-exempt minimum makes the transfer fail.
- On TON the W5 wallet contract is deployed by its first outgoing transfer, which the balance pays for. Send with mode 130 (128 to carry the whole remaining balance, plus 2, which W5 requires on every action of an external message).
@ton/tonadds the 2 by itself; hand-built messages must include it, or nothing moves. - On the XRP Ledger only the balance above the account reserve can be sent. Most of the reserve can be recovered by deleting the account, which is only allowed once the account is old enough.
Token vouchers
The voucher address needs native coin to pay the fee. The only exception is TON, where a W5 wallet can pay a gasless relay in USDT instead.
- On TRON, a USDT transfer needs energy. If the address has no energy of its own, the network burns TRX to pay for it. The amount depends on the current energy price and on whether the receiving address currently holds USDT: sending to an address with a zero USDT balance costs about twice as much. Set
feeLimithigh enough, because a transfer that runs out of energy fails and the fee is still burned. Delegated energy also works. - On the EVM chains, a token transfer needs ETH, BNB, POL or AVAX for gas.
- On Solana, the transfer needs SOL for the fee, and if your own token account for that token does not exist yet, whoever creates it pays its rent deposit.
- On TON, the transfer needs TON for gas. W5 wallets can also send USDT through gasless relay services that take their fee in USDT.
The simplest setup is for the issuer to add enough native coin when funding a token voucher. On TRON it is required: an address that has only received tokens is not activated and cannot send anything until it receives TRX. If a voucher arrives without gas, the merchant has to send a small amount of native coin to the voucher address, wait for it to confirm, and then move the tokens immediately. Gas sent this way is exposed just like the tokens, so send only what the transfer needs.
When the transfer is done, anything left on the voucher address (dust, unused gas) can be swept the same way or ignored.
The race window
Every person or system that has seen the voucher can spend it:
- the issuer's system, which generated the key,
- the buyer,
- anyone who saw the voucher (a photo, a chat message, a receipt left on a counter),
- any other merchant the voucher was shown to.
The race window is the time between your balance check and the moment your sweep becomes final. If any other holder moves the funds during that time, your sweep fails or gets dropped, even though the balance looked fine a moment earlier.
Pending transactions do not close the window:
- On EVM chains, another holder can broadcast a transaction with the same nonce and a higher fee and replace yours before it is mined.
- On Bitcoin, replace-by-fee does the same thing.
- On TRON and the other networks, if two transactions spend the same balance, whichever lands in a block first wins.
No library can close this window, because any bearer key works this way. Keep it short and wait for finality before delivering:
-
Read the balance from confirmed or finalized state.
-
Sweep immediately, not in a batch job later.
-
Deliver only after the sweep is final:
Network When to treat the sweep as final TRON After 19 blocks (about one minute), when the block is solidified Ethereum When the block is finalized, usually 13 to 19 minutes BNB Smart Chain When the block is finalized, usually within a few seconds Polygon PoS When the block is finalized ( finalizedblock tag)Arbitrum One, OP Mainnet, Base When the block is finalized ( finalizedblock tag), which follows Ethereum finalityAvalanche C-Chain When the block is accepted, usually within a few seconds Solana At the finalizedcommitment, usually under a minuteTON When the transaction is in a masterchain-confirmed block, usually within seconds XRP Ledger When the transaction is in a validated ledger, usually within seconds Bitcoin, Bitcoin Cash 1 to 6 confirmations, depending on the amount Litecoin, Dogecoin More confirmations than on Bitcoin, since blocks come faster (2.5 minutes and 1 minute) -
Treat a failed or empty sweep as a voucher that has already been used.
-
Record the voucher address together with the sweep transaction ID. If there is a dispute, the chain is the source of truth.
Elixir and Ruby are deprecated
The Elixir and Ruby versions are no longer maintained and will not be updated. They were frozen at an interim 44-character format without a network code. No release ever used that format, and the maintained versions do not accept it, so do not use them for new vouchers. The Ruby version prints a warning when it is loaded, and the Elixir functions are marked @deprecated.
Go, Node.js, Python and PHP are what most payment backends and shops are written in today. Six copies of the same logic are hard to keep in sync: every format change had to be written and tested six times, and the copies drifted apart anyway. The library is one file of 220 to 250 lines and the format is fully specified above, so porting it to another language is a small job, and AI coding tools can do most of it. The test vectors in vectors.json are enough to check a port.
Notes
- The voucher flow has been tested on TRON. The other networks use standard key and address rules, but test on your own setup before accepting real vouchers.
- The voucher always carries a number in
1 <= key < n. secp256k1 networks use it as the private key, Solana and TON as the Ed25519 seed. Networks with other key schemes, such as Cardano, Monero or Polkadot, are not supported. - Vouchers without a network code are rejected by the current version. That includes vouchers from the first release, which used plain Base62 with no padding and no check character (a 28-character key plus up to 15 characters). To decode one of those, use the code from the first release (commit
fea1e8f).
Contributing
Issues and pull requests are welcome. Any change to the voucher format has to land in all four maintained versions (Go, Node.js, Python and PHP) and has to keep the sample data and vectors.json valid.
To add a network:
-
Add an entry at the end of
networks.jsonwith the next unused code. Check the token contract and decimals in at least two independent sources, for example the token issuer and the network's block explorer. -
Run
python tools/sync_registry.pyto regenerate the tables in the four libraries and in this README. Never edit those tables by hand. -
Create a voucher for the new network with any of the libraries and add it to
validinvectors.json. The per-network vectors use the SHA-256 ofCryptoVoucher vector <network ID>as the private key, in lower-case hex. -
Run the checks:
python tools/sync_registry.py --check cd Golang && go test ./... && cd .. cd Nodejs && npm test && cd .. python -m unittest discover -s Python php PHP/test_vectors.php
CI runs the same checks on every pull request. It also fails if an assigned code was changed or removed.