Skip to content

feat(mentions): tag victim protocol's official X account in hack posts - #16

Merged
jungjipdo merged 4 commits into
mainfrom
feat/victim-protocol-mention
Jul 10, 2026
Merged

jungjipdo merged 4 commits into
mainfrom
feat/victim-protocol-mention

Conversation

@jungjipdo

@jungjipdo jungjipdo commented Jun 29, 2026 •

Copy link
Copy Markdown
Member

What this does

When Backlight posts a hack report to X, it now tags the victim protocol's official account so the protocol gets notified and our post reaches further:

🚨 Curve (@CurveFinance) exploit on Ethereum
Tx: 0xabc…

When we can't confidently identify the account, the post stays plain text (🚨 Curve exploit on Ethereum). Tagging the wrong account (a politician's personal account, an unrelated same-name project, a parent company) is worse than not tagging at all, so the rule is "tag only when sure, otherwise plain text."

This ships dormant and safe:

  • The store is empty until a one-time seed is run (see Rollout). Until then every lookup misses → plain text, so behavior is unchanged.
  • There's a kill-switch: HELIOS_VICTIM_MENTION (default true). Set it to false to force plain text everywhere.

How it works (3 commits)

1. 333b6d5 — store + seeder.
A new SQLite store (protocol_mention_store + mention_override) holds, per protocol: the official handle, the immutable numeric Twitter id, name/aliases/slug/token for matching, and a mention_policy (allow / suppress / review). cmd/seed-mentions loads it from the Surf export CSV. It also installs guards so dangerous rows never get tagged (details below).

2. 7d939c1 — resolver.
internal/mention builds an in-memory index from the store and maps a raw protocol name (whatever the engine output called it) to a decision: tag with @handle, or stay plain text. It tags only on a single confident allow match; anything ambiguous, missing, suppressed, or under review → plain text.

3. 2b14c07 — render + kill-switch.
Both post renderers (the live xfeed thread and the xpublish template) call one shared formatter, mention.FormatTag, so they can't drift apart. The index is built once when the service starts and reused (no database call while composing a post).

Tagging policy

  • Tag the victim only (one account). Parent/corporate accounts (e.g. a product pointing at @BNBCHAIN) are suppressed to avoid over-tagging.
  • Mention placement: next to the name in parentheses — Name (@handle).

Rollout

  1. Seed the store once against the running service's DB (empty until then). The Surf export is committed at seeds/surf-full-x-account-db.csv, so no separate data hand-off is needed — from the repo root:
    HELIOS_DB_PATH=/path/to/var/backlight.db go run ./cmd/seed-mentions
    
    Idempotent (upsert), safe to re-run on a refreshed export. SURF_EXPORT_CSV overrides the path.
  2. Restart the Backlight service so it builds the mention index from the freshly seeded store.
  3. (Optional) Stage it: run with HELIOS_VICTIM_MENTION=false first, confirm posts are unchanged, then flip to true + restart.

The seeder is idempotent (upsert), so re-running it on a fresh Surf export is safe.


Implementation notes & reviewer context

Resolution lives in Backlight (compose-time), not in the detector

The original design doc routed entity resolution to hack-detector, arguing the detector "holds contract_address + chain first." That premise is false in the code: the detector extracts only attacker_address + tx_hash + a protocol_name string — it never extracts the victim contract. So resolution is done entirely in Backlight at compose time, which is already async/batched (runs minutes after detection, after the lumoskit RCA subprocess). Net effect: the detection hot path is untouched and gains zero latency. The detector still hands off just the protocol_name string it always did.

Data foundation (Surf export)

Seeded from the Surf full export (6,279 entities). Verified coverage: handle 99.1%, numeric x_id 97.5%, website 2.4% (ignored — too sparse). x_id (immutable numeric Twitter user id) is the anchor, not the handle: handles get renamed/squatted, the numeric id doesn't. The seeder stores both; Phase 2 jobs use x_id to repair drifted handles.

Mistag guards (this is the important correctness part)

