62 KiB
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_methodfield 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 getcanPayListingFee: falsein the asset registry and never appear in thefee_methodenum. Two sentinel-grep smokes guard the invariant in CI:packages/asset-registry/scripts/fee-method-enum-frozen-smoke.tsandpackages/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. Seedocs/FEES-AND-REWARDS.md§"What is FROZEN" anddocs/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 aneconomics.tsat cp370 but RE-INLINED intoindex.tsthe 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 thelistingFee*derivation helpers), imported by both the frontend (quote) and the indexer (validation) so the two cannot drift. Theeconomics-canonical-smoke.tslocks 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_v1treasury.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_TOLERANCEabsorbs the drift between re-pins. See the cp372 entries in TARBALL.md / docs/REVISIT-LIST.md andOPERATIONS.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
transferop sibling to the order's custom_json
Several decisions from Phase 4 planning require extending this model:
- Grandma's first order is free (buy-only, one per account) to onboard users with no crypto.
- 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. - Multi-asset fee payment. Users should be able to pay Morphit's listing fee in BLURT, BTC, or XMR. BLURT is the default.
- 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.
- 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_v1submission 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
transferop 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 asfee_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 amorphit_fee_attest_v1custom_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 frompending_externaltoverified_by_attestationwhen two independentmorphit_fee_attest_v1ops 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:
- The account has no prior orders in the indexer's
orderstable. - The order's
sideis"buy". - The
fee_methodfield 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:
- Call Blurt RPC
database_api.get_chain_properties. - Extract
account_creation_fee.amount(in BLURT). - 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). - Update the indexer's
/v1/listing-feeresponse.
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 NULLin theiraccountsrow - The feedback references an order the subject posted
Then the indexer:
- Sets
accounts.first_trade_complete_atto the block timestamp. - Writes a row to
relay_pending_transfers:- 10 BLURT liquid via
transfer - 10 BLURT via
transfer_to_vesting(staked as BP)
- 10 BLURT liquid via
- 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_externaland 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_transferstable 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-feeendpoint - First-order-free-for-BUY indexer logic
fee_method: "waived_first_buy"handler path- Delayed welcome bonus trigger (on feedback submission)
relay_pending_transfersqueue writer (indexer side)- Relay polling the queue and broadcasting
Sub-phase 4b — BTC/XMR fees (2-3 turns):
FeeVerifierinterface refactorBitcoinExplorerFeeVerifier(blockchain.info / blockstream.info)MoneroExplorerFeeVerifier(xmrchain.net with tx-key proof)morphit_fee_attest_v1op handler for two-party attestation fallbackAttestationFeeVerifierthat 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_loyaltytable added- Cumulative BLURT tracking during fee processing
- Milestone threshold checking +
relay_pending_transferswrites 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.tsline 332:MORPHIT_INDEXER_FEE_BASE_BLURTdefaults to60(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 samefeeBaseBlurt × 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_loyaltytable tracks BLURT-paid cumulative; thresholds trigger BP delegation rewards viarelay_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:
- A hostile fork edits its env vars to the attacker's own BTC/XMR addresses.
- 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.
- 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. - The federated
morphit.ioindexer scrapes the same chain ops, fetches the txid from a BTC explorer, checks againstmorphit.io's configured address — mismatch → marks the orderfee_status='underpaid'/missing→ the order never appears on canonical's orderbook. - 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:
- Most recent valid
morphit_release_v1row'streasury.{btc|xmr}field — chain-pinned canonical. - 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.
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, starts8). Testnet (9/B) and stagenet (5/7) rejected. - XMR viewkey: exactly 64 lowercase hex characters,
no
0xprefix. 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.tsand indexer handler'svalidateTreasury()no longer accept aviewkeyfield. If a payload contains one (legacy/hostile), it is silently stripped — never persisted, never surfaced. TreasurySource.resolveXmr()resolves the view key ONLY fromMORPHIT_INDEXER_XMR_FEE_VIEWKEYenv, never from chain. When the chain-pinned XMR address is present but env viewkey is empty (community-operator case),XmrTreasury.viewkeyisundefined— the poller leaves the verifier disabled and the order handler rejects xmr orders cleanly./v1/releaseendpoint 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.tsdoes not prompt for the viewkey; refuses to emit any payload containing a 64-hex string (catches any future regression).ListingFeeAddressPanel.svelteno 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+ localmonerodDocker stack to eliminate any third-party dependency. Documented inOPERATIONS.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"). - Monero CLI:
The fix scope.
Code changes:
- New
MoneroProofFeeVerifier(apps/indexer/src/ indexer/fee/moneroProofVerifier.ts). Same circuit- breaker + multi-explorer pattern as BTC. Usestxprove=1mode 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
MoneroExplorerFeeVerifierdeleted along with its 2 test files. FeeClaiminterface gainstxProof: string | nullfield.- Order handler structural validator requires +
validates
tx_proofforfee_method=xmronly (BTC unchanged). - Schema migration v29 adds nullable
tx_proof TEXTcolumn toorderstable. - Indexer poller bootstrap + refresh paths rewritten: no more viewkey-required gate, address-only rebuild trigger.
/v1/release.treasuryAPI endpoint already strips any viewkey (Part 107 defense-in-depth) — invariant preserved.release-build-payload.tsalready 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_VIEWKEYis 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 §8community-operator callout simplified — every operator can verify XMR independently now.- This ADR-0011 Part 108++ amendment.
AUDIT-2026-05.mdPart 108++ entry.MORPHIT-BRAG-LIST.mdnew 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 xmrFeeViewKeyfield removed from Config interface- loader assignment removed
viewkeyfield removed fromXmrTreasuryinterfacexmrViewkeyfield removed fromTreasurySourceEnvFallbackresolveXmr()no longer references view keys- poller TreasurySource construction no longer passes
xmrViewkey release-build-payload.tsno 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
treasurycolumn 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_RESPONSESMORPHIT_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_URLandMORPHIT_FRONTEND_XMR_CHAT_LINK_URLwith 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.tsrefactored: pure helpers moved intourlsCore.ts(no Svelte-store import) so node- side smokes can test the substitution / validator without pulling SvelteKit's$libalias
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/heightfor Esplora,/api/networkinfofor 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: 1field
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_externalresults).
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.sourcerecords'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.tshelpers (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
listingFeefield 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.tsshared 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 editflow 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:
-
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_byfails Priority #1. -
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:
-
apps/indexer/src/indexer/operatorEarnings.ts: attributeBlurtFeeToOperator— operator-payout (90% BLURT fee share). NewAttributionResultdiscriminantattributed_other_instance. Zero DB writes when the gate fails. -
apps/indexer/src/indexer/loyalty.ts: trackVerifiedBlurtFee— first-fee welcome BP + cumulative milestone BP delegations. New paramsorderOperatorTag,instanceOperatorTag. Global state (account_loyalty + account_loyalty_milestones) still UPSERTs; only the relay queue insert is gated. -
apps/indexer/src/indexer/handlers/feedback.ts— welcome bonus (20 BLURT). Looks up cited order'soperator_tagfrom theorderstable. Global state (accounts.first_trade_complete_at) still flips on the upsert; only the queue insert is gated. -
apps/indexer/src/indexer/lowBalanceScanner.ts: selectCandidates— refill scope. Now JOINsorders.operator_tag = MY taginstead ofEXISTS 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_tagfield. 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_taggating. Each operator's relay pays only for ops served by their own instance.