Skip to content

Commit 5ac857c

Browse files
Toby1009claude
andcommitted
Make every analysis reachable, and reachable from the landing page
Found by operating the site rather than reading it. The accessibility tree's own summary of the landing page was that there is no way to reach the tool from it --- the primary button read "read the docs", which is not what somebody arriving with an address wants to do. The nav now opens a case from every page and the landing leads with it. The case view offered three analyses of thirteen, because the list was a literal in the component. Clustering, peel chains, taint, mixer and the rest were installed, documented and reachable from the CLI, and invisible here. `/analyses` now serves the entry-point registry, so the page cannot drift from what is installed, and reports plugins that failed to import rather than silently omitting them. The dispatcher runs any registered analyzer instead of matching four names. The landing lists all thirteen with what each cannot tell you, and each links into the case view with that analyzer preselected. Three problems the walkthrough surfaced, all of which would have shipped: Thirteen buttons under `flex: 1` divided one row between them and truncated every label to three characters. Content width and wrapping. `temporal` returned `AttributeError: 'NoneType' object has no attribute 'enumerate'` --- this handler passes no router by design, and the analyzer reached for one. It now says it needs to fetch and names the CLI command that can. `taint` and `mixer` demanded parameters the page gave no way to supply. Analyzers declare `REQUIRES` and the page renders an input per entry. The test checks declarations against `run()`'s signature, which immediately caught that I had declared `funder` for `common_funder` when it takes `addresses` --- the error message I had matched belonged to a different class in the same file. Hiding inapplicable analyses over-corrected at first and hid all thirteen, visible only by looking again: the browser had cached the bundle from before the server grew the field. Which is itself the bug --- HTML carries this run's token and must never be cached, so it is now `no-store`, with the content-hashed assets cached hard. Also adds `code_at`/`is_eoa_at` for historical `eth_getCode`, and lets an endpoint declare archive state via `CHAINSCOPE_RPC_<NAME>_ARCHIVE`. Without it every historical `eth_call` was refused, so "was this an EOA at block b" and "what were this token's decimals then" could only be answered about the present --- a different question with a plausible-looking answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXWQ25VgFeMuuMhGWjvdof
1 parent 1d3b01a commit 5ac857c

20 files changed

Lines changed: 900 additions & 68 deletions

src/chainscope/analysis/funding.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from dataclasses import dataclass, field
3737
from datetime import datetime, timezone
3838
from functools import partial
39-
from typing import Any
39+
from typing import Any, ClassVar
4040