"Tag only when sure" is enforced by three guards, not just a happy-path lookup:

  1. Curated landmines → suppress: e.g. Libra → @JMilei (a politician's personal account), Binance* → @BNBCHAIN (parent/chain account).
  2. Shared-handle collisions → review: when one handle maps to 2+ genuinely different protocols, none auto-tag (11 such groups in the real data).
  3. Surf fuzzy-resolution errors → review: the export's notes column carries defi_name_resolved_as:X when Surf substituted a different protocol. The smoke test caught a real one: IPOR Protocol was seeded with @QHUB_ (which is actually IQ Protocol). The seeder flags these by comparing the two names — if they don't share a brand token / prefix, it's a cross-protocol substitution → review (9 such rows: IPOR/Raft/Pika/Trevee/…). Same-org variants (Curve → Curve DAO, TrueUSD → True USD) stay allow.

The net guarantee: a unique allow match is required to tag; everything else degrades to plain text.

Canonical aggregation + aliases

Surf assigns one project_id to a parent and its name variants/products (Curve/Curve DAO, Binance/Binance Staked SOL). The seeder collapses these to one canonical entity (keyed by project_id) but keeps every name as an alias, so the resolver can still match on any of them. The displayed canonical name is the shortest variant (the base brand).

Resolver matching

Exact normalized match on name / alias / slug, then a generic-suffix strip ("IPOR Protocol" → "IPOR"; suffix set = protocol/finance/network, prefix-only so "raft" inside "Metakraft" doesn't false-match), then token-symbol as a last fallback. No fuzzy substring matching anywhere — that's what produced the original IPOR → IQ class of bug. A key mapping to >1 canonical = ambiguous = plain text.

Gotchas worth knowing

  • Surf encodes missing values as the literal 없음 (not empty string) and the CSV has a UTF-8 BOM — both handled in the seeder.
  • Schema is applied via idempotent CREATE TABLE IF NOT EXISTS on every store.Open, so the two new tables auto-migrate on the next service start. (This pattern creates new tables but cannot ALTER existing ones — fine here since both tables are new.)
  • A nil mention index = feature disabled = plain text. Both renderers default to this, so every pre-existing renderer test stays byte-identical and the kill-switch-off path is a true no-op.

File map

  • internal/store/schema.go — two new tables + indexes.
  • internal/store/mentions.go — store types + upsert/policy/stats + AllMentionEntities (index loader).
  • cmd/seed-mentions/main.go — Surf CSV → store, with the three guards.
  • internal/mention/index.go — in-memory index, Resolve, and the shared FormatTag formatter.
  • internal/xfeed/runner.go, internal/xpublish/publisher.go — inject FormatTag (one call each).
  • internal/config/config.go, cmd/helios/main.go — HELIOS_VICTIM_MENTION flag + build the index once at startup.

Pre-existing test failures (NOT from this PR)

go test ./... shows two reds that also fail on origin/main (verified by stashing this branch's changes): internal/lumoskit/TestRunnerUsesLumoskitRepoRootForBinaryUnderBin (fails on the committed base too) and internal/e2e/TestBacklightToBridgeToMCPReadArtifact (passes in isolation; flakes only under full-parallel ./...). Every package this PR touches is green.

Phase 2 (deferred to issues, not in this PR)

The store schema already carries the seams (source, last_verified_at, reverify_due_at, status). The actual automation is tracked in #13 (drift repair: refresh handles via x_id), #14 (optional per-post revalidation), #15 (backfill: resolve protocols missing from the store). No stub/dead code was added for these.

jungjipdo and others added 4 commits June 29, 2026 10:57
Foundation for tagging the victim protocol's official X account in hack
posts. Resolution + render will live in Backlight compose (chunks 2-3);
the detector stays untouched, so the detection hot path is unaffected.

- protocol_mention_store + mention_override tables (idempotent IF NOT
  EXISTS DDL -> auto-migrates existing deployments)
- x_id (immutable numeric Twitter id) is the anchor; x_handle/website are
  mutable/optional
- cmd/seed-mentions ingests the Surf export CSV: handles the "없음"
  missing-encoding and UTF-8 BOM, normalizes multi-handle cells
- Surf groups a parent + its name variants/products under one project_id
  (Curve/Curve DAO, Binance/Binance Staked SOL); collapse to one canonical
  entity while preserving every name as an alias for the writer to match on
- mistag guards: shared-handle collisions -> review; curated landmines
  (Libra/@jmilei personal, Binance/@bnbchain parent) -> suppress

Phase 2 freshness/self-healing tracked in #13 #14 #15.
Verified seed: 6245 entities, handle 99.12% / x_id 97.47%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add internal/mention: an in-memory index over the store that maps a raw
protocol name (engine output) to a tag/plain-text decision. Tags only on
a unique policy=allow match; ambiguous/miss/suppress/review -> plain text
("맞으면 태그, 애매하면 평문").

- name/alias/slug match + generic-suffix stripping ("IPOR Protocol" ->
  "IPOR") + token fallback to widen coverage without fuzzy substring match
- store.AllMentionEntities loads the index once (no hot-path DB call)

Harden the seeder against a class of Surf data errors the real-data smoke
test surfaced: rows whose notes carry `defi_name_resolved_as:X` where X is
a DIFFERENT protocol (IPOR Protocol -> IQ Protocol's @QHUB_, Raft ->
Metakraft, Trevee -> Paladin) are flagged `review` so they never auto-tag.
Same-org variants (Curve -> Curve DAO) stay allowed via brand-prefix/token
overlap. Shared-handle + curated landmines unchanged.

Verified on full export: IPOR Protocol -> plain text; Curve/Lido/Sushi/
Aave still tag; 9 name-resolution mismatches flagged review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the resolver into both Backlight post renderers through a single
shared mention.FormatTag so the two render paths cannot drift:
- xfeed headline:  "🚨 Curve (@curvefinance) exploit on Ethereum"
- xpublish template: "Protocol: Curve (@curvefinance)"
Not taggable (miss / suppress / review / ambiguous) -> plain protocol
name, unchanged.

The index is built once at service startup from store.AllMentionEntities
(no hot-path DB call) and gated by HELIOS_VICTIM_MENTION (default on).
A nil index = disabled = plain text, so every existing renderer test and
the kill-switch-off path stay byte-identical.

Until cmd/seed-mentions is run against the prod DB the store is empty, so
every resolve misses -> plain text: this ships dormant and safe, and only
activates once seeded + restarted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…S_DB_PATH

Commit the Surf export (seeds/surf-full-x-account-db.csv, 6,279 rows) and
default SURF_EXPORT_CSV to it, so no separate data hand-off is needed. An
operator with access to the service DB seeds the store with just:

    HELIOS_DB_PATH=/path/var/backlight.db go run ./cmd/seed-mentions

SURF_EXPORT_CSV still overrides the path. Verified: default-path run seeds
6245 entities (handle 99.12% / x_id 97.47%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jungjipdo
jungjipdo merged commit 710f22b into main Jul 10, 2026
1 check passed
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.

1 participant