491 lines
18 KiB
Markdown
491 lines
18 KiB
Markdown
# ADR-0009 — Phase 3c order posting + enforcement architecture
|
||
|
||
**Status:** Accepted
|
||
**Date:** 2026-04-19
|
||
**Deciders:** project maintainer
|
||
**Supersedes:** none
|
||
**Superseded by:** none
|
||
|
||
> **2026-05-12 forward note (Part 120 audit):** the
|
||
> "3 minutes" replace-window references throughout this
|
||
> ADR describe the value as originally specified. Updated
|
||
> to **15 minutes** in Part 70 per ADR-0001's 2026-05-07
|
||
> Amendment. This ADR is preserved with the original
|
||
> 3-minute references for historical accuracy; ADR-0001
|
||
> is authoritative for the current window.
|
||
|
||
## Context
|
||
|
||
Phase 3b delivered the indexer's **read** path: users can browse
|
||
orders that already exist on chain. Phase 3c delivers the **write**
|
||
path: a user composes an order in the browser, signs and broadcasts
|
||
it to Blurt, and pays the Morphit listing fee.
|
||
|
||
Several decisions couldn't be made in Phase 3b because they depend
|
||
on the compose flow:
|
||
|
||
1. **Fee payment mechanics.** The PLAN says listing costs "$0.25
|
||
USD-equivalent, 50% off in BLURT." How does the indexer verify
|
||
the user actually paid? What happens if the user underpays?
|
||
2. **Transaction composition.** The `morphit_order_v1` op is a
|
||
`custom_json` signed by the posting key. A BLURT transfer is
|
||
signed by the active key. These are different auth levels. Do
|
||
they go in one transaction or two?
|
||
3. **Replace-window enforcement.** ADR-0001 mandates 3 minutes.
|
||
Enforced where — on the client (so the UI hides the button),
|
||
on the indexer (so late replaces are ignored), or both?
|
||
4. **Sybil fee escalation.** "Orders 1–3 at $0.25, 4–10 at +25%,
|
||
11+ at +50%, resets when old orders cancel or expire" is
|
||
prescriptive but underspecified. What counts as "an order"
|
||
for the purpose of counting? Rolling 24 hours, calendar day, or
|
||
since-last-reset?
|
||
5. **Self-trade detection.** "Indexer correlates fee-payment
|
||
patterns" — concretely, which signals?
|
||
|
||
## Decision
|
||
|
||
### 1. Fee payment — single Blurt transaction, two active-level operations
|
||
|
||
Posting an order paid in BLURT is a single Blurt transaction
|
||
containing two operations in order:
|
||
|
||
1. `custom_json` — the `morphit_order_v1` op, declared at **active**
|
||
authority (`required_auths: [signer]`, empty `required_posting_auths`)
|
||
2. `transfer` — a BLURT transfer from the signer to the Morphit
|
||
fee-collection account `@morphit-fees`, with a specific memo
|
||
format (active authority, as all transfers are)
|
||
|
||
Both ops go in **one** signed transaction. This gives atomicity:
|
||
either both land on chain or neither does. A user cannot post an
|
||
order without paying, nor pay without posting.
|
||
|
||
**Why the order op is active-level here, not posting (cp407).** A
|
||
transfer requires active authority, and Blurt (Graphene) rejects any
|
||
transaction that mixes posting-level and active-level operations — it
|
||
asserts `required_active.size() == 0` when a posting op is present.
|
||
So an order op that shares a transaction with the fee transfer MUST
|
||
itself be active-level; the whole transaction is then active-level and
|
||
takes a **single active signature**. (The original design signed a
|
||
posting-level order op alongside the active transfer, which the chain
|
||
rejected on every BLURT-paid order — this was fixed by making the
|
||
order op active-level rather than splitting the fee into a second
|
||
transaction, which would have broken atomicity and the sibling-op fee
|
||
verification below.)
|
||
|
||
The frontend asks the user to unlock their active key once, signs the
|
||
whole transaction with the active key alone, and broadcasts.
|
||
|
||
Orders whose fee is **waived** or paid **externally** (BTC/XMR) carry
|
||
no `transfer`, so they remain a lone posting-level `custom_json`. The
|
||
indexer therefore accepts an active-level order op for exactly the
|
||
fee-bearing op ids (`morphit_order_v1`, `morphit_feature_bid_v1`,
|
||
`morphit_stranger_fee_v1`) and stays posting-only for every other op
|
||
(see the indexer's `extractSigner`).
|
||
|
||
The transfer memo format is:
|
||
|
||
```
|
||
morphit-fee:<permlink>
|
||
```
|
||
|
||
This ties the fee to the specific order. The indexer's order
|
||
handler looks for a matching transfer op in the same transaction,
|
||
with the correct amount (±1% tolerance for price-feed drift) and
|
||
recipient. If not found, the order is marked `rejected:fee_missing`
|
||
and invisible in the orderbook.
|
||
|
||
### 2. Fee currency and amount calculation
|
||
|
||
BLURT-only in Phase 3c. Paying in BTC or XMR would require
|
||
waiting for confirmations before the order goes live —
|
||
unreasonable for a listing that may only be up for 24h anyway.
|
||
The $0.25 ✕ 50% BLURT discount nets to **$0.125 USD-equivalent in
|
||
BLURT**, which at the fallback price of $0.002/BLURT is 62.5
|
||
BLURT. A round-up to 63 BLURT is what the compose UI quotes; the
|
||
indexer accepts anything in 62.5..65 BLURT to allow for price-feed
|
||
drift (see below).
|
||
|
||
The frontend fetches the current BLURT price, computes the fee,
|
||
and displays it in three ways simultaneously:
|
||
|
||
- The USD-equivalent number ($0.125 at list; more for escalation
|
||
tiers)
|
||
- The BLURT amount the user will actually pay
|
||
- The fresh-ness of the price (per ADR-0004 "prices updated X
|
||
seconds ago" indicator)
|
||
|
||
The indexer also fetches BLURT prices, but its verification
|
||
allows a **±1% tolerance** around the expected fee amount to
|
||
absorb the mismatch between the price the frontend saw and the
|
||
price the indexer sees one block later.
|
||
|
||
### 3. Replace-window enforcement — indexer is authoritative
|
||
|
||
The 3-minute edit window is enforced at the **indexer**, per
|
||
ADR-0001. The client also respects it (hides the Edit button
|
||
after 3 minutes) but only as a UX courtesy — a misbehaving or
|
||
out-of-sync client cannot bypass the rule because the indexer
|
||
silently drops late `morphit_order_replace_v1` ops.
|
||
|
||
This means the client computes the window from *block time* of
|
||
the original order, not local wall-clock time. The indexer uses
|
||
block time too. The `orderReplace` handler checks:
|
||
|
||
```
|
||
current_block_time - original.created_at <= 3 minutes
|
||
```
|
||
|
||
If exceeded: `rejected:replace_window_expired`. The original
|
||
order remains on chain, unchanged.
|
||
|
||
### 4. Sybil fee escalation — 24h rolling window, per account
|
||
|
||
The listing fee escalates as an account posts more orders in a
|
||
rolling 24-hour window. "Orders posted" means orders that are
|
||
currently `live` OR that were created in the last 24h (even if
|
||
since cancelled/expired). Cancellation does not retroactively
|
||
reduce the fee for the cancelled order, but it removes that
|
||
order from the *count* so the *next* order pays less.
|
||
|
||
Concretely, at the moment of posting, the indexer computes:
|
||
|
||
```
|
||
n = count of orders where:
|
||
account = signer AND
|
||
(status = 'live' OR created_at >= now - 24h)
|
||
```
|
||
|
||
The expected fee for the nth order is:
|
||
|
||
| n | Multiplier | Expected USD |
|
||
|----------|-----------|--------------|
|
||
| 1, 2, 3 | 1.00× | $0.125 |
|
||
| 4 | 1.25× | $0.156 |
|
||
| 5 | 1.56× | $0.195 |
|
||
| 6 | 1.95× | $0.244 |
|
||
| 7 | 2.44× | $0.305 |
|
||
| 8 | 3.05× | $0.381 |
|
||
| 9 | 3.81× | $0.477 |
|
||
| 10 | 4.77× | $0.596 |
|
||
| 11+ | 1.50× per additional, compounding on top of tier-10 |
|
||
|
||
These numbers grow fast by design: an honest user rarely posts
|
||
more than 3 orders a day; a spammer pays real BLURT in
|
||
escalating amounts. A user who posts and cancels, posts and
|
||
cancels, still pays escalating fees as long as they're doing it
|
||
within a 24h window.
|
||
|
||
The compose UI shows the user which tier they're in and what the
|
||
fee is, before they sign. No surprises.
|
||
|
||
### 5. Self-trade detection — two signals
|
||
|
||
An indexer cannot prove two accounts belong to the same person,
|
||
but it can flag patterns that strongly suggest it:
|
||
|
||
**Signal A — fee-address timing.** Two accounts that:
|
||
- Post orders within 5 minutes of each other
|
||
- Have never previously broadcast to chain before their
|
||
respective `morphit_profile_v1` ops
|
||
- Have their `account_create` ops signed by the same creator
|
||
account
|
||
|
||
→ flagged as `related_accounts`. Feedback between flagged pairs
|
||
is weighted to zero in their reputation totals.
|
||
|
||
**Signal B — feedback reciprocity.** Two accounts that:
|
||
- Exchange ≥3 mutual 5-star reviews within 7 days
|
||
- Have no other feedback from third parties
|
||
|
||
→ flagged as `suspicious_reciprocity`. Same weighting treatment.
|
||
|
||
Both signals are advisory, not dispositive. Flagged pairs are
|
||
publicly visible on their profile pages so other users can judge
|
||
for themselves. No automated penalties beyond the feedback
|
||
weighting.
|
||
|
||
## Schema changes
|
||
|
||
Three new indexer tables:
|
||
|
||
- `fee_transfers` — records of observed BLURT transfers to
|
||
`@morphit-fees` with parsed memo. Indexed on `memo_permlink`
|
||
and on `signer` for the Sybil counting query.
|
||
- `related_accounts` — self-trade Signal A output. Rows written
|
||
when the pattern is detected; never deleted.
|
||
- `suspicious_reciprocity` — Signal B output. Same write-once
|
||
semantics.
|
||
|
||
And one new field on `orders`:
|
||
|
||
- `fee_status` — one of `unverified`, `verified`, `missing`,
|
||
`underpaid`. Set by the order handler based on what it finds
|
||
in `fee_transfers`.
|
||
|
||
Migration version bumps to 2 in `src/db/migrations.ts`.
|
||
|
||
## Alternatives considered
|
||
|
||
### Two separate transactions for order + fee
|
||
|
||
- **Pros:** simpler crypto ceremony — one op per signature.
|
||
- **Cons:** race conditions (fee paid but order broadcast fails;
|
||
order broadcast but fee payment times out). No atomicity.
|
||
- **Rejected.**
|
||
|
||
### BTC or XMR fees accepted
|
||
|
||
- **Pros:** user doesn't need to hold BLURT to list.
|
||
- **Cons:** waiting for BTC/XMR confirmations (10+ minutes for
|
||
BTC) makes listing unusable for the "post now while I'm
|
||
grocery shopping" case. Also creates an on-chain feedback
|
||
loop: the indexer would need to watch BTC/XMR networks too.
|
||
- **Rejected for Phase 3c.** Revisit if BLURT price crashes and
|
||
the fee becomes prohibitive on a per-listing basis.
|
||
|
||
### Client-only replace window
|
||
|
||
- **Pros:** no server-side state to track.
|
||
- **Cons:** a misbehaving client bypasses the rule entirely, and
|
||
the chain would accumulate replacement ops that the indexer has
|
||
to silently swallow anyway. Better to make the indexer
|
||
authoritative and have the client match.
|
||
- **Rejected.**
|
||
|
||
### Calendar-day Sybil window instead of rolling 24h
|
||
|
||
- **Pros:** simpler to reason about.
|
||
- **Cons:** a spammer posts their 11th, 12th, 13th orders at
|
||
23:55, then 11th, 12th, 13th again at 00:05. Calendar-day
|
||
window lets them pay tier-1 fees for all 20.
|
||
- **Rejected.**
|
||
|
||
## Consequences
|
||
|
||
### Positive
|
||
|
||
- A user who honestly posts 1-3 orders per day pays exactly
|
||
$0.125 per order, every time, forever. No surprises.
|
||
- A spammer faces steeply escalating costs within a 24h window.
|
||
- The "edit a typo" case works for 3 minutes after posting, which
|
||
is plenty of time for the user to notice the typo during their
|
||
own "did I spell everything right?" review.
|
||
- The fee payment and order posting are atomic. No "I paid but
|
||
my order isn't showing up" scenarios.
|
||
|
||
### Negative
|
||
|
||
- BLURT price volatility means the USD-equivalent fee shifts.
|
||
A user fetching the price at T=0 and broadcasting at T=30s may
|
||
be ±0.5% off the indexer's later price; we absorb this with the
|
||
1% tolerance band.
|
||
- The fee-collection account (`@morphit-fees`) is a centralised
|
||
point. Compromise of this account's owner keys lets an
|
||
attacker divert fees. Mitigation: owner/active keys are cold-
|
||
stored, posting key used only for public announcements.
|
||
- A user who pays the fee but whose order is rejected for
|
||
*other* reasons (malformed payload, for example) loses the
|
||
BLURT. This is acceptable: the fee is a cost of trying to
|
||
post, not a refundable prepayment.
|
||
|
||
### Neutral
|
||
|
||
- The Sybil escalation table is tunable via config without
|
||
schema changes. If real-world usage shows the numbers are
|
||
too aggressive or too lenient, adjust and redeploy.
|
||
|
||
## Non-goals (explicit, for future phases)
|
||
|
||
- Featured-slot auction (Phase 3c is "minimum viable orderbook";
|
||
featured slots are Phase 4+).
|
||
- Multi-currency fee acceptance (Phase 3c is BLURT-only).
|
||
- Automated chargeback or refund mechanism for rejected orders
|
||
(Phase 5 at earliest).
|
||
- Email notification when an order's fee is rejected (Phase 5;
|
||
the UI shows the fee_status inline on the user's own orders
|
||
page).
|
||
|
||
## Amendment — 2026-05-07 (Part 70)
|
||
|
||
The replace-window referenced throughout this ADR ("3 minutes")
|
||
was extended to **15 minutes** in Part 70. The full rationale,
|
||
threat-model re-analysis, and complete list of updated call
|
||
sites lives in `docs/adr/0001-custom-json-replacement.md`
|
||
under "Amendments → 2026-05-07."
|
||
|
||
This ADR's enforcement-point claim still holds: the window is
|
||
indexer-enforced, the client respects it as a UX courtesy, and
|
||
a misbehaving or out-of-sync client cannot bypass the rule.
|
||
Only the numeric value changed. References in the body of
|
||
this document to "3 minutes" should be read as "the
|
||
replace-window value, now 15 minutes per the 2026-05-07
|
||
amendment."
|
||
|
||
---
|
||
|
||
## Part 113 amendment (2026-05-10) — Signal C + cited-order fee_status gate
|
||
|
||
### Reputation attack surface enumerated
|
||
|
||
A from-scratch audit of every way someone's
|
||
reputation score can be **faked** (inflation) or
|
||
**hurt** (deflation) caught two real gaps in the
|
||
pre-Part-113 defenses.
|
||
|
||
**Vector A5 — feedback citing an order whose
|
||
`fee_status` is NOT 'verified'.** The feedback
|
||
handler's cited-order check (added per Finding R17)
|
||
only required that the order exists and belongs to
|
||
the subject. An order with `fee_status='missing'`
|
||
or `'underpaid'` — meaning the listing fee was
|
||
never paid — was still a valid citation target.
|
||
An attacker could broadcast many `morphit_order_v1`
|
||
ops without paying any fee and use those rows as
|
||
free citation targets for fake feedback from sock
|
||
puppets. Forging a citation target was effectively
|
||
free (sub-BLURT op-broadcast cost).
|
||
|
||
**Vector B3 — coordinated low-rating pile-on.**
|
||
Signal A catches same-creator close-timed clusters
|
||
(reputation inflation via tightly-related sock
|
||
puppets). Signal B catches mutual 5-star exchange
|
||
clusters (inflation via reciprocal sock-puppet
|
||
reviews). Neither catches the deflation case: a
|
||
malicious actor coordinates 3+ sock-puppet accounts
|
||
from different creators to leave 1-2 star reviews
|
||
on a victim's orders, cratering their visible
|
||
average rating.
|
||
|
||
### Fixes shipped in Part 113
|
||
|
||
**Fix #1 — fee_status='verified' on cited orders.**
|
||
The feedback handler's order EXISTS check now
|
||
includes `AND fee_status = 'verified'`. Rejection
|
||
reason renamed `order_permlink_not_found` →
|
||
`order_permlink_not_found_or_unverified` to make
|
||
the distinction explicit.
|
||
|
||
Economic effect: forging a citation target now
|
||
costs the actual listing fee (~$0.25 in
|
||
BLURT/BTC/XMR equivalent). An attacker
|
||
provisioning 5 sock puppets to leave reviews about
|
||
themselves now pays $1.25 in real fees just for
|
||
the citation targets, plus the cost of provisioning
|
||
the sock accounts. Reputation inflation is no
|
||
longer free.
|
||
|
||
### Signal C — one-way pile-on
|
||
|
||
**Trigger criteria (all must hold):**
|
||
|
||
1. ≥3 distinct reviewers targeting the same subject
|
||
2. Each reviewer's avg rating to that subject ≤2 stars
|
||
3. All reviews posted within a 7-day window
|
||
4. All reviewer `first_activity_at` clusters within
|
||
a 14-day window (newly-active cluster vs
|
||
varied-history real users)
|
||
5. Each reviewer's `distinct_subjects` in last 30
|
||
days ≤2 (focused on the target, not diversified
|
||
across the marketplace)
|
||
|
||
→ flagged as `one_way_pile_on`. Same advisory-not-
|
||
dispositive treatment as Signals A and B: feedback
|
||
from flagged reviewer→subject pairs is excluded
|
||
from the reputation summary aggregate but still
|
||
appears on the subject's public profile list.
|
||
|
||
**False-positive guard:** Criterion 5 is the
|
||
backbone. A real user reviewing five different
|
||
counterparties in the last month and giving one a
|
||
1-star has `distinct_subjects=5` and isn't
|
||
flagged. A sock-puppet whose only Morphit activity
|
||
is a few low-star reviews on one target has
|
||
`distinct_subjects=1`. Criterion 4 (clustered
|
||
first-activity timing) is the secondary guard
|
||
against patient-attacker sock puppets that activate
|
||
spread out over months.
|
||
|
||
### New schema (migration v31)
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS one_way_pile_on (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
subject TEXT NOT NULL,
|
||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
detection_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||
attacking_reviewers JSONB NOT NULL,
|
||
avg_rating NUMERIC(3, 2) NOT NULL,
|
||
review_count INTEGER NOT NULL,
|
||
review_window_days INTEGER NOT NULL,
|
||
activity_cluster_days INTEGER NOT NULL,
|
||
UNIQUE (subject, detection_date)
|
||
);
|
||
```
|
||
|
||
UNIQUE (subject, detection_date) means same-day re-
|
||
runs don't insert duplicate rows; an expanding
|
||
attack appears as a new row the next day with the
|
||
larger attacker set.
|
||
|
||
### Aggregate exclusion
|
||
|
||
Both summary aggregates that drive user-visible
|
||
"reputation" — the per-account
|
||
`/v1/accounts/:account/feedback` summary and the
|
||
orderbook `weighted_rating` column — now filter out
|
||
rows where the (reviewer, subject) pair appears in
|
||
ANY of:
|
||
|
||
- `suspicious_reciprocity` (Signal B)
|
||
- `related_accounts` (Signal A)
|
||
- `one_way_pile_on` attacking_reviewers JSONB array
|
||
(Signal C — new)
|
||
|
||
The list view (`/v1/accounts/:account/feedback`
|
||
list response) still returns the suppressed rows
|
||
so the subject can see what's been said about
|
||
them; only the aggregate excludes them.
|
||
|
||
### Priority-lens evaluation
|
||
|
||
- **Privacy (#1)**: zero new on-chain data; uses
|
||
existing `feedback` + `accounts` tables.
|
||
Detector runs locally on each indexer; results
|
||
are local DB state, not federated.
|
||
- **Decentralization (#2)**: each operator's
|
||
indexer runs the detectors independently.
|
||
Different operators may flag different subsets
|
||
if their indexers see different chain ranges
|
||
(e.g. operator started later, missed early
|
||
history) — that's expected and acceptable.
|
||
- **Grandma-friendliness (#3)**: end-user visible
|
||
behavior is "the rating average ignores
|
||
obviously-coordinated review clusters." No new
|
||
UX, no new operator config.
|
||
|
||
### What's NOT done (deferred)
|
||
|
||
- **Per-row "this review excluded by Signal C"
|
||
badge in the list view.** Filed as future UX
|
||
improvement. Currently the subject sees the
|
||
flagged review in their list but no indicator
|
||
that it's excluded from the aggregate. Reviewing
|
||
attackers via the database directly works for
|
||
operators.
|
||
|
||
- **Display name-only "Signal A/B/C flagged"
|
||
badge on profile.** ADR-0009 says flagged
|
||
pairs are "publicly visible on their profile
|
||
pages." Currently this is implicit — the
|
||
rating is suppressed but the profile doesn't
|
||
say "this account was flagged." Filed as
|
||
future UX work.
|
||
|
||
- **Time-decay of reputation.** Old reviews
|
||
weighted same as recent ones. Acceptable per
|
||
the FAQ design ("permanent and public").
|
||
|
||
- **A6 — verifying the reviewer was a counterparty
|
||
on the trade.** Structurally undecidable
|
||
(Morphit trade settlement is off-chain).
|
||
Acknowledged limitation.
|