feat(earnings): Phase 2 anti-gaming — first-funder, ring, alt-address (#978)#982
Conversation
…#978) Excludes self-dealing from earnings before it counts. Runs only on agent_peer inflows (inbox/bounty are proven legit by their txid match). - migrations/021_earnings_anti_gaming.sql: address_first_funder cache + earnings_manual_override. - lib/earnings/anti-gaming.ts: - manual override (exclude/include/reclassify) — any class - alt-address: sender + recipient agents share an owner -> self_funded - self_funded: sender + recipient share a first-funder address - ring: prior A->B->A reverse leg within 14d, similar amount -> both legs ring - Wired into the indexer resolveRow after classify+price. Cost: first-funder is immutable -> cached forever in D1, <=2 Hiro calls per address EVER, and only for agent_peer transfers. Everything else is D1 queries. Known limit: a ring whose two legs land in the same sweep tick isn't caught (reverse leg not persisted yet); cross-tick rings are.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
landing-page | 53ddc7b | Commit Preview URL Branch Preview URL |
Jun 08 2026, 08:25 AM |
arc0btc
left a comment
There was a problem hiding this comment.
Phase 2 anti-gaming engine for the earnings ledger: manual overrides, alt-address detection (shared owner), self-funded detection (shared first-funder), and two-hop ring detection — wired into resolveRow for all freshly-classified agent_peer transfers.
What works well:
- Conservative design throughout: uncertainty → keep the earning, never false-exclusion. Only two confident
okfirst-funder lookups trigger self-funded exclusion; a failed cache entry doesn't block. - Correct parallel fetching for first-funder lookups:
Promise.all([getFirstFunder(sender), getFirstFunder(recipient)])— clean. - Test coverage is solid: 9 cases covering all four rules, the non-
agent_peerpassthrough, differing-funder non-exclusion, and the clean pass-through. The mock DB pattern viasql.includes()is a bit fragile but acceptable at this scale. - Cache-forever for first-funder is the right call — immutable data, no need to expire.
- Clean module separation —
anti-gaming.tsis easy to unit-test in isolation, doesn't bleed into the indexer. markRingretroactively flags the reverse leg immediately; the current leg is excluded via the returned Classification. Both legs end up excluded, which is correct.
[question] Ring query and indexes (lib/earnings/anti-gaming.ts:175)
The findReverseLeg query filters on recipient_agent_stx, sender_stx, asset, amount_raw, block_time. Does migration 020 put a composite index on agent_earnings covering the sender/recipient columns? Without one this is a full-table scan per transfer. At volume (many agents × many transfers per sweep) that adds up. If there's no index covering (recipient_agent_stx, sender_stx) at minimum, worth adding one here in 021.
[suggestion] Silent no-op when reclassify has null new_source_class (lib/earnings/anti-gaming.ts:207)
The DB allows new_source_class TEXT nullable, so a malformed override row with action='reclassify' and new_source_class=NULL silently falls through to heuristics. The result isn't wrong (conservative), but a warn log would make future debugging much easier:
if (override.action === "reclassify" && override.new_source_class) {
const sc = override.new_source_class as SourceClass;
→
if (override.action === "reclassify") {
if (!override.new_source_class) {
logger.warn({ txId: transfer.txId, eventIndex: transfer.eventIndex }, "reclassify override missing new_source_class — falling through to heuristics");
} else {
const sc = override.new_source_class as SourceClass;
(close the extra else block after the existing return)
[nit] inbound[0].senderStx in fetchFirstFunderFromChain silently picks the first inbound sender of the oldest tx. Covers 99%+ of real addresses, but a short comment noting "first inbound of genesis tx is almost always the sole funder" would help a future reader who wonders about multi-sender genesis blocks.
Code quality notes:
exclude()helper is a nice touch — intent reads clearly at the call site.- SQL is parameterized throughout, no injection risk.
extractInboundTransfersreuse fromingestis the right call — no duplication.- Migration follows naming conventions; the
CHECK (lookup_status IN ...)andCHECK (action IN ...)constraints are good data quality guardrails.
Operational note: We monitor the Hiro API budget closely after a recent circuit-breaker was added for budget storms (PR #958). The first-funder cache design (≤2 calls per address ever) is the right shape — no per-transfer Hiro load once the cache is warm.
Self-review found the ring detector used a backward-only window (block_time BETWEEN now-14d AND now). The indexer processes newest-first during backfill, so the later round-trip leg gets indexed BEFORE the earlier one — and a backward-only window puts the reverse leg in the future, missing every ring discovered during backfill. Fix: symmetric ±14d window. The two legs are within 14d of each other regardless of which is seen first; markRing flips whichever was already persisted and the current leg is excluded directly, so both end up excluded cross-tick in either order. Added a test asserting the window is symmetric.
- migration 021: add idx_agent_earnings_ring (recipient_agent_stx, sender_stx, asset, block_time) so findReverseLeg uses an index instead of scanning a recipient's full history per agent_peer transfer - anti-gaming: warn-log a malformed reclassify override (action=reclassify with null new_source_class) instead of silently falling through - anti-gaming: comment why first inbound sender of the genesis tx is the funder
|
Thanks @arc0btc — all three addressed:
|
Phase 2 of the verified earnings ledger (#978). Excludes self-dealing from earnings before it counts, so the new metric can't be gamed the way trade-count was. Builds on the merged Phase 1 indexer.
What's here
Every freshly-classified
agent_peerearning now runs through exclusion checks (inbox/bounty skip it — they're already proven legit by their txid match against our own D1).exclude/include/reclassify(applies to any class)owner(X handle) →self_fundedself_fundedA→B→Areverse leg exists within 14d for a similar amount → both legsringmigrations/021_earnings_anti_gaming.sql—address_first_fundercache +earnings_manual_overridelib/earnings/anti-gaming.ts— the engine, wired into the indexer'sresolveRowCost (the first-funder lookup is the only new Hiro cost)
First-funder is immutable, so it's cached forever in
address_first_funder: ≤2 Hiro calls per address EVER, and only triggered foragent_peertransfers. The lookup reuses the existingtransactions_with_transfershelper (fetch the oldest tx, take its first inbound sender). Failed lookups retry after 1h;ok/noneare permanent. All other checks are plain D1 queries.Notes / limits
agent_peer— inbox/bounty earnings are untouched (txid-proven).okfirst-funder lookups (uncertainty ⇒ keep the earning).Checks
tscclean ·lintclean · full suite 1504 passing (+9 new inanti-gaming.test.tscovering all four rules, the non-agent_peerpassthrough, differing-funder non-exclusion, and the clean passthrough).Part of #978. Next: Phase 3 (public read API) → Phase 4 (leaderboard flips to earnings — the visible win).