morphit/docs/PHASE-F-AUDIT.md

50 KiB
Raw Permalink Blame History

Phase F → F.5 audit — 2026-04-27

User asked for deep code + security audit of Phase F through F.5 before considering Phase G. Methodology mirrored the chat audit (C-1..C-62): line-by-line code-read of every file in the F → F.5 surface, looking for cap-math errors, validation gaps, privacy-contract violations, race conditions, off-by-one bugs, type-contract mismatches, and key-handling exposure.

Methodology

Six audit passes, each focused on one area:

  1. Wire format & payload module (apps/web/src/lib/chat/payload.ts)
  2. On-chain verifier (apps/web/src/lib/chat/blurtVerify.ts)
  3. broadcastTransfer and active-key handling (apps/web/src/lib/blurt/sign.ts)
  4. Global SSE listener (apps/web/src/lib/trades/tradeEventListener.ts)
  5. Toast/notification rendering + tradeStatus store integration
  6. Cross-cutting concerns (privacy, race conditions, i18n quality, forward-compat, accessibility)

NO code fixes during the audit. Findings consolidated into this doc; fixes implemented in priority order after sign-off.

Severity counts

Severity Count
HIGH 2
MEDIUM 6
LOWMEDIUM 4
LOW 30
VERY LOW 3
Total 45 findings

(F-17 was considered and dropped — see notes below.)

Findings — HIGH severity

F-7 — Verifier multi-transfer first-match bug — HIGH

Status: [x] fixed (2026-04-27)verifyBlurtTransferAgainstTx now scans all transfers with to === recipient, returns verified if any matches every field, and otherwise reports the closest- match candidate's first failed field. Helper compareTransferToExpect factors the per-candidate comparison. 6 new smoke scenarios in chat-blurt-verify-smoke.ts cover decoy- ahead, decoy-behind, neither-matches closest-field reporting, the single-mismatch-tie case, sender-mismatch isolation, and the legitimate-bundle case. All 23 scenarios passing.

Where: apps/web/src/lib/chat/blurtVerify.ts lines 167-180.

Description: The verifier loops over tx.operations looking for the FIRST transfer with to === recipient, breaks, then checks all fields against expected. If a transaction contains multiple transfers to the same recipient, only the first is checked.

A malicious buyer could craft a transaction with a tiny "decoy" transfer to the seller AHEAD of the real payment in the same tx. The seller's verifier picks up the decoy, sees amount mismatch (or memo mismatch), and falsely flags the trade as mismatch — the real payment is never inspected. The seller might refuse to release goods/services believing they weren't paid; on chain, they were.

Symmetric edge: a buyer who legitimately bundles multiple payments to the same seller in one tx (rare but valid) gets flagged for whichever transfer happens to come first if the seller-expected metadata only matches the second.

Recommended fix: Scan ALL to === recipient transfers in the tx. Return verified if ANY of them matches recipient + sender + amount + memo. Only return mismatch if no transfer matches all fields (and report the closest-match field). Only return wrong_op if no transfer with to === recipient exists at all.

Test additions needed: smoke scenarios for:

  • Decoy to: alice (small amount) before legit to: alice (big amount) — should verify
  • Two to: alice transfers, neither matches → mismatch (which field surfaces)
  • Two to: alice transfers, one matches memo, one doesn't — should verify

F-40 — tradeStatus store poisoning by any chat partner — HIGH

Status: [x] fixed (2026-04-27) — implemented Option A (lock-on-first-engage).

Changes:

  • Added engagedPeer?: string field to TradeState — the peer the local user has affirmatively engaged with for this trade.
  • recordAddressSharedPure and recordFundsSentPure now take a required direction: 'outgoing' | 'incoming' argument.
  • Outgoing payloads always apply. Engagement is set on FIRST outgoing (sticky thereafter — UI bugs sending outgoing to a different peer don't flip engagement).
  • Incoming payloads from a peer ≠ engagedPeer are dropped at the mutator layer. Tentative entries (no engagement) still accept incoming updates.
  • Verifier in ChatMessage.svelte now consults the store's expectedMemo ONLY when engagedPeer === message.sender. Falls back to the buyer's echoed memo otherwise — restoring Phase F.4 baseline behavior so a poisoned tentative entry cannot drive a false-mismatch.
  • All call sites updated: chatService outgoing (sendMessage) and incoming (mergePollResponse) paths, and the global listener (always incoming).

7 new smoke scenarios in trade-status-smoke.ts cover: outgoing locks, incoming-tentative succeeds, incoming-from- non-engaged dropped, incoming-from-engaged applied, poison- before-engagement-then-engage, engagement-stickiness against different-peer outgoing, incoming funds_sent from non-engaged dropped.

All 30 trade-status scenarios passing. Frontend typecheck 0 errors.

Where: apps/web/src/lib/trades/tradeStatusPure.ts mutators.

Description: Any peer in the user's recent-peers list can poison a tradeStatus entry for any public orderPermlink by sending a chat message with a structured payload referencing it. Mallory just needs to be a chat partner and to know an orderPermlink — orderPermlinks are public Blurt posts, so Mallory can scrape Alice's blog to find them.

The expectedMemo field in the store is preserved across recordFundsSent calls (only peer/method overwrite). If Mallory pre-poisons the entry with a wrong expectedMemo, the legitimate buyer Bob's funds-sent payload doesn't overwrite the memo. The verifier compares chain truth (Bob's correct memo) against the poisoned expectedMemo → false mismatch:memo.

Result: the verifier becomes untrustworthy in the presence of ANY malicious chat partner. The "auto-correlation across pills" feature (F.5 design promise) becomes a vulnerability when the trust scope isn't pinned to the actual trade counterparty.

Recommended fix: tradeStatus entries should be scoped to a specific peer. Two options:

  • Option A (lock-on-first-engage): Track per-permlink which peer the local user has actively engaged with (sent an outgoing structured payload to). Once engaged, drop incoming payloads from any other peer that reference the same permlink. Until engaged, accept payloads but tag them as "tentative" so the badge doesn't promote to a confirmed state.

  • Option B (key by permlink+peer): Store keyed by (orderPermlink, peer) rather than just permlink. PaymentStatusBadge aggregates over peers (typically 1 entry per permlink, but architecture doesn't assume).

Option B is the more robust architectural answer. Option A is simpler to retrofit. Recommend A as the immediate fix, B as a longer-term refactor.

Test additions needed:

  • Mallory sends address-shared with poison memo → Bob's funds-sent for same permlink → verifier should NOT consult Mallory's memo.
  • Lock-on-engage: user sends outgoing → only that peer can update entry afterward.

Findings — MEDIUM severity

F-8 — BLURT amount precision asymmetry — MEDIUM

Status: [x] fixed (2026-04-27) — encoders now normalize BLURT amounts to 3 decimals via Math.ceil round-up at encode time.

Changes:

  • encodeAddressPayload and encodeFundsSentPayload apply (Math.ceil(parseFloat(amount) * 1000) / 1000).toFixed(3) when method is blurt. Round UP for symmetry with formatBlurtAmount (sellers slightly overpaid rather than slightly underpaid).
  • BTC and XMR amounts unchanged — those chains have different decimal conventions (8, 12) and the wire format isn't trying to mirror chain rounding for them.
  • Updated existing test (decode: BLURT address payload round-trips) which asserted the pre-fix behavior.
  • 10 new F-8 scenarios in chat-payload-smoke covering: 4+ decimal rounding, integer→.000 padding, 1-decimal padding, 4-decimal round-up, 3-decimal unchanged, BTC verbatim, XMR verbatim, funds_sent symmetric, leading-zeros incidental fix, tiny-amount preservation.

Side benefit: F-4 (leading zeros) is incidentally fixed for BLURT amounts. "0001.500" → "1.500" via parseFloat + toFixed. F-4 remains open for non-BLURT amounts.

Frontend typecheck 0 errors. All 553 smoke scenarios stable.

Where: payload.ts AMOUNT_RE (12 decimals allowed) vs chain rounding (3 decimals) vs verifier epsilon (0.0005).

Description: Address-pill amount accepts up to 12 decimals. Buyer's chain transfer rounds UP to 3 decimals via formatBlurtAmount (Math.ceil-based). Verifier epsilon (0.0005) is exactly half a chain-unit, which fails to absorb the worst-case round-up. Sellers typing amounts like 1700.4994 see buyer pay rounded 1700.500 and the verifier flags mismatch:amount (diff = 0.0006 > 0.0005).

Recommended fix (preferred): Round the seller's expected amount UP to 3 decimals at encode time, matching what the buyer's wallet will see. Implement in encodeAddressPayload for BLURT method.

Alternative fixes: loosen verifier epsilon to 0.001 OR constrain modal input to 3 decimals via step attribute and validation.


F-11 — Single-RPC trust (verifier inherits C-35) — MEDIUM, residual

Status: [x] documented (2026-04-27) — added Phase F.5 addendum to docs/SECURITY.md covering single-RPC trust.

The addendum names the residual trust assumption explicitly, lists existing mitigations (defense via observability, default RPC operator transparency, settlement is on-chain not via verifier), and points to multi-node quorum verification as a post-launch enhancement.

The same addendum also documents the F-40 lock-on-engagement semantics and the F-23 ambient decryption tradeoff so operators and security-conscious users have all three Phase F.5 trust boundaries in one place.

Description: Verifier trusts the single configured Blurt RPC node. Hostile node can fabricate "verified" results. Same class as chat audit C-35.

Recommended fix: Document the residual trust assumption in docs/SECURITY.md so operators and security-conscious users know the boundary. Future architectural fix: multi-node quorum verification — heavy, defer.


F-14 — Buyer-side verification missing — MEDIUM, design

Status: [x] fixed (2026-04-27) — extended verification to outgoing funds-sent pills with direction-aware copy.

Changes:

  • ChatMessage.svelte gained an optional peer prop (the conversation counterparty). Used to determine the chain recipient for outgoing self-verification.
  • The verifier $effect no longer early-returns on isOutgoing. For outgoing messages, the chain recipient/sender mapping flips: recipient = peer (seller), sender = me (buyer). Self-verification proceeds when the peer prop is supplied; falls back to skipping when absent (back-compat for callers not yet aware).
  • The render block dropped its !isOutgoing gate. Each verifyResult branch now picks between two copy variants — peer-verification ("Verified" / "Wrong memo") for incoming, self-verification ("Sent as expected" / "Your wallet sent something different") for outgoing.
  • 9 new i18n keys under chat.funds_sent.self_verify_* populated across all 10 locales (1469 keys total, 0 drift).
  • ConversationView passes the peer prop through to ChatMessage.

The buyer's self-verifier catches wallet-typo bugs (wrong recipient, wrong amount, wrong memo) at the moment they post funds-sent — preventing the buyer from being unaware until the seller's verifier eventually flags the mismatch hours later.

Frontend typecheck 0 errors. All 543 smoke scenarios stable.

Where: apps/web/src/lib/components/ChatMessage.svelte verifier $effect.

Description: Verifier runs only on the seller's incoming funds-sent path (isOutgoing check). Buyer never independently verifies their own broadcast.

A buyer wallet typo (wrong recipient, wrong amount) → buyer broadcasts → marks funds sent → buyer's UI shows nothing wrong. Hours later the seller's verifier flags mismatch. Funds are already gone.

Recommended fix: Run the same verifier on the buyer's outgoing funds-sent pill (when isOutgoing && method === 'blurt'). Render a self-verification badge. Different copy for buyer ("✓ Sent to alice as expected" vs "⚠ Wallet sent to wrong account") to keep mental model clear.


F-21 — SSE listener connection ceiling — MEDIUM, real functional bug

Status: [x] fixed (2026-04-27) — listener stream count capped at MAX_LISTENER_STREAMS = 5.

Changes:

  • New constant MAX_LISTENER_STREAMS = 5 in tradeEventListener.ts with a long rationale comment explaining the HTTP/1.1 6-connection-per-origin limit.
  • startTradeEventListener and refreshTradeEventListener both slice(0, MAX_LISTENER_STREAMS) the recent-peers list before iterating. Recent-peers is sorted newest-first, so the cap picks the user's most active conversations.
  • MAX_RECENT_PEERS = 20 in recentPeers.ts unchanged — the listener cap doesn't affect recent-peers storage which drives chat-list UX.
  • New OPERATIONS.md section #24: HTTP/2 deployment requirement. Includes verification steps (DevTools + curl), nginx + Caddy config snippets, symptoms of HTTP/1.1 deployment, and rationale for not raising the cap higher.

Conversations rotate as new peers appear at the top of recent-peers; older peers drop out of the listener's coverage even if still in the recent-peers list. When the user opens a chat for a peer that's no longer in the listener's top 5, the in-page chatService takes over (already does — chat-page opens its own stream).

Frontend typecheck 0 errors. All 553 smoke scenarios stable.

Where: apps/web/src/lib/trades/tradeEventListener.ts startTradeEventListener.

Description: Listener opens up to 20 SSE streams (recent-peers cap). Browsers limit 6 concurrent connections per origin on HTTP/1.1. On HTTP/1.1 deployments most streams queue indefinitely; cross-page trade-status feature silently fails for most peers.

Recommended fix (immediate): Cap listener at 5 most-recent peers. Document HTTP/2 as deployment requirement in docs/OPERATIONS.md.

Recommended fix (architectural): Indexer-side single global stream /v1/chat/<me>/all/stream that delivers events for any peer of me. Eliminates fan-out entirely. Larger refactor; defer to post-launch.


F-41 — Verifier not cross-page — MEDIUM, design gap

Status: [x] fixed (2026-04-27) — extracted the verifier trigger into a centralized module and wired it from every entry point.

Changes:

  • New module apps/web/src/lib/trades/tradeVerify.ts with triggerBlurtVerification(args). Reads the F-40 engagement gate (engagedPeer === sender → use stored memo; else fall back to echoed memo), runs the verifier, writes the result to the store via recordVerification.
  • Listener (tradeEventListener.ts) calls the trigger after recordFundsSent for every incoming BLURT funds-sent payload. Verification now fires regardless of which page the user is on.
  • chatService merge layer also calls the trigger after recordFundsSent. Idempotent with the listener via the cache + first-wins semantics.
  • ChatMessage.svelte's $effect refactored: when the funds-sent payload has an orderPermlink, delegate to triggerBlurtVerification (single source of truth). When there's no permlink, fall back to the legacy direct-verify path with component-local result state (rare — pre-Phase-F.5 payloads or hand-crafted ones).

The cross-page promise of Phase F.5 is now fully realized: state propagates AND state computation (verification) fires regardless of user navigation. PaymentStatusBadge auto-flips from "Payment pending" to "✓ Paid" within seconds of the funds-sent landing — no chat-page visit required.

Frontend typecheck 0 errors. All 543 smoke scenarios stable (118 in F-relevant runners: 65 chat-payload + 23 chat-blurt- verify + 30 trade-status, plus the rest).

Where: Verifier $effect in ChatMessage.svelte.

Description: The on-chain BLURT verifier runs only when ChatMessage is mounted (component-scoped $effect). If a user never visits the chat page after a funds-sent payload arrives, the verifier never runs and the badge stays at blue "Payment pending" indefinitely.

The cross-page promise of Phase F.5 (badge updates without visiting chat) is incomplete: state PROPAGATES cross-page but state COMPUTATION (the verifier) is gated behind chat-page mount.

Recommended fix: Move the verifier trigger to the listener (or chatService merge) so verification fires immediately on funds-sent arrival regardless of which page the user is on. ChatMessage retains its own trigger as a fallback for the case where the listener isn't running (e.g., the chat page is open but the listener was stopped during lock-unlock cycle).


F-45 — Translation quality for fa/zh-CN/zh-HK/ru/pl — MEDIUM, i18n

Status: [ ] open

Description: Phase F.5 added 9 trade_status keys + 1 toast.view_action key × 10 locales. Translations for fa, zh-CN, zh-HK, ru, pl were generated mechanically. Some are awkward (ru "Адрес отправлен" = "Address sent" not "shared").

Recommended fix: Native-speaker review for the 5 affected locales before user launch. Scope: just the new trade_status.* namespace + toast.view_action. Other locales (en, es, fr, de, it) reviewed during F.5 development; OK.


Findings — LOWMEDIUM severity

F-18 — Active key alive during broadcast network roundtrip — LOWMEDIUM

Status: [x] fixed (2026-04-27) — split each broadcast operation into three phases:

  1. prepare (async, no key) — prepareUnsignedTransfer, prepareUnsignedOrderWithFee fetch ref-block info and assemble the unsigned Transaction.
  2. sign (pure sync, ~10ms) — signTransferWithKey, signOrderWithFeeWithKey take the unsigned Transaction + raw active scalar and return a SignedTransaction. Caller invokes from inside a runWithActiveKey / useActiveKey closure so the active key is wiped immediately after.
  3. broadcast (async, network only) — broadcastSignedTransaction takes a pre-signed transaction and broadcasts. No keys in scope.

The legacy broadcastTransfer and broadcastOrderWithFee functions, which held the active key alive for the full network roundtrip (200-2000ms), have been removed.

Higher-level operations (broadcastNewOrder, broadcastStrangerFee, broadcastFeatureBid) now take a signCallback: (tx) => SignedTransaction parameter instead of activePriv: Uint8Array. Callers wrap runWithActiveKey / useActiveKey around the construction of the callback so the active key only lives during the synchronous sign.

Migrated call sites:

  • PayBlurtModal.svelte (chat pay-now flow → broadcastSignedTransaction direct)
  • routes/post/+page.svelte (BLURT-fee order broadcast)
  • StrangerFeeModal.svelte (stranger-fee payment)
  • FeatureBidForm.svelte (featured-slot bid)

dblurt's Client.signTransaction is non-mutating: returns a deep-copy with appended signature. SignedTransaction holds only signature strings, no key references — safe to carry past the wipe.

Active-key heap-resident window reduced from ~200-2000ms (network roundtrip) to ~10ms (signing only). Frontend typecheck 0 errors; 614/614 smokes stable across 3 runs.

Where: broadcastTransfer, broadcastOrderWithFee, broadcastCustomJson in sign.ts.

Description: Active-key scalar remains in heap for the duration of broadcast_transaction_synchronous's network roundtrip (~200-2000ms). Could be reduced to signing-only window (~10ms) by restructuring: sign inside useActiveKey, broadcast outside.

dblurt's signTransaction is non-mutating (returns deep-copy with signature appended); the SignedTransaction holds only signature strings, no key references. Restructure is safe.

Recommended fix: Refactor the broadcast-with-active-key pattern: sign inside useActiveKey callback, return the signed tx, broadcast outside. Apply to all three broadcast helpers. Pattern-wide change.


F-23 — Ambient decryption privacy posture — LOWMEDIUM

Status: [x] fixed (2026-04-27) — added a separate opt-out toggle distinct from tradeNotificationsEnabled (which only gates OS-level notifications). New module lib/notifications/crossPageTradeEvents.ts exposes crossPageTradeEventsEnabled (Readable), enableCrossPageTradeEvents, disableCrossPageTradeEvents. Layout $effect for the listener now gated on the store: when off, listener fully torn down (no streams open, no ambient decryption). Default ON since cross-page UX value is high; privacy-conscious users opt out via Settings. 3 new i18n keys under settings.privacy.* × 10 locales (1478 total, 0 drift). New section card in NotificationSettings.svelte renders the toggle.

Where: Listener decrypts every chat message across all recent peers.

Description: Listener decrypts every incoming chat message just to check for structured payloads. Plaintext briefly resident in memory for messages the user never reads from the chat page. Implicit privacy tradeoff that's undocumented.

Recommended fix: Document explicitly in design docs. Offer opt-out toggle (Settings) for users who want to disable cross- page trade events in exchange for less ambient decryption. Pair with the tradeNotificationsEnabled setting that already exists.


F-30 — handleAppend writes survive lock — LOWMEDIUM

Status: [x] fixed (2026-04-27) — added post-decrypt re-check in handleAppend.

After the await tryDecrypt(rec) async gap, the function now checks me !== null AND streams.has(peer) before any store write. If explicit lock fired during the ~1ms decrypt window (clearing me and the trade-status store) OR the stream was otherwise closed, the function aborts cleanly.

Without this guard, a post-lock recordFundsSent could leak a fresh trade-status entry that the explicit-lock contract promised to wipe — privacy class violation.

Frontend typecheck 0 errors. All 553 smoke scenarios stable.

Where: tradeEventListener.ts handleAppend.

Description: handleAppend can complete a write to tradeStatus store AFTER stopTradeEventListener has fired and even after clearAllTradeStates has run during a lock. A single trade-state entry can survive the lock when it shouldn't. Privacy class violation (recentPeers/readState are wiped; this entry leaks).

Recommended fix: At the top of handleAppend, after the seenIds check, also verify streams.has(peer) AND me !== null. If stream was closed during decrypt, abort before writing.


F-43 — Navigate-away during pay-now loses receipt — LOWMEDIUM

Status: [x] fixed (2026-04-27) — both fallback paths in handlePaidBlurt now record to the trade-status store and surface the txid in the toast.

Changes in ConversationView.svelte:

  • Added recordFundsSent import.
  • Refactored handlePaidBlurt: ALWAYS records the funds-sent in the trade-status store (when orderPermlink is present) before attempting the chat broadcast. The store mutator is idempotent — when the broadcast succeeds, chatService's merge won't overwrite (incoming would be dropped by F-40 lock if it ever arrived from elsewhere; outgoing path already wrote).
  • Both fallback toasts (controller-null and sendMessage-failed) now use the new key chat.pay_blurt.success_toast_no_chat_with_txid with {txid} placeholder. User sees the full txid in the toast for copy-paste convenience.
  • Removed the old success_toast_no_chat references (still in the i18n bundles for backward-compat, but no callers).

i18n: 1 new key × 10 locales (1470 total, 0 drift).

The on-chain transfer is settled, the trade-status store now reflects the buyer-side intent, and the user has the txid in the toast for manual recovery if they need to post the receipt later. Combined with F-19 (txid in fallback toast — same finding, same fix) the navigate-away gap is closed.

Frontend typecheck 0 errors. All 553 smoke scenarios stable.

Where: ConversationView.svelte handlePaidBlurt.

Description: If user navigates away (browser back, etc.) between Pay-now broadcast and the auto-funds-sent post, controller is null when handlePaidBlurt resolves → the funds- sent payload never broadcasts. Combined with F-19 (no txid in fallback toast), manual recovery requires the user to find the txid in their wallet.

Recommended fix: Surface the txid prominently in the fallback toast for copy-paste convenience. At minimum: change the toast message from generic "couldn't post receipt" to "BLURT sent (txid: abc...). Mark funds sent manually if you'd like."

Future polish: detect navigation-during-pay and either block (browser will warn) or queue the funds-sent broadcast on a session-level service that survives navigation.


Findings — LOW severity

F-1 — note field charset filter missing — LOW

Status: [x] fixed (2026-04-27)noteHasForbiddenChars filter added next to existing constants in payload.ts. Encoder throws on bidi overrides (U+202A-E, U+2066-9), C0 controls, DEL. Decoder returns null → plaintext fallback. Both encoders + both decoders updated. 6 new scenarios cover RLO rejection, newline rejection, legitimate Unicode (Cyrillic+emoji), forbidden chars in wire → plaintext, DEL on funds_sent, ZWJ allowed. ZWJ deliberately preserved — legitimate in many scripts.

Where: payload.ts encoder + decoder.

Description: note field has length-only check (≤100), no charset restriction. Allows newlines, control chars, RTL override marks, ZWJ, combining marks. Svelte auto-escape prevents XSS but visual spoofing within the text remains.

Recommended fix: Filter on encode (reject) and decode (return null for plaintext fallback): control chars (≤0x20 except space, plus 0x7F), bidi controls (U+202A-202E, U+2066-2069), unassigned codepoints.


F-2 — Decoder for v:1 + unknown kind falls to plaintext — LOW

Status: [x] fixed (2026-04-27) — added unknown_kind discriminant to DecodeResult. Decoder returns { kind: 'unknown_kind', name } when o.v === 1 and o.kind starts with morphit_ but isn't recognized. ChatMessage.svelte renders unknown_kind same as unknown_version (italic "this message uses a newer protocol — please update"). Old test that asserted plaintext-fallback updated. 4 new scenarios.

Where: payload.ts decodePayload line 393.

Description: Decoder returns plaintext for v:1 messages with unknown kind, rather than unknown_version or unknown_kind. Future protocol additions of new kinds at v:1 (e.g. morphit_dispute) won't surface "old client, please update."

Recommended fix: Add a new unknown_kind discriminant. When o.v === 1 && typeof o.kind === 'string' && o.kind.startsWith('morphit_'), return { kind: 'unknown_kind', name: o.kind }. ChatMessage renders unknown_kind similarly to unknown_version.


F-3 — memo field on non-BLURT methods accepted — LOW

Status: [x] fixed (2026-04-27) — encoder rejects memo on non-BLURT methods (throws). Decoder treats memo on non-BLURT as shape failure (returns null, falls to plaintext). Both encoders + both decoders updated. 4 new scenarios: BTC + memo encoder rejects, XMR + memo encoder rejects, BTC + memo decoder plaintext, BLURT memo positive case.

Where: payload.ts encoder + decoder.

Description: Encoder + decoder accept memo on btc/xmr methods even though memo is BLURT-only by design. Cross-method state pollution theoretically possible.

Recommended fix: Encoder rejects memo set on non-BLURT methods. Decoder treats memo on non-BLURT as shape failure (returns null for plaintext fallback).


F-4 — Leading zeros in amount strings — VERY LOW

Status: [partial] BLURT amounts incidentally normalized via F-8 (Math.ceil + toFixed strips leading zeros). BTC/XMR amount leading-zeros remain — defer to launch prep, low priority.

Where: payload.ts AMOUNT_RE.

Description: AMOUNT_RE permits "0001.000". Wire output passes verbatim. Wallet UIs may render the literal string ugly.

Recommended fix: Canonicalize on encode (parseFloat then format).


F-5 — Decoder uses 'amount' in o (prototype-chain) — VERY LOW

Status: [x] fixed (2026-04-27) — replaced 'X' in o with Object.hasOwn(o, 'X') across all 8 sites in payload.ts (4 in optionalFieldsAddress, 4 in optionalFieldsFundsSent). Defense-in-depth: prototype-chain phantoms can't produce false-positive field detection. 1 new smoke scenario asserts a JSON-decoded payload doesn't see note from prototype.

Where: payload.ts optionalFieldsAddress / optionalFieldsFundsSent.

Description: 'amount' in o traverses prototype chain. Defense-in-depth: prefer Object.hasOwn(o, 'amount').

Recommended fix: Replace in with Object.hasOwn.


F-6 — Encoder emits empty-string optional fields — VERY LOW

Status: [x] fixed (2026-04-27) — encoders now skip empty-string optionals (note: '', memo: '', amount: '', orderPermlink: ''). Saves ~11 chars per omitted field of plaintext budget in encrypted payloads. Validation regexes (MEMO_RE, AMOUNT_RE, ORDER_PERMLINK_RE) also skip on empty so callers can pass '' interchangeably with undefined. 4 new smoke scenarios cover note, memo, funds_sent, and non-empty positive case.

Where: payload.ts encoders.

Description: Encoder emits note: "" etc. rather than skipping. Wastes ~11 chars of plaintext budget.

Recommended fix: Coerce '' → undefined / not-emit on encode.


F-9 — Empty-memo case treats any chain memo as mismatch — LOW

Status: [x] fixed (2026-04-27)compareTransferToExpect skips memo comparison when expect.memo === ''. Asymmetric: when seller pinned a memo and buyer omitted, still mismatch. 2 new scenarios.

Where: blurtVerify.ts line 209.

Description: When seller's expect.memo === '' (no memo requested), the verifier flags any non-empty chain memo as mismatch. But buyer adding their own accounting memo shouldn't fail verification when the seller didn't ask for one.

Recommended fix: When expect.memo === '', accept any chain memo as verified. The case where seller requested a memo and buyer omitted remains a mismatch (different direction).


F-10 — not_found regex over-matches — LOW

Status: [x] fixed (2026-04-27) — extracted classifyRpcError pure helper. Tighter heuristic: requires BOTH a chain-object word (transaction/trx/hash) AND an absence word (not found / find / exist / unknown / missing / no such). Generic network errors like "host not found" no longer misclassified as not_found. 12 new scenarios cover real Blurt-RPC error patterns + DNS/network exclusions.

Where: blurtVerify.ts line 134.

Description: Regex /not\s*found|unknown.*trans|missing.*trans/i catches generic "not found" phrasings (network errors, missing nodes) and misclassifies as not_found (tx-not-on-chain). Misleading UX.

Recommended fix: Tighten to specific patterns observed in actual Blurt node responses. Or simplify: collapse not_found and rpc_error into a single "could not verify" UX since the user- visible distinction is small.


F-12 — Dynamic import type cast shadows TS — LOW

Status: [x] fixed (2026-04-27) — dynamic import now uses typeof import('$blurt/client') to derive the type from the real module. Hand-written cast removed. Future signature drift in getBlurtClient would now produce a typecheck error rather than silent runtime mismatch.

Where: blurtVerify.ts lines 117-121.

Description: Hardcoded type cast on await import('$blurt/client'). If client signature changes, code calls wrong shape silently.

Recommended fix: import type { ... } for type info, dynamic import for value.


F-13 — Verifier doesn't validate expect.amountBlurt — LOW

Status: [x] fixed (2026-04-27)verifyBlurtTransferAgainstTx returns mismatch:amount when expect.amountBlurt is NaN/Infinity/0/negative. 4 new scenarios.

Where: blurtVerify.ts verifyBlurtTransferAgainstTx.

Description: Function trusts caller's amountBlurt. If upstream bug passes NaN/Infinity, Math.abs(actualAmount - NaN) = NaN, NaN > 0.0005 is false → verifier falsely returns verified.

Recommended fix: Add if (!Number.isFinite(expect.amountBlurt) || expect.amountBlurt <= 0) return { kind: 'mismatch', field: 'amount' }; at function entry.


F-15 — broadcastTransfer no account-name validation — LOW

Status: [x] fixed (2026-04-27)BROADCAST_ACCOUNT_RE = /^[a-z][a-z0-9.-]{2,15}$/ checked at function entry for both from and to.

Where: sign.ts broadcastTransfer lines 264-265.

Description: Truthy-only check on from/to. Defense-in- depth gap; current callers validate but future callers may not.

Recommended fix: Apply BLURT_ACCOUNT_RE at function entry.


F-16 — broadcastTransfer no amount validation — LOW

Status: [x] fixed (2026-04-27)BROADCAST_AMOUNT_RE (matches <digits>.<3-digits> BLURT) checked at function entry. Caller sees a clear error message instead of a confusing chain-rejection downstream.

Where: sign.ts broadcastTransfer.

Description: Amount string passed through with no validation.

Recommended fix: Regex /^\d+\.\d{3}\s+BLURT$/ at entry.


(F-17 dropped — see notes.)

F-19 — txid lost in fallback toast — LOW

Status: [x] fixed (2026-04-27) — closed by F-43. Both fallback paths in handlePaidBlurt now embed the full txid in the toast message via the chat.pay_blurt.success_toast_no_chat_with_txid i18n key.


F-20 — ref_block_prefix uint32 conversion bug-shape — LOW, latent, pre-existing

Status: [x] fixed (2026-04-27) — outer parens around the OR-combine ensure >>> 0 applies to the FINAL value, not just the last shift. Operator precedence trap closed.

Where: sign.ts getRefBlockInfo.

Description: (D << 24) >>> 0 — the >>> 0 only applies to the last shift; surrounding | operations re-convert to int32. Result can be negative. May not currently misbehave (Graphene's int parsing tolerant) but is correctness-fragile.

Pre-existing from Phase B, surfaced in audit.

Recommended fix: Wrap whole expression: ((A | (B << 8) | (C << 16) | (D << 24)) >>> 0).


F-22 — seenIds set per stream unbounded — LOW

Status: [x] fixed (2026-04-27)MAX_SEEN_IDS = 100 constant + addSeenId() helper. When cap hit, evicts the oldest entry (Sets preserve insertion order in JS; iterator's first value is oldest). Memory bounded at ~4KB total across 5 streams × 100 IDs × 8 bytes.

100 chosen because the snapshot is 50 IDs and live arrivals append; 2× headroom prevents reconnect-replay from colliding with the cap.

Where: tradeEventListener.ts.

Description: seenIds Set per stream grows unbounded. ~800KB per stream at 10K entries. Snapshot is 50 IDs; only need to retain that many for dedup.

Recommended fix: Cap seenIds to 100 most-recent IDs. Use a small ring or evict-oldest pattern.


F-24 — Lock-during-decrypt race — VERY LOW

Status: [x] accepted (2026-04-27) — risk is vanishingly low (~1ms decrypt window with active session-lock). Worst case is a single garbage decrypt that returns null silently. No symptom observed in practice. Documented for completeness; will revisit if a real-world report surfaces.

Where: tryDecrypt reads liveIdentity at function entry.

Description: Live identity keys can be zeroed by lockSession mid-flight. Worst case: garbage decrypt → silent miss.

Recommended fix: Note for completeness only. Risk vanishingly low; no action needed unless a real symptom appears.


F-25 — tryDecrypt swallows non-DecryptError silently — LOW

Status: [x] fixed (2026-04-27) — non-DecryptError exceptions in tryDecrypt now log a console.warn with the error class name (PII-safe; no rec contents). Listener stays best-effort: returns null after warning so caller continues.

Where: tryDecrypt catch block.

Description: All non-DecryptError exceptions swallowed. Listener bugs surface as silent inaction.

Recommended fix: console.warn for unexpected errors with PII-safe message. Don't surface to user (best-effort design intentional).


F-26 — Listener doesn't validate rec.sender regex — LOW

Status: [x] fixed (2026-04-27)handleAppend now calls isValidBlurtAccount(rec.sender) near the top and bails on mismatch. Defense-in-depth — indexer-side validation already catches malformed names, but a slipped-through value would corrupt store keys and deep-links.

Where: handleAppend.

Description: Trusts indexer-supplied sender field without re-validating account regex. SvelteKit route matcher catches malformed names at deep-link resolution but defense-in-depth gap.

Recommended fix: if (!ACCOUNT_NAME_RE.test(rec.sender)) return; near top of handleAppend.


F-27 — Toast/notification text hard-coded English — LOW, i18n debt

Status: [x] fixed (2026-04-27) — listener now uses get(_) from svelte-i18n with 5 new keys under chat.trade_event.*:

  • address_shared_title — browser-notification title
  • address_shared_body — toast + notification body, placeholders {peer}, {method}, {orderPermlink}
  • funds_sent_title — browser-notification title with {peer}
  • funds_sent_body_with_amount — when payload includes amount
  • funds_sent_body — when payload omits amount

5 keys × 10 locales = 50 translations. i18n parity stable at 1475 keys, 0 drift.

Where: tradeEventListener.ts toast body + browser notification title/body strings.

Description: Strings like "alice paid X BLURT for trade Y" are hard-coded English. Other locales see English toasts.

Recommended fix: Add i18n keys for toast bodies (separate namespace from trade_status badges) and browser-notification text. Propagate translations across all 10 locales.


F-28 — Toast body length wrapping — LOW

Status: [x] fixed (2026-04-27) — listener truncates orderPermlink at 22 chars (19 + ellipsis) for the visible toast/notification body. Deep-link URL still uses the full permlink so navigation works for any length. Toasts stay readable in narrow viewports.

Description: Toast body for long order permlinks (256 chars max) wraps to 5+ lines. Visual clutter.

Recommended fix: Truncate orderPermlink in visible toast ("...trade-abc123" with ellipsis); full permlink in deep-link target. Or use simpler body text without permlink.


F-29 — refreshTradeEventListener never called — LOW

Status: [x] fixed (2026-04-27)recordRecentPeer dispatches CustomEvent('morphit:recent-peers-changed') after writing. Layout subscribes inside the listener $effect and calls refreshTradeEventListener on event. Cleanup removes listener on lock. Decoupled approach — recentPeers doesn't import the listener; communication via window event.

Where: tradeEventListener.ts exported but no caller.

Description: New chats started mid-session not picked up by listener until next lock/unlock cycle.

Recommended fix: Make recentPeers a reactive Svelte store and subscribe to its changes from layout, calling refreshTradeEventListener on update.


F-31 — Browser notification tag coalescing — LOW

Status: [x] fixed (2026-04-27)maybeBrowserNotify takes an orderPermlink argument and uses tag: morphit-trade-<permlink>. Different trades produce separate notifications. Same-trade updates (address-shared → funds-sent) still coalesce, which is desirable.

Where: maybeBrowserNotify tag: 'morphit-trade'.

Description: All trade notifications share a fixed tag. Multiple events coalesce — only most recent visible.

Recommended fix: tag: 'morphit-trade-${orderPermlink}' so different trades get separate notifications, but updates to the same trade collapse.


F-32 — No smoke coverage for listener — LOW, test debt

Status: [x] fixed (2026-04-27) — extracted post-decode routing logic into a pure planListenerDispatch function in lib/trades/listenerDispatch.ts. Takes a decoded payload + context (sender, me, currentPathname) and returns a ListenerDispatchPlan describing three orthogonal effects: store mutation, BLURT verification trigger, notification intent. handleAppend is now a thin wrapper: decrypt → plan → apply. New smoke runner listener-dispatch-smoke covers 24 scenarios: empty-plan paths (plaintext, unknown_version, unknown_kind, missing permlink), store effect shape (address vs funds_sent), verify effect (BLURT funds_sent only with valid amount), notify effect (i18n keys, toast kind, tag), F-38 same-page suppression (exact path, sub-route, lookalike defense), F-28 truncation, F-31 tag scoping, deep-link encoding, method uppercasing, empty-pathname handling. Registered in run-smokes.sh between trade-status and profile-handler.

Description: tradeEventListener has no smoke tests. Pure routing/dedup logic is testable.

Recommended fix: Extract pure dispatch (taking decoded payload + state, returning store mutation + toast intent) into a separate module. Smoke-test:

  • handleAppend dedup (same id twice → second no-ops)
  • non-structured plaintext → no toast
  • structured payload but no orderPermlink → no toast
  • lock-race → drops cleanly

F-33 — Toast href no scheme validation — LOW

Status: [x] fixed (2026-04-27)showToast now validates options.href at the entry point. Allowed schemes: paths starting with / (in-app navigation), explicit https:// URLs. Anything else (javascript:, data:, vbscript:, etc.) is silently dropped with a console.warn. Toast still renders without the href; the message text remains visible.

Defensive boundary at the API entry so callers passing arbitrary strings (e.g. derived from chat content in a future feature) can't introduce XSS via toast.

Where: ToastRegion.svelte binds href={toast.href} without sanitization.

Description: If a future caller passes href: 'javascript:alert(1)', click would execute. Currently all callers construct safe URLs but the toast surface doesn't enforce.

Recommended fix: showToast validates options.href starts with / or https://. Reject (or throw in dev) otherwise.


Status: [x] fixed (2026-04-27) — moved arrow into a separate span with aria-hidden="true" and CSS-driven content via ::after. Screen readers now announce only the action label ("View"), not the decorative arrow.

Where: ToastRegion.svelte clickable variant.

Description: "View →" inside <a> is read by screen readers as part of the link text.

Recommended fix: aria-hidden="true" on the View span. Optionally clearer aria-label on the link.


F-35 — Toast auto-dismiss too short for keyboard users — LOW, accessibility

Status: [x] fixed (2026-04-27)showToast extends timeout by 6s when safeHref is set. Default 4s for info/success becomes 10s when the toast has an action link. Keyboard users have time to Tab to the link before auto-dismiss. Explicit options.timeout still overrides.

Description: 4s timeout (info/success) may not give keyboard users time to Tab-reach the link.

Recommended fix: Pause-on-focus (cancel dismiss timer when toast or its action gains focus). Or extend timeout to 10s when href is set.


F-36 — RTL arrow direction hardcoded LTR — LOW, RTL

Status: [x] fixed (2026-04-27).toast-arrow:dir(rtl)::after { content: "←" } flips the arrow for RTL locales (Persian). CSS-driven so no per-locale i18n duplication. Modern browsers support :dir(); older browsers gracefully degrade to LTR arrow.

Description: "→" in "View →" hardcoded LTR. Persian users see wrong direction.

Recommended fix: Use logical arrow via i18n-localized character, or CSS pseudo-content with dir-aware content.


F-37 — Toast no pause-on-hover — LOW, UX, not F.5-specific

Status: [x] fixed (post-F.5, task #6, 2026-04-28) — toast store now exposes pauseToast / resumeToast. ToastRegion calls them on pointerenter / pointerleave and on focusin / focusout (so keyboard users tabbing into the toast also pause it). Remaining-time math floors at 1s so a hover-and-leave with <1s left doesn't dismiss instantly.


F-38 — Toast fires on active-chat page — LOW, UX

Status: [x] fixed (2026-04-27) — listener checks window.location.pathname against /chat/<rec.sender> after the store-write block; bails before the toast/notify section if matched. Store update still happens for /my/orders + other surfaces.

Description: Listener fires toast even when user is on matching /chat/<peer> page. Double-notification (inline pill + toast).

Recommended fix: Check window.location.pathname against /chat/<rec.sender> and skip toast in that case. Store update still happens.


Status: [x] fixed (2026-04-27) — removed the tradeState(permlink) helper. PaymentStatusBadge and ChatMessage now read directly from $tradeStates.get(permlink) inside $derived blocks. No more per-instance derived-store allocation; one subscription on the underlying map drives all consumers. derived import dropped from tradeStatus.ts.

Description: Each call creates a new derived store. N orders on /my/orders → N deriveds subscribe to _states. Wasteful at scale.

Recommended fix: Remove tradeState() helper. Consumers do $tradeStates.get(permlink) directly. PaymentStatusBadge updated accordingly.


F-42 — phaseForVerify unknown VerifyResult kind not tested — LOW, test debt

Status: [x] fixed (2026-04-27) — added forward-compat scenario in trade-status-smoke asserting that an unknown future VerifyResult kind falls through to paid_unverifiable. 31 scenarios passing.

Description: Future VerifyResult kind would fall through to paid_unverifiable default, but no test asserts.

Recommended fix: Add scenario in trade-status-smoke.


F-44 — verifyCache not cleared on lock — LOW, privacy

Status: [x] fixed (2026-04-27)runExplicitLockExtras now calls _clearVerifyCache() alongside clearAllTradeStates().

The verifier's module-level cache held (txid, recipient, sender, amount, memo) → result tuples for every BLURT verification done this session. Inspectable via debugger / JS console after lock. Same privacy class as recentPeers, readState, pubPins, and tradeStates — all of which are wiped by explicit lock.

Frontend typecheck 0 errors. All 553 smoke scenarios stable.

Where: blurtVerify.ts module-level Map.

Description: Cache holds {txid, recipient, sender, amount, memo} → result for the session. Reveals trade activity by inspection. Same privacy class as recentPeers / readState.

Recommended fix: Add _clearVerifyCache() call to runExplicitLockExtras.


F-46 — PaymentStatusBadge role="alert" persistent — LOW, accessibility

Status: [x] fixed (2026-04-27) — dropped role="alert" from paid_mismatch and disputed badges, dropped role="status" from paid_verified. Persistent state badges no longer trigger redundant screen-reader announcements on every render. Screen readers announce naturally when focus passes through the badge.

Where: PaymentStatusBadge.svelte.

Description: Uses role="alert" on persistent state badges (mismatch, disputed). Causes redundant screen-reader announcements on every render.

Recommended fix: Drop role="alert" from badges (they're persistent state, not transient alerts). Keep role="status" on verified. If transition announcement is desired, add a separate aria-live region keyed off store transitions.


Note on F-17 (skipped)

Initially considered: enforce MEMO_RE on broadcastTransfer's memo parameter to prevent off-spec memo content. Dropped because this would constrain legitimate non-Morphit uses of the function — a future caller might want to transfer with a free-form memo ("Birthday gift!") which isn't memo-shaped. The chain itself caps memo length; that's the right boundary.

Recap: structural lesson

Phase F.4/F.5 traded audit-rigor for shipping speed. The HIGH and MEDIUM findings are predominantly "what if the world isn't friendly" questions that were deprioritized:

  • F-7: "What if there are MULTIPLE transfers in the tx?"
  • F-40: "What if a third party sends payloads claiming this orderPermlink?"
  • F-8: "What if amounts have precision the chain can't represent?"
  • F-41: "What if the user never opens the chat?"
  • F-21: "What if the browser limits 6 connections?"
  • F-14: "What if a buyer typos their wallet address?"

For pre-launch, every new feature gets an explicit threat-modeling exercise: not just "does it work in the happy path" but systematically "what's the worst a malicious actor / unfortunate edge case can do."

Fix order

Recommended sequence:

  1. F-7 verifier multi-transfer (HIGH)
  2. F-40 store poisoning (HIGH)
  3. F-41 verifier not cross-page (MEDIUM, design gap)
  4. F-14 buyer-side verification (MEDIUM, design)
  5. F-8 amount precision (MEDIUM)
  6. F-21 SSE connection ceiling (MEDIUM)
  7. F-45 translation review (MEDIUM, requires native speakers)
  8. F-11 document single-RPC trust (MEDIUM, doc-only)
  9. LOWMEDIUM batch (F-18, F-23, F-30, F-43)
  10. LOW selection (privacy/security: F-44, F-26, F-33, F-25, F-1, F-2, F-3, F-9, F-10, F-13)
  11. LOW remainder (UX/perf/test debt)

Items marked [ ] backlog are deferred to post-launch.