morphit/docs/adr/0038-reputation-hardening.md

15 KiB
Raw Permalink Blame History

ADR-0038 — Reputation hardening campaign: time decay + concentration detector + verifiable receipt + side distinction + dormancy signal (cp123cp125)

Status: Accepted (shipped 2026-05; pre-launch hardening campaign)

Date: 2026-05-23 Deciders: project maintainer (Ken) Related: ADR-0009 (order posting, Signal A/B framing), ADR-0014 (chat-and-counterparty-reputation, verified-chat badge), Part 113 audit (reputation attack-surface enumeration, 2026-05-10).

Context

Pre-cp123, Morphit shipped a comprehensive sybil-resistant reputation system audited in Part 113 (2026-05-10) across 15 attack vectors (A1A10 inflation, B1B5 deflation, C1C2 identity, D1D3 aggregation). Of those 15, 4 remained open:

# Vector Part-113 status
A4 Signal B evasion via diversification RESIDUAL — indirectly mitigated by A5 economics
A6 Trade never happened (off-chain settlement) STRUCTURALLY UNDECIDABLE
D3 Time decay / stale reputation DEFERRED
D1 New trader cold-start penalty DESIGN CHOICE — is_new_trader badge

Ken's cp123 ask ("make sure reputation scores cannot be spoofed, faked, artificially pumped … real, verified feedbacks, successful trade counts, factor in as many variables as we need to ensure provability of one's reputation score") re-opened the deferred and residual items, and added a new explicit requirement: provability.

Eight hardening opportunities were enumerated (H1H8). Ken locked in H1 + H2 + H4 + H5 + H6, skipping H3 (reviewer-credibility weighting — too punitive for newcomers) and H7/H8 (asymmetric-feedback signal and operator dashboard — lower-value).

Decision

Five coordinated changes, shipped across cp123 (foundation) → cp124 (surfaces) → cp125 (docs).

H1 — Time-decay weighting (closes D3)

The published weighted_rating becomes a 365-day-half-life exponential-decay weighted average instead of a flat AVG. Every feedback row's contribution is scaled by:

weight(age_days) = 0.5 ^ (age_days / 365)
  • Review today: weight 1.0
  • Review 1 year old: weight 0.5
  • Review 2 years old: weight 0.25
  • Review 3 years old: weight 0.125

The aggregate becomes SUM(rating × weight) / SUM(weight), computed in SQL at the 3 aggregation sites (apps/indexer/src/api/feedback.ts summary, apps/indexer/src/api/orderbook.ts, apps/indexer/src/api/orderbookStream.ts).

Rationale for exponential decay (rather than linear or step):

  • Exponential has the memoryless property — the ratio of any two reviews' weights depends only on their AGE DIFFERENCE, not their absolute ages. The formula is stable under clock skew between indexers.
  • Linear decay has a "fall off a cliff" date after which reviews count zero, creating perverse incentives to game the cutoff.
  • Step decay (e.g., reviews older than 1 year count zero) makes the boundary a target for manipulation.

Rationale for 365-day half-life:

  • A year is the natural "long enough ago that things might have changed."
  • Shorter (180d) penalizes seasonal traders too harshly.
  • Longer (730d) defers the decay benefit unhelpfully far.
  • At 365d, a 5-year-old review is worth ~3% of a fresh review — effectively forgotten but not erased.

Rationale for SUM(weight) denominator (not COUNT):

  • A trader with 10 fresh 5-stars should rank above one with 100 ancient 5-stars at the same numeric weighted_rating. Both would otherwise land at 5.00 (every row rating=5, COUNT denominator).
  • The SUM(weight) denominator means fresh contributors get full influence; ancient contributors get partial.

Raw COUNT preservation: The count field is unchanged (raw total). Only weighted_rating carries decay. By-rating histogram unchanged (per-bucket raw).

H2 — Signal D: review-concentration detector (closes A4 residual)

Signal B requires distinct_subjects=1 (the reviewer reviewed ONLY the target). A smart attacker reviews 2-3 throwaway third parties to evade Signal B while still pumping the primary target.

Signal D triggers when a reviewer concentrates ≥80% of their reviews on a single high-star target across a 30-day window:

  • Minimum 5 total reviews in the 30-day window (noise floor)
  • Concentration ≥ 80% on one subject
  • Avg rating to dominant subject ≥ 4.5 stars (inflation case; deflation cases captured by Signal C)
  • Pair stored in new review_concentration (reviewer, dominant_subject) table; PK on the pair

Aggregation filter updated in all 3 sites to exclude rows where (reviewer, subject) matches a review_concentration row.

Scheduling: runs hourly from poller.maybeRunSignals alongside Signals A/B/C. Same advisory-not-dispositive treatment, same ON CONFLICT DO NOTHING semantics, same operator-side false- positive recovery (DELETE row).

H4 — Verifiable reputation receipt endpoint

