morphit/docs/adr/0011-dynamic-fee-model.md

62 KiB
Raw Permalink Blame History

ADR-0011 — Dynamic fee model, multi-asset fees, and user incentives

Status: Accepted (Phase 4) Date: 2026-04-19 Deciders: project maintainer Supersedes: supersedes parts of ADR-0009 (fee model) Superseded by: none Related: ADR-0010 (key custody), ADR-0009 (order posting)

2026-05-13 forward note (Part 121 — fee_method enum frozen): the fee_method field type union described throughout this ADR — 'blurt' | 'waived_first_buy' | 'btc' | 'xmr' — is now a wire-format-frozen invariant, not a configuration knob. Per memory #23 (2026-05-13): listing fees can ONLY be paid in BLURT, XMR, or BTC. New tradable assets added to Morphit (USDT, ARRR, etc.) are peer-to-peer TRADING ONLY — they get canPayListingFee: false in the asset registry and never appear in the fee_method enum. Two sentinel-grep smokes guard the invariant in CI: packages/asset-registry/scripts/fee-method-enum-frozen-smoke.ts and packages/asset-registry/scripts/first-buy-waiver-payment-agnostic-smoke.ts. If either fails, the wire format or waiver gate has drifted and this ADR's fee-method assumptions no longer hold — treat such failures as charter-level decisions, not routine PR adjustments. See docs/FEES-AND-REWARDS.md §"What is FROZEN" and docs/ADDING-A-COIN.md §"2026-05-13 architectural update" for the full rationale.

2026-06-27 forward note (cp370 — canonical economics source of truth): the USD figures this ADR describes — the ~$0.25 BTC/XMR listing fee, the ~$0.125 (50%-discounted) BLURT listing fee, the $1 first-order minimum, and the Sybil multipliers — are now hardcoded in ONE place: packages/asset-registry/src/index.ts (the canonical economics was briefly factored into an economics.ts at cp370 but RE-INLINED into index.ts the same session — the package is consumed as raw source by the built mcp-server, which plain-Node ESM resolution requires to be a single self-contained file) (LISTING_FEE_USD, FIRST_ORDER_MIN_USD, FEE_PRICE_TOLERANCE, FEE_FALLBACK, and the listingFee* derivation helpers), imported by both the frontend (quote) and the indexer (validation) so the two cannot drift. The economics-canonical-smoke.ts locks the numbers + the 50%-BLURT-discount invariant + black-hat garbage-price handling.

2026-06-27 forward note (cp372 — live tracking + chain-pinned BLURT base shipped): the deferral below is now RESOLVED. cp372 built the BTC/XMR USD price subsystem (multi-source averaging + feed-health), made the displayed listing fee track the live canonical USD target (Model-A Option 1), chain-pinned the BLURT base in the morphit_release_v1 treasury.blurt.base (so the BLURT floor is deterministic across the federation like BTC/XMR), and added an automated auto-re-pin (maintainer timer, failsafes, a manual Plan B) that keeps the on-chain amounts on their USD targets. The enforced amount stays a fixed chain-pin (no price read in the verifier → no fork, no quote→pay race); FEE_PRICE_TOLERANCE absorbs the drift between re-pins. See the cp372 entries in TARBALL.md / docs/REVISIT-LIST.md and OPERATIONS.md §40.3a.

Context

ADR-0009 established Morphit's order-posting fee model:

  • Flat $0.25 base listing fee per order
  • Sybil escalation: 1× / 1× / 1.25× / 1.5× / ... based on prior order count in the last 24h
  • Fee paid exclusively in BLURT via a transfer op sibling to the order's custom_json

Several decisions from Phase 4 planning require extending this model:

  1. Grandma's first order is free (buy-only, one per account) to onboard users with no crypto.
  2. Witness fees are not constant. The account_creation_fee (currently 100 BLURT) is witness-controlled. If witnesses raise it, Morphit's unit economics break. Listing fees must auto-adjust to preserve margin.
  3. Multi-asset fee payment. Users should be able to pay Morphit's listing fee in BLURT, BTC, or XMR. BLURT is the default.
  4. BLURT payment incentives. 50% discount for BLURT-paid fees. Sybil tier escalation only applies to BLURT payments (flat tier-1 for BTC/XMR). Loyalty BP rewards at cumulative- BLURT milestones.
  5. Cross-fee interaction with delayed welcome bonus. ADR- 0010 delays the 10 BLURT liquid + 10 BLURT Power welcome bonus until the user's first successful trade, defined as the first morphit_feedback_v1 submission from a counterparty (NOT the user themselves) on an order the user posted. Self- feedback can't trigger the bonus — that would defeat the anti-grifting design.

This ADR specifies how all five interact.

Important scope note: MVP vs. future

The FULL design below (multi-asset fees, witness fee polling, loyalty milestones) represents the end-state. Implementation sub-phases within Phase 4:

  • Sub-phase 4a (MVP): BLURT-only fees, witness fee polling, first-order-free-for-BUY, delayed welcome bonus.
  • Sub-phase 4b: Multi-asset fees (BTC/XMR fee verification), BLURT discount mechanics, tier escalation restriction.
  • Sub-phase 4c: Loyalty milestones and BP reward payouts.

Each sub-phase is a shippable increment. The ADR defines the contract; the sub-phases stage the delivery.

Decision

1. Dynamic listing fee formula

The base USD-denominated listing fee is recomputed dynamically from chain state:

base_fee_blurt = account_creation_fee_blurt × amortization_factor
                 + operational_margin_blurt

listing_fee_usd = base_fee_blurt × blurt_usd_price

Where:

  • account_creation_fee_blurt — current witness-set value, polled hourly from get_chain_properties. Morphit caches the last observed value + timestamp.
  • amortization_factor — how much of a new user's account-creation cost each listing pays toward. At a factor of 0.5, 2 listings amortize one full account-creation fee. At 1.0, every listing pays for one in full — suitable if most listings come from new users. Operator-configurable; default 0.5.
  • operational_margin_blurt — Morphit's unit profit per listing. Operator-configurable; default ~25 BLURT so that at 100-BLURT account-creation cost and factor 0.5, a listing covers 50 BLURT of that cost + 25 BLURT margin + some buffer ≈ $0.15 cost + $0.10 margin = $0.25 listing fee.
  • blurt_usd_price — price feed (Klingex via ADR-0004 + phase 4 implementation). When unavailable, the indexer falls back to $0.002 and logs a warning.

The indexer republishes the current listing_fee_blurt via /v1/listing-fee hourly (or whenever witness fees change), and the frontend fetches it on the compose page to quote the user. A user's quote has a 5-minute TTL; if they delay past 5 minutes, the frontend re-fetches.

2. Payment-asset options

The user chooses one of three fee assets at compose time:

BLURT (default):

  • Paid via a transfer op sibling to the order's custom_json (as today in ADR-0009).
  • Recipient: @morphit-fees.
  • Memo: morphit-fee:<permlink> (unchanged from ADR-0009).
  • 50% discount applied. User pays listing_fee_blurt / 2.
  • Sybil tier escalation applies (1× / 1× / 1.25× / 1.5× / ... on prior BLURT-paid orders in last 24h).
  • Counts toward cumulative BLURT fee loyalty tracking.

