12 KiB
ADR-0037 — Physical-shipment tracking & mailing-address share (cp120–cp121)
Status: Accepted (shipped 2026-05; pre-launch hardening campaign)
Context part: Part 122 cp120–cp122, follow-on to ADR-0021 (payment-method registry).
Context
Pre-launch, Morphit supported two-party trades over BTC, XMR, BLURT, and a
catalogue of trade-only assets (USDT/USDC/DAI/BCH/LTC/DASH/DOGE/ZEC/ARRR/DCR/
SOL/ETH/XRP). Chat carried two structured payloads — morphit_addr (carried inside morphit_chat_v1) for
sharing crypto receiving addresses and morphit_funds_sent (carried inside morphit_chat_v1) for txid
receipts — to let either party hand off cryptographic-grade trade evidence
without re-typing.
A first-principles workflow gap remained: trades involving physical shipments had no structured chat evidence. Two cases surfaced in user feedback:
-
Cash by mail. The buyer mails physical paper currency to the seller in exchange for crypto. Until the cash arrives, the seller has no verifiable signal that the buyer mailed anything. The buyer says "I sent it"; the seller waits.
-
Physical goods for crypto. Existing
barter_goodspayment method covered face-to-face trades (a used bicycle, home-grown garlic, etc.), but not the case where one party ships a physical good (e.g. a Barbie doll for Monero) and the other pays with crypto. The shipping party needs to prove the shipment.
The existing single cash payment method (category in_person) conflated
two operationally distinct flows (face-to-face vs. asynchronous mail), and
no chat payload existed to share a physical mailing address or a carrier
tracking number.
Decision
Three coordinated changes, shipped together as cp120 (foundation) + cp121 (UI) + cp122 (docs).
1. Split cash into two payment methods, add by_mail category
- Remove the single
cash(in_person) entry from the payment-method registry. - Add
cash_in_person(categoryin_person) andcash_by_mail(categoryby_mail). - Add a fourth payment category
by_mailtoPaymentCategoryfor asynchronous mail-based payments. Currently holds one method (cash_by_mail); future additions like postal money orders fit here. PAYMENT_CATEGORIES_ORDEREDkeeps UX-display order:crypto → in_person → by_mail → online(same-machine → same-room → same-country → anywhere).
Rationale: the in-person/by-mail split reflects a fundamentally different operational reality (face-to-face vs. third-party-carrier hand-off with days of latency), and downstream UX (in-chat triggers for the mailing- address-share and shipment-tracking modals) only makes sense for the by-mail subset.
Pre-launch posture: zero instances live, no chain history to preserve;
the rename is clean. Indexer's RESERVED_CANONICAL_KEYS is updated
in lockstep with the frontend registry (enforced by
reserved-keys-parity-smoke).
2. Two new chat payloads (E2EE chat-only, never on-chain)
Versioning note: chat-payload
kindvalues are bare (morphit_addr,morphit_mailing_address,morphit_shipment, ...) without a_v1suffix. Per ADR-0015, versioning lives at the OUTER envelope (morphit_chat_v1); the innerkindis a discriminator within that envelope and evolves by adding new fields or new kinds, not by bumping a per-kind version. An earlier draft of this ADR (and PHASE-5 docs) wrotemorphit_addr_v1etc., which never matched the code; cp131 LOW-007 corrected this.
-
morphit_mailing_address— share a physical mailing address. Fields:country(ISO 3166-1 alpha-2),street, optionalstreet2,city, optionalstate,postal_code, optionalrecipient_name, optionalnote, optionalorder_permlink. Length-bounded to defeat DoS-shaped messages (MAILING_ADDRESS_LIMITS). Country code validated against/^[A-Z]{2}$/(any ISO alpha-2; not enum-locked because country lists evolve). -
morphit_shipment— share carrier + tracking number. Fields:carrier(canonical key from carrier registry OR the special'other'),tracking(5-50 chars alphanumeric+space+dash+slash), optionalcustom_carrier_name+custom_tracking_url(used only whencarrier === 'other'), optionalnote, optionalorder_permlink.
Both payloads:
- Stay in E2E-encrypted chat ONLY. Never written to indexer. Never stored in chain ops. Never federation-readable. The chat envelope is opaque to server infrastructure.
- Round-trip through
encodePayload()/decodePayload()with full field-shape validation; the decoder falls through to plaintext on malformed input (same defense pattern as existing payloads). custom_tracking_urlis scheme-locked tohttps://viaisValidCustomTrackingUrl(), which (a) requires the literalhttps://prefix, (b) round-trips throughnew URL()to confirm well-formedness, (c) rejects any other scheme. This blocksjavascript:,data:,file:, etc. URL-injection attacks via the chat-pill clickable-tracking-link affordance.
Rationale for two distinct payloads (vs. extending morphit_funds_sent
with an optional tracking field): morphit_funds_sent is crypto-
specific (carries a chain txid + asset method); a physical shipment
carries fundamentally different metadata (carrier name, paper-currency
context). Two payloads mirror the existing addr vs. funds_sent
split — one event = one payload type.
3. Bundled top-20 carrier registry with tracking URL templates
apps/web/src/lib/shipping/carriers.ts exposes:
- Top 20 worldwide carriers by global parcel volume + Morphit locale relevance: USPS, UPS, FedEx, DHL Express (en/global); China Post EMS, SF Express, Hongkong Post (zh-CN/zh-HK); Japan Post; Royal Mail (UK); La Poste (FR); Deutsche Post (DE); Poste Italiane (IT); Correos (ES); Poczta Polska (PL); Pochta Rossii (RU); Iran Post (FA); Australia Post; Canada Post; India Post; Aramex (Middle East/global).
- An
'other'free-text fallback (caller suppliescustomCarrierNamecustomTrackingUrl).
- Per-carrier
trackingUrlTemplate(best-effort https:// URL with a literal{tracking}placeholder).buildTrackingUrl()URL-encodes the tracking number at substitution time, so spaces / slashes / special chars in tracking numbers don't break the URL. getCarrier(key)lookup;CARRIER_KEYSset for O(1) validation.
Structural invariants (every key matches /^[a-z0-9_]{2,32}$/,
canonical entries are alphabetical, 'other' is last, every canonical
entry has an https template with {tracking}, every Morphit locale
has at least one region-relevant carrier) are enforced by
carrier-registry-invariants-smoke.
Carriers that change their tracking URL structure can be updated
in-place; the bundled list is best-effort and operators / users can
override via the 'other' free-text path.
Privacy posture
Highest-sensitivity user data this entire feature touches. Design choices in order of importance (per Morphit's standing priority #1):
-
Both payloads never leave E2E chat. No indexer write, no chain op, no relay-readable form. The federation cannot see the address or tracking number.
-
Recipient's tracking-link click is the only external touchpoint. When the recipient clicks "Track package" on a shipment pill, their browser visits the carrier's tracking page. The tracking number then becomes visible to the carrier (which already knew it) and to any network observer in the recipient's path (which gains carrier-visit + tracking number, but not the address payload). Users who want fully air-gapped lookup can copy the tracking number from the pill (
📋 Copybutton) and look it up via Tor or a different browser. -
Mailing-address recipient is the destination — already knows. Sharing a mailing address with a counterparty who needs it to ship something is intrinsically a disclosure. The modal's privacy aside names four facts before the user shares: (a) E2EE chat only, (b) irreversible once sent, (c) consider a P.O. box / mail-drop / virtual mailbox instead of a home address, (d) consider clearing chat history after the trade completes.
-
Shipment safety aside is contextual. Always shown: insurance, plain envelope, return-address tradeoff (anonymity vs. recovery), tracking-optional. **Collapsible "If you're mailing CASH" expander ** with cash-specific tips: tinfoil-wrap (defeats envelope-fishers holding envelopes up to bright light to see contents — well-known P2P-cash wisdom), UPS/FedEx prohibit cash shipments in their terms (opened packages can be confiscated with no recourse — use a postal service), international/customs warning (don't lie on declarations; high-value cash will be seized).
-
Tracking-number spoofing is a known soft attack — a malicious buyer could paste any random tracking number. Mitigation is user-education (the seller should sanity-check that the destination ZIP on the carrier-lookup matches their actual ZIP) — covered in the FAQ entry shipped with this ADR. Code-level mitigation would require integrating with each carrier's tracking API; the operator trust + decentralization story doesn't justify that complexity.
Wire-format example
Mailing address (morphit_mailing_address):
{
"v": 1,
"kind": "morphit_mailing_address",
"country": "DE",
"street": "Hauptstraße 42",
"street2": "Hinterhof Aufgang 3",
"city": "Berlin",
"state": "Berlin",
"postal_code": "10115",
"recipient_name": "Max Mustermann",
"note": "Klingel 12 — bitte zweimal klingeln",
"order_permlink": "order-abc-123"
}
Shipment (morphit_shipment, USPS):
{
"v": 1,
"kind": "morphit_shipment",
"carrier": "usps",
"tracking": "9400 1234 5678 9012 3456 78"
}
Shipment, custom carrier:
{
"v": 1,
"kind": "morphit_shipment",
"carrier": "other",
"tracking": "XYZ-123-456",
"custom_carrier_name": "Acme Couriers",
"custom_tracking_url": "https://acme.example/track?id=XYZ-123-456"
}
Consequences
Positive:
- Cash-by-mail and physical-goods-for-crypto trades now have structured chat evidence — the same UX class as crypto address-share and funds-sent pills.
- Generic by design: the same shipment pill works for cash, Barbie dolls, sourdough starters, any other physical good.
- Pre-launch clean rename — no migration debt for
cash→ split. - Privacy aside surfaces real risks before users share PII; safety aside encodes user-tested operational wisdom (tinfoil-wrap, UPS/FedEx prohibition).
Negative / accepted tradeoffs:
- Bundled carrier tracking URLs are best-effort. Carriers occasionally restructure their URL parameter scheme; the recipient gets a clear "couldn't load tracking" outcome rather than silent failure (browser opens a broken page, user falls back to copy-and-lookup).
- Operators cannot extend the carrier list per-instance. We
intentionally don't expose carriers as on-chain operator-configurable
registry entries (the federation-wide consistency rationale for
payment methods doesn't apply to carriers — recipients in different
jurisdictions might want different sets). The
'other'free-text fallback covers every gap. - The mailing-address modal bundles only 15 common ISO countries in the dropdown (per Morphit's 10 locales' primary jurisdictions); the "Other (type ISO code)" path accepts any 2-letter ISO alpha-2 code. Grandma test passes: most users see their country in the dropdown directly.
Operational hygiene:
- Carrier tracking URLs need periodic refresh. Marked in code with a doc comment explaining the best-effort posture and pointing to this ADR.
- Translation polish for cp121 strings (~590 new strings) flagged in REVISIT-LIST translation-quality block for native-speaker review.
Related
- ADR-0021 (payment-method registry) — baseline shape this ADR extends.
carrier-registry-invariants-smoke(cp120) — 13 structural scenarios over the carrier registry.shipping-payload-roundtrip-smoke(cp120) — 17 scenarios over the two new payload types, including S-8 javascript: URL rejection.apps/web/src/lib/components/MailingAddressModal.svelte(cp121).apps/web/src/lib/components/ShipmentModal.svelte(cp121).- ChatMessage pill rendering — cp121.
docs/faq/cash-by-mail-trading.md(cp122 companion FAQ).