Skip to content

feat(earnings): Phase 2 anti-gaming — first-funder, ring, alt-address (#978)#982

Merged
biwasxyz merged 3 commits into
mainfrom
feat/earnings-anti-gaming-phase2
Jun 8, 2026
Merged

feat(earnings): Phase 2 anti-gaming — first-funder, ring, alt-address (#978)#982
biwasxyz merged 3 commits into
mainfrom
feat/earnings-anti-gaming-phase2

Conversation

@biwasxyz

@biwasxyz biwasxyz commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

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_peer earning now runs through exclusion checks (inbox/bounty skip it — they're already proven legit by their txid match against our own D1).

Rule Excludes when
manual override operator flagged the line item exclude / include / reclassify (applies to any class)
alt-address sender & recipient agents share an owner (X handle) → self_funded
self-funded sender & recipient share a first-funder address → self_funded
ring a prior A→B→A reverse leg exists within 14d for a similar amount → both legs ring
  • migrations/021_earnings_anti_gaming.sqladdress_first_funder cache + earnings_manual_override
  • lib/earnings/anti-gaming.ts — the engine, wired into the indexer's resolveRow

Cost (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 for agent_peer transfers. The lookup reuses the existing transactions_with_transfers helper (fetch the oldest tx, take its first inbound sender). Failed lookups retry after 1h; ok/none are permanent. All other checks are plain D1 queries.

Notes / limits

  • Heuristics run only on agent_peer — inbox/bounty earnings are untouched (txid-proven).
  • Exclusion is conservative: self-funded only fires on two confident ok first-funder lookups (uncertainty ⇒ keep the earning).
  • Known limit: a ring whose two legs land in the same sweep tick isn't caught (the reverse leg isn't persisted yet); cross-tick rings are. Documented in the architecture doc.

Checks

tsc clean · lint clean · full suite 1504 passing (+9 new in anti-gaming.test.ts covering all four rules, the non-agent_peer passthrough, 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).

…#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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 8, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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 arc0btc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ok first-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_peer passthrough, differing-funder non-exclusion, and the clean pass-through. The mock DB pattern via sql.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.ts is easy to unit-test in isolation, doesn't bleed into the indexer.
  • markRing retroactively 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.
  • extractInboundTransfers reuse from ingest is the right call — no duplication.
  • Migration follows naming conventions; the CHECK (lookup_status IN ...) and CHECK (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.

biwasxyz added 2 commits June 8, 2026 14:06
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
@biwasxyz

biwasxyz commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @arc0btc — all three addressed:

  • Ring query index — good catch. Migration 020's (recipient_agent_stx, block_time) only covered the recipient + time predicates, not sender_stx. Added idx_agent_earnings_ring (recipient_agent_stx, sender_stx, asset, block_time) in 021, which serves findReverseLeg exactly (equality on recipient/sender/asset + range on block_time) — no more per-transfer scan of a recipient's history.
  • Malformed reclassify override — now logger.warn("earnings.reclassify_missing_class", { txId, eventIndex }) and falls through to heuristics, so the misconfig is visible instead of silent.
  • Genesis-funder nit — added a comment noting the first inbound sender of the genesis tx is taken as the funder (single-sender in ~all real cases; multi-sender just picks the first, which is fine for the shared-origin signal).

tsc + lint clean, earnings tests green (23).

@biwasxyz biwasxyz merged commit 5f4767b into main Jun 8, 2026
8 checks passed
@biwasxyz biwasxyz deleted the feat/earnings-anti-gaming-phase2 branch June 8, 2026 08:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants