morphit/docs/adr/0023-usdt-multi-network.md

320 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-0023 — USDT (Tether) multi-network support
**Status:** Accepted (Part 121)
**Date:** 2026-05-13
**Deciders:** project maintainer
**Supersedes:** none
**Superseded by:** none
**Related:** ADR-0011 (fee model, including the Part 121
fee_method enum-freeze forward-note), ADR-0021 (payment-method
registry — fiat rails, intentionally separate from
trade-asset registry).
---
**2026-05-17 (Part 122 cp22) forward-note — operator-stance UX
closure.** This ADR established `MORPHIT_INDEXER_DISABLED_ASSETS`
as the operator-stance knob for USDT. Part 122 cp22 shipped an
interactive `morphit-ops init` wizard step ("Trade-only asset
policy", step 13) that walks through every Category-B asset and
asks per-ticker whether to enable it, then emits the right
`MORPHIT_INDEXER_DISABLED_ASSETS=` line into morphit.config.env.
Operators can decide their USDT stance at install time without
needing to know which env var to edit afterward. The env-var
path still works (and is what the wizard writes), so this is
purely a UX affordance — the design contract from this ADR is
unchanged.
---
## Context
Morphit's pre-launch asset registry shipped with three trade-asset
tickers: BTC, XMR, and BLURT. Each is a single-network asset. In
the post-cp2 audit Ken asked whether adding new coins, specifically
USDT (Tether), would be easy — and committed to add USDT as the
fourth tradable asset.
The investigation findings (Part 121 AUDIT entry) confirmed the
asset registry already carried the right discriminator flags
(`canBeTraded` and `canPayListingFee` with a literal "reserved
for future fee-only/stable-only tickers" comment), but three
real gaps existed:
1. `apps/web/src/lib/explorer/urls.ts` hardcoded `if (asset ===
'BTC')` / `if (asset === 'XMR')` branches — adding a
trade-only asset's explorer link would require new hardcoded
branches.
2. No sub-network field for multi-network coins. USDT exists on
Ethereum (ERC-20), Tron (TRC-20), Solana (SPL), BNB Smart
Chain (BEP-20), Bitcoin Omni Layer (deprecated), and several
L2s. The order-row had no way to express "USDT on Tron."
3. No privacy-warning surface. USDT is centrally controlled
(Tether Inc. can freeze any address) and has no on-chain
privacy. Per Memory #19 (privacy is priority #1), users
considering USDT must be told this clearly — not buried in
docs.
This ADR documents the design decisions that closed those gaps
and shipped USDT as Morphit's first multi-network and first
trade-only asset.
## Decision
### 1. USDT is **trade-only** (cannot pay listing fees)
Memory #23 invariant: listing fees can ONLY be paid in BLURT,
XMR, or BTC. USDT (and any future trade-only asset) is
peer-to-peer trading only. Users can buy/sell USDT, the
listing fee for those orders is paid in BLURT/BTC/XMR.
Enforced by two wire-format-frozen sentinel smokes
(`fee-method-enum-frozen-smoke`,
`first-buy-waiver-payment-agnostic-smoke`) and the asset
registry's hard invariant
(`canPayListingFee: true → ticker ∈ {BLURT, BTC, XMR}`).
The first-buy waiver still applies regardless: a new user
buying their first BLURT and paying their counterparty in USDT
still gets the waiver, because the waiver covers the LISTING
FEE (which is paid in BLURT or waived entirely), not the trade
settlement currency.
### 2. **Single registry entry**, network picked at trade time
We considered three options:
- **Option A** — separate entries per network: `USDT-ERC20`,
`USDT-TRC20`, etc., as distinct tickers in the registry.
Rejected: would explode the orderbook into N variants per
asset, fragment liquidity, confuse users (which is the
"real" USDT?), and produce ugly UI ("buy USDT-ERC20 with
USD via Wise").
- **Option B** (chosen) — single `USDT` entry, network picked
at trade time via a required `assetNetwork` field on the
order payload. The asset registry declares
`supportedNetworks: ['erc20', 'trc20', 'spl', 'bep20']`
and `defaultNetwork: null`. The /post form requires
explicit user choice every trade; no remembered default.
The orderbook row renders the network as a chip
("Tron (TRC-20)") next to the asset.
- **Option C** — auto-detect network from address shape.
Rejected: ERC-20 and BEP-20 share the same address format
(both EVM-compatible), so shape isn't sufficient to
disambiguate. Would also defeat the deliberate friction we
want users to feel ("I am committing to USDT on Tron").
### 3. **`defaultNetwork: null`** — force explicit choice every trade
Cross-network sends lose funds permanently. USDT-ERC20 sent
to a TRC-20 address is unrecoverable. We refuse to default
the user into any of those losses; every USDT trade requires
a deliberate network commit.
The form-level smoke (`usdt-network-picker-required-smoke`)
sentinel-greps `/post +page.svelte`, `AddressShareModal`, and
`FundsSentModal` for the `usdtNetwork !== null` gate in
their respective `canSubmit` derivations. If any future
refactor drops the gate, the smoke fails loudly in CI.
### 4. **Native USDT only** — no bridged versions
USDT exists in native form on each supported chain (the
canonical Tether-issued contract). It also exists in bridged
forms (`USDT.e` on Avalanche-L2, etc.). We support **native
only**: fewer footguns, cleaner mental model, simpler audit
surface for operators.
If a future bridged variant gains material P2P-trading
adoption, that's a new ADR — not a registry edit.
### 5. **Omni Layer USDT is excluded**
Tether themselves deprecated Omni-Layer USDT. Adding it to
our supported networks list would endorse a path Tether is
actively winding down. Omitted at launch.
### 6. **Information chip** on every USDT surface
Per Memory #19 (privacy is priority #1) and Memory #27 (respectful
copy about every listed asset), users considering an asset whose
technical properties differ from Morphit's defaults should be told
about those properties so they can make an informed choice. For
USDT, two facts are worth surfacing:
- **Issuance and administration**: USDT is issued and administered
by Tether Inc., who have the technical ability to freeze addresses
on the host chain and have used that ability in the past (mostly
in response to law-enforcement requests).
- **On-chain visibility**: USDT transactions on each supported host
chain (Ethereum, Tron, Solana, BNB Smart Chain) are publicly
visible on those chains. This is a property of the chain, not
unique to USDT.
The information chip (`<PrivacyWarningChip
privacyWarningKey="usdt_centralized" />`) renders in the
`/post` form, in `AddressShareModal` (when sharing a USDT
address), and as a permanent per-message banner in
`ChatMessage` (so a buyer re-checking an old chat message
still sees the context before sending). The copy is
factual, not judgmental — millions of traders use USDT
every day for its stability and liquidity, and the chip is
information for an informed choice, not a warning to avoid
the asset.
The component is named `PrivacyWarningChip` for historical
reasons (the first asset to use it was USDT and the
shorthand stuck); the i18n key is the source of truth and
the body copy is neutral.
### 7. **Operator opt-out** — default-ON with `MORPHIT_INDEXER_DISABLED_ASSETS`
Memory #25: every new tradable asset ships **default=ON
instance-wide**, with an operator-config override to disable.
Pattern: `MORPHIT_INDEXER_DISABLED_ASSETS` env var
(comma-separated tickers). Operators with philosophical or
regulatory objections to a specific asset flip the switch;
canonical morphit.io ships everything on.
Per-asset opt-out is OPERATOR-level, not user-level.
Individual users who object to a particular asset pick a
different Morphit instance. Federation rules: orders for an
asset disabled on instance A but enabled on instance B still
appear in B's orderbook (cross-instance read-only visibility
preserved); A simply refuses to ACCEPT new orders for that
asset from its own users.
### 8. **Bundled explorer defaults** per network
Operators can override per-network explorer templates via the
instance config (`chat_link_urls.usdt.{erc20,trc20,spl,bep20}`),
but Morphit ships bundled defaults so a fresh install renders
working tx-links out of the box:
- ERC-20 → `https://etherscan.io/tx/{txid}`
- TRC-20 → `https://tronscan.org/#/transaction/{txid}`
- SPL → `https://solscan.io/tx/{txid}`
- BEP-20 → `https://bscscan.com/tx/{txid}`
These were drawn from the canonical block-explorer list for
each chain. Operators running self-hosted instances of the
same chains (privacy-conscious) override via the per-network
env vars (`MORPHIT_FRONTEND_USDT_CHAT_LINK_URL_TRC20`, etc.).
### 9. **Live USDT/USD price subline** on every USDT order row
USDT pegs 1:1 to USD by design, but pegs break. 2018 saw
USDT trade at $0.91; 2022 saw it at $0.95. When the peg
breaks, a "1000 USDT" order's displayed USD-value becomes
a lie unless we surface the actual peg state.
The orderbook row carries a small `<UsdtPriceSubline />`
component that reads from the existing `$lib/prices` store
(Coingecko provider on the live path, fallback static-1.00
when the feed is unreachable). Renders as `1 USDT = $1.00
live` when fresh; falls back to `USDT/USD price feed
unavailable — last seen 12m ago.` when stale (5+ minutes old).
## Consequences
### Positive
- USDT trades work end-to-end: post, orderbook, address-share,
funds-sent, chat-message rendering, per-network explorer
links — all driven by single registry entries with no
hardcoded branches per network.
- The wire-format-frozen `fee_method` enum invariant
(memory #23) is preserved. Three sentinel smokes guard
against future drift.
- Operators with philosophical objections to USDT can disable
it instance-wide with one env-var flip. The canonical
morphit.io ships USDT on; alternative instances can be
XMR-pure or BTC+XMR-only.
- The pattern generalizes: adding ARRR (or any future
trade-only asset) is a single asset-registry entry plus
optional per-network metadata, no code-level branches.
### Negative / accepted costs
- USDT traders on Morphit see the information chip every
time they post or share an address. This is a small
friction in service of an informed-choice user model —
Memory #19 keeps the chip in place, Memory #27 keeps
its tone factual.
- The network picker is required on every USDT trade with no
default. Slightly more friction than a default-and-edit
flow, accepted because cross-network-mis-send is
unrecoverable.
- We carry per-network metadata (regexes, explorers) for
four networks. Adding a fifth is one entry in
`apps/web/src/lib/assets/networks.ts` plus four i18n
translations per locale (displayName + feeHint per locale
× 10 locales = 40 strings).
- Operators with `MORPHIT_INDEXER_DISABLED_ASSETS` listing
USDT see USDT orders from peer instances in their read-only
feeds but cannot accept new USDT orders from their own users.
Cross-instance discoverability is preserved; local
posting authority is the operator's call.
### Forward-looking
If/when Tether adds support on a new chain that gains
material P2P-trading adoption, the addition is:
1. Add the new network key to `USDT_NETWORKS` in
`apps/web/src/lib/assets/networks.ts` (regex + bundled
explorer URL).
2. Add the matching i18n keys: `assets.usdt.network.<key>.{
displayName, feeHint}` × 10 locales.
3. Add the canonical registry update:
`supportedNetworks` array grows to N+1.
4. Document in this ADR (a "2026-XX-XX forward note"
section).
No structural code changes needed.
## Implementation references
Code:
- `packages/asset-registry/src/index.ts` — canonical USDT entry
- `apps/web/src/lib/assets/registry.ts` — frontend mirror
- `apps/web/src/lib/assets/networks.ts` — per-network metadata
- `apps/web/src/lib/components/PrivacyWarningChip.svelte`
- `apps/web/src/lib/components/UsdtNetworkPicker.svelte`
- `apps/web/src/lib/components/UsdtPriceSubline.svelte`
- `apps/web/src/lib/components/AddressShareModal.svelte`
- `apps/web/src/lib/components/FundsSentModal.svelte`
- `apps/web/src/lib/components/ChatMessage.svelte`
- `apps/web/src/lib/explorer/urls.ts` (registry-driven dispatch)
- `apps/web/src/routes/post/+page.svelte`
- `apps/web/src/routes/orderbook/+page.svelte`
- `apps/indexer/src/indexer/handlers/order.ts` (instance-wide
disable gate + `asset_network_required_for_usdt` validation)
- `apps/indexer/src/config/index.ts`
(`MORPHIT_INDEXER_DISABLED_ASSETS`)
- `apps/indexer/src/db/schema.sql` (v32 migration:
`orders.asset_network`)
Smokes:
- `packages/asset-registry/scripts/usdt-trade-only-smoke.ts`
- `packages/asset-registry/scripts/usdt-network-picker-required-smoke.ts`
- `packages/asset-registry/scripts/fee-method-enum-frozen-smoke.ts`
(Part 121 cp1 — still relevant; pins USDT out of the
fee-method enum)
i18n: 25+ new keys × 10 locales documenting USDT-specific
copy (privacy warning, network names, fee hints, picker UI,
address-share copy, order-row hints, price subline,
disabled-on-instance message).
Docs:
- `docs/FEES-AND-REWARDS.md` §"What is FROZEN" — USDT named
in the trade-only row.
- `docs/ADDING-A-COIN.md` — Category B worked example
references USDT as the canonical reference.
- `docs/OPERATIONS.md` — operator-config for
`MORPHIT_INDEXER_DISABLED_ASSETS` and per-network explorer
overrides.
- `docs/RUN-A-MORPHIT-NODE.md` — USDT support setup section.
- `docs/PRE-LAUNCH-CHECKLIST.md` — operator stance on USDT
checklist item.