morphit/docs/adr/0037-physical-shipment-tracking.md

280 lines
12 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-0037 — Physical-shipment tracking & mailing-address share (cp120cp121)
**Status:** Accepted (shipped 2026-05; pre-launch hardening campaign)
**Context part:** Part 122 cp120cp122, 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:
1. **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.
2. **Physical goods for crypto.** Existing `barter_goods` payment 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` (category `in_person`) and `cash_by_mail` (category
`by_mail`).
- Add a fourth payment category `by_mail` to `PaymentCategory` for
asynchronous mail-based payments. Currently holds one method
(`cash_by_mail`); future additions like postal money orders fit here.
- `PAYMENT_CATEGORIES_ORDERED` keeps 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 `kind` values are bare
> (`morphit_addr`, `morphit_mailing_address`, `morphit_shipment`,
> ...) without a `_v1` suffix. Per ADR-0015, versioning lives at
> the OUTER envelope (`morphit_chat_v1`); the inner `kind` is 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) wrote
> `morphit_addr_v1` etc., 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`, optional `street2`,
`city`, optional `state`, `postal_code`, optional `recipient_name`,
optional `note`, optional `order_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),
optional `custom_carrier_name` + `custom_tracking_url` (used only
when `carrier === 'other'`), optional `note`, optional `order_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_url` is scheme-locked to `https://` via
`isValidCustomTrackingUrl()`, which (a) requires the literal
`https://` prefix, (b) round-trips through `new URL()` to confirm
well-formedness, (c) rejects any other scheme. This blocks
`javascript:`, `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 supplies `customCarrierName`
+ `customTrackingUrl`).
- 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_KEYS` set 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):
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.
2. **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 (`📋 Copy` button) and look it up via Tor or a
different browser.
3. **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.
4. **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).
5. **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`):
```json
{
"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):
```json
{
"v": 1,
"kind": "morphit_shipment",
"carrier": "usps",
"tracking": "9400 1234 5678 9012 3456 78"
}
```
Shipment, custom carrier:
```json
{
"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).