BTC:

  • Paid via on-chain Bitcoin transaction to Morphit's BTC fee address.
  • No sibling op on Blurt — payment happens out-of-band.
  • User declares fee_method: "btc" in their order's custom_json payload and includes the Bitcoin transaction ID they used as fee_external_txid.
  • Verification via public block explorer. The indexer queries a public explorer (blockchain.info, blockstream.info, or a configured alternative) to confirm the declared txid sent at least the expected amount to Morphit's BTC address.
  • If explorer verification fails or the explorer is unavailable, the fee is NOT auto-verified. The order enters fee_status = "pending_external" and both parties (the order poster and their counterparty on the first matched trade) can collectively attest via a morphit_fee_attest_v1 custom_json op that the fee payment occurred. Two independent attestations flip the status to "verified_by_attestation". This is the manual fallback for degraded explorer availability.
  • No discount, flat full price.
  • No Sybil tier escalation — flat tier-1.
  • Does NOT count toward BLURT loyalty tracking.

XMR:

  • Same structure as BTC but via Monero. User sends to Morphit's XMR subaddress; declares fee_method: "xmr" and a transaction ID + optional tx_key (Monero's receive-side proof mechanism).
  • Verification via public explorer (xmrchain.net or configured alternative). Monero's view-key/tx-key proof mechanism lets an external explorer confirm a transfer landed at a specific address without revealing wallet balances.
  • If explorer verification fails, the same two-party attestation fallback applies as for BTC.
  • Same economics as BTC (no discount, flat tier-1, no loyalty credit).

Rationale for external explorers over self-hosted nodes: Morphit deliberately does not operate Bitcoin or Monero nodes. Running those nodes would expand the operational surface significantly (disk, bandwidth, sync time, reorg handling, key management for Monero). Public explorers provide read-only verification at zero infrastructure cost. The two-party attestation fallback closes the availability gap — if the explorer is unreachable, mutual user confirmation substitutes.

Rationale for the BLURT-favored economics: Morphit benefits from BLURT circulation in the ecosystem. BLURT payments are cheaper to verify (single-chain, existing tooling), grow the Blurt economy, and align our users with the platform. BTC/XMR are accepted for users who don't want to deal with BLURT, but with worse economics to nudge preference.

3. Fee-verification interface (abstraction)

The indexer defines a FeeVerifier interface with one implementation per fee method:

interface FeeVerifier {
  /** Given an order's declaration and context, verify that
   *  the fee was paid correctly. Returns 'verified', 'missing',
   *  'underpaid', or 'pending' (for off-chain methods where
   *  payment may arrive within a grace period). */
  verify(order: Order, context: VerificationContext): Promise<FeeStatus>;
}

Implementations:

  • BlurtFeeVerifier — reads sibling ops as today (ADR-0009). Shipped in sub-phase 4a.
  • BitcoinExplorerFeeVerifier — queries a public Bitcoin explorer (configurable: blockchain.info / blockstream.info / mempool.space). Shipped in sub-phase 4b.
  • MoneroExplorerFeeVerifier — queries a public Monero explorer (configurable: xmrchain.net / localmonero.co). Uses Monero's tx-key proof mechanism for confirmation. Shipped in sub-phase 4b.
  • AttestationFeeVerifier — flips orders from pending_external to verified_by_attestation when two independent morphit_fee_attest_v1 ops are observed. Shipped in sub-phase 4b alongside BTC/XMR.

The dispatcher routes to the right verifier based on the order's declared fee_method. Orders with unknown or missing fee_method default to BLURT (backward-compat with ADR-0009 orders).

New op: morphit_fee_attest_v1 — a custom_json op with payload {"order_permlink": "...", "order_account": "..."}. Signed with posting authority. An order in pending_external requires exactly two attestations from distinct accounts — the poster and their counterparty on the first matched trade — before transitioning to verified_by_attestation.

Counterparty identification: the first morphit_feedback_v1 submission against the order identifies the counterparty. A subsequent morphit_fee_attest_v1 from either the poster or that specific counterparty counts as an attestation. This prevents grifting by self-attestation: the order poster alone cannot flip their own fee to verified.

4. First-order-free for BUY

Once per account, the first order the account posts may be a BUY order with fee_method: "waived_first_buy". The indexer verifies:

  1. The account has no prior orders in the indexer's orders table.
  2. The order's side is "buy".
  3. The fee_method field is exactly "waived_first_buy".

If all three pass, the order is marked fee_status: "verified" and becomes live. The accounts table gains a column first_buy_waived_at TIMESTAMPTZ recording when the waiver was used, preventing re-use.

If conditions fail (wrong side, not first order, etc.) — the order is rejected with reject_reason: "waiver_invalid".