New endpoint GET /v1/accounts/:account/reputation-receipt. The "show your work" endpoint. Returns the FULL set of inputs that go into the published weighted_rating so any third party can re-derive the score locally.

Response fields:

  • account, as_of (ISO), decay_half_life_days (365), formula (human-readable string describing both the math AND the exclusion rules)
  • summary with count_total, count_included, count_excluded, weight_sum, weighted_rating
  • rows[] — every feedback row about the subject, including excluded ones, with per-row fields: source_trx_id, reviewer, rating, created_at, order_permlink, age_days, decay_weight, included, excluded_reason

excluded_reason is one of: null (counted), no_order_permlink, suspicious_reciprocity, related_accounts, one_way_pile_on, review_concentration.

Provability path: A reader can fetch the chain feedback ops for an account, apply the documented exclusion rules, run computeWeightedRating() (same function the indexer uses, exported from apps/indexer/src/indexer/reputation/decay.ts), and verify the published score. Without this endpoint, "provable reputation" requires running an indexer. WITH this endpoint, "provable reputation" requires only the ability to read the chain.

as_of parameter: Optional ISO timestamp. Used for deterministic comparison and archival re-verification. Honest limitation: signal-table flags are always evaluated at REQUEST time (no historical flag-state reconstruction). Two indexers with different signal-table states will produce different receipts — this is intentional transparency.

Caching: ETag + Cache-Control: 60s.

H5 — Buy/sell side distinction

The feedback summary endpoint now returns by_side: { buy: {count, weighted_rating}, sell: {count, weighted_rating} } alongside the single conflated weighted_rating. Computed via SQL FILTER (WHERE side='buy') / FILTER (WHERE side='sell') clauses after JOINing feedback to its cited order's side.

Rationale: A trader great as a buyer but careless as a seller (or vice versa) deserves to be visible to readers. The previous single-number rating conflated both roles.

UI: profile page renders separate chips for each side when that side has count > 0. Hidden gracefully when only one side has history.

H6 — Dormancy signal (last_traded_at)

The feedback summary endpoint now returns last_traded_at (ISO or null). Computed as GREATEST(MAX(orders.created_at WHERE account=$1 AND fee_status='verified'), MAX(feedback.created_at WHERE subject=$1)).

UI: profile page renders "Last traded: N ago" using the existing RelativeTime component. Hidden when null (account never posted a verified order AND never received feedback).

Rationale: A trader with great old reviews who hasn't traded in 18 months may no longer hold their key. Visible freshness informs trust without changing the numeric score.

H7 — Composite reputation score (cp404)

Order cards show TWO distinct trust signals side by side: the raw trade count (feedback_count, e.g. "852" / "1.4K") and a 05 reputation score (e.g. "4.06"). Prior to cp404 the only numeric was weighted_rating (the H1 time-decayed mean), which answers "what's the average rating" but not "how much should I trust this, accounting for how much history exists and whether the trader earned it." The reputation score is that composite, computed by apps/indexer/src/indexer/reputation/score.ts from the SAME sock-puppet-filtered feedback (H2/Signal-B/C/D exclusions already applied):

base  = (n·avg + K·μ) / (n + K)                      # Bayesian shrink
bonus = BONUS_MAX · exp_frac · rec_frac · aboveNeutral
score = clamp(base + bonus, 0..5)
  • avg = the H1 time-decayed mean rating; n = included count.
  • Bayesian shrinkage toward a neutral prior (μ=3.0, K=4) so a newcomer with one glowing (possibly fake) review can't spike to 5.0 — trust is earned as good trades accumulate and the shrunk mean rises toward the true high average.
  • exp_frac = ln(1+n)/ln(1+40) (experience, saturates at 40 trades); rec_frac = 0.5^(days_since_last_feedback/180) (recency).
  • The bonus gate aboveNeutral = max(0, (baseμ)/(5μ)) is the key fairness property: it is zero at or below the neutral prior, so experience and recency can only reward a trader whose rating is already above neutral, and never rescue a poor or mediocre one. A 500-trade scammer rated 2.0 stays ~2.0; a 5-star veteran climbs toward 5.0.

Behaviours (locked by reputation-score-smoke, 10 scenarios): zero feedback → null (card shows nothing; the 🌱 new-trader chip signals newness instead); one 5-star → ~3.4, not 5; 200 recent 5-stars → ≥4.8; more good trades strictly raise the score; a dormant good trader scores below an active one; always bounded to [0, 5].

Constants are tunable (REPUTATION_PRIOR_MEAN, REPUTATION_PRIOR_WEIGHT, REPUTATION_EXPERIENCE_FULL, REPUTATION_RECENCY_HALF_LIFE_DAYS, REPUTATION_BONUS_MAX) — a future instance could expose them via env like the H1 half-life.

Transparent + verifiable: the H4 receipt endpoint now also returns the score plus its factor breakdown (reputation_score, reputation_base, reputation_bonus, reputation_experience_frac, reputation_recency_frac) and an extended formula string, so any reader can re-derive the " 4.06" from the raw feedback rows — the same "show your work" posture as the underlying weighted_rating.

Privacy posture

Per Morphit's standing priority #1 (privacy):

  1. No new on-chain data. All changes operate on already-chain- anchored feedback ops + locally-derived signal tables. No new custom_json types. No new fields on existing ops.
  2. Receipt endpoint reveals which (X, Y) pairs are flagged — but this is already implicit in the missing rows of X's published aggregate. No new privacy leak.
  3. The receipt does NOT reveal flags for pairs (Y, Z) that don't involve X. Each account's receipt is scoped to its own relationships.
  4. Signal D detector reads existing tables only. No new chain-data inputs.
  5. as_of parameter is caller-controlled. No information leak in either direction.

Decentralization posture

Per Morphit's priority #2:

  • Per-instance signal-table state is the same posture as the pre-existing Signals A/B/C. Two indexers can show slightly- different scores based on their local signal-table state. The receipt endpoint surfaces this disagreement explicitly when comparing receipts across instances.
  • No new federation-wide constants. Half-life is per-indexer configurable (future enhancement); default 365 days hardcoded.
  • No new operator-mandatory actions. Existing operators upgrade and the new aggregation logic + Signal D detector run automatically.

Operator action required

  • None mandatory. Schema migration adds one new table (review_concentration) idempotently via CREATE TABLE IF NOT EXISTS. Existing instances pick this up on next indexer restart.
  • Optional: operators may DELETE rows from review_concentration to clear false-positive flags. Same recovery path as the existing signal tables.

Performance posture

  • Decay computation adds ~2 POWER + 2 EXTRACT calls per feedback row in 3 aggregation sites. Postgres handles these as scalar functions on small per-row inputs; cost is negligible against the existing GROUP BY scan.
  • Signal D detector is hourly; runs alongside Signal C with the same query-cost profile (CTE + JSONB-free).
  • Receipt endpoint is O(N) in feedback rows about the subject. A subject with 10,000 reviews produces a 10,000-row receipt. 60s ETag caching mitigates repeat-call cost.

Consequences

Positive:

  • D3 (time decay) and A4 (Signal B evasion via diversification) both closed.
  • Provability gained: any chain-readable party can verify the published score without trusting any indexer.
  • Buy/sell asymmetry visible to readers (H5).
  • Dormancy visible to readers (H6) — no numeric change to the score, just freshness context.
  • 2-decimal precision throughout (server already at NUMERIC(3,2); UI was truncating to 1).

Negative / accepted tradeoffs:

  • Score now requires NOW()-dependent computation. Cross- indexer determinism limited by clock skew. Mitigated by as_of parameter for explicit comparison.
  • Receipt endpoint is heavier than the existing summary — ~1 KB per feedback row. Acceptable; transparent reputation outweighs payload size.
  • Stale 5-star reviews now matter less — some long-time traders may see their headline number drop slightly until they trade again. This is the intended behavior; raw COUNT is preserved separately so historical context isn't lost.
  • Signal D can false-positive on a real trader who happens to have many repeat trades with one counterparty (e.g., a regular trading partner). Mitigation: 5-review noise floor + operator DELETE recovery + the dominant-subject pair flagging semantics (only the pair is flagged, not the reviewer globally).

Honest limitations

  • A6 remains structurally undecidable. Settlement is off-chain by design; no software can verify that a trade actually happened. Reputation is built on the PATTERN of consistent verified-fee orders + mutual feedback over time, not on any individual claim.
  • D1 (cold start) remains a design choice. Brand-new accounts still get the is_new_trader orderbook badge until they have 4+ verified-fee trades. The H1 decay benefits old-trader recency but doesn't accelerate newcomer onboarding.
  • A10 (stolen private key) remains out of scope. User opsec problem; reputation system cannot defend against a compromised key.
  • ADR-0009 §5 (Signal A/B framing — Part 113 closure context)
  • ADR-0014 Component C (verified-chat badge framing)
  • apps/indexer/src/indexer/reputation/decay.ts (the new shared formula module)
  • apps/indexer/src/indexer/reputation/score.ts (H7 composite reputation score — cp404)
  • apps/indexer/src/api/reputationReceipt.ts (H4 endpoint; cp404 extends its summary with the score breakdown)
  • apps/indexer/scripts/reputation-decay-smoke.ts (13 scenarios)
  • apps/indexer/scripts/reputation-score-smoke.ts (10 scenarios — cp404)
  • apps/indexer/scripts/reputation-receipt-shape-smoke.ts (7 scenarios)
  • docs/faq/how_to_build_high_reputation.md (cp125 companion FAQ — Ken's explicit ask: "make sure an faq article explains the best ways to get yourself a high reputation score"; updated for the cp404 composite score)