morphit/docs/adr/0021-batch-l-payment-methods.md

14 KiB
Raw Permalink Blame History

ADR-0021: Canonical payment-methods registry with instance additions

Status: Accepted Date: 2026-04-29 Deciders: Agorise team (Claude collaborating) Supersedes:Related:

  • ADR-0018 (operator blocks) — same operator-signed-broadcast pattern this ADR's instance-additions mechanism reuses.
  • ADR-0008 (Phase 3b indexer architecture) — defines the chain- op handler contract and event-log write conventions.

Context

Pre-Batch-L, payment methods on Morphit orders were free-text strings. The user types whatever they want — "PayPal," "paypal," "Pay-Pal," "PaiPal" all become distinct values in the orderbook filter. This is bad UX (filter doesn't match what users mean) and bad UX-for-builders (we can't show tooltips, descriptions, or categorical organization for an open-ended string set).

The user requested a curated, searchable, alphabetized, multi- select picker organized by category. Plus the ability for operators to add region-specific methods that aren't shipped in the canonical list (e.g. PromptPay for a Thailand-focused instance, or PIX-locale-specific variants).

The design tension: who owns the canonical list? Three options were considered:

  • (A) Code-only canonical list, instance additions allowed but not removals.
  • (B) Operators have full control of their instance's list — add, remove, edit canonical entries.
  • (C) Code-only canonical list, no instance additions at all.

User picked (A). This ADR documents the implementation.

The user also requested the registry include the top P2P-capable payment providers in Mexico, USA, and Canada (the largest expected initial user base). After auditing the inherited list, merchant-acquirer-only entries (Stripe, Adyen, Braintree, etc.) were dropped; Zelle (USA), Interac e-Transfer (Canada), SPEI (Mexico), and Oxxo Pay (Mexico) were added.

Decision

Canonical list, code-defined

apps/web/src/lib/payments/registry.ts ships a const array of 40 entries across three categories:

  • Crypto (3): Bitcoin, BLURT, Monero.
  • In Person (3): Barter (goods), Cash, Precious metals.
  • Online (34): Airwallex, Alipay, Amazon Pay, Apple Pay, Bancontact, Bitso, Bizum, BLIK, Cash App, GCash, Google Pay, iDEAL, Interac e-Transfer, Klarna, M-PESA, Mercado Pago, Mir, MTN MoMo, Oxxo Pay, Payoneer, PayPal, Paytm, PayU, Pix, Przelewy24, Revolut, ShebaPay, Sofort, SPEI, Square, UnionPay, Venmo, WeChat Pay, Wise, Zelle.

Categories alphabetized (Crypto, In Person, Online) per user preference. Within each category, entries alphabetized by display name (default JS sort, which puts e.g. m-pesa before mercado pago because - < space in codepoint order).

Each entry has:

  • key: machine-readable id, [a-z][a-z0-9_]+, ≤32 chars. Stable forever once shipped — orders on chain reference it.
  • name: display name (typically a brand name; not translated).
  • url: optional canonical website (https only).
  • category: crypto | in_person | online.
  • assetExclusion: for crypto entries, the asset that should hide this method when picking ("buy BTC with BTC" makes no sense). Undefined for non-crypto.

Removing an entry is forbidden once shipped. If a method becomes irrelevant, mark it deprecated in a future revision but keep the key. Old orders still resolve to a name.

Crypto category with asset exclusion

