11 KiB
Morphit — Architecture
High-level topology
┌───────────────────────────────────────┐
│ User device (browser) │
│ │
│ ┌─────────────────────────────────┐ │
│ │ SvelteKit app (SSG + PWA) │ │
│ │ │ │
│ │ libsodium WebCrypto │ │
│ │ │ │
│ │ Encrypted keystore (in-memory │ │
│ │ + localStorage, user password) │ │
│ └─────────────────────────────────┘ │
└──────┬──────────┬────────────┬────────┘
│ │ │
signed ops │ │ reads │ E2EE chat
(no keys) │ │ orderbook │ ciphertext
▼ ▼ ▼
┌──────────────────────────────────────────────────────┐
│ nginx (4 vhosts: clearnet / Tor / Lokinet / I2P) │
└────┬─────────────┬──────────────┬────────────────────┘
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌──────────────┐
│ relay │ │ indexer │ │ avatar │
│ (Node.js │ │ (Node.js │ │ static │
│ /tsx) │ │ /tsx) │ │ (nginx) │
│ │ │ │ │ │
│ broadcasts│ │ REST+SSE │ │ user-PUT │
│ signed │ │ filters │ │ to chain │
│ ops │ │ │ │ pubkey- │
│ │ │ │ │ committed │
│ │ │ │ │ hash │
└─────┬─────┘ └─────┬─────┘ └──────────────┘
│ │ │
│ │ └────────── gossip ──────► peer indexers
│ │
│ ▼
│ ┌─────────────┐
│ │ PostgreSQL │
│ └─────────────┘
│
▼
┌─────────────────────┐
│ Blurt RPC pool │
│ (public, rotated) │
│ 6 defaults, e.g.: │
│ rpc.blurt.blog, │
│ rpc.beblurt.com … │
└─────────────────────┘
Parallel paths (independent of the services above):
┌────────────────────────────────────────────────────┐
│ Fee verifiers live INSIDE the indexer process: │
│ - BTC explorer-poll (bitcoinExplorerVerifier.ts) │
│ - XMR tx-proof verifier (moneroProofVerifier.ts) │
│ - BLURT direct on-chain (no external verifier) │
│ Operator-side (off-monorepo): │
│ - Zabbix or similar — infra monitoring, alerts │
│ via the operator's chosen channel │
│ - Matrix bridge bot — tracked separately for │
│ future iteration (see ADDING-A-COIN.md tail) │
└────────────────────────────────────────────────────┘
Data flow — placing an order
- User composes order in browser.
- Browser asks for fee payment (BTC / XMR / BLURT), displays address + QR.
- For BTC/XMR: indexer's verifier polls explorers (or accepts a
user-supplied tx_proof for XMR) and credits the fee. For BLURT:
the indexer sees a
transferop directly on the next Blurt block. - Browser constructs
custom_jsonop (id = morphit_order_v1), signs with user's active key in memory only. - Browser POSTs signed op to relay.
- Relay wraps the signed op in a Blurt transaction paid from the relay's RC pool, broadcasts to the Blurt RPC pool.
- Next Blurt block (~3s) contains the op.
- Indexer subscribes to Blurt, sees op, inserts into PostgreSQL.
- Other indexers pick it up via gossip (federation).
- Browsers polling
/v1/orders(or subscribed to the SSE orderbook bus) see the new order.
At no point does the relay, indexer, or any server see the user's private key. The relay receives a signed op; the signature was produced locally.
Data flow — chat
- Sender derives a fresh ephemeral X25519 keypair and computes the shared message key via ECDH against the recipient's chat pubkey (deterministically derived from their Blurt posting key per ADR-0015).
- The plaintext is encrypted with ChaCha20-Poly1305-IETF using a per-message key derived via BLAKE2b. The ephemeral pubkey travels in the message header.
- Ciphertext is published as
custom_json(id = morphit_chat_v1) via the posting relay. - Counterparty's client reads the op from Blurt, decrypts locally.
- Indexer, relay, server operators only ever see ciphertext.
Data flow — feedback
- After trade completion, each party signs a feedback op with:
- trade reference (the completion op's ID)
- role (
buyer|seller) - rating + short text
- Relay broadcasts. Indexer ingests and attaches to both profiles.
- Feedback is never editable. A response can be posted (as a separate signed op) but the original remains.
Unstoppability layers
- Static frontend (SvelteKit SSG) — any HTTP server can host it.
- Multi-mirror — ships with a list of community frontend hosts; auto-failover.
- PWA cache — once installed, works offline / through host outages.
- IPFS release — every release has a stable IPNS address.
- Distributable artifacts — APK, Flatpak, signed tarball on Forgejo and mirrored to IPFS.
- Blurt discovery op — tiny on-chain pointer (version + IPFS CID + mirror list) makes the current release findable from any Blurt node.
- Indexer gossip — data survives any single indexer outage.
- Four transports — clearnet + Tor + Lokinet + I2P.
No single host, domain, operator, or network is load-bearing.
Key handling — architectural guarantees
- Private keys only exist:
- in user memory (decrypted, during active signing)
- in user localStorage (encrypted with Argon2id + XSalsa20-Poly1305)
- in user-initiated encrypted backup file (downloaded locally)
- Private keys never:
- appear in any
fetch(),XHR,WebSocket, orpostMessage()to a remote origin (enforced by CSPconnect-src 'self'+ code review checklist) - appear in any log
- appear in any database
- transit any Morphit-operated server
- appear in any
Service specifications
morphit-relay
- Runtime: Node.js / TypeScript (tsx), single process under systemd
- Input: signed
custom_jsonops (POSTed by browser), inbound HTTP (account create, chat-identity, low-balance refill triggers, etc.) - Process: validates op is well-formed, wraps in transaction, pays
resource credits from relay Blurt account, broadcasts to Blurt RPC
pool. Maintains a Postgres-backed
relay_pending_transfersqueue for welcome bonuses, operator payouts, low-balance refills, and account-creation retries; drainer broadcasts queued transfers asynchronously. - Output: Blurt transaction IDs, structured error logs, queue state
- State: shares the indexer's Postgres database (one schema, two workspaces) — relay reads the indexer's read models for attribution decisions and writes its own queue tables.
morphit-indexer
- Runtime: Node.js / TypeScript (tsx), single process under systemd
- Input: Blurt blocks (pulled from RPC pool, 3s cadence) + peer
federation gossip (
/v1/federation/...) - Process: filters
morphit_*custom_json ops, normalizes, persists - Output: PostgreSQL state + REST API at
/v1/*+ Server-Sent Events for the orderbook + chat fan-out buses + the federation endpoints other indexers consume - Enforces application-layer rules (15-min replace window, sybil fee
check, self-trade detection, suppression Signals A/B/C, attestation
phase eligibility). Note that
custom_jsonops are protocol-level immutable; "edits" are handled as layer-2_replace_v1ops that the indexer recognizes within the edit window and ignores outside it.
Fee verification (lives inside the indexer)
There is NO separate payment-watcher service. Fee verification runs as modules inside the indexer process:
apps/indexer/src/indexer/fee/bitcoinExplorerVerifier.ts— polls configured BTC explorers, marks orders verified when the expected sats arrive at the canonical chain-pinned BTC treasury address.apps/indexer/src/indexer/fee/moneroProofVerifier.ts— accepts user-suppliedtx_proofstrings carried in the order op, verifies against a configured Monero block explorer (no view-key required since Part 108++). See ADR-0019.- BLURT fees are verified directly: the indexer sees the
transferop land on chain alongside the order op and matches amount + recipient.
Avatars
Avatars are handled inside the apps/web frontend, not by a
separate service. Users sign a profile-update op committing a
SHA-256 hash of their avatar to chain; nginx (or whichever
reverse proxy is in use) serves the avatar PNG/SVG/JPEG from a
static directory. The hash commitment is verified browser-side
when the avatar renders — a malicious operator who swaps the
file at the static path can't fool an audit because the chain
record is the source of truth.
Deployment
Single Ubuntu 24.04 host. All services as systemd units under /opt/morphit/,
each as an unprivileged user. Inter-service comms over localhost or
WireGuard. nginx reverse-proxies to the four public endpoints.
Hidden-service daemons (Tor, Lokinet, i2pd) run as unprivileged services, each with their keys in root-owned, mode-0600 files. Keys generated on operator hardware and copied to the server; never generated server-side.