Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ Lifecycle statuses: `PENDING` (mempool), `CONFIRMED` (in block), `ROLLED_BACK` (
| Method | Path | Description |
|---|---|---|
| GET | `/api/v1/analysis/results` | Analysis results (params: `risk_band`, `min_score`, `min_corroboration`, `attack_class`, `contract`, `sort`, `analyzed_from`, `analyzed_to`, `limit`, `offset`). `contract` is tri-state: omitted means no filter, an address restricts to that contract, and the EMPTY string selects exactly the alerts that name no contract at all. Valid `attack_class` values are the nine stored classes (`token_dust`, `large_value`, `large_datum`, `multiple_sat`, `front_running`, `sandwich`, `circular`, `fake_token`, `phishing`) plus the synthetic `contract_anomaly` (resolved at read time; returns empty when the clustering profile is off). |
| GET | `/api/v1/analysis/results/grouped` | Alerts collapsed to one row per contract, for the grouped alerts table. Same filters as `/results`. Returns one ordered list mixing `kind="group"` rows with `kind="alert"` rows for alerts that name no contract, so both shapes interleave correctly across page boundaries. The envelope carries two totals: `total` counts ROWS (what the pager steps through) and `alert_total` counts ALERTS. |
| GET | `/api/v1/analysis/results/grouped` | Alerts collapsed to one row per contract, for the grouped alerts table. Same filters as `/results`. Returns one ordered list mixing `kind="group"` rows with `kind="alert"` rows for alerts that name no contract, so both shapes interleave correctly across page boundaries. The envelope carries two totals: `total` counts ROWS (what the pager steps through) and `alert_total` counts ALERTS. A group row names an attack type as well as counting: `attack_class` is the class of the alert that also gives the row its `worst_band` and `worst_score`, and `attack_classes` is every distinct class under the row, so a client can say how many other kinds the contract holds without expanding it. |
| GET | `/api/v1/analysis/results/{tx_hash}` | Analysis result for a single transaction |
| GET | `/api/v1/analysis/stats` | Risk-band distribution and per-class score stats. `avg_max_score` averages the FINDING population (`max_score >= 1`), matching the alert list beside it rather than every scored transaction; `finding_count` is that population's size |
| GET | `/api/v1/analysis/stats/timeseries` | Daily High and Critical alert counts (params: `days`) |
Expand Down
23 changes: 22 additions & 1 deletion backend/app/api/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,22 @@ class GroupedAlertRow(BaseModel):
)
attack_class: str | None = Field(
None,
description="Winning class, for kind='alert' only",
description=(
"The winning class of the alert this row stands for: the alert "
"itself for kind='alert', and the group's highest-scoring alert for "
"kind='group'. Same row worst_band and worst_score describe, so a "
"group names an attack type instead of only counting alerts."
),
)
attack_classes: list[str] = Field(
default_factory=list,
description=(
"Every distinct class under this row, so a client can say how many "
"OTHER kinds of alert a group holds without expanding it. One "
"element for kind='alert'. Bounded by the nine-class vocabulary. A "
"contract_anomaly verdict is unioned in, never subtracted: see "
"_augment_groups_with_contract_anomaly."
),
)
unclusterable_model: bool = Field(
False,
Expand Down Expand Up @@ -173,6 +188,7 @@ async def _unattributed_alert_rows(
"latest_analyzed_at": r["analyzed_at"],
"tx_hash": r["tx_hash"],
"attack_class": r["max_class"],
"attack_classes": [r["max_class"]],
"unclusterable_model": False,
}
for r in rows
Expand Down Expand Up @@ -319,6 +335,11 @@ async def list_analysis_result_groups(
"worst_score": float(g["worst_score"]),
"worst_band": g["worst_band"],
"latest_analyzed_at": g["latest_analyzed_at"],
"attack_class": g.get("worst_class"),
# SQL already sorts these, but the contract_anomaly merge APPENDS
# its synthetic class, so re-sort at the boundary or an augmented
# group reshuffles its list between two identical refreshes.
"attack_classes": sorted(g.get("classes") or []),
"unclusterable_model": bool(g.get("unclusterable_model", False)),
}
for g in groups
Expand Down
14 changes: 13 additions & 1 deletion backend/app/api/contract_anomaly_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ async def _augment_groups_with_contract_anomaly(

1. it is added to its effective group, creating that group if SQL produced
none for the target;
2. that group's ``worst_score`` / ``worst_band`` / ``latest_analyzed_at``
2. that group's ``worst_score`` / ``worst_band`` / ``worst_class`` / ``latest_analyzed_at``
rise to the effective values.

A transaction is never REMOVED from the group its stored contract names,
Expand Down Expand Up @@ -705,6 +705,8 @@ def _passes(res: ClassScoreResult) -> bool:
"alert_count": 0,
"worst_score": 0.0,
"worst_band": res.risk_band.value,
"worst_class": res.max_class,
"classes": [],
"latest_analyzed_at": res.analyzed_at,
"unclusterable_model": False,
}
Expand All @@ -713,9 +715,19 @@ def _passes(res: ClassScoreResult) -> bool:
# Already counted by SQL only when it was counted under THIS contract.
if not (stored_counted and stored_contract == effective_contract):
group["alert_count"] = int(group["alert_count"]) + 1
# The class set is UNION-only. Removing the stored class when a verdict
# rewrites a row would require knowing no sibling row still carries it,
# which a set-valued aggregate cannot answer; over-stating by one is the
# safe direction for a "+N other kinds" hint.
classes = group.setdefault("classes", [])
if res.max_class not in classes:
classes.append(res.max_class)
if res.max_score > float(group["worst_score"]):
group["worst_score"] = res.max_score
group["worst_band"] = res.risk_band.value
# Kept in step with worst_band: both describe the group's top row, so
# a row that takes over the group renames its attack type too.
group["worst_class"] = res.max_class
# Tracks the WORST row, so it flips back off when a stored-class
# alert outranks the un-clusterable verdict. A group marked
# un-clusterable on a row that no longer tops it would tell the
Expand Down
26 changes: 23 additions & 3 deletions backend/app/db/clickhouse_scores.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,23 @@ def group_class_scores_by_contract(
(via argMax), not a band re-derived from ``worst_score``: a past
recalibration can move the thresholds, and the badge should agree with the
row the analyst sees when they expand the group.

The band and the class come out of ONE ``argMax`` over the tuple
``(risk_band, max_class)``, not two argMax calls sharing an ordering. Two
aggregates can break a tie on DIFFERENT rows (ClickHouse picks arbitrarily
among equal ``max_score`` values, the more so across merge threads), which
would put a severity badge and an attack type from two different alerts on
one line. One aggregate returns one row's pair, so the invariant holds by
construction rather than by luck.

``classes`` is every distinct class under the group, which lets the caller
say how many OTHER kinds of alert it holds. ``arraySort`` makes the order
stable, so an unchanged group does not reshuffle its tooltip between two
identical refreshes. Both are aliased away from ``max_class`` for the reason
``worst_score`` is aliased away from ``max_score``: on ClickHouse 26.x,
aliasing an aggregate to a source column name that a sibling aggregate also
reads returns Code 184. ``groupUniqArray`` is bounded by the nine-class
vocabulary, so the array is at most nine short strings.
"""
conditions, params = _score_filter_conditions(
network,
Expand All @@ -642,7 +659,8 @@ def group_class_scores_by_contract(
SELECT contract_address,
count() AS alert_count,
max(max_score) AS worst_score,
argMax(risk_band, max_score) AS worst_band,
argMax((risk_band, max_class), max_score) AS worst_pair,
arraySort(groupUniqArray(max_class)) AS classes,
max(analyzed_at) AS latest_analyzed_at
FROM tx_class_scores FINAL
WHERE {where}
Expand All @@ -657,8 +675,10 @@ def group_class_scores_by_contract(
"contract_address": r[0],
"alert_count": int(r[1]),
"worst_score": float(r[2]),
"worst_band": r[3],
"latest_analyzed_at": r[4],
"worst_band": r[3][0],
"worst_class": r[3][1],
"classes": list(r[4]),
"latest_analyzed_at": r[5],
}
for r in rows
]
Expand Down
117 changes: 116 additions & 1 deletion backend/tests/api/test_grouped_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,15 @@ def _clustering_off(monkeypatch):
monkeypatch.setattr(settings, "CLUSTERING_ENABLED", False)


def _group(address, count=3, worst=72.0, band="High", at=None):
def _group(
address,
count=3,
worst=72.0,
band="High",
at=None,
worst_class="large_value",
classes=None,
):
# clickhouse-driver hands back tz-NAIVE datetimes; the reconciliation has
# to normalise before comparing against the tz-aware model value.
at = at if at is not None else datetime(2026, 8, 1, 10, 0)
Expand All @@ -52,6 +60,8 @@ def _group(address, count=3, worst=72.0, band="High", at=None):
"alert_count": count,
"worst_score": worst,
"worst_band": band,
"worst_class": worst_class,
"classes": [worst_class] if classes is None else list(classes),
"latest_analyzed_at": at,
}

Expand Down Expand Up @@ -344,6 +354,44 @@ def test_runs_when_the_filter_is_the_synthetic_class_itself(self, client, monkey
assert calls[0]["anomaly_only"] is True


class TestGroupAttackType:
"""A group row names an attack type, not only a count.

The row's severity badge is its worst alert's stored band, so the type shown
beside it has to be that SAME alert's class or the two disagree on one line.
"""

def test_a_group_names_the_class_of_its_worst_alert(self, client, monkeypatch):
_stub_groups(
monkeypatch,
[_group(DJED, worst_class="large_datum", classes=["large_datum", "token_dust"])],
unattributed=[],
)
row = client.get(f"{GROUPED_URL}?network=preprod").json()["data"][0]
assert row["kind"] == "group"
assert row["attack_class"] == "large_datum"

def test_a_group_lists_every_distinct_class_it_holds(self, client, monkeypatch):
# Feeds the "+N other kinds" hint, so the client never has to expand a
# group just to learn whether it is homogeneous.
_stub_groups(
monkeypatch,
[_group(DJED, worst_class="large_datum", classes=["large_datum", "token_dust"])],
unattributed=[],
)
row = client.get(f"{GROUPED_URL}?network=preprod").json()["data"][0]
assert row["attack_classes"] == ["large_datum", "token_dust"]

def test_an_unattributed_alert_is_its_own_single_class(self, client, monkeypatch):
# One alert cannot hold two classes, so the set is exactly its own and a
# client can render both row shapes through one code path.
_stub_groups(monkeypatch, [], unattributed=[_alert_row("tx9", cls="phishing")])
row = client.get(f"{GROUPED_URL}?network=preprod").json()["data"][0]
assert row["kind"] == "alert"
assert row["attack_class"] == "phishing"
assert row["attack_classes"] == ["phishing"]


class TestValidation:
def test_unknown_attack_class_is_422(self, client, monkeypatch):
_stub_groups(monkeypatch, [])
Expand Down Expand Up @@ -421,6 +469,73 @@ async def test_verdict_moves_a_tx_to_the_watched_target(self, monkeypatch):
assert by_address[STRIKE]["alert_count"] == 1
assert by_address[STRIKE]["worst_band"] == "Critical"
assert by_address[DJED]["alert_count"] == 1
# The group the verdict invented is named by the verdict's own class:
# there is no stored class under it to name instead.
assert by_address[STRIKE]["worst_class"] == "contract_anomaly"

@pytest.mark.anyio
async def test_a_verdict_that_takes_over_renames_the_group_attack_type(self, monkeypatch):
# worst_class has to move with worst_band. A group badged Critical by the
# verdict while still naming the stored class would put two different
# alerts on one line.
from tests.analysis.test_contract_anomaly_projection import _full_score_row

stored = _full_score_row("tx1", 20.0)
stored["contract_address"] = DJED
self._stored(monkeypatch, [stored], {"tx1": [self._flagged_row(DJED)]})

groups = [
_group(
DJED,
count=1,
worst=20.0,
band="Informational",
worst_class="phishing",
classes=["phishing"],
)
]
await _augment_groups_with_contract_anomaly(
"preprod",
groups,
bands=None,
min_score=1.0,
analyzed_from=None,
analyzed_to=None,
min_corroboration=0,
)
assert groups[0]["worst_band"] == "Critical"
assert groups[0]["worst_class"] == "contract_anomaly"

@pytest.mark.anyio
async def test_the_verdict_class_is_added_to_the_group_class_set(self, monkeypatch):
# SQL cannot see the synthetic class, so without the union a mixed group
# would under-report how many kinds of alert it holds.
from tests.analysis.test_contract_anomaly_projection import _full_score_row

stored = _full_score_row("tx1", 20.0)
stored["contract_address"] = DJED
self._stored(monkeypatch, [stored], {"tx1": [self._flagged_row(DJED)]})

groups = [
_group(
DJED,
count=1,
worst=20.0,
band="Informational",
worst_class="phishing",
classes=["phishing"],
)
]
await _augment_groups_with_contract_anomaly(
"preprod",
groups,
bands=None,
min_score=1.0,
analyzed_from=None,
analyzed_to=None,
min_corroboration=0,
)
assert sorted(groups[0]["classes"]) == ["contract_anomaly", "phishing"]

@pytest.mark.anyio
async def test_group_is_created_when_sql_produced_none(self, monkeypatch):
Expand Down
50 changes: 42 additions & 8 deletions backend/tests/db/test_contract_grouping.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,10 @@ def test_aliases_do_not_shadow_source_columns(self, monkeypatch):
clickhouse_scores.group_class_scores_by_contract(network="preprod")
sql = fake.queries[0]
assert "AS worst_score" in sql
assert "AS worst_band" in sql
assert "AS worst_pair" in sql
assert "AS classes" in sql
assert "AS latest_analyzed_at" in sql
for shadowed in ("AS max_score", "AS risk_band", "AS analyzed_at"):
for shadowed in ("AS max_score", "AS risk_band", "AS analyzed_at", "AS max_class"):
assert shadowed not in sql

def test_excludes_rows_naming_no_contract(self, monkeypatch):
Expand All @@ -97,18 +98,47 @@ def test_groups_by_contract(self, monkeypatch):
clickhouse_scores.group_class_scores_by_contract(network="preprod")
assert "GROUP BY contract_address" in fake.queries[0]

def test_worst_band_is_the_stored_band_of_the_worst_row(self, monkeypatch):
# argMax, not a band re-derived from worst_score: a past recalibration can
# move the thresholds and the badge must agree with the row the analyst
# sees on expanding the group.
def test_band_and_class_come_from_ONE_argmax(self, monkeypatch):
# The band is argMax, not re-derived from worst_score: a past
# recalibration can move the thresholds and the badge must agree with the
# row the analyst sees on expanding the group.
#
# And band and class come out of a single argMax over a TUPLE. Two
# separate argMax calls sharing an ordering can break a tie on different
# rows, which would put a severity badge and an attack type belonging to
# two different alerts on one line.
fake = _patch_client(monkeypatch, [])
clickhouse_scores.group_class_scores_by_contract(network="preprod")
assert "argMax(risk_band, max_score)" in fake.queries[0]
assert "argMax((risk_band, max_class), max_score) AS worst_pair" in fake.queries[0]
assert "argMax(risk_band, max_score)" not in fake.queries[0]
assert "argMax(max_class, max_score)" not in fake.queries[0]
# Aliased away from the source column names: on 26.x an aggregate aliased
# to a column a sibling aggregate reads returns Code 184.
assert "AS max_class" not in fake.queries[0]
assert "AS max_score" not in fake.queries[0]

def test_distinct_classes_are_collected_and_sorted(self, monkeypatch):
# Sorted so an unchanged group does not reshuffle its "+N more" tooltip
# between two identical refreshes; groupUniqArray has no stable order.
fake = _patch_client(monkeypatch, [])
clickhouse_scores.group_class_scores_by_contract(network="preprod")
assert "arraySort(groupUniqArray(max_class)) AS classes" in fake.queries[0]

def test_rows_are_mapped_by_name(self, monkeypatch):
_patch_client(
monkeypatch,
[("addr_test1wq9", 12, 92.5, "Critical", "2026-08-01 10:00:00")],
[
(
"addr_test1wq9",
12,
92.5,
# One argMax over a tuple, so the driver hands back the band
# and the class of the SAME row as one value.
("Critical", "large_datum"),
("large_datum", "large_value"),
"2026-08-01 10:00:00",
)
],
)
groups = clickhouse_scores.group_class_scores_by_contract(network="preprod")
assert groups == [
Expand All @@ -117,6 +147,10 @@ def test_rows_are_mapped_by_name(self, monkeypatch):
"alert_count": 12,
"worst_score": 92.5,
"worst_band": "Critical",
"worst_class": "large_datum",
# The driver hands back an array column as a tuple; the row map
# normalises it so callers can treat it as a plain list.
"classes": ["large_datum", "large_value"],
"latest_analyzed_at": "2026-08-01 10:00:00",
}
]
Expand Down
Loading
Loading