Each Morphit asset (BTC, BLURT, XMR) appears as a payment-method option (you can pay XMR for someone's BTC). The assetExclusion field hides the matching crypto entry when the order's traded asset matches — e.g. if you're trading BTC, "Bitcoin (BTC)" is hidden from the payment-method picker. The picker's excludeForAsset prop carries the order's asset; the search helper applies the filter before scoring.

Search semantics

  • Whitespace-trimmed, case-folded, diacritic-stripped.
  • Empty query → all entries.
  • Whitespace-split into terms; ALL terms must match (AND).
  • Match is substring (no fuzzy). On a 40-entry curated list, fuzzy matching adds more surprise than help.
  • Name match scores 3× description match.
  • Sort: score desc, name asc on tie.

The description is supplied via a callback so the search module stays pure (no svelte-i18n import). Components pass (key) => $_(\payment_method.${key}.description`)`.

Picker UX

PaymentMethodsPicker.svelte replaces the pre-Batch-L free-text input on the post and edit pages. Layout:

  1. Selected chips (with × to remove).
  2. Description-of-last-selected (inline tooltip).
  3. Search box.
  4. Picker body — categorical view (collapsible sections) when no search query, flat ranked list when searching.
  5. Optional "Instance additions" section at the bottom.

Max 12 selected (mirrors the pre-Batch-L cap).

Migration: legacy free-text orders

Pre-Batch-L orders carry strings like "PayPal". The matcher module apps/web/src/lib/payments/match.ts provides:

  • resolveLegacy("PayPal")"paypal" (folded-name lookup).
  • resolveLegacyMany([...]) deduplicates after canonicalization.

Display sites use displayNamesForMethods() which routes:

  1. @instance:foo keys → instanceLookup callback.
  2. Canonical keys → registry lookup.
  3. Legacy text → resolveLegacy then registry lookup; falls through to verbatim display if no match.

The orderbook filter is case-insensitive substring match on the indexer side, so a filter for paypal matches both new-canonical-key orders AND legacy "PayPal"-string orders. Migration is transparent.

Instance additions: operator-broadcast chain ops

A new chain op morphit_payment_method_addition_v1:

{
  "v": 1,
  "action": "add" | "remove",
  "key": "promptpay",
  "name": "PromptPay",
  "description": "Thai instant retail payments…",
  "category": "online",
  "url": "https://www.bot.or.th/...",
  "ts": 1730000000
}

Signed by the configured operator account's posting key. Indexer handler enforces:

  • Signer === officialAccountName (gate).
  • Version === 1.
  • Action ∈ {add, remove}.
  • Key matches /^[a-z][a-z0-9_]+$/, length 324.
  • Key NOT in RESERVED_CANONICAL_KEYS (the critical federation-safety check).
  • Name 164 chars, post-sanitization non-empty.
  • Description ≤300 chars.
  • Category ∈ {crypto, in_person, online}.
  • URL null or https:// prefix, ≤200 chars.

Sanitization strips bidi-override and zero-width codepoints (same set as operator_block reasons). Strip rather than reject — operators paste from elsewhere occasionally.

Storage: instance_payment_methods table, keyed (operator, key). State flips between 'active' and 'removed' but the row persists for audit trail.

Public read endpoint: GET /v1/instance/payment-methods returns active additions with the @instance: prefix already applied. Frontend store fetches lazily on first subscribe; cached for the session.

Federation safety: namespaced keys

When stored on chain in an order's payment_methods array, instance additions use @instance:promptpay (the INSTANCE_KEY_PREFIX reserved). Cross-instance filtering still works:

  • Buyer on instance A filters orderbook by paypal → matches canonical entries on any instance.
  • Buyer on instance A views a seller's order from instance B that uses @instance:promptpay → if instance A has the same addition, displays "PromptPay"; otherwise displays "promptpay" (prefix stripped). Slightly degraded but informative.

Operators cannot remove canonical entries — federation breaks if they could (a buyer on instance A filtering by paypal wouldn't match orders from instances where PayPal was removed). Plan (B) was rejected for this reason.

Reserved-keys parity smoke

Critical security mechanism: the indexer's RESERVED_CANONICAL_KEYS set must match the frontend's canonical-key list exactly. Drift means an operator could silently shadow a canonical entry.

apps/indexer/scripts/reserved-keys-parity-smoke.ts reads both sources at smoke-runtime and asserts they match. Any drift fails CI immediately.

ops-cli surface

morphit-ops payment-method add <key> --name <n> --description <d> --category <c> [--url <u>] morphit-ops payment-method remove <key> morphit-ops payment-method list

Mirrors the operator-block command pattern: signs and broadcasts a chain op via the operator's posting key. Includes client-side reserved-keys check (informational; indexer rejects regardless). List subcommand reads from local DB.

Consequences

Positive

  • Curated UX with descriptions, tooltips, categorical navigation. Users discover methods they didn't know to type.
  • Cross-instance filterability — orderbook filter for paypal matches reliably regardless of which instance posted the order.
  • Migration transparency — legacy free-text orders still filter correctly via the case-insensitive substring filter on the indexer side, while displaying via the canonical lookup on the frontend.
  • Federation safety — operators can extend but not break the canonical list. The namespaced key prefix is the security boundary.
  • Operator autonomy preserved — region-specific methods can be added without a project release.

Negative

  • Three-place duplication of canonical keys (frontend registry, indexer reserved-keys set, CLI reserved-keys set). The first two have a parity smoke; the third is informational. Drift between 1 and 3 results in CLI-permits-then-indexer- rejects UX, not security loss.
  • Code-defined canonical list means project releases gate additions. A market that needs a new canonical method has to file an issue and wait for a release. Mitigation: instance additions cover the urgent case.
  • Removing canonical entries forbidden. A method that becomes deprecated stays in the registry forever. Not a practical issue today but worth documenting.
  • No URL-as-link rendering yetentry.url is stored but not currently shown as a clickable link anywhere. Future code that adds linking would need the existing https:// validation (already in place); audit flagged for awareness.

Trade-offs explicitly considered

  • Plan A vs B vs C. User picked A (canonical-only-with- additions). B fails federation; C fails operator autonomy.
  • Drop merchant-acquirers (Stripe et al.) vs keep. Dropped per concern #3 — they aren't P2P-capable from a regular user's perspective and would mislead.
  • Top-three-markets coverage. Audited the inherited list against USA, Canada, Mexico (largest expected initial user base) and added Zelle, Interac e-Transfer, SPEI, Oxxo Pay.
  • Crypto category. Added per user request — "buy BTC with XMR" is a real Morphit use case. assetExclusion prevents the nonsensical "buy X with X" combos.
  • In Person category restructured. User clarified "Edibles" meant general barter for goods (orange trees, raw garlic, used bicycle), not the cannabis-edibles interpretation. Single "Barter (goods)" entry with the order's free-form terms field carrying specifics. "Precious metals (gold/silver)" combines what the user listed as four separate entries (gold/silver coins/bars) since the trade pattern is identical.
  • Fuzzy search vs strict substring. Strict on a 40-entry curated list. Fuzzy creates more surprise than help.

Implementation

  • apps/web/src/lib/payments/registry.ts — canonical list with shape invariants.
  • apps/web/src/lib/payments/search.ts — pure search/filter.
  • apps/web/src/lib/payments/match.ts — legacy resolver.
  • apps/web/src/lib/payments/display.ts — keys → display names, routing canonical/instance/legacy.
  • apps/web/src/lib/components/PaymentMethodsPicker.svelte — multi-select picker UI.
  • apps/web/src/lib/stores/instanceAdditions.ts — frontend store for instance additions.
  • apps/web/src/lib/net/config.ts — added operatorPaymentMethod op id.
  • apps/web/src/lib/indexer/client.ts — added getInstancePaymentMethods wrapper.
  • apps/indexer/src/db/schema-v24.sql — schema migration.
  • apps/indexer/src/indexer/handlers/operatorPaymentMethod.ts — handler.
  • apps/indexer/src/indexer/dispatcher.ts — wired.
  • apps/indexer/src/api/instancePaymentMethods.ts — public read endpoint.
  • apps/indexer/src/main.ts — endpoint mounted.
  • apps/ops-cli/src/commands/paymentMethod.ts — operator CLI.
  • apps/ops-cli/src/main.ts — wired into dispatcher.
  • Wired display sites: routes/post, routes/post/edit, routes/orderbook, routes/[account], routes/[account]/[permlink].
  • Smokes:
    • payments-smoke.ts — 40 scenarios (registry shape, search, legacy resolver).
    • reserved-keys-parity-smoke.ts — 1 scenario, the critical federation-safety check.
    • operator-payment-method-handler-smoke.ts — 28 scenarios (gate, payload validation, key validation, reserved keys, field-shape, sanitization, state transitions).
  • i18n: 51 new keys × 10 locales = 510 strings (40 method descriptions + 7 picker UI keys + 4 category labels).
  • Audit: docs/audit/2026-04-29-batch-l-payment-methods.md.

Open questions / future work

  • Per-operator addition cap. No limit currently. Could add a soft cap (say 50 additions per operator) if abuse becomes a concern.
  • URL rendering as link in the picker. Stored but not yet surfaced. Could add an "info" icon next to each entry that opens the canonical URL in a new tab.
  • Description-search weight tuning. Currently 3× weight for name vs description. May need adjustment based on real-world search analytics (which we won't have until launch).
  • Crypto-asset filter for non-Morphit-supported pay-with crypto. A user might want to pay with USDT (a stablecoin Morphit doesn't trade as an asset). Could add USDT/USDC/etc. as crypto entries with no assetExclusion. Deferred — wait for user demand.
  • Canonical list expansion via formal proposal process. Each new canonical entry should require: P2P verification, documented website, region/market relevance. Currently ad-hoc; could formalize.