New-user indicator: an account with low rep (the trustworthy feedback_count < 4 per the orderbook endpoint's sock-puppet-filtered count) is marked with an is_new_trader: true field in /v1/orderbook responses. The frontend renders these with the 🌱 sprout chip and gentle pulse animation per the project owner's UI direction. As trustworthy feedback accumulates, the flag clears at the count-≥4 threshold.

5. Witness fee polling

The indexer runs an hourly job:

  1. Call Blurt RPC database_api.get_chain_properties.
  2. Extract account_creation_fee.amount (in BLURT).
  3. If the value differs from the last cached value, log the change, update the cache, recompute listing_fee_blurt, and emit an operator alert (webhook or structured log entry consumable by the operator's monitoring stack).
  4. Update the indexer's /v1/listing-fee response.

If the RPC call fails, the indexer retains the last known fee and retries on the next hourly tick. Operator alerted after 3 consecutive failures.

Operator alert format — structured log record, parseable by syslog, discord webhook, jq pipeline, or email extract. Text-sink form (dev / default journalctl):

[witness-fee] fee_changed old_blurt=100 new_blurt=150 observed_at=2026-04-19T12:00:00.000Z

JSON-sink form (MORPHIT_LOG_FORMAT=json):

{"ts":"2026-04-19T12:00:00.000Z","level":"warn","module":"witness-fee",
 "event":"fee_changed","context":{"old_blurt":100,"new_blurt":150,
 "observed_at":"2026-04-19T12:00:00.000Z"}}

Operator has discretion to adjust operational_margin_blurt or amortization_factor if the new economics change the margin strategy. No automatic parameter changes beyond the formula recomputation.

6. Sybil tier escalation — BLURT-only

ADR-0009's tier system (1× / 1× / 1.25× / 1.5× / 1.75× / 2×) applies ONLY when computing BLURT fees. It escalates based on the user's count of BLURT-paid orders in the last 24h, not total orders across all fee methods.

A user who pays 10 orders in 24h with BTC remains tier-1 for their 11th order. If that 11th order pays in BLURT, it is tier-1 (first BLURT order in window). If they THEN switch to BLURT for subsequent orders, each counts toward the tier.

This protects the BLURT-paying active user from being penalized for mixed-method usage. BTC/XMR payments are simply flat-priced.

7. Cumulative BLURT loyalty tracking

Each time a user pays a listing fee in BLURT, the indexer adds the amount to a running per-account cumulative. Milestones trigger a BP reward from @morphit-relay:

Cumulative BLURT fees BP reward Approx. spend
100 BLURT 10 BP $0.20
500 BLURT 50 BP $1.00
2,000 BLURT 200 BP $4.00
10,000 BLURT 1,000 BP $20.00

These values are placeholders; operator can adjust via config without an ADR change. The schema supports arbitrary thresholds configured at startup.

Implementation: a new account_loyalty table tracks:

  • account (pk)
  • cumulative_fees_blurt (total BLURT fees ever paid)
  • last_milestone_blurt (the most recent milestone threshold crossed)

When processing a BLURT fee payment, indexer updates cumulative_fees_blurt. If the new total crosses a configured milestone threshold higher than last_milestone_blurt, the indexer triggers a reward: relay sends BP via transfer_to_vesting from @morphit-relay to the user.

Triggering happens via the same delayed-bonus mechanism as the welcome reward (ADR-0010 §2 step 6): the indexer writes a row to relay_pending_transfers; the relay polls that table and broadcasts the transfer on its next active-key session.

Risk: loyalty rewards are real BLURT paid by Morphit. Operator-configurable thresholds + BP amounts let us tune the program to match revenue. Default values above assume an active user who pays ~10,000 BLURT in fees ($20) gets ~$2 of BP back over their journey — 10% of spend. Adjust as data warrants.

8. Delayed welcome bonus — per ADR-0010

When the indexer processes a morphit_feedback_v1 op where:

  • The feedback is from a counterparty (not the user themselves)
  • The subject is a user who has first_trade_complete_at IS NULL in their accounts row
  • The feedback references an order the subject posted

Then the indexer:

  1. Sets accounts.first_trade_complete_at to the block timestamp.
  2. Writes a row to relay_pending_transfers:
    • 10 BLURT liquid via transfer
    • 10 BLURT via transfer_to_vesting (staked as BP)
  3. The relay polls this table on its next session and executes both transfers in one transaction.

Why the relay polls instead of the indexer pushing: the relay has the active key; the indexer does not. Keeping the active-key operations in the relay's purview maintains the key-custody boundary from ADR-0010.

9. Schema migration v4

New/modified tables:

-- Track first-trade completion + first-buy-waiver per account.
ALTER TABLE accounts
    ADD COLUMN first_buy_waived_at TIMESTAMPTZ,
    ADD COLUMN first_trade_complete_at TIMESTAMPTZ;

-- Cumulative BLURT fee tracking for loyalty milestones.
CREATE TABLE account_loyalty (
    account TEXT PRIMARY KEY,
    cumulative_fees_blurt NUMERIC NOT NULL DEFAULT 0,
    last_milestone_blurt NUMERIC NOT NULL DEFAULT 0,
    updated_at TIMESTAMPTZ NOT NULL
);

-- Queue of pending transfers the relay should execute. The
-- relay polls this table during its active-key session and
-- clears rows as it broadcasts.
CREATE TABLE relay_pending_transfers (
    id BIGSERIAL PRIMARY KEY,
    recipient TEXT NOT NULL,
    kind TEXT NOT NULL CHECK (kind IN ('liquid', 'vesting')),
    amount_blurt NUMERIC NOT NULL,
    reason TEXT NOT NULL, -- 'welcome_bonus', 'loyalty_milestone', 'dust_refill', etc.
    created_at TIMESTAMPTZ NOT NULL,
    broadcast_at TIMESTAMPTZ,
    broadcast_trx_id TEXT
);
CREATE INDEX relay_pending_transfers_unbroadcast_idx
    ON relay_pending_transfers (created_at)
    WHERE broadcast_at IS NULL;

-- Record of witness-fee changes Morphit has observed.
CREATE TABLE witness_fee_history (
    observed_at TIMESTAMPTZ NOT NULL,
    account_creation_fee_blurt NUMERIC NOT NULL,
    PRIMARY KEY (observed_at)
);

10. Payload schema extensions

morphit_order_v1 gains an optional fee_method field:

{
  "permlink": "...",
  "side": "buy",
  "asset": "BTC",
  "...": "...",
  "fee_method": "blurt" | "btc" | "xmr" | "waived_first_buy"
}

Default if omitted: "blurt" (back-compat with ADR-0009).

For BTC/XMR, additional fields are required:

{
  "...": "...",
  "fee_method": "btc",
  "fee_external_txid": "<bitcoin tx id>"
}

The indexer's verifier for the chosen method uses these fields to locate the payment.

Alternatives considered

Keep a fixed listing fee indefinitely

Rejected. Witness fee changes would silently destroy Morphit's margin. Dynamic tracking is table-stakes.

Morphit runs its own price oracle

Rejected. Out of scope. Klingex polling (ADR-0004 + Phase 4 implementation) is sufficient.

Accept fees in any ERC-20 / cross-chain token

Rejected. BTC and XMR cover the main privacy-focused crypto users. Additional assets expand verifier complexity without user demand evidence.

Charge small upfront registration fees instead of amortizing account-creation cost across listings

Rejected per ADR-0010. Grandma has no crypto at signup.

Pre-purchased vouchers from community services (e.g., blurtplugin.online)

Rejected. Would make Morphit dependent on a third-party service for its core registration flow. We create accounts ourselves via account_create.

Consequences

Positive

  • Fee economics auto-adjust to witness changes; Morphit always profits on each registration.
  • BLURT becomes the preferred fee asset via explicit discount and Sybil tier benefit — grows the Blurt ecosystem.
  • Loyalty program rewards long-term users with BP, staking them further into Morphit.
  • First-order-free-for-BUY onboards zero-crypto users cleanly.
  • BTC/XMR fee payment removes the "you must hold BLURT" barrier for privacy-maximalist users.

Negative

  • Multi-asset fee verification depends on public block explorers for BTC and XMR. Morphit does not run those nodes, so verification is only as reliable as the explorer APIs we consult. When explorers are degraded, orders enter pending_external and require two-party attestation to verify. This is a UX tax on BTC/XMR payers.
  • Loyalty BP payouts consume BP voting mana from @morphit- relay. Needs monitoring; if abused, the relay's mana runs dry and the rewards pause until regen.
  • The relay_pending_transfers table creates a queue the relay must drain. If the relay is down for a week, users don't receive their bonuses until it comes back up. Deferred, not lost.
  • Listing-fee quote TTL introduces a minor UX wrinkle: a user who pauses for 6 minutes during compose may see a slightly different fee when they resume.
  • The two-party attestation path is attackable if a poster colludes with their counterparty to falsely attest payment. But doing so requires successfully completing a real trade (the counterparty is identified by feedback submission), so the attacker cost is the same as for any trade-level fraud. The attestation doesn't meaningfully lower the attack bar.

Neutral

  • The witness fee change history is preserved for operator auditing.
  • Sub-phase structure (4a, 4b, 4c) means BLURT-only fees are the initial behavior. Users will know this is MVP; BTC/XMR payment arrives later.

Implementation plan (per sub-phase)

Sub-phase 4a — MVP (2-3 turns):

  • Migration v4 (accounts cols + relay_pending_transfers + witness_fee_history; skip account_loyalty for now)
  • Witness fee polling
  • Dynamic listing fee formula + /v1/listing-fee endpoint
  • First-order-free-for-BUY indexer logic
  • fee_method: "waived_first_buy" handler path
  • Delayed welcome bonus trigger (on feedback submission)
  • relay_pending_transfers queue writer (indexer side)
  • Relay polling the queue and broadcasting

Sub-phase 4b — BTC/XMR fees (2-3 turns):

  • FeeVerifier interface refactor
  • BitcoinExplorerFeeVerifier (blockchain.info / blockstream.info)
  • MoneroExplorerFeeVerifier (xmrchain.net with tx-key proof)
  • morphit_fee_attest_v1 op handler for two-party attestation fallback
  • AttestationFeeVerifier that consumes attestations
  • Frontend fee-method selector with BLURT default
  • Sybil tier escalation restricted to BLURT
  • 50% BLURT discount applied

Sub-phase 4c — Loyalty (1 turn):

  • account_loyalty table added
  • Cumulative BLURT tracking during fee processing
  • Milestone threshold checking + relay_pending_transfers writes for BP rewards

Non-goals

  • Fee discounts for power users beyond the Sybil tier interaction.
  • Refunds for fees paid on cancelled orders (ADR-0009 established cancellation doesn't refund).
  • Fee payment via fiat (Morphit never touches fiat).
  • Integrated BTC/XMR wallets in Morphit for paying the fee from the same UI. Users manage their own BTC/XMR wallets.

Amendments

2026-05-09 (Part 90, Category I ADR-fidelity audit) — fee model evolved to BLURT-native

The body of this ADR describes a USD-denominated fee ($0.25 base, $0.125 BLURT-discounted, computed via dynamic USD-to-BLURT conversion at verification time). What actually shipped is BLURT-native: fees are denominated directly in BLURT, with no USD anchor at verification time. The canonical reference is docs/FEES-AND-REWARDS.md, which carries line-cited figures.

What actually shipped (Phase 4 onward):

  • apps/indexer/src/config/index.ts line 332: MORPHIT_INDEXER_FEE_BASE_BLURT defaults to 60 (BLURT units). At BLURT ≈ $0.002 this is ~$0.12 — close to the original $0.125 BLURT-discounted target, but decoupled from the USD oracle. Operators can tune per-instance.
  • apps/indexer/src/indexer/fee.ts — Sybil multiplier table indexed by per-account 24h post count: tiers 1-3 flat at 1.0×, then escalating 1.25× / 1.5625× / ... up to ~4.77× at tier 10 with compounding 1.5× per tier beyond. Indexer and frontend compute the same formula from the same feeBaseBlurt × sybilMultiplier(nth) signature; tolerance band absorbs floating-point rounding.
  • BTC and XMR fees: 100% to treasury (@morphit-fees) rather than the 90/10 split — see ADR-0013 §"Q3 Fee split" for the rationale (BTC/XMR are paid by the buyer to a treasury-owned address, no operator attribution path that's chain-verifiable).

Why it changed: the USD-denominated model required a live, authenticated USD-to-BLURT oracle at every fee verification, which (a) added a privacy and reliability dependency the operator-class doesn't want, (b) made fees non-deterministic at submit time (the user sees one number, the indexer sees another), and (c) created operator-tunability headaches when BLURT prices moved fast. BLURT-native fees are deterministic, locally computable, and let operators set their own price without either side blocking on an oracle.

What's preserved from the original model:

  • Sybil tier escalation (multiplier-based, scales per-account, 24h rolling window).
  • BLURT incentive (BTC/XMR pay treasury 100% so users paying in BLURT enjoy the operator's split-back rebate as a discount-by-other-means).
  • Loyalty (account_loyalty table tracks BLURT-paid cumulative; thresholds trigger BP delegation rewards via relay_pending_transfers).
  • First-buy waiver (one free order per account, BLURT buy only, BLURT-paid fee only).

Net: the spirit of ADR-0011 (Sybil-resistant, BLURT-incentivized, loyalty-rewarded) is fully implemented. The USD-anchored verification mechanism was simplified out. The "Flat $0.25" and "$0.125 in BLURT" figures in the body should be read as historical target prices, not current configuration.

2026-05-10 (Part 106) — treasury chain-pin closes the BTC/XMR fork-attack vector

Background. ADR-0011's 2026-05-09 amendment established the policy that "BTC and XMR fees: 100% to treasury (@morphit-fees)" — but no code enforced the addresses themselves. Each operator's indexer trusted its own MORPHIT_INDEXER_BTC_FEE_ADDRESS and MORPHIT_INDEXER_XMR_FEE_ADDRESS env vars as gospel. This left a real fork-attack vector unmitigated until Part 106:

  1. A hostile fork edits its env vars to the attacker's own BTC/XMR addresses.
  2. A user posts a buy/sell order through the hostile instance and pays the listing fee in BTC. The pre-Part-106 frontend never displayed the actual address — the locale string just said "Send the fee to our Bitcoin address" — so the hostile operator was free to surface their own address via a help link or sidebar.
  3. The hostile indexer verifies the txid against its own configured address, marks the order fee_status='verified', and the order goes live on the hostile orderbook.
  4. The federated morphit.io indexer scrapes the same chain ops, fetches the txid from a BTC explorer, checks against morphit.io's configured address — mismatch → marks the order fee_status='underpaid'/missing → the order never appears on canonical's orderbook.
  5. Hostile operator pockets the BTC. Treasury gets nothing. No alarm anywhere.

The fix. Extend the existing signed morphit_release_v1 op (already authenticated by the @morphit posting key via the trust anchor pinned in apps/web/src/lib/net/config.ts) with an optional treasury block:

{
  "version": ...,
  "hash_manifest": {...},
  "endpoints": {...},
  "treasury": {
    "btc": { "address": "bc1q...", "satoshis": 416 } | null,
    "xmr": { "address": "4...", "viewkey": "<64-hex>",
             "piconero": "781250000" } | null
  }
}

A TreasurySource in the indexer (apps/indexer/src/indexer/ treasurySource.ts) resolves the canonical address with this precedence:

  1. Most recent valid morphit_release_v1 row's treasury.{btc|xmr} field — chain-pinned canonical.
  2. The operator's env-var fallback (existing pre-Part-106 env vars) — bootstrap fallback for fresh indexers that haven't seen a treasury-bearing release op yet.
  3. null — meaning that fee method is disabled on this instance.

The poller queries TreasurySource on each cycle (cached 30s); when the canonical address changes, the BTC/XMR verifiers are rebuilt to use the new address. No restart required.

The frontend reads the same chain-pinned addresses from /v1/release (validated by @morphit/release-schema (cp170; formerly apps/web/src/lib/net/releaseValidate.ts) with rules that mirror the indexer's), and renders them on the post-order page (ListingFeeAddressPanel.svelte) with copy-button + QR code + "chain-pinned by @morphit" badge. This closes both the indexer-side authority gap AND the frontend-side social-engineering surface.

Validation rules (mirrored on indexer + frontend so any release the indexer accepts also passes the frontend's validator and vice versa):

  • BTC address: mainnet only — bech32 (bc1q...), legacy (1...), or P2SH (3...). Testnet (tb1, m, n) rejected to prevent fat-finger config from reaching mainnet indexers.
  • BTC satoshis: positive integer, sanity-bounded at 1000 BTC per listing.
  • XMR address: mainnet primary (95 chars, starts 4) or subaddress (95 chars, starts 8). Testnet (9/B) and stagenet (5/7) rejected.
  • XMR viewkey: exactly 64 lowercase hex characters, no 0x prefix. Despite the term "private", this key is publish-safe by Monero design — it reveals only INCOMING transactions to the address.
  • XMR piconero: positive decimal string, 16-digit sanity bound (~1000 XMR).

Hostile-fork containment. After Part 106, a hostile fork that edits its own indexer to ignore the chain pin only succeeds in marking orders verified on its own instance — every other federated indexer still marks them unverified, and the orderbook fork is visibly inconsistent with the federation. This is a defection signal that anyone scraping multiple instances can detect. The treasury cannot be silently diverted anymore.

XMR view-key publication. Publishing the private view key on chain is intentional and benign per Monero's design — view keys reveal incoming transactions to an address only, never outgoing spends or balance after spend. This is the standard mechanism for transparency- required wallets (charity, escrow, treasury). The apps/indexer/scripts/verify-xmr-viewkey.ts helper validates that a candidate (address, viewkey) pair actually decodes a known transaction before broadcast — operators MUST run it before any release op carrying a treasury XMR field, since a typo in the viewkey looks identical to a working config until the first XMR fee silently fails.

Operational discipline (OPERATIONS.md §40). The @morphit posting key signs release ops including the treasury chain-pin. This key lives off the morphit.io production server, on a personal machine the operator trusts — typically a laptop with a Blurt- aware wallet (Vessel, beempy, blurt-cli, dblurt-script). Same offline-key discipline as the rest of the release- op infrastructure.

Community-operator deviation. Community operators running their own Morphit instance default to inheriting the canonical's chain-pinned addresses (leave their env vars empty). Operators who deliberately fill in their own env vars accept BTC/XMR fees to their own address but are visibly inconsistent with the federation — their orders won't appear on other instances' orderbooks because the txids paid the wrong address from canonical's perspective. This deviation is permitted (federation, not centralization) but not in the spirit of the design.

2026-05-10 (Part 107) — XMR view key REMOVED from chain-pin (privacy correction)

Background. Part 106's design embedded the Monero private view key in the chain-pinned treasury block under the rationale "the private view key is publish-safe by Monero design." That framing is true narrowly (no theft risk) and wrong for privacy.

The privacy harm. Publishing the private view key reveals:

  • Every incoming payment to the treasury wallet.
  • Every amount, every timing, every subaddress, forever.
  • Future inflows, not just past ones.

For a treasury wallet receiving recurring listing fees, this means any analyst can:

  • See the entire fee-flow history of the project.
  • Correlate XMR fee payments to user Blurt accounts (the order op naming the user is broadcast on Blurt at roughly the same time as the XMR payment lands).
  • Track payment patterns to deanonymize users at scale.

This is incompatible with Morphit's positioning (privacy-preserving, federated, no KYC) and is particularly egregious because the treasury wallet receives high-volume, statistically-rich payment data from many users.

The fix. Remove viewkey from the chain-pinned treasury block:

  • Frontend releaseValidate.ts and indexer handler's validateTreasury() no longer accept a viewkey field. If a payload contains one (legacy/hostile), it is silently stripped — never persisted, never surfaced.
  • TreasurySource.resolveXmr() resolves the view key ONLY from MORPHIT_INDEXER_XMR_FEE_VIEWKEY env, never from chain. When the chain-pinned XMR address is present but env viewkey is empty (community-operator case), XmrTreasury.viewkey is undefined — the poller leaves the verifier disabled and the order handler rejects xmr orders cleanly.
  • /v1/release endpoint strips any viewkey from response output (defense-in-depth — the handler should already prevent persistence, but a third strip point catches any DB-write bypass).
  • release-build-payload.ts does not prompt for the viewkey; refuses to emit any payload containing a 64-hex string (catches any future regression).
  • ListingFeeAddressPanel.svelte no longer displays a "view key" disclosure in the UI.

Federation behavior change for XMR. Pre-Part-107, community operators inheriting canonical's chain-pinned XMR address would automatically also inherit the view key (because it was on chain). Post-Part-107, they inherit the address but NOT the view key. Three options for them:

(a) Trust canonical's federated XMR verdict — requires a federation-trust path that doesn't exist yet. Tracked as Part 108+ work.

(b) Run their own XMR treasury wallet with their own env address + view key. Deviates from canonical (their XMR-fee orders won't appear on canonical's orderbook), but they keep 100% of the XMR that flows in.

(c) Disable XMR fee acceptance on their instance. Cleanest option today.

The Part 106 chain-pin defense for ADDRESS still holds. Part 107 only changes the view key handling. The fork-attack vector of "hostile fork redirects to a different XMR address" is closed by the address- chain-pin alone; the view key was never the defense for that — it was just a convenience for community-operator verification.

Operational discipline (OPERATIONS.md §40.2). The view key now has the same operational discipline as the @morphit-relay active key: lives in env on the production server only, mode 0600, owned by morphit-indexer, read into process memory at boot, never published in any form. Operators who broadcast release ops MUST NOT include a viewkey field (the builder script enforces this; the handler strips it defense-in-depth).

2026-05-10 (Part 108++) — XMR per-payment tx_proof verification (no view key required by any indexer)

Background. Part 107 corrected the Part 106 design error of broadcasting the treasury wallet's private view key on chain (privacy regression). The fix kept the view key env-only on the canonical operator's box — but that meant only canonical morphit.io could verify XMR fees. Community operators inheriting canonical's chain-pinned XMR address had no view key, so they faced a three-options dilemma documented in OPERATIONS.md §40.8 (Part 107):

(a) Trust canonical's federated verdict — required a federation-trust path that never shipped. (b) Run their own treasury wallet. (c) Disable XMR fees.

Why (a) was a priority #2 violation. A federation- trust path for XMR verification would have made every community indexer dependent on canonical morphit.io's existence and willingness to verify. That contradicts priority #2 (decentralization, fully distributed, unstoppable — no chokepoints, no "morphit.io must be up" assumptions). The path was tracked as a Part 108+ TODO but plowing it would have built the wrong thing.

Part 108++ resolves the dilemma structurally. Use Monero's standard per-payment proof mechanism (get_tx_proof / prove_tx). The user generates a proof from their own wallet after paying; any indexer verifies the proof against the txid + treasury address using a public Monero block explorer endpoint or a local monerod RPC. NO view key is required by any indexer.

Properties under the three priorities:

  • Priority #1 — Privacy. The proof reveals only "this txid paid this address this amount." It does NOT reveal: other payments to the address, other transactions in the user's wallet, the user's other addresses, wallet balance, or any wallet metadata. The user is the ONLY party that holds any verification secret (their tx_key, in their own wallet, never published). Indexers hold nothing.

    Compared with the Part 107 status quo (treasury view key on canonical operator's box, sent over HTTPS to the explorer for every verification), the proof approach is strictly less leaky:

    • View key approach: explorer learns about EVERY incoming payment to the treasury, forever (one request → cumulative wallet-history visibility).
    • Proof approach: explorer learns about ONE payment per verification request (one request → one payment, period).
  • Priority #2 — Decentralization. Every Morphit indexer can verify every XMR payment independently. No shared secret. No central instance. Canonical morphit.io is one indexer among many, with no privileged role in verification. Federation tolerates any single instance disappearing — including canonical — without breaking XMR for anyone else.

    Operators can self-host a monero-block-explorer + local monerod Docker stack to eliminate any third-party dependency. Documented in OPERATIONS.md §40.4.

  • Priority #3 — Grandma-friendliness. Trade-off: the user must generate a proof from their wallet after paying (one extra step vs. just pasting a txid). Mitigated by inline per-wallet instructions on the post-order page in 10 locales:

    • Monero CLI: get_tx_proof <txid> <address>
    • Monero GUI: Advanced → Prove transaction → fill in txid + address → Generate
    • Cake Wallet: Settings → Privacy → Verify a transaction → Generate proof
    • Feather: Tools → Prove/check transaction → Generate

    All four wallets implement this feature in their standard UI. An expandable details block on the post-order page walks the user through each.

    An FAQ entry (xmr_tx_proof) provides extended explanation including the privacy invariant ("the proof reveals only this one payment, nothing else").

The fix scope.

Code changes:

  • New MoneroProofFeeVerifier (apps/indexer/src/ indexer/fee/moneroProofVerifier.ts). Same circuit- breaker + multi-explorer pattern as BTC. Uses txprove=1 mode of xmrchain.net's /api/outputs, passing the user's proof in place of a viewkey. Includes tx_hash echo check (Item 4 / Audit Part 26 parity with BTC verifier).
  • Old MoneroExplorerFeeVerifier deleted along with its 2 test files.
  • FeeClaim interface gains txProof: string | null field.
  • Order handler structural validator requires + validates tx_proof for fee_method=xmr only (BTC unchanged).
  • Schema migration v29 adds nullable tx_proof TEXT column to orders table.
  • Indexer poller bootstrap + refresh paths rewritten: no more viewkey-required gate, address-only rebuild trigger.
  • /v1/release.treasury API endpoint already strips any viewkey (Part 107 defense-in-depth) — invariant preserved.
  • release-build-payload.ts already refuses to emit payloads containing 64-hex strings (Part 107 defense-in-depth) — invariant preserved.
  • Frontend post-order page: new tx_proof state + validator + UI section (privacy reassurance banner, per-wallet instructions, textarea, error feedback). Submit-gate + draft persistence include tx_proof.

Config changes:

  • MORPHIT_INDEXER_XMR_FEE_VIEWKEY is now a deprecated stub. No code path reads it. Removed entirely in Part 109 (see Part 109 amendment below).

Test additions:

  • 25 new MoneroProofFeeVerifier unit tests (happy path, all rejection paths, explorer health, two privacy-invariant tests).
  • 3 new MoneroProofFeeVerifier breaker integration tests (parity with BTC).
  • 5 new order handler tx_proof validation tests.
  • 5 new frontend payload tests.
  • explorer-txid-echo-smoke rewritten — XMR scenario 4-6 use the new proof verifier.
  • Old MoneroExplorerFeeVerifier tests (15) deleted.

Locale additions:

  • 19 new strings × 10 locales = 190 string additions for the proof workflow UI.
  • New FAQ entry xmr_tx_proof × 10 locales.
  • Locale parity 2,401 → 2,422 keys × 10.

Doc updates:

  • OPERATIONS.md §40 — major rewrite (~400 lines). New §40.2 "Three priorities: how Part 108++ realizes them." New §40.4 "Choosing your XMR explorer backend" with self-hosted Docker recipe. Simplified §40.7 community-operator section (was three-options dilemma; now one default flow). New §40.11 migration path.
  • RUN-A-MORPHIT-NODE.md §8 community-operator callout simplified — every operator can verify XMR independently now.
  • This ADR-0011 Part 108++ amendment.
  • AUDIT-2026-05.md Part 108++ entry.
  • MORPHIT-BRAG-LIST.md new entry on per-payment proofs eliminating any need for view keys on any indexer.

The Part 106 ADDRESS chain-pin defense and Part 107 privacy invariant both remain in force. Part 108++ is additive on top: it changes XMR verification mechanics, not the address-pinning trust model and not the view-key-never-published invariant. All three parts compose:

  • Part 106: BTC/XMR addresses + amounts pinned on chain by @morphit.
  • Part 107: View key NEVER on chain, NEVER in API, NEVER in logs.
  • Part 108++: View key NEVER required at all. Per- payment proofs replace it.

Federation behavior change (positive). Pre-Part- 108++, only canonical morphit.io could verify XMR fees. Post-Part-108++, every Morphit instance can. The "three options for community operators" dilemma is obsolete; the canonical flow now works for everyone. Federation health is strictly improved.


Part 109 amendment (2026-05-10)

Title: cleanup + hardening — viewkey env removed, multi-explorer quorum gate, per-instance chat-link URLs, wizard explorer configurability.

This part is mostly cleanup and tightening on top of Parts 106/107/108++. No fundamental design changes; the priorities lens (#1 privacy, #2 decentralization, #3 grandma-friendliness) drove each individual fix.

Changes

1. MORPHIT_INDEXER_XMR_FEE_VIEWKEY removed entirely. Part 108++ marked the env var "deprecated stub" pending one transitional cycle. Part 109 deletes it:

  • env-var dropped from apps/indexer/src/config/index.ts
  • xmrFeeViewKey field removed from Config interface
  • loader assignment removed
  • viewkey field removed from XmrTreasury interface
  • xmrViewkey field removed from TreasurySourceEnvFallback
  • resolveXmr() no longer references view keys
  • poller TreasurySource construction no longer passes xmrViewkey
  • release-build-payload.ts no longer prints the pre-broadcast viewkey-verify nudge
  • env example, OPERATIONS.md, RUN-A-MORPHIT-NODE.md swept clean
  • treasurySource.ts header comment block rewritten for Part 109
  • schema.sql treasury column comment updated

Stale viewkey fields on Part 106-vintage chain-pin rows continue to be silently stripped at parse time (belt-and-suspenders: the validator has never persisted the field, and the resolver never reads it).

2. minSuccessfulResponses quorum gate on both BTC and XMR fee verifiers. Pre-Part-109 behavior: if N-1 of N configured explorers fail (or return data_not_found), the verifier promotes a payment to verified based on the single remaining response. A 1-of-5 result during a degraded outage is structurally weaker than the multi-explorer cross-check the operator signed up for.

Part 109 adds a minSuccessfulResponses config field (both verifiers; default 1 preserves back-compat), plumbed from new env vars:

  • MORPHIT_INDEXER_BTC_MIN_SUCCESSFUL_RESPONSES
  • MORPHIT_INDEXER_XMR_MIN_SUCCESSFUL_RESPONSES

The loader cross-validates: if the threshold exceeds the URL count, the indexer refuses to start with a clear error message ("quorum can never be met").

When the configured threshold isn't met at verify time, the verifier returns pending_external with reason quorum not met: <got>/<wanted> explorers returned usable data. The order can still be promoted later (next polling cycle) or via the attestation path.

With the default 5-explorer XMR list, operators can set the threshold to 2 or 3 for genuine multi-source cross-check. BTC operators with both Esplora explorers configured can set 2 for the same effect.

3. Per-instance chat-link external explorer URLs. The frontend's apps/web/src/lib/explorer/urls.ts now consults the instance store for operator-configured templates, falling back to bundled defaults (mempool.space/tx/{txid} for BTC, xmrchain.net/tx/{txid} for XMR) when no override is set.

Plumbing:

  • new env vars MORPHIT_FRONTEND_BTC_CHAT_LINK_URL and MORPHIT_FRONTEND_XMR_CHAT_LINK_URL with zod refinement (https://, contains {txid}, parses as URL after substitution, no credentials)
  • new fields on InstanceResponse.chat_link_urls (indexer + indexer-client + frontend store)
  • urls.ts refactored: pure helpers moved into urlsCore.ts (no Svelte-store import) so node- side smokes can test the substitution / validator without pulling SvelteKit's $lib alias

Privacy framing: every click on a BTC/XMR txid in chat sends the user's IP + browser fingerprint to the configured explorer's host. Operators who self-host their own explorer (or who trust a different third party) can override per-instance. The override is per-OPERATOR (not per-user) — a user who wants different behavior chooses a different Morphit instance.

4. Setup wizard extended for fee-verifier explorer URLs and chat-link URLs. The morphit-ops init CLI gains:

  • step 11: fee-verifier explorer URL editor for BTC and XMR with live parallel health-probes on screen load. Edit / keep / reset-to-defaults menu per asset. Probes hit the explorer's standard health endpoints (/blocks/tip/height for Esplora, /api/networkinfo for Monero) — no user data sent.
  • step 12: chat-link URL editor for BTC and XMR, with template-shape validator and reachability probe per host.
  • existing SEO and Backup steps renumbered (13/14). TOTAL_STEPS bumped from 12 to 14.

5. docs/PRE-LAUNCH-CHECKLIST.md consolidated. A single document tracks every pre-launch operator action item across Parts 106/107/108++/109, with explicit memory-rule binding ("update in same turn as any change that adds or closes an item") so it never goes stale.

Test additions

  • 7 new quorum-gate tests (3 BTC + 4 XMR)
  • 7 new explorer-urls-smoke scenarios (regexes, bundled defaults, substitution, validator, privacy invariant on distinct hosts)
  • existing 4 verifier test files updated with the required minSuccessfulResponses: 1 field

Numbers

  • Indexer tests: 406 → 413 (+7 net)
  • Frontend tests: 550 (unchanged)
  • Relay tests: 244 (unchanged)
  • explorer-urls smoke: 20 → 27 scenarios (+7)
  • Locale parity: 2,424 × 10 (unchanged — no user-facing UI strings changed)
  • TypeScript projects: 0 errors (all 8)
  • svelte-check: 0 / 0

Priority-lens evaluation

Each change was checked against the three priorities:

  • Privacy (#1): chat-link configurability respects user-IP privacy by giving operators a mechanism to point users at self-hosted explorers. Quorum gate reduces the attack surface of a single compromised explorer (verifier needs N agreeing responses, not just N=1). Viewkey removal completes the Part 107 privacy invariant.
  • Decentralization (#2): every change keeps morphit.io optional. No new central dependencies. Wizard URL-configurability lets operators substitute any explorer they trust (including self-hosted).
  • Grandma-friendliness (#3): wizard health-checks surface bad URLs with a clear ✓/⚠/✗ indicator before the operator commits. Cross-validation at config load catches misconfigurations at boot rather than at first verification attempt (clear error message instead of mysterious pending_external results).

Federation behavior

No federation behavior changes. Quorum and chat-link URLs are per-instance operator decisions; community operators who keep defaults see the same behavior they saw in Part 108++.

The Part 106 ADDRESS chain-pin defense, Part 107 privacy invariant, and Part 108++ no-view-key verification model all remain in force. Part 109 is additive cleanup + hardening; it changes operator configurability and adds defensive gates, not the fundamental design.


Part 110 amendment (2026-05-10)

Title: operator-facing cleanup + listing-fee configurability in the wizard.

This part is mostly cleanup and one operator-requested UX improvement. No design changes; the BLURT-native fee verification model from Part 105+ and the per- payment XMR proof model from Part 108++ are unchanged.

Changes

1. verify-xmr-viewkey.ts retired. The Part 107- era diagnostic helper for sanity-checking a (XMR address, view key) pair against a real test transaction is gone. Part 108++ replaced view-key- based verification with per-payment proofs; Part 109 removed the view-key env var; Part 110 retires the script. Sanity-checking your XMR fee address now flows through the modern path: configure MORPHIT_INDEXER_XMR_FEE_ADDRESS, restart the indexer, have a trusted contact send a small test payment with a tx_proof, and submit it through the real Morphit UI. This exercises the exact code path users will hit (better than any operator-only diagnostic).

Files swept: file deleted; OPERATIONS.md §12 rewritten as a Part-110-retirement notice; OPERATIONS.md §40 keys-table updated; OPERATIONS.md runbook command-ref cleaned; release-build-payload.ts header + trailer comments updated; releaseValidate.ts stale comment fixed; PRE-LAUNCH-CHECKLIST.md XMR setup entry updated.

2. Listing fee USD target now configurable in the wizard with live Coingecko recompute. Pre-Part-110, the operator had to run a separate CLI helper (recommend-fee-amounts.ts --target-usd 0.25) and paste BTC sat + XMR piconero values into morphit.config.env by hand. Part 110 promotes this to a first-class wizard step (new step 13, after fee- explorer URLs and chat-link URLs):

  • Operator enters USD target (default 0.25).
  • Wizard fetches live BTC/USD + XMR/USD from Coingecko's free public ticker (10s timeout).
  • Wizard computes amounts via computeFeeAmounts(targetUsd, prices) (round half- up); displays them; asks operator to accept or override.
  • On Coingecko unreachable: operator can enter amounts manually or keep hardcoded defaults (calibrated for $0.25 at ~$60K BTC / ~$320 XMR, flagged as likely stale).
  • Same step prompts for the fallback BLURT/USD price (used by the indexer's composite price source only when both Klingex and Coingecko have been failing).
  • WizardAnswers.listingFee.source records 'coingecko' | 'manual' | 'default' for the post-wizard review.

3. Same wizard step reachable via morphit-ops edit. The edit subcommand's menu gained a "Listing fee + fallback BLURT price" option that calls the same stepListingFee() and writes the results back to morphit.config.env atomically. Use case: re-recompute amounts when BTC/XMR drift significantly.

4. 50% BLURT-paid discount stays separate. MORPHIT_INDEXER_FEE_BASE_BLURT (default 60 BLURT, representing the ~$0.125 BLURT-paid fee = half of the $0.25 USD target) is intentionally NOT exposed in the wizard. Per operator decision: it's not a knob most operators routinely tune, and surfacing it would clutter the wizard for a marginal use case. Operators who need to change it can edit morphit.config.env by hand.

5. Pre-launch + day-zero + post-launch docs. Three new operator-facing documents:

  • docs/PRE-LAUNCH-CHECKLIST.md (Part 109; extended in Part 110 with relay-funding [blocking] item and listing-fee review [recommended] item).
  • docs/LAUNCH-DAY.md (Part 110, new): T-minus 24h, T-minus 1h, T-zero, what-to-watch first hour, rollback, 24h pacing, end-of-day retrospective.
  • docs/POST-LAUNCH-WEEK-ONE.md (Part 110, new): daily AM/PM checks, weekly rollups, paging thresholds, common situations playbook.

Test additions

  • 12 new tests for the shared feeAmountCalc.ts helpers (math: defaults, zero target, linear scaling; fetch: happy path, non-2xx, network failure, missing fields, zero/negative, string coercion, garbage).

  • No new smoke files; the init-smoke fixture gained one listingFee field on the WizardAnswers sample, exercising the env-render path.

Numbers

  • Indexer tests: 413 → 425 (+12 net for feeAmountCalc)
  • Frontend tests: 550 (unchanged)
  • Relay tests: 244 (unchanged)
  • Smoke scenarios: 2,271 (no change; Part 110 adds no new smoke files)
  • Locale parity: 2,424 × 10 (unchanged — wizard text is operator-facing, English-only)
  • Wizard TOTAL_STEPS: 14 → 15 (+1 listing-fee step)
  • Brag list: 259 → 261 (+2 new entries)
  • New code files: 1 (feeAmountCalc.ts shared helper)
  • New doc files: 2 (LAUNCH-DAY.md, POST-LAUNCH-WEEK-ONE.md)
  • Files retired: 1 (verify-xmr-viewkey.ts)

Priority-lens evaluation

  • Privacy (#1): no change. The wizard hits Coingecko from the operator's box (the operator chose to use Coingecko by accepting the live- recompute path); the indexer doesn't gain or lose any data flow. The retired viewkey script's last remaining "operator runs it locally with the view key in env" pattern is gone entirely.
  • Decentralization (#2): no change. Wizard Coingecko dependency is opt-out (manual entry + defaults available). Indexer continues to verify fees BLURT-native with no live USD oracle.
  • Grandma-friendliness (#3): wizard surfaces a setting that previously required CLI fluency. morphit-ops edit flow for ongoing maintenance. Three operator-facing runbooks help newcomers walk through launch without ad-hoc knowledge.

Federation behavior

No federation behavior changes.

All previous parts' invariants remain in force:

  • Part 106: BTC/XMR addresses + amounts pinned on chain by @morphit.
  • Part 107: View key NEVER on chain, NEVER in API, NEVER in logs.
  • Part 108++: View key NEVER required at all. Per- payment proofs replace it.
  • Part 109: View-key env var REMOVED. Wizard configures fee-verifier explorer URLs + chat-link URLs with live health probes. Quorum gate on both verifiers.
  • Part 110: Wizard configures listing-fee USD target
    • fallback BLURT price. Retired diagnostic helper. Pre-launch / launch-day / week-one runbooks shipped.

Part 111 amendment (2026-05-10)

Title: federation-cost attribution via operator_tag gating.

This part closes a federation-design gap that pre- dated Part 110: pre-Part-111, every operator's relay queued payouts on every chain-op it saw, multiplying treasury spend by the federation count. Account creation was already correctly scoped (HTTP endpoint) but the chain-op-triggered payouts (welcome bonus, low-balance refill, operator earnings, loyalty BP) were not.

cp408 amendment (2026-07-04): operator earnings no longer flow through a relay payout — the owner's 90% is paid directly by the payment-time fee split (see ADR-0013 amendment + FEES-AND-REWARDS.md). The Part-111 gate below still applies to the operator earnings attribution (which instance books the dashboard credit); the three remaining relay-queued payouts (welcome bonus, low-balance refill, loyalty BP) are gated exactly as described.

Design

Use the EXISTING operator_tag field on order ops as the gate. No new on-chain fields, no new privacy leak, uses already-published data.

Each operator's indexer compares the op's tag against MORPHIT_INSTANCE_OPERATOR_TAG (set via the new wizard step 16); only the operator whose tag matches queues the payout. Global state (orders, account_loyalty, account_loyalty_milestones, accounts.first_trade_complete_at) is still updated on every indexer for federation-consistent orderbook + audit; only the payout queue insert is per-operator.

Why not on-chain served_by?

Original instinct was to add a new field served_by: <operator-account-name> to every user- signed Morphit op. Two problems caught BEFORE shipping:

  1. Priority #1 privacy regression. Publishing "which Morphit instance this user routed through" on chain forever doxes the user-base of niche operators (Tor-only, language-specific) and defeats the privacy benefit of choosing those operators in the first place. Naive served_by fails Priority #1.

  2. Brittleness. Tying obligations to a relay account name on chain creates a coordination problem when operators rotate accounts, go offline, or get seized.

operator_tag is already on chain and already public (the user-readable directory of operators is keyed on it), so gating on it adds no new information disclosure. Operators are already self-doxing by registering a public tag; gating payouts on that tag is consistent with that existing public commitment.

Gating sites

Four queue insertion sites, each guards on operator_tag === instanceOperatorTag:

  1. apps/indexer/src/indexer/operatorEarnings.ts: attributeBlurtFeeToOperator — operator-payout (90% BLURT fee share). New AttributionResult discriminant attributed_other_instance. Zero DB writes when the gate fails.

  2. apps/indexer/src/indexer/loyalty.ts: trackVerifiedBlurtFee — first-fee welcome BP + cumulative milestone BP delegations. New params orderOperatorTag, instanceOperatorTag. Global state (account_loyalty + account_loyalty_milestones) still UPSERTs; only the relay queue insert is gated.

  3. apps/indexer/src/indexer/handlers/feedback.ts — welcome bonus (20 BLURT). Looks up cited order's operator_tag from the orders table. Global state (accounts.first_trade_complete_at) still flips on the upsert; only the queue insert is gated.

  4. apps/indexer/src/indexer/lowBalanceScanner.ts: selectCandidates — refill scope. Now JOINs orders.operator_tag = MY tag instead of EXISTS ops (which matched federation-wide activity).

Schema migration v30

Adds orders.operator_tag TEXT column (nullable) plus index (operator_tag, account, created_at) for the scanner's JOIN. Backward-compat: pre-Part-111 rows stay NULL. Pre-launch reality means this compat is for replay tests only.

Economic alignment

The operator getting the 90% reward is the same operator obligated for the 10% treasury + welcome bonus + refills + loyalty BP consequences. A spammer trying to dump payouts onto a victim operator would have to pay 90% of every fee TO that victim — net break-even, zero leverage.

Wizard step 16

MORPHIT_INSTANCE_OPERATOR_TAG. Captured at init time; canonical morphit.io uses morphit. Community operators MUST pick a unique tag AND register it on chain via morphit_operator_register_v1 before launch (otherwise their indexer recognizes no incoming ops as theirs and queues nothing — conservative default). Same step reachable via morphit-ops edit → Operator tag (federation attribution).

Default behavior

When MORPHIT_INSTANCE_OPERATOR_TAG is undefined, the gate refuses everything — the relay queues NOTHING. Better to pay nothing than to pay for ops you can't prove are yours. Community operators standing up an instance will see this explicitly in the wizard's "Operator tag" prompt and in the env file comments.

Test additions

apps/indexer/test/indexer/federationScopeGate.test.ts — 11 new scenarios covering all four gating sites with both gate-passes and gate-fails flows. Loyalty test fixture extended with orderOperatorTag + instanceOperatorTag overrides. Feedback test mocks gained the cited- order operator_tag SELECT. Smoke files updated for the new function signatures.

Numbers

  • Indexer tests: 425 → 436 (+11 federation-scope scenarios)
  • Schema version: v29 → v30
  • Wizard TOTAL_STEPS: 15 → 16
  • Smoke: 2,271 / 100 stable (triple-pulse)
  • Brag list: 261 → 262 (+1)
  • TypeScript: 0 errors all 8 projects

Priority-lens evaluation

  • Privacy (#1): zero new on-chain data; uses existing operator_tag field. No new leak class.
  • Decentralization (#2): each operator independently gates; no central coordination needed. Federation health unchanged.
  • Grandma-friendliness (#3): zero UX change for users. Operators get a clear wizard step explaining the tag's purpose and the consequences of leaving it unset.

Federation behavior

Federation-cost is now properly scoped. All previous-part invariants remain in force:

  • Part 106: BTC/XMR addresses + amounts pinned on chain by @morphit.
  • Part 107: View key NEVER on chain.
  • Part 108++: View key NEVER required at all.
  • Part 109: View-key env var REMOVED. Wizard configures fee-verifier + chat-link URLs with health probes. Quorum gate on both verifiers.
  • Part 110: Wizard configures listing-fee USD target + fallback BLURT price. Retired diagnostic helper. Pre-launch / launch-day / week-one runbooks.
  • Part 111: Federation-cost attribution via operator_tag gating. Each operator's relay pays only for ops served by their own instance.