This month, Utexo, a Tether-backed company, plans to launch USDT natively on Bitcoin using the RGB protocol, with Lightning support to follow. Once it does, "USDT on Bitcoin" will refer to at least four different things: Omni (the 2014 original), Liquid, Lightning via Taproot Assets, and RGB.
If your system identifies assets by ticker, or even by ticker + chain, that's a data model problem waiting to happen.
The bug pattern
Most codebases start like this:
type Asset = "BTC" | "ETH" | "USDT" | "USDC";
Then multi-chain support arrives:
type Asset = { symbol: string; chain: string };
// { symbol: "USDT", chain: "bitcoin" } β which one?
With four distinct USDT rails tied to Bitcoin, chain: "bitcoin" is ambiguous. Each rail has different address formats, different wallets, different deposit detection, and different settlement semantics.
Model the rail, not the chain
An asset needs enough fields to be unambiguous and to drive behavior:
type Protocol =
| "native"
| "erc20"
| "trc20"
| "bep20"
| "spl"
| "jetton"
| "omni"
| "liquid"
| "taproot-assets"
| "rgb";
interface AssetId {
symbol: string; // display only, never a key
network: string; // "bitcoin", "ethereum", "tron", "lightning", ...
protocol: Protocol; // how the asset exists on that network
contract?: string; // token contract / asset id where applicable
}
const assetKey = (a: AssetId): string =>
[a.network, a.protocol, a.contract ?? "native"].join(":");
Two rules follow from this:
-
symbolis a label, not an identifier. Never join, dedupe, or route on it. - The key must include the contract or asset ID wherever one exists. Tickers can be copied by anyone; contract addresses can't.
Capabilities belong on the rail
Different rails need different flows. Encode that as data instead of scattering if (chain === ...) checks:
interface RailCapabilities {
needsMemo: boolean; // XRP, XLM, TON deposits
publicLedger: boolean; // false for RGB-style client-side validation
addressFormat: RegExp;
minConfirmations: number;
supportedForDeposit: boolean;
supportedForWithdrawal: boolean;
}
const rails = new Map<string, RailCapabilities>();
publicLedger: false matters for RGB. With client-side validation, transaction data isn't published to a public ledger the way it is on Tron or Ethereum, so deposit detection and status tracking can't rely on the usual "watch the chain" approach.
Gate new rails explicitly
New rails will keep arriving. Treat every new one as unsupported until enabled, and validate before showing a deposit address:
function assertDepositable(asset: AssetId): RailCapabilities {
const caps = rails.get(assetKey(asset));
if (!caps || !caps.supportedForDeposit) {
throw new Error(`Deposits not supported for ${assetKey(asset)}`);
}
return caps;
}
Failing closed here prevents the most expensive class of user error: funds sent to a rail you can't detect.
Show the network everywhere
Every UI surface that shows a symbol should also show the network and protocol. "USDT" alone is now as ambiguous to users as it is to your database.
Plan for more fragmentation, not less
USDT on Bitcoin isn't the only change. Open USD launched on September 30 with $1 billion in committed liquidity from Coinbase, Mastercard, Visa, Stripe, and Shopify. More rails for the same stablecoin and more stablecoins for the same dollar are both trends. A schema that assumes either one is stable will need a migration.
Disclosure: written by the NefiSwap team. NefiSwap converts across 8,000+ assets and their networks, with crypto swaps that need no account, a Telegram Mini App and bot, and an API for teams that want conversion built in. nefiswap.com
Top comments (0)