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

13 KiB
Raw Permalink Blame History

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.