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

1625 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```typescript
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`):
```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:
```sql
-- 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:
```json
{
"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:
```json
{
"...": "...",
"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.**