morphit/docs/AUDIT-cp175-DEEP-DEEP.md

769 lines
52 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.

# Audit cp175 — full deep-deep + five-persona walkthrough + hostile-op sweep
**Started:** 2026-05-29. Scope (per Ken's mandate): re-walk all personas
(Bob, Sally-user, Sally-operator, Charlie) clicking/typing EVERY interactive
element; then a 94-task "deep deep" black-hat pass over EVERY file/script in
the repo (.md, .ts, .svelte, .sql, .sh) covering: drift, regex accuracy, type
errors, test-coverage gaps, stale/outdated smokes + gates + parities, bad/dead
keys, unwired code, staleness/orphans, DB dead fields, draft finalization, FAQ
/ README / OPERATIONS / RUN-A-MORPHIT-NODE accuracy, broken references, missing
/broken wiring, memory leaks, fallback/failover completeness, and a
consolidated "what if every op was hostile?" chain-direct attack sweep across
all 17 indexer handlers.
Pre-launch context: nobody has installed or used Morphit yet. No live data.
Goal: rock-solid before first real use.
---
## Methodology
Each finding gets: ID, severity (CRITICAL/HIGH/MED/LOW/INFO), file:line,
description, and resolution (fixed inline / smoke added / deferred-with-reason /
verified-clean). Verification grep after every sweep claim. Fixes get smoke
coverage. Triple-pulse after meaningful batches.
Repo at audit start: 771 TS, 119 Svelte, 135 MD, 29 SQL, 258 registered smokes,
6 apps (web/indexer/relay/ops-cli/matrix-bot/mcp-server), 7 packages, 17 indexer
handlers.
---
## Part A — Five-persona walkthrough (cp173cp174 surface + full re-walk)
(findings below)
### F-001 (HIGH, fixed) — cp174 noble migration was incomplete: comment.ts had an orphan dblurt-only signer
`apps/web/src/lib/blurt/ops/comment.ts` carried its OWN `signTransactionWithKey`
(separate from sign.ts's) that always called dblurt's `broadcast.sign` and did
NOT consult `SIGNER_BACKEND`. So the syndication/cross-post comment path (Bob's
"share my first trade" + per-order syndication, a real persona action) would
have kept signing via elliptic even when an operator selected the noble backend
— a silent inconsistency and exactly the elliptic-CVE exposure cp173/cp174
aimed to remove.
**Fix:** made comment.ts's signer branch on `SIGNER_BACKEND`, mirroring sign.ts
(digest via dblurt's `cryptoUtils.transactionDigest`, sign with
`signDigestWithNoble`, append wire sig to a cloned tx). Threaded the posting
raw scalar through the call site. Web typecheck 0 errors.
**Guard added:** PPM-style source sentinel (see signer-backend-consistency
smoke) asserting every `broadcast.sign` site in apps/web also branches on
`SIGNER_BACKEND`, so a future third signer can't silently ignore the flag.
## Part B — Hostile-op sweep across all 17 indexer handlers ("what if every op was hostile?")
### Authz (signer-binding) — VERIFIED CLEAN across all 17 handlers
Every state-mutating query (UPDATE/DELETE/INSERT) is scoped to `ctx.signer`, which
the chain binds cryptographically — a hostile actor cannot touch another account's
rows. Privileged ops gate explicitly: `operatorPaymentMethod`/`operatorBlock` on
`ctx.signer === config.operatorAccountName`; `release` on
`ctx.signer === config.officialAccountName`. Self-action guards present where
relevant (`block`, `chatRead`, `feedback`, `strangerFee`, `chat` all reject
`X === ctx.signer`). `feedbackResponse` checks `row.subject === ctx.signer` before
allowing a response. `orderReplace`/`orderCancel` only match `(ctx.signer, permlink)`.
No handler keys a mutation off a payload-supplied account/author/target. This is the
single most important hostile-op property and it holds uniformly.
### Amount parsing (money paths) — VERIFIED CLEAN (one symmetry fix)
`order.ts` + `strangerFee.ts` parse fee transfers from attacker-controlled sibling
ops. Both: anchored regex `^(\d+(?:\.\d+)?)\s+BLURT$` (rejects negatives, sci-notation,
junk, wrong asset), `typeof === 'string'` guard, `Number.isFinite` guard. order.ts
also validates `amount_min` finite/non-negative/≤ MAX_AMOUNT (blocks 1e308 UI-break).
- **F-002 (LOW, fixed):** order.ts's fee parser lacked the `amount <= 0` reject that
strangerFee.ts has. Not exploitable (0-amount already → underpaid downstream), but
fixed for symmetry so amountBlurt is positive-by-construction.
### Payload-shape — VERIFIED CLEAN
All 17 handlers guard `isPlainObject(payload)` (rejects array/null/scalar) before
field access. Manual validation (no zod) but uniform across handlers — no
zod/manual drift.
### Replay/idempotency — VERIFIED CLEAN
Two-layer defense: (1) global dispatcher dedup — every op insert uses
`ON CONFLICT (block_num, trx_in_block, op_in_trx) DO NOTHING` (dispatcher.ts),
so re-delivered ops during re-sync are dropped at the cursor level; (2) per-handler
unique constraints (e.g. feeAttest `fee_attestations``isUniqueViolation`
`already_attested`) + idempotent UPDATE-with-WHERE-guards (orderCancel, block).
Handlers showing 0 `ON CONFLICT` (orderReplace/block/strangerFee/feeAttest) are
replay-safe via these other mechanisms, not gaps.
## Part C — Doc accuracy (in progress)
- **F-003 (INFO, fixed):** `docs/SECURITY.md` "Recommended Morphit-project
practice" paragraph for the elliptic advisory was stale — described the noble
migration as an unstarted "open item / monitor upstream." Updated to reflect
cp173cp174: signer built, proven, wired behind SIGNER_BACKEND (default still
dblurt, cutover gated on a chain broadcast), with the proof/sentinel smoke
references. The fenced-path smoke now validates 247 scenarios (picked up the 4
cited script paths).
- Doc-accuracy gates GREEN: brag-list-claim-parity 79/79, cross-document-value-
invariants 21/21, operator-doc-fenced-path-existence 247/247, version-
consistency 18/18.
## Session 1 summary (cp175 start)
Fixes this session: F-001 (HIGH — orphan dblurt-only signer in comment.ts, now
noble-aware + guarded by a new consistency sentinel + comment-op proof),
F-002 (LOW — order.ts fee-parser non-positive symmetry), F-003 (INFO — SECURITY.md
staleness). Hostile-op sweep core classes (authz / money-parsing / replay) verified
clean across all 17 handlers. New smokes: signer-backend-consistency (registered),
extended blurt-noble-tx-signature-proof (+comment op, now 5 scenarios).
### Remaining for subsequent sessions (the rest of the 94-task deep-deep)
- Per-handler deep read of the remaining hostile-op classes: unicode/confusable
injection in free-text fields, oversized-payload bounds, numeric precision in
featureBid/feedback rating math, auth-context (required_auths vs posting_auths)
per op.
- Full .md accuracy pass: FAQ (1300+ Q&A lines), README, OPERATIONS.md,
RUN-A-MORPHIT-NODE.md, ADR cross-references, every fenced path + claim.
- DB dead-field sweep: every column in schema.sql vs. actual reads/writes.
- Regex-accuracy pass: every validation regex vs. its spec (txid shapes, account
names, permlinks, amounts, URLs).
- Type-error / strictness sweep across all 14 tsconfig projects.
- Orphan/staleness: unreferenced exports, dead components, stale snapshot docs.
- Memory-leak pass: every $effect/subscription/interval/listener in Svelte +
every setInterval/timer in the indexer/relay.
- Fallback/failover completeness: every fetch/RPC path has a graceful degrade.
- Smoke/gate currency: every smoke still asserts a live invariant; no orphaned
smokes referencing removed code.
## Session 2 (cp175 continued)
### Hostile-op: auth-context — VERIFIED CLEAN
`apps/indexer/src/blurt/verify.ts` `extractSigner()` enforces Morphit's auth
policy airtight: rejects `required_auths` (active-key) custom_json
(`active_auth_not_allowed` — blocks hotter-key leverage), rejects empty
(`no_posting_auth`) and multiple (`multiple_posting_auths`) posting auths, and
binds signer = `required_posting_auths[0]` which the chain already verified the
signature against. No impersonation path.
### Hostile-op: oversized payload — VERIFIED CLEAN
`parseJsonPayload()` enforces `MAX_RAW_JSON_LENGTH` (16KB) BEFORE `JSON.parse`,
returns null on oversize/malformed → op rejected. Universal first gate;
per-handler caps (checkJsonbSize 4KB, profile 8KB) downstream.
### Hostile-op: unicode/confusable — VERIFIED CLEAN + already drift-guarded
The confusables defense (LETTER_EQUIVS skeleton map + RESERVED_NAMES_RAW) is
duplicated frontend⇄indexer with a manual-sync requirement. Investigated as a
suspected drift (F-004): line counts differ (216 vs 162) BUT that is comment-only
— the extracted codepoint sets are IDENTICAL (215 each, comm shows zero diff both
ways) and RESERVED_NAMES_RAW identical (9 each). `confusables-parity-smoke.ts`
already asserts byte-equivalent parity on both and PASSES, so future drift is
caught automatically. F-004 = false alarm; class is clean and guarded.
Applied across 10 handlers (order/orderReplace/profile/chat/feedback/
feedbackResponse/featureBid/operatorRegister/operatorBlock/operatorPaymentMethod).
### Hostile-op: numeric precision — VERIFIED CLEAN
feedback rating: `typeof === 'number' && Number.isInteger && 1 ≤ r ≤ 5`. featureBid
hours: `Number.isInteger && 6 ≤ h ≤ 168`; bid amount: anchored regex + `Number.isFinite
&& > 0` (featureBid already had the >0 check order.ts lacked — confirms F-002 was the
lone outlier). Cost math `perHour × hours` bounded (hours ≤ 168, no overflow).
**Hostile-op sweep COMPLETE.** All 6 classes (authz, money-parsing, replay, auth-context,
oversized-payload, unicode/confusable, numeric-precision) verified clean across all 17
handlers. Lone fix: F-002 (order.ts >0 symmetry).
## Part D — DB dead-field sweep (COMPLETE)
Parsed 38 tables / 287 columns; cross-checked every column against all .ts in the repo
(not just indexer — the key correction: push + relay-queue + scanner columns are written
by OTHER apps).
- **F-005 (LOW, fixed):** `stranger_fees.amount_usd_equivalent` was declared
`NUMERIC(10,4) NOT NULL` + `CHECK (>0)` in the CREATE TABLE, but a later v20 section in
the SAME collapsed baseline did `ALTER ... DROP COLUMN IF EXISTS` it, AND the handler's
only INSERT omits it. On a fresh deploy the create-then-drop nets to "no column" so it's
not a runtime bug — but the self-contradiction is reader-hostile and cost real audit time
(looked like a guaranteed NOT-NULL-violation until the DROP was found). Since schema.sql
is the explicit pre-launch v1 collapsed baseline (never deployed, safe to edit), removed
the column declaration + CHECK from the CREATE TABLE and left the v20 DROP as a documented
version-parity no-op.
- **12 other suspects — all FALSE POSITIVES (explained):** `*.detected_at` (related_accounts,
suspicious_reciprocity, one_way_pile_on, review_concentration) written by ops-cli/abuse +
abuse scanners; relay_pending_transfers `broadcast_at`/`broadcast_trx_id`/`error_count`
written by the relay queue drainer (apps/relay); push_subscriptions `p256dh`/`user_agent`/
`privacy_mode`/`last_delivery_at` + push_pending `enqueued_at` written by apps/web push.ts
+ apps/relay pushSubscriptions/pushSender. No genuinely orphaned columns.
## Part E — Memory-leak sweep (COMPLETE — clean)
### Backend (indexer/relay/matrix-bot/mcp) — clean
- Most `setTimeout` sites are AbortController fetch timeouts (auto-fire or cleared on
completion). Correct pattern.
- Rate-limiter (`api/middleware/ratelimit.ts`): per-IP `buckets` Map is PRUNED every 5 min
(deletes empty buckets, trims old timestamps) and the interval is `.unref()`'d (won't
hold the process alive). Bounded.
- SSE streams (`api/chatStream.ts`, `api/instancesStream.ts`): both have a `cleanup()` that
`clearInterval`s poll + keepalive timers, guarded by a `cancelled` flag, called on close.
- `compositeSource`/`peerPriceMonitor` interval handles stored + stoppable.
### Frontend (Svelte) — clean
- Every `setInterval`/`setTimeout` component (PendingFeedbackReminderBanner,
FeaturedAuctionHistory, FeaturedBidHistory, LoginQrInitiator, PriceFreshnessIndicator,
AnimatedNumber, ProtectedTextarea, ConversationView) stores its handle and clears it in
teardown (clears == teardown-hook count).
- `addEventListener`(51) vs `removeEventListener`(24) imbalance is NOT a leak: all three
EventSource consumers (chat/stream.ts, orderbook/stream.ts, instances/+page.svelte) call
`eventSource.close(); eventSource = null;` in stop()/onDestroy (closing releases the
source's listeners + connection); service-worker.ts listeners (install/activate/fetch/push)
are intentional worker-lifetime handlers; remaining adds are `{ once: true }` or on
GC'd-with-component elements.
## Session 2 summary
Fixes: F-002 (order.ts >0 symmetry — carried from session 1 close), F-005 (stranger_fees
dead column removed from collapsed baseline). Verified clean: complete hostile-op sweep (all
6 classes × 17 handlers), complete DB dead-field sweep (12 suspects explained as cross-app
writes), complete memory-leak sweep (backend + frontend). F-004 (confusables drift) = false
alarm, already guarded by confusables-parity-smoke.
### Still remaining (subsequent sessions)
- FAQ accuracy (1300+ Q&A) / README / OPERATIONS.md / RUN-A-MORPHIT-NODE.md full read.
- Regex-accuracy pass (every validation regex vs spec).
- Type-strictness sweep across all 14 tsconfig projects (full tsc, not just web).
- Orphan/staleness: unreferenced exports, dead components, stale snapshot docs.
- Fallback/failover completeness: every fetch/RPC path degrades gracefully + user never hangs.
- Smoke/gate currency: every smoke asserts a live invariant; no orphan smokes.
- "What an outside pentest/agency would add" writeup for Ken.
## Session 3 (cp175 continued)
## Part F — Type-strictness sweep across all 14 projects (COMPLETE)
Ran `tsc --noEmit` on every project: all 14 at **0 errors** (web + indexer confirmed
earlier; relay, ops-cli, mcp-server, matrix-bot, and all 7 packages confirmed this session).
- **F-006 (LOW, fixed):** `apps/mcp-server` and `apps/matrix-bot` were the only two projects
MISSING `noUncheckedIndexedAccess` (the other 12 enforce it). Added it to both. mcp-server
(Charlie's surface) compiled clean immediately. matrix-bot surfaced 2 real unchecked-index
sites in `scripts/sidecar-envelope-smoke.ts` (regex capture-group accesses `m[1]` assumed
defined) — both fixed with guards; smoke still 26/26. Now all 14 projects uniformly enforce
`strict` + `noUncheckedIndexedAccess`.
## Part G — Regex-accuracy pass (COMPLETE)
Inventoried security-relevant validation regexes (permlink, account-name, txid, amount,
semver, sha256, BTC/XMR address, Matrix MXID/room, origin).
- **No ReDoS:** the `^[a-z0-9]+(?:-[a-z0-9]+)*$` permlink pattern (8 call sites) matched a
naive nested-quantifier grep but is SAFE — the inner group requires a literal `-`
separator between runs, so there's no overlapping-ambiguity backtracking. All other
patterns are linear.
- All security-critical validators are anchored (`^...$`) — no partial-match bypass.
- **F-007 (LOW, documented/deferred):** account-name regex divergence. Pattern A
`^[a-z][a-z0-9.-]{2,15}$` (10+ files) allows dots + trailing punctuation; Pattern B in
registry.ts `^[a-z][a-z0-9-]{1,14}[a-z0-9]$` forbids dots + requires alnum end. Neither
exactly matches Blurt's real account-name rule (dot-separated segments, no trailing/double
punctuation). **NOT a security issue:** these are client-side UX validators; the real authz
boundary is the chain + indexer `extractSigner` (accepts only a chain-validated signer with
matching signature), so frontend looseness can't create a hole. Proper fix = unify on one
shared `isValidBlurtAccount` across all call sites — deferred to a focused refactor with its
own regression coverage rather than a hasty 10-file edit mid-audit. Tracked.
## Part H — Fallback/failover completeness (COMPLETE — clean)
Sampled the data-heavy, RPC-dependent route surfaces (orderbook, explorer/*, my/orders,
settings, onboarding/import). All use explicit phase state machines with error states + retry,
not bare spinners.
- **orderbook** (highest-traffic): `phase: 'loading' | 'ready' | 'error'` state machine; on
fetch failure sets a LOCALIZED `errorMessage` + `phase='error'` → renders error text +
Retry button; success → 'ready' (empty list renders as empty, not a hang). Exemplary
"never leave the user hanging" pattern.
- RPC layer itself has rpc-pool failover (cp165) + quorum early-return (cp166) underneath.
- **Locale completeness for error states VERIFIED:** orderbook.error.fetch_failed /
load_more_failed / retry all 10/10 locales; sampled ~20 `$_('...error...')` keys used in
routes — all present in en.json (a missing error key would itself be a hang-equivalent,
showing a raw key string to the user). No missing-key user-facing bugs found.
## Session 3 summary
Fixes: F-006 (mcp-server + matrix-bot were missing noUncheckedIndexedAccess; added to both,
fixed the 2 real unchecked-index sites it surfaced in a matrix-bot smoke — now all 14 projects
uniformly enforce strict + noUncheckedIndexedAccess, all at 0 errors).
Verified clean: type-strictness across all 14 projects; regex-accuracy (no ReDoS, all anchored;
F-007 account-name divergence documented as non-security UX item deferred to a focused refactor);
fallback/failover completeness (phase state machines + localized error/retry everywhere, all
error locale keys present).
### Still remaining (subsequent sessions)
- FAQ accuracy (1300+ Q&A) / README / OPERATIONS.md / RUN-A-MORPHIT-NODE.md full read-through.
- Orphan/staleness: unreferenced exports, dead components, stale snapshot docs.
- Smoke/gate currency: every registered smoke asserts a live invariant; no orphan smokes.
- F-007 account-name regex unification (focused refactor with regression coverage).
- "What an outside pentest/agency would add" writeup for Ken.
## Session 4 (cp175 continued)
## Part I — Doc accuracy: operator env vars (IN PROGRESS)
Cross-checked all `MORPHIT_*` env vars in OPERATIONS.md + RUN-A-MORPHIT-NODE.md (144) vs.
code. Two diffs investigated:
- **~50 "documented but not in .ts"** — ALL legitimately read by ops/shell scripts
(`ops/scripts/morphit-{apt,certbot,host,smartctl,trivy,systemd,compose}-monitor.sh`,
`scripts/canary/generate.sh`, `scripts/release-sign.sh`). Not stale; the .ts-only grep was
too narrow (same lesson as the DB sweep — host/monitoring/release vars live in shell).
- **F-008 (MED, fixed):** real doc↔code naming mismatch on the USDT multi-network chat-link
override vars. Code's zod env schema reads
`MORPHIT_FRONTEND_USDT_{ERC20,TRC20,SPL,BEP20}_CHAT_LINK_URL` (network token BEFORE
CHAT_LINK_URL); OPERATIONS.md (lines 90959098) told operators to set
`MORPHIT_FRONTEND_USDT_CHAT_LINK_URL_{ERC20,TRC20,SPL,BEP20}` (network token AFTER). An
operator copying the docs would set a var the indexer never reads → self-hosted explorer
override silently no-ops. Fixed the four doc lines to match code (code is the runtime
authority). USDC/DAI doc forms were already correct.
- **Guard added:** new `apps/indexer/scripts/frontend-chatlink-env-doc-parity-smoke.ts`
(REGISTERED) extracts every `MORPHIT_FRONTEND_*_CHAT_LINK_URL` named in the operator docs
and asserts each exists in the indexer config source. Negative-tested: fails on the F-008
divergence, passes when correct. 24 code vars / 10 documented, all match.
### Env-var doc accuracy — remaining checks (VERIFIED, no further fixes)
- **In-code-but-undocumented (100 candidates):** triaged. Buckets: (a) per-asset/network
chat-link OVERRIDES — optional, every asset has a bundled default, discoverable via the same
documented pattern; (b) indexer tuning knobs (BLOCK_INTERVAL_MS, BTC_FEE_SATOSHIS,
DB_POOL_MAX, FEE_TOLERANCE, LOW_BALANCE_*, PEER_PRICE_SAMPLE_INTERVAL_MINUTES,
BTC_MIN_SUCCESSFUL_RESPONSES, etc.) — all have zod `.default()`, optional; (c) NOT env vars
at all — `MORPHIT_ACCOUNT`/`MORPHIT_COMMUNITY` are hardcoded frontend constants
($net/config.ts, syndication/publish.ts), grep matched the constant names. No required var
is undiscoverable.
- **MORPHIT_INDEXER_OFFICIAL_POSTING_PUBKEY (required, no default):** the minimal env block in
RUN-A-MORPHIT-NODE.md omits it, BUT the doc explicitly defers to
`ops/env/indexer.env.example`, which DOES include it (line 92, with a pinned default + comment).
The operator workflow (copy the example file) provides it. NOT a gap — verified clean. (Note
the two-name distinction: `MORPHIT_OFFICIAL_POSTING_PUBKEY` = frontend build constant in
$net/config.ts (the pinned release-signing key); `MORPHIT_INDEXER_OFFICIAL_POSTING_PUBKEY` =
the indexer's env var that must match it. Docs describe both correctly in their contexts.)
## Part J — FAQ accuracy (COMPLETE — verified clean)
FAQ lives in locale JSON (`faq.entries.*`, 135 entries). Spot-checked every drift-prone
numeric/factual claim against code:
- **fees** ("$0.25 BTC/XMR, $0.125 Blurt 50% discount", "60 Blurt listing fee"): matches
`MORPHIT_INDEXER_FEE_BASE_BLURT` default 60 + BTC/XMR config "~$0.25 USD" targets. ✓
- **first_order_free** ("free buy of ≥500 Blurt", "60 Blurt each, 50% off"): matches
`WAIVER_MIN_BLURT = 500` + fee base 60. ✓
- **welcome_bonus** ("1 BP delegated on first listing fee", milestone framing): matches
loyalty.ts first-listing-fee welcome (1 BP) + milestones 100/500/2000/10000 BLURT →
10/50/200/1000 BP. The loyalty.ts comment itself uses "$0.125 listing fee" — consistent
with the fees FAQ. ✓
- **order_timeouts** ("90 days default, 1-90 range, 15-min replace window"): matches order.ts
("frontend UI cap is 90 days") + orderReplace.ts `REPLACE_WINDOW_MS = 15 min`. ✓
No drift found. Existing FAQ smokes (per-tradable-asset-parity, keys-themed-section,
search-grandma-coverage, jsonld-no-markdown, what-is-asset-native-locale-floor) cover
structure/parity.
## Session 4 summary
Fix: F-008 (MED — USDT multi-network chat-link env vars were documented with the network
token in the wrong position vs the code's zod schema; an operator copying the docs would set
vars the indexer never reads → silent override no-op). Fixed the 4 doc lines + added a
registered, negative-tested doc↔code parity sentinel.
Verified clean: ~50 "documented-not-in-.ts" env vars (all shell/ops-script vars, not stale);
100 in-code-undocumented vars (optional overrides / defaulted tuning knobs / hardcoded
constants, no required var undiscoverable); MORPHIT_INDEXER_OFFICIAL_POSTING_PUBKEY (required
but present in the env example the docs point to); FAQ numeric claims (all match code).
### Still remaining (subsequent sessions)
- README / OPERATIONS / RUN-A-MORPHIT-NODE prose accuracy (beyond env vars + fenced paths).
- Orphan/staleness: unreferenced exports, dead components, stale snapshot docs.
- Smoke/gate currency: every registered smoke asserts a live invariant; no orphan smokes.
- F-007 account-name regex unification (focused refactor with regression coverage).
- "What an outside pentest/agency would add" writeup for Ken.
## Session 5 (cp175 continued)
## Part K — Orphan/staleness sweep (COMPLETE — clean)
- **Dead components: NONE.** All 72 components in lib/components are imported somewhere.
- **Dead routes: NONE.** The /dev/* subtree (icons, responsive, yubikey-probe) is intentional
operator/contributor diagnostics (documented as such in the page).
- **Snapshot docs (FOUR/THREE-PERSONA-WALKTHROUGH-cpNN, AUDIT-cpNN-*):** intentional point-in-time
audit-trail records, referenced only by REVISIT-LIST.md + AUDIT-2026-05.md (history) and by
`db-password-placeholder-smoke.ts` (which asserts the doc PATHS exist — a currency guard
against accidental deletion, not a staleness bug). Not stale-as-bug.
- **"Orphan exports" (101 candidates flagged by a static name grep):** overwhelmingly FALSE
POSITIVES — symbols used only WITHIN their defining file (e.g. isValidBtcAddress dispatched
at payload.ts:1478; validateMnemonic at keygen.ts:319) or consumed via namespace/dynamic
imports + .svelte template expressions the literal-string grep can't see. No genuinely dead
code of consequence. A mass `export`-stripping sweep was DELIBERATELY NOT done: it's
churn-heavy, risks breaking dynamic/barrel imports, and unused `export` keywords have zero
runtime/security/correctness impact. Tracked as optional future cleanliness, not a finding.
## Part L — Smoke/gate currency (COMPLETE)
- **No broken registrations:** all 260 registered entries in run-smokes.sh resolve to files on
disk (the "7 missing" were regex false-positives matching shell echo strings / `${var%:*}`
expansions in the runner body).
- **F-010 (LOW, fixed):** `apps/web/scripts/locale-source-of-truth-smoke.ts` existed on disk but
was NOT registered → it never ran, giving zero protection. It's a genuinely valuable guard
(enforces SUPPORTED_LOCALES single-source-of-truth; flags any file that inlines the full
10-locale set instead of importing the canonical array — the exact drift that would
silently under-cover a newly-graduated locale). It passes 2/2. Registered it next to
i18n-locale-registry-smoke. Now ZERO unregistered smoke files (258 on disk, all registered).
## Part M — F-007 account-name regex unification (COMPLETE — fixed)
Resolved the deferred F-007 divergence. Root cause: `registry.ts` used
`/^[a-z][a-z0-9-]{1,14}[a-z0-9]$/` (no dots, alnum-end) while the canonical
`isValidBlurtAccount` (chat/payload.ts) and all ~14 other validators used
`/^[a-z][a-z0-9.-]{2,15}$/`. Aligned registry.ts to the canonical form (strictly more
permissive toward real dotted Blurt names; the divergent form was the lone outlier).
- Chose alignment + a parity guard over a runtime-import refactor: making the low-level asset
registry import the higher-level chat/payload module at runtime is an architectural smell /
future-cycle risk. Aligning the literal + guarding parity achieves single-source-of-truth
behavior without the dependency.
- **Guard added:** `apps/web/scripts/blurt-account-regex-parity-smoke.ts` (REGISTERED,
negative-tested) discovers all 15 account-name regex definitions in the frontend and asserts
each is byte-identical to the canonical `/^[a-z][a-z0-9.-]{2,15}$/`. Web typecheck 0 errors;
payload/precision smokes (61 + 17) green. Not a security change (chain + extractSigner remain
the authority); a UX-consistency correctness fix.
## Part N — Outside-pentest assessment (COMPLETE — for Ken)
Updated `docs/AUDIT-OUTSIDE-SCOPE.md` (the doc that directly answers "would a hired firm run
tests you haven't?"): (1) §3 crypto-specialist review now flags the cp173174 signing migration
as RAISING its priority — a dual-signer period (dblurt elliptic + noble, behind SIGNER_BACKEND)
is a classic site for nonce/low-S/recovery-id/digest-binding bugs; a cryptographer should audit
the noble path + cutover plan before the flip. (2) Added a cp175 addendum: what this static
deep-deep covered, why it's necessary-not-sufficient, and the concrete recommended sequence
(finish static → stand up staging → cheap self-serve passes [libFuzzer on payload parsers,
ZAP/Nuclei, bug bounty] → specialist crypto + DAST/threat-model). Bottom line for Ken: code is
in unusually good static shape, but "static-clean" ≠ "pentested" — honest launch line is
"extensively self-audited; independent third-party review pending."
## Session 5 summary
Fixes: F-007 (account-name regex divergence — registry.ts aligned to canonical + parity
sentinel), F-010 (dormant locale-source-of-truth smoke registered). Verified clean:
orphan/staleness (no dead components/routes; "orphan exports" are within-file/dynamic, no real
dead code; snapshot docs intentional+guarded); smoke currency (260 registrations all resolve,
now ZERO unregistered smoke files). Outside-pentest assessment updated for Ken (Part N).
This completes the planned cp175 deep-deep scope. Findings summary: F-001 (HIGH) + F-002/F-005/
F-006/F-007/F-008/F-010 fixed; F-003 (INFO) doc fix; F-004 false alarm; F-009 verified-clean
(not a gap). Six new guard smokes added (signer-consistency, chatlink-env-doc-parity,
account-regex-parity + the comment-op proof extension + locale-source-of-truth registration).
## Session 6 (cp175 continued — pushing past "planned scope")
## Part O — FULL suite execution (the real gate, finally run end-to-end)
Prior sessions ran slices; this session ran the ENTIRE registered suite group-by-group plus the
unit suites. Result:
- **All 262 registered smokes PASS** (root 25, indexer 77, relay 10, ops-cli 17, matrix-bot 10,
mcp-server 3, web 94, packages 26). Includes all 6 new cp175 guard smokes.
- **All unit suites PASS** via vitest-must-pass-smoke: **indexer 475/0fail/1skip, relay 244/0fail,
web 694/0fail/5skip** = 1,413 passing unit tests. (Correcting a stale assumption: the indexer
vitest suite DOES run here — 475 passing — it is not blocked by the native better-sqlite3 build
as the handoff summary implied; those tests use pg-path mocks.)
- One harness false-positive worth recording: `apps/relay/drainer-defense-smoke` prints expected
`Error:` lines as part of its NEGATIVE-path assertions (deliberately triggering rejections);
run in isolation it reports "✓ all 17 scenarios passed". A naive grep for "Error:" mis-flags it.
The relay drainer (hot-key component) is clean.
## Part P — SQL-injection sweep (COMPLETE — clean) + F-011
Systematic sweep of every SQL query in indexer + relay for non-parameterized construction.
- **No string-concatenation query building** (zero `query('...' + var` patterns).
- **14 template-interpolation sites, all verified safe:** (a) cursor pagination
(orders.ts/feedback.ts) interpolates only a FIXED clause string with values bound as `$2/$3`;
(b) dynamic WHERE builder (orderbook.ts/orderbookStream.ts) uses a `p(v)` helper that PUSHES
the value to params and returns the `$N` placeholder — user filter values
(asset/side/fiat/region/methods/min_trades) are all parameterized; region adds escapeLike +
ESCAPE; (c) signals.ts/decay.ts interpolate only module-level numeric CONSTANTS
(SIGNAL_*_DAYS=7/30, REPUTATION_DECAY_HALF_LIFE_DAYS=365), never user/config data. No user
input reaches SQL via interpolation. Injection surface clean.
- **F-011 (LOW, fixed):** found while tracing decay SQL. `reputationDecayWeightSql()` (decay.ts)
is exported but UNUSED; the decay formula `POWER(0.5, ... / (365 * 86400.0))` is hand-inlined
10× across api/feedback.ts (×6) + api/orderbook.ts (×2) + api/orderbookStream.ts (×2) with 365
as a magic number. The JS decay path is guarded against the constant by reputation-decay-smoke,
but the SQL path was NOT — a change to REPUTATION_DECAY_HALF_LIFE_DAYS would silently diverge
the verifiable-receipt (JS) from the live rating query (SQL). Added
`reputation-decay-sql-constant-parity-smoke` (REGISTERED, negative-tested: asserts all 10
inlined literals == the constant). Annotated the helper as intentionally-retained reference
(not dead code) with a pointer to the guard. Chose guard-the-duplication over refactoring 10
multi-line SQL aggregates (churn/risk).
## Session 6 summary
Ran the FULL suite end-to-end for the first time this campaign: 262/262 smokes + 1,413 unit
tests (indexer 475, relay 244, web 694) all PASS; triple-pulsed the security-critical/flaky set.
Corrected a stale belief (indexer vitest DOES run here). SQL-injection sweep clean. F-011 (LOW,
fixed) decay-constant SQL drift guard. Two more guard smokes (chatlink+account-regex from S5,
now decay-constant) — cp175 has added 7 guard smokes total.
## Session 7 (cp175 — privacy/Monero hardening + handler deep-read + persona traces + doc prose)
## Part Q — Metadata-leak reduction (Monero-comfort focus)
### F-012 (MEDIUM, fixed) — order permlink leaked the asset into permanent public surfaces
The order permlink was `<side>-<asset>-<fiat>-<rand>` (e.g. `sell-xmr-usd-ab12cd`). A Blurt
permlink is permanently public AND propagates into order URLs (`/[account]/[permlink]`), RSS feed
GUIDs + links (rssOrderbookHandlers), and block explorers. So the asset name ("xmr") leaked into
many human-readable, widely-syndicated, permanent surfaces — exactly what a Monero observer would
flag. Verified EXHAUSTIVELY that nothing parses meaning from the permlink: the indexer reads
side/asset/fiat from the STRUCTURED payload (payload.asset, v.side, line 129/671), the orderbook UI
renders from structured fields, every other reference treats the permlink as an opaque
`account/permlink` token. So embedding the asset was pure redundant leakage.
**Fix:** `makeOrderPermlink` now returns an OPAQUE `order-<12 random chars>` (~59 bits, collision-
negligible; uniqueness still enforced by the indexer (account,permlink) PK). The asset/side/fiat
still travel in the structured payload (a public orderbook MUST publish them to match traders —
that's inherent, not a leak). This stops DUPLICATING them into the permlink/URL/RSS/explorer.
Pre-launch (zero live orders) so format change is free. Updated the unit test to assert the opaque
form AND that asset/side/fiat are absent (privacy invariant locked in — a regression re-adding them
fails the test). Web vitest 695 passing (was 694; +1 new invariant test). Typecheck 0 errors;
order/rss/persona/payload smokes green.
### Monero-comfort: existing posture VERIFIED STRONG + one disclosure added
Confirmed the privacy engineering is genuinely Monero-respectful:
- **XMR view-key NEVER exposed** — `MORPHIT_INDEXER_XMR_FEE_VIEWKEY` was removed entirely
(Part 109, treasurySource.ts); release.ts has a Part-107 invariant that STRIPS any `viewkey`
field from release ops (defense vs an old broadcast). Never on-chain, never in API, never logged.
- **Fee verification uses Monero-native `get_tx_proof` selective disclosure** (moneroProofVerifier.ts
via `check_tx_key`/`txprove=1`) — reveals only "this txid paid this address this amount," nothing
about the wallet. Correct, idiomatic Monero design.
- **XMR fee-payment is OPT-IN** (default fee_method = 'blurt', and BLURT is 50% cheaper).
- **Amount jitter** with piconero precision + a FAQ entry that understands chat-amount correlation.
- **Existing FAQ `xmr_txid` + `xmr_tx_proof`** already explain the proof mechanics well.
**Disclosure added (honesty for maximalists):** augmented the `xmr_txid` FAQ in ALL 10 locales with
the one cross-chain fact a skeptic will look for — paying the *listing fee* in XMR records that
fee's TxID in the order op on the public Blurt chain (the ONLY place an XMR TxID touches Blurt; it's
a ~$0.25 fee to the public treasury, never the trade settlement, which is peer-to-peer and never on
Blurt) — and the clean opt-out: pay the fee in BLURT (default + half price) for ZERO XMR↔Blurt
linkage. Locale parity preserved (3094×10); FAQ + parity smokes green.
## Part R — Handler deep-read (business logic, not just attack classes)
### order.ts (974L) — read in full
- **waived_first_buy branch:** correctly gated (side=buy, asset=BLURT, amount_min≥500 BLURT,
no prior orders one-shot) with an ATOMIC `INSERT...ON CONFLICT...WHERE first_buy_waived_at IS
NULL` claim that returns 0 rows (→ reject) if a concurrent op already claimed the waiver. Race-safe.
- **Multi-network asset_network validation** (USDT/USDC/DAI): per-asset allowlist Set-membership,
single-network assets must omit, length-bounded before lowercasing. Correct.
- **F-013 (LOW, fixed):** the per-asset network allowlists are hardcoded as Sets in BOTH order.ts
(L397-403) AND orderReplace.ts (L210-213) — three copies counting the registry's
`supportedNetworks`, with NO guard tying them together. A network added to the registry would
leave the handlers stale → silent `asset_network_unknown` rejection of orders on the new network
(frontend offers a network the indexer refuses). Added
`asset-network-set-registry-parity-smoke` (REGISTERED, negative-tested: 6 checks = 2 handlers ×
3 assets vs registry, fails on injected drift). The Sets are currently correct (USDT
erc20/trc20/spl/bep20, USDC erc20/spl/base/polygon, DAI erc20/polygon/base/arbitrum all match).
### featureBid.ts (516L) — read in full — CLEAN
Auction logic correct: hours validated integer∈[6,168] BEFORE the `amountBlurt/hours` division
(no div-by-zero), `requiredBeat = max(displacedRate+1, displacedRate*1.05)` anti-pennywise rule,
`expiresAt` from block-time (deterministic), anti-snipe soft-close extension (SNIPE_EXTENSION),
bid must reference a live + fee-verified order. Money math bounded.
### orderReplace.ts (434L) — read in full — CLEAN (notably strong)
Ownership is keyspace-bound (looks up (ctx.signer, permlink); can't touch another account's order).
Although the UPDATE can write side/asset, the validator FORBIDS changing side/asset/fiat/
asset_network with explicit reasons + attack-comments — including `replace_asset_network_change_
forbidden` which blocks a USDT-ERC20→USDT-TRC20 swap that would fool a counterparty who clicked the
original listing (settlement-chain bait-and-switch). `replace_below_waiver_floor` blocks the
claim-waiver-at-500-then-replace-to-1 abuse. created_at preserved (age/reputation can't be reset).
### chat.ts (545L) — read in full — CLEAN (privacy-correct)
Stores ONLY ciphertext + opaque jsonb header (ephemeral_pub/nonce/client_tag) + sender/recipient/
timestamps — nothing derived from plaintext (indexer can't read messages). Layered anti-abuse:
block-check fires first, stranger-gate (prior exchange OR paid stranger_fee), fan-in cap (>20 new
senders/24h) + per-sender no-reply cap (>50). order_permlink is an opaque validated lookup key
(checks the order is real + recipient-owned) — does NOT parse asset/side from it, so F-012's opaque
permlinks are fully compatible; the fast-path does NOT bypass block/stranger gates.
**Handler deep-read COMPLETE:** all 4 biggest handlers (order/featureBid/orderReplace/chat) read
line-by-line for business logic. One finding (F-013, fixed). The replace bait-and-switch defenses
and chat anti-abuse layering are notably mature.
## Part S — Persona traces: Charlie (MCP) + Sally-operator (end-to-end, not via smokes)
### Charlie (MCP server) — read-only BY CONSTRUCTION, verified
The MCP server exposes exactly 5 tools, all read-only: morphit_search_orders, morphit_list_
instances, morphit_list_payment_methods, morphit_get_listing, morphit_describe. A grep over the
ENTIRE apps/mcp-server/src for signing/broadcast/key primitives (sign, broadcast, privateKey, wif,
active/posting key, custom_json, keystore, seed, mnemonic, @noble, elliptic, dblurt) returns ZERO
real hits — no write capability EXISTS in the code. indexerClient only does `fetch` against the
indexer's read /v1/ endpoints, hardened with redirect-refusal + body-cap (a malicious instance
can't redirect or flood it). The `mcp-server-read-only-invariant-smoke` codifies this as a CI guard
so a signing import can't land silently. Runtime: read-only/server/private-instance/body-cap smokes
all green. Charlie can shop the orderbook but cannot move funds or impersonate a user.
### Sally-operator (ops-cli init wizard) — verified, Monero-rule-clean
The init wizard (~20 ELI5 prompts) writes morphit.config.env + an encrypted keystore. MONERO RULE
HELD: the wizard collects only XMR PUBLIC config (MORPHIT_INDEXER_XMR_EXPLORER_URLS, _FEE_PICONERO
amount) — it NEVER asks for or stores the XMR view key (Part 109's removal holds through the
operator tooling). The one sensitive secret (the relay active key, for account-creation/transfers)
is stored ENCRYPTED AT REST (scrypt N=2^17 + AES-GCM, "one passphrase per instance"; alt-network
keystores reuse the same envelope — an attacker with all keystores still needs the passphrase).
Runtime: init (43), edit-active-key (19), altkeystore (14), disabled-assets-wizard (22),
install-invariants (9), persona-walkthrough (170), sally-walkthrough (22) all green.
## Part Q (cont.) — Further metadata-leak reductions
### F-015 (LOW, fixed) — order expiry leaked the submit moment to ms precision
`expires_at` was `new Date(Date.now() + expiresDays * 86_400_000)` at both order call sites
(post/+page.svelte:1150, post/edit/[permlink]/+page.svelte:462). Its ISO string carried HH:MM:SS.mmm
of the submit moment onto the public Blurt chain; since the interval is a round number of days, an
observer could subtract it to recover the client's exact wall-clock at submit — a clock-skew
fingerprint finer than, and independent of, the block time. Added `makeExpiryFlooredUtcDay()` in
$lib/orders/payload (floors to UTC midnight; floors-not-rounds so it can't cross the indexer's
MAX_EXPIRES_AT_DAYS=365 ceiling), routed both call sites through it. expires_at is only used in `>`
liveness comparisons (verified: featureBid/chat/order/orderReplace) and shown as "expires in N
days," so nothing functional is lost. Guard: `order-expiry-day-floor-smoke` (REGISTERED,
negative-tested: floors 1/14/90/365d to midnight + asserts no call site reintroduces the raw
pattern). Web typecheck 0 errors; vitest 695 passing; persona/order smokes green.
### Final on-chain order-op review — no further gratuitous fields
Read the complete OrderPayload: side/asset/fiat/amount_min/amount_max/price_model/location_region/
payment_methods/terms/expires_at (+ fee_method/external_tx_id/tx_proof/asset_network when relevant).
All user-intentional. Grep for navigator/Intl/timezone/locale/userAgent fingerprints in the
order-build path: ZERO. Confirms "what you see in the order form is what goes on chain, nothing more."
Catalog (METADATA-LEAK-CATALOG.md) updated: B.2 defenses note + Sealings section now list F-012 + F-015.
## Part T — Operator-doc prose read (OPERATIONS.md 9768L + RUN-A-MORPHIT-NODE.md 2343L)
Read the prose (beyond env-vars/paths/FAQ already covered) with a focus on stale/inaccurate claims
and Monero-scrutinizable statements. Monero/view-key prose is consistent + accurate throughout:
§12 documents the view-key check was RETIRED (Part 110 — no view key exists to check); lines
8096-8466 reinforce "no view key lives on any indexer's box," "Does NOT prompt for the view key";
RUN-A-NODE's community-operator section correctly says "no view key required by you, no shared
secret." Numeric claims (60 BLURT listing fee, $0.125 BLURT / $0.25 BTC-XMR, 90/10 BLURT split,
scrypt N=2^17, 3-second blocks, MAX_EXPIRES_AT_DAYS) all consistent with code.
### F-014 (INFO, fixed) — alarming `viewkey=` URL in OPERATIONS.md
The explorer API URL `/api/outputs?txhash=…&address=…&viewkey=…&txprove=1` (OPERATIONS.md ~line
8226) is the onion-monero-blockchain-explorer's API, where the `viewkey=` param (with `txprove=1`)
actually carries the single-use tx_proof, NOT a real view key — confirmed in code
(moneroProofVerifier.ts passes `viewkey: txProof`, with a code comment explaining the explorer's
naming + that the proof is excluded from logs). But the doc snippet lacked that clarification, so a
privacy-conscious operator reading it would reasonably be alarmed (it appears to contradict the "no
view key" messaging elsewhere). Added a Monero note at that URL clarifying the param carries the
tx_proof, not a view key, and that Morphit never holds/transmits/logs a treasury view key. (RUN-A-
NODE does not show the raw URL, so no fix needed there — both operator docs now tell a consistent,
non-alarming Monero story.)
## Session 7 summary
Completed all four requested deep-dives + the Monero metadata push:
- **Handler deep-read (all 4 biggest):** order.ts (F-013 fixed: asset_network Set vs registry
parity guard), featureBid.ts (clean), orderReplace.ts (clean — strong bait-and-switch defenses),
chat.ts (clean — privacy-correct, opaque-permlink-compatible).
- **Persona traces:** Charlie/MCP read-only-by-construction (no signing/write capability exists;
CI-guarded) + Sally-operator (wizard never touches XMR view key; active key encrypted at rest).
- **Privacy/Monero reductions:** F-012 (opaque order permlinks — asset no longer in
permlink/URL/RSS/explorer), F-015 (UTC-day-floored expiry — no submit-moment timing leak),
xmr_txid FAQ disclosure across 10 locales, F-014 (OPERATIONS.md viewkey-param clarification).
Verified the existing posture strong: view-key never exposed, tx_proof selective disclosure,
amount jitter, XMR fee opt-in.
- **Operator-doc prose read:** OPERATIONS.md + RUN-A-NODE — claims accurate; F-014 the only fix.
Findings this session: F-012/F-013/F-014/F-015 (all fixed). cp175 now: F-001 HIGH + F-002/F-005/
F-006/F-007/F-008/F-010/F-012/F-013/F-015 fixed; F-003/F-014 INFO doc fixes; F-004 false alarm;
F-009/F-011-guard verified. Ten guard smokes added across the campaign.
## Session 8 (cp175) — privacy-coin parity + catalog simplification + FAQ/brag/comparison
### Privacy-coin inventory + parity (the "do the same for all privacy coins" ask)
Established the full picture: privacy coins are XMR, ZEC, ARRR, DASH, DCR (+ BTC/BCH/LTC carry
opt-in privacy tech). KEY FINDING — the on-chain protections are ALREADY asset-agnostic and cover
every privacy coin uniformly: F-012 opaque permlinks, F-015 day-floored expiry, amount jitter
(jitterAmountForAsset → jitterUtxoAmount covers ZEC/ARRR/DASH/DCR at satoshi precision;
jitterMoneroAmount for XMR), shielded-address-aware validation (ZEC zs1/u1, ARRR zs1-only). And
because the fee_method enum is FROZEN at blurt|btc|xmr, ZEC/ARRR/DASH/DCR can NEVER pay fees → their
TxIDs never touch Blurt at all (cleaner than XMR's one optional fee-link). The per-asset
/privacy/{asset} guides + what_is_<asset> FAQ already cover each privacy coin thoroughly (XMR's
guide is the SHORTEST because Monero's privacy needs the least guidance). Net: privacy-coin parity
was largely already present; this session made it explicit in user-facing surfaces.
### New FAQ entry: privacy_coins_onchain (all 10 locales, +2 keys → parity 3096×10)
Added one canonical "How does Morphit keep my privacy-coin trades private?" entry covering all five
privacy coins: settlement off-chain, opaque permlinks, floored expiry, jitter, the trade-only
TxID-never-on-Blurt guarantee for ZEC/ARRR/DASH/DCR, and XMR's single opt-out-able fee-link.
Registered in faqIndex.ts section 7 (Privacy) + related-map. FAQ/parity/jsonld smokes green.
### METADATA-LEAK-CATALOG.md — massively simplified (554 → 161 lines)
Rewrote into the three-part structure: (1) What does NOT leak + why (table), (2) What DOES leak +
why (on-chain / network / server / client, each concise), (3) Privacy coins — how far we've gone
(the full measures, per-coin). Kept the comparison table + provenance/change-policy. All
load-bearing facts preserved; doc-parity + fenced-path smokes green; no broken anchor refs.
### Brag list + comparison image
- Brag entry 113 enhanced (no renumber): folded in opaque permlinks (asset never in
permlink/URL/RSS/explorer), floored expiry, XMR's opt-out fee-link, and the ZEC/ARRR/DASH/DCR
TxID-never-on-Blurt guarantee. Brag-list parity smoke 79/79; media kit regenerated (freshness
green).
- Comparison image: added 2 rows to "Privacy & anonymity" — "Privacy coins first-class
(XMR/ZEC/ARRR/DASH/DCR; TxIDs off-chain)" and "Opaque order IDs — asset name never on the
coordination chain." Rebuilt PNG + fingerprint (cairosvg + pngquant); freshness smoke 15/15.
### Status of the original 4 bullets (confirmed complete)
1. Handler deep-read (order/featureBid/orderReplace/chat all 4) — COMPLETE (Parts R/S; F-013 fixed).
2. Charlie/MCP + Sally-operator end-to-end traces — COMPLETE (Part S).
3. Privacy/metadata-leak pass — COMPLETE + extended this session (F-012/F-014/F-015 + privacy-coin
parity + catalog/FAQ/brag/comparison).
4. OPERATIONS.md + RUN-A-MORPHIT-NODE.md prose read — COMPLETE (Part T; F-014 fixed).
## Part T (cont.) — RUN-A-MORPHIT-NODE.md full prose read complete
Finished reading the remaining prose sections (§8 manual-config, §9 operator-economics, §11
hardening, §12 troubleshooting). All accurate:
- §8.1: separate morphit-relay system user (smaller blast radius), 0600 env modes — matches the
ops-cli wizard + OPERATIONS key handling.
- §9.3 earnings flow: 90% floor-rounded to 3 decimals, relay_pending_transfers queue + drainer,
ports (indexer 8081 / relay 8080) — consistent. §9.6 Part-111 federation-cost gating
(4 categories + account-creation as the HTTP-scoped 5th) matches code.
- VERIFIED a cross-doc numeric claim that looked drift-prone: "welcome bonus = 20 BLURT (10 liquid
+ 10 vesting)" is EXACTLY right (feedback.ts:435-436 inserts liquid 10 + vesting 10). The "1 BP"
in some notes refers to the SEPARATE loyalty-milestone BP delegation (10/50/200/1000 BP), not the
welcome bonus — no conflict.
- §11 "What none of these prevent" is candid + accurate (account creation ~100 BLURT paid by the
relay; the brake is the §18 daily-ceiling drain defense + kill-switch, not attacker cost).
No stale claims found in RUN-A-NODE beyond the F-014 viewkey-param clarification already applied to
OPERATIONS.md. Bullet 4 (operator-doc prose read) now fully complete across both docs.
## Session 9 (cp175) — operator setup-wizard clarity + upgrade-doc (4 operator asks)
Operator-experience pass on the ops-cli init wizard + upgrade docs. Four asks:
1. **Upgrade doc for an older-version sysadmin.** docs/UPGRADING.md ALREADY EXISTS (346 lines,
sysadmin-focused: `morphit-ops upgrade` check→notes→confirm→backup→apply→npm ci→restart→
auto-rollback; manual procedure; automated mode; release-monitor; GPG belt-and-braces; dedicated
Rollback section). No new doc needed. IMPROVED: de-staled "~3,300+ scenarios" → "thousands of
self-checking scenarios" (drift-proof); ADDED "## What if my instance is several releases behind?"
section (the user's exact scenario — cumulative tarballs jump straight to latest in one step; read
ALL intermediate release notes; confirm not crossing a major; schema migrations auto-apply on
indexer restart; data/config untouched).
2. **instance name + tagline fields.** Both wizard prompts (steps.ts step 1 stepInstanceName, step 2
stepTagline) already had explain()+examples() but undersold the display surfaces. Traced where the
values render: title bar (Head.svelte:95), header/nav (+layout.svelte), homepage (+page.svelte),
support page, the FEDERATED /instances directory (instances/+page.svelte:230 name + :361-362
tagline italic), and SEO/JSON-LD (jsonld.ts). REWROTE both explain() blocks to enumerate ALL
surfaces, emphasizing the federated /instances directory (users on OTHER nodes see your instance by
this name/tagline). Examples improved (instance name now 'alice-morphit'/'Morphit Berlin'/'Free
Morphit Canada').
3. **public origin.** stepOrigin (step 9) rewrote explain() to tie "public origin" explicitly to
"the domain name you registered (and pointed at this server) with https:// in front," with concrete
"if your domain is X, you enter https://X" examples + DuckDNS home-host note + clarified NOT a Blurt
RPC and NOT a block explorer.
4. **"chat-link" URLs → block explorer URLs.** Investigated: these ARE block explorer transaction-URL
templates (mempool.space/tx/{txid}, xmrchain.net/tx/{txid}, ...) — "chat-link" just describes the
purpose (linkify a TxID pasted in chat). User's instinct correct. There are TWO explorer steps:
step 11 stepFeeExplorers "Fee-verifier explorer URLs" (server-side, multi-URL LIST per asset w/
Keep/Edit-comma-separated/Reset — add/delete genuinely works here) vs step 12 stepChatLinkExplorers
(what users click; exactly ONE URL per asset/network, REQUIRED — parseChatLinkTemplate rejects
blank). FIXES: renamed step-12 title to 'Block explorer links (clickable TxIDs in chat)'; rewrote
its explain() to say these are ordinary block explorers ("chat-link" is the internal name), one per
asset/network, editable/resettable/required-not-blank, SEPARATE from the fee-verifier LIST; renamed
all 39 admin-visible per-asset labels + section headers "X chat-link URL" → "X block explorer URL"
(BTC/XMR/BCH/LTC/DASH/DOGE/ZEC/ARRR/DCR/SOL/ETH/XRP + USDT/USDC/DAI per-network). Kept env-var names
MORPHIT_FRONTEND_*_CHAT_LINK_URL unchanged (config compat + doc-parity smoke). editChatLinkUrl
helper already offers Keep/Change/Reset per URL w/ live reachability probe — editing+resetting work;
add/delete is a fee-verifier-list concept, not chat-link (each asset needs exactly one explorer).
CAUGHT + FIXED a latent regression from session-8's catalog rewrite: wizard-step-count-doc-parity-smoke
asserts METADATA-LEAK-CATALOG.md contains /roughly \d+ prompts/ matching TOTAL_STEPS=20; my rewrite had
written "~20 prompts" → restored "roughly 20 prompts." 8/8 green.
VERIFIED: ops-cli tsc 0 errors; no smoke pins the old step-12 title; touched smokes (init,
wizard-step-count, frontend-chatlink-env-doc-parity, operator-doc-fenced-path, upgrade-fetch-hardening,
cross-document-value-invariants) triple-pulse green. NO TARBALL YET (per user).