4141
from ..chains import fold_if_hex
4242
from ..core.attribution import Attribution, Category, Confidence, Method
@@ -269,6 +269,12 @@ def _history(
269269
class CommonFunderAnalyzer(Analyzer):
270270
"""Group addresses by who first funded them."""
271271

272+
#: Parameters this needs beyond an address. Read by the web UI to
273+
#: render an input for each, so a reader is not asked to press a button
274+
#: whose only possible outcome is an error naming what they should have
275+
#: typed. Kept beside the check that enforces it.
276+
REQUIRES: ClassVar[tuple[str, ...]] = ("addresses",)
277+
272278
name = "common-funder"
273279
version = "1.0"
274280
description = "Group account-model addresses by who first funded them"

src/chainscope/analysis/mixer.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
from dataclasses import dataclass, field
7979
from datetime import datetime, timezone
8080
from functools import partial
81-
from typing import Any
81+
from typing import Any, ClassVar
8282

8383
from ..chains import fold_if_hex
8484
from ..core.attribution import Attribution, Category, Confidence, Method
@@ -403,6 +403,12 @@ def _transaction(provider: Any, *, chain: ChainId, tx_hash: str) -> Any:
403403
class MixerAnalyzer(Analyzer):
404404
"""Correlate deposits and withdrawals for one mixer pool."""
405405

406+
#: Parameters this needs beyond an address. Read by the web UI to
407+
#: render an input for each, so a reader is not asked to press a button
408+
#: whose only possible outcome is an error naming what they should have
409+
#: typed. Kept beside the check that enforces it.
410+
REQUIRES: ClassVar[tuple[str, ...]] = ("deposits",)
411+
406412
name = "mixer"
407413
version = "1.0"
408414
description = "Pair mixer deposits with withdrawals by timing, with the anonymity set"

src/chainscope/analysis/peel.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
from dataclasses import dataclass, field
4040
from datetime import datetime, timezone
4141
from decimal import Decimal
42-
from typing import Any
42+
from typing import Any, ClassVar
4343

4444
from ..core.attribution import Confidence
4545
from ..core.hypothesis import Hypothesis, ScoreFactor
@@ -204,6 +204,12 @@ class PeelStep:
204204
class PeelChainAnalyzer(Analyzer):
205205
"""Follow the change output of a transaction chain, hop by hop."""
206206

207+
#: Parameters this needs beyond an address. Read by the web UI to
208+
#: render an input for each, so a reader is not asked to press a button
209+
#: whose only possible outcome is an error naming what they should have
210+
#: typed. Kept beside the check that enforces it.
211+
REQUIRES: ClassVar[tuple[str, ...]] = ("start",)
212+
207213
name = "peel-chain"
208214
version = "1.0"
209215
description = "Follow a UTXO peel chain, identifying payments shed at each hop"

src/chainscope/analysis/route.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
from collections import defaultdict
5050
from dataclasses import dataclass, field
5151
from datetime import datetime, timezone
52-
from typing import Any
52+
from typing import Any, ClassVar
5353

5454
from ..chains import address_key, fold_if_hex
5555
from ..core.result import Finding, Result, Severity
@@ -543,6 +543,12 @@ def read(provider: Any) -> Any:
543543
class RouteAnalyzer(Analyzer):
544544
"""How money could have got from one address to another."""
545545

546+
#: Parameters this needs beyond an address. Read by the web UI to
547+
#: render an input for each, so a reader is not asked to press a button
548+
#: whose only possible outcome is an error naming what they should have
549+
#: typed. Kept beside the check that enforces it.
550+
REQUIRES: ClassVar[tuple[str, ...]] = ("source", "target")
551+
546552
name = "route"
547553
description = "find time-respecting routes between two addresses"
548554
requires = Capability.ASSET_TRANSFERS

src/chainscope/analysis/taint.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
from datetime import datetime, timezone
7171
from enum import Enum
7272
from functools import partial
73-
from typing import Any, Union
73+
from typing import Any, ClassVar, Union
7474

7575
from ..core.chainid import ChainId
7676
from ..core.result import Finding, Result, Severity
@@ -503,6 +503,12 @@ def trace_origins(
503503
class TaintAnalyzer(Analyzer):
504504
"""Trace stolen value forward from a source address."""
505505

506+
#: Parameters this needs beyond an address. Read by the web UI to
507+
#: render an input for each, so a reader is not asked to press a button
508+
#: whose only possible outcome is an error naming what they should have
509+
#: typed. Kept beside the check that enforces it.
510+
REQUIRES: ClassVar[tuple[str, ...]] = ("source",)
511+
506512
name = "taint"
507513
version = "1.0"
508514
description = "Trace how much of each address's holdings came from a given source"

src/chainscope/analysis/xchain.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from dataclasses import dataclass
3434
from datetime import datetime, timedelta, timezone
3535
from decimal import Decimal
36-
from typing import Any
36+
from typing import Any, ClassVar
3737

3838
from ..core.attribution import Confidence
3939
from ..core.hypothesis import Hypothesis, ScoreFactor, rank
@@ -121,6 +121,12 @@ def calibrate(
121121
class CrossChainMatcher(Analyzer):
122122
"""Find the far side of a swap on another chain."""
123123

124+
#: Parameters this needs beyond an address. Read by the web UI to
125+
#: render an input for each, so a reader is not asked to press a button
126+
#: whose only possible outcome is an error naming what they should have
127+
#: typed. Kept beside the check that enforces it.
128+
REQUIRES: ClassVar[tuple[str, ...]] = ("amount", "asset", "at")
129+
124130
name = "cross-chain"
125131
version = "1.0"
126132
description = "Match a deposit on one chain to its payout on another"

src/chainscope/config.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ class ConfigError(RuntimeError):
6363
#: is not a legal environment variable name.
6464
RPC_PREFIX = "CHAINSCOPE_RPC_"
6565

66+
#: Appended to an RPC variable to say that endpoint serves historical state:
67+
#: ``CHAINSCOPE_RPC_ETHEREUM_ARCHIVE=1``. Without it, historical `eth_call` and
68+
#: `eth_getCode` are refused rather than silently answered about the present.
69+
ARCHIVE_SUFFIX = "_ARCHIVE"
70+
6671

6772
def load_dotenv(
6873
path: str | Path | None = None, *, search_from: Path | None = None
@@ -122,6 +127,9 @@ class Settings:
122127
rpc: dict[str, str] = field(default_factory=dict)
123128
"""Chain short name (lowercased) to endpoint URL."""
124129

130+
rpc_archive: dict[str, bool] = field(default_factory=dict)
131+
"""Which of those endpoints serve historical state. See `ARCHIVE_SUFFIX`."""
132+
125133
cache_dir: Path | None = None
126134
audit_log: Path | None = None
127135
rate_limit: float = 5.0
@@ -152,7 +160,20 @@ def load(
152160
rpc = {
153161
name[len(RPC_PREFIX) :].lower(): value.strip()
154162
for name, value in merged.items()
155-
if name.startswith(RPC_PREFIX) and value.strip()
163+
if name.startswith(RPC_PREFIX)
164+
and value.strip()
165+
and not name.endswith(ARCHIVE_SUFFIX)
166+
}
167+
# Declared, never guessed from the hostname. An endpoint that keeps
168+
# historical state can answer "was this an EOA at block b" and "what
169+
# were this token's decimals then"; one that cannot will answer those
170+
# about *now*, which is a different question with a plausible-looking
171+
# answer. So the capability is off unless somebody says otherwise.
172+
rpc_archive = {
173+
name[len(RPC_PREFIX) : -len(ARCHIVE_SUFFIX)].lower(): value.strip().lower()
174+
not in ("", "0", "false", "no")
175+
for name, value in merged.items()
176+
if name.startswith(RPC_PREFIX) and name.endswith(ARCHIVE_SUFFIX)
156177
}
157178
# An endpoint may itself embed a credential in its path. Registering the
158179
# whole URL means a cassette recorded against it cannot carry the key,
@@ -163,6 +184,7 @@ def load(
163184
return cls(
164185
credentials=credentials,
165186
rpc=rpc,
187+
rpc_archive=rpc_archive,
166188
cache_dir=_path(merged.get("CHAINSCOPE_CACHE_DIR")),
167189
audit_log=_path(merged.get("CHAINSCOPE_AUDIT_LOG")),
168190
rate_limit=_number(

src/chainscope/providers/jsonrpc.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,17 @@ def from_settings(cls, settings: Any, chain: ChainId, client: Any = None) -> lis
9595
lookup goes through the alias table rather than CAIP-2 --- a user types
9696
``eth``, and ``eip155:1`` is not a thing anybody sets in a shell profile.
9797
98-
Archive and trace are left off. Whether a node keeps historical state is
99-
a property of that node, not of the URL, and declaring a capability the
100-
endpoint does not have makes the router pick it and fail rather than
101-
pick something else that works.
98+
Archive is **declared, never assumed**. Whether a node keeps historical
99+
state is a property of that node, not of the URL, and inferring it from
100+
a hostname would make the router pick an endpoint that then fails --- so
101+
it is off unless `CHAINSCOPE_RPC_<NAME>_ARCHIVE` says otherwise.
102+
103+
It has to be declarable, though. Without it every historical `eth_call`
104+
and `eth_getCode` is refused, and those are what answer "was this an
105+
EOA at block b" and "what were this token's decimals then" --- questions
106+
whose present-tense answers are quietly different. Asking a live node
107+
for state two years old is exactly the confident wrong answer this
108+
package refuses elsewhere.
102109
"""
103110
from ..core.chainid import ALIASES
104111

@@ -109,7 +116,13 @@ def from_settings(cls, settings: Any, chain: ChainId, client: Any = None) -> lis
109116
url = settings.rpc.get(name)
110117
if url:
111118
return [
112-
cls(url, chain, client=client, native_symbol=native_symbol(chain, "ETH"))
119+
cls(
120+
url,
121+
chain,
122+
client=client,
123+
native_symbol=native_symbol(chain, "ETH"),
124+
archive=settings.rpc_archive.get(name, False),
125+
)
113126
]
114127
return []
115128

@@ -284,6 +297,34 @@ def call(self, chain: ChainId, to: str, data: str, block: int | str = "latest")
284297
vol = Volatility.LIVE if block == "latest" else Volatility.IMMUTABLE
285298
return self._call("eth_call", [{"to": to, "data": data}, tag], vol) or "0x"
286299

300+
def code_at(self, chain: ChainId, address: str, block: int | str = "latest") -> str:
301+
"""``eth_getCode`` at a specific block. Historical needs ``ARCHIVE_STATE``.
302+
303+
`get_account` asks at ``latest``, which answers a different question.
304+
Whether an address is a contract is not a fixed property: a
305+
counterfactual deployment turns an EOA into a contract, and a
306+
self-destruct turned one back. Asking "now" to decide what something was
307+
two years ago produces a confident wrong answer with nothing to notice,
308+
which is the failure mode this package exists to refuse.
309+
310+
Returns the raw byte string. `"0x"` means no code *at that block*, which
311+
is what "EOA at block b" means and is a narrower claim than "not a
312+
contract" --- an address with no code yet may still receive one.
313+
"""
314+
if block != "latest" and not self.capabilities & Capability.ARCHIVE_STATE:
315+
raise ProviderError(
316+
f"{self.name} is not configured as an archive node; "
317+
f"eth_getCode at block {block} needs archive state. "
318+
f"Pass archive=True if this endpoint does serve history."
319+
)
320+
tag = block if isinstance(block, str) else hex(block)
321+
vol = Volatility.LIVE if block == "latest" else Volatility.IMMUTABLE
322+
return self._call("eth_getCode", [address, tag], vol) or "0x"
323+
324+
def is_eoa_at(self, chain: ChainId, address: str, block: int | str = "latest") -> bool:
325+
"""Whether ``address`` had no code at ``block``."""
326+
return self.code_at(chain, address, block) == "0x"
327+
287328
def block_at_time(self, chain: ChainId, when: datetime) -> Block:
288329
"""Last block at or before ``when``, by binary search.
289330

0 commit comments

Comments
 (0)