RELEASETOOLINGONCHAIN

Introducing Dandelion — an open-source tool for reconstructing on-chain architecture from addresses

Dan OgurtsovJul 202612 min read

Today we are releasing dandelion, a free and open-source tool that reconstructs a protocol's on-chain architecture from addresses, not from a repository. It is MIT-licensed and available now: github.com/danogurtsov/dandelion.

dandelion — reconstruct a protocol's on-chain architecture from addresses
dandelion — reconstruct a protocol's on-chain architecture from addresses

You hand it a set of addresses and a network. It resolves the code, reads the real state, and follows the links between contracts. It works out roles and where the money sits, measures how the contracts are actually used, and folds all of it into one typed architecture graph you can build your on-chain research on.

addresses + chain  ─▶  dandelion  ─▶  ArchitectureGraph (JSON)
                                        nodes · proxies↔impl · roles & authorities ·
                                        reserves & own tokens · clone-classes ·
                                        cross-chain mirrors & peers · activity

One example up front: a single Aave v3 Pool address is enough for dandelion to unfold the Pool's whole cluster on its own - the addresses provider, the interest-rate strategy, the logic libraries and the payloads controller, plus every per-reserve aToken, debt token and rate strategy, folded into clone-classes. No hints, no repo. We trace that exact run later in this post.

The problem

Protocol research usually starts from a GitHub repo. You clone it, read the contracts, follow the imports, and you have a map. But very often all you actually have is a set of addresses on a live chain: a fresh deployment, an unknown fork, a system someone asks you to look at before an audit or after an incident. There is no repo, or the repo is unverified, or the code on-chain has drifted from whatever is on GitHub.

Indexers like Etherscan make one thing easy: looking at a single transaction or a single contract. What they do not do is hand you the shape of the whole system. And that shape is where the hard questions live:

  • Often there is no repo at all - a fork with no verified source, a closed deployment, someone else's contract. You are staring at bytecode.
  • The source is not the live state. Who is the admin right now, what are the current parameters, where do the funds actually sit? That lives in on-chain state, and it is frequently the whole point.
  • Nothing ties the pieces together. There is no table of contents across contracts. To learn which addresses actually make up one protocol - and which are just external dependencies it references - you crawl traces, proxy slots, role events and factory logs by hand, one address at a time.

There are two things that help here, and dandelion is built on both. First, you can look at transaction history and traces and statistically link clusters of dependent contracts: contracts that share an admin, that were deployed by the same non-public deployer, that keep showing up together in the same traces, that reference each other in storage. Second, where deterministic linking stalls - an opaque contract, an idiom the engine has never seen - you can bring in AI to decide what to read next. dandelion combines the two into a single loop.

How it works

dandelion is not a linear "stage 1 through 6" pipeline. It is a converging loop of determinism and an exploring LLM over shared state, breadth-first and fully async. The graph itself is the memory: every pass reads it, adds facts, and decides where to look next.

The convergence loop: determinism produces facts, the optional LLM pass decides where to look, deterministic probes verify, repeat until convergence
The convergence loop: determinism produces facts, the optional LLM pass decides where to look, deterministic probes verify, repeat until convergence

Each address on the frontier goes through four movements.

1. Resolve. Fetch the code and detect the proxy pattern - EIP-1967, 1822, 1167 minimal, beacon, diamond, ZeppelinOS - and follow it to the implementation and admin. Classify the contract type from bytecode selectors. Resolve name and ABI through a source ladder: Etherscan V2 → Sourcify → Blockscout → Heimdall. If there is no verified source anywhere, the last rung decompiles the bytecode, so an unverified fork still gets mapped, not skipped.

2. Expand. Walk the project's own structure deterministically, and do it by purpose, not blindly:

  • Purpose-driven getter walk. Structural getters like ADDRESSES_PROVIDER() or getACLManager() are walked deep and confer membership - they point at the protocol's own organs. Asset getters like getReservesList() are treated differently: they mark external assets as reserved (the accepted deposit list) without recursing into the world of every token.
  • Speculative probing. Struct getters are read even when there is no ABI, which is what keeps the tool working on bytecode-only contracts and exotic chains.
  • Reserve-keyed expansion. getReserveData(asset) over the reserve list surfaces each aToken, debt token and rate strategy. The same idiom generalizes to Compound, Euler and Morpho.
  • Factory and singleton events. Create* logs enumerate instances; hot singletons are read with topic-filtered logs. Thousands of identical instances collapse into a single clone-class instead of being crawled one by one.
  • Plus diamond facets, role hubs, deployer siblings, cross-chain mirrors and LayerZero peers.

3. Membership by control. This is the core question: which of these addresses are actually one project? Membership is decided by control, not by reference. The protocol's authorities - admins, role-holders, timelocks, multisigs - and its deployers form a set that closes iteratively: a member's own authorities pull in the next contract, which pulls in the next. A bare reference, like an external oracle a pool reads a price from, does not confer membership and stays external. Known-infrastructure labels (WETH, USDC, Permit2, the Safe singleton) are external by default, even when a member references them. Under the hood each candidate accumulates weighted signals - explicit storage reference, shared admin or roles (very strong, since projects rarely share a private admin), co-occurrence in traces, same deployer (with a denylist for shared CREATE2 and Safe factories), shared codehash, same-address-on-another-chain - and the score decides member, candidate, or external.

4. Reason (optional). The LLM sees the current graph, proposes which reads to run next, and the deterministic layer executes and merges them. It repeats until nothing new appears.

The rule that holds it together: the graph is built only from facts the tool verified on-chain. The LLM never writes to it - it just suggests where to look next. More on that in the next section. It is also model-agnostic - any OpenAI-compatible key (DeepSeek, OpenAI, OpenRouter, Groq) or an Anthropic key / Claude subscription plugs in behind the same port.

What comes out

The output is one typed ArchitectureGraph:

  • nodes - each contract with its code, source tier, proxy kind, implementation, admin, codehash, type (token / pool / vault / router / factory / oracle / governance / timelock / multisig), roles, key state, and a membership verdict (member / candidate / external).
  • token roles - every token is classified as own (project-issued: aTokens, debt tokens, governance/LP), reserved (assets the protocol custodies), or transient (merely seen in a flow).
  • edges - is_proxy_for, holds_role_over, depends_on, created_by, calls, mirrors_deployment (cross-chain), peer_of (LayerZero), and the semantically typed reads_price_from and holds_funds (the latter confirmed by a live balance read).
  • clone_classes and logical entities - thousands of identical instances folded into one class, and address-less market state (e.g. Morpho Blue markets) captured as hyperedges to their real dependencies.

What the AI actually adds

The easy version of this is to bolt an LLM on and call the whole thing AI-powered. We went the other way: the AI here cannot touch a single fact in the graph, and we measured what it actually adds. Two things enforce that.

The AI is never the writer of the graph. It plays two isolated roles, both behind a validation membrane:

  • Proposer. It may only return typed actions - read_addr, read_addr_array, enumerate_index, reserve_keyed - aimed at nodes where determinism stalled. The deterministic layer executes each one and validates it (well-formed signature, no revert, strict address decode, the target actually has code, not a known-external) before an edge marked origin="llm" is added. The membrane is superset-only: it can add verified leads, but it can never delete or reclassify a deterministic finding. A wrong proposal - or a prompt-injected one - wastes one read, gets counted in diagnostics, and changes nothing else. Untrusted on-chain text (names, state) is sanitized before it ever reaches the prompt.
  • Judge. A separate, read-only panel with three lenses (control / code / adversarial-refute) that measures membership precision, calibrated against a human gold set with Cohen's κ, so we trust its numbers only as far as it agrees with the human labels.

And we measured what it buys. An ablation harness (evals/ablation.py) compares the reasoning loop against pure determinism. On protocols that have verified source, the deterministic engine already recovers the structure, so the LLM adds roughly zero new nodes - it contributes a semantic layer instead: a protocol summary, role labels, a type guess on unknown contracts. Its structural value shows up only on genuinely opaque contracts - bytecode, no ABI, an enumeration idiom the deterministic layer does not have. We do not claim more than that.

A full run: Aave v3 from one address

Here is the run from the intro, in detail. The input is a single address - the Aave v3 Pool, 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2, on Ethereum:

dandelion map 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 \
    --chain 1 --rpc https://eth.drpc.org --out aave.graph.json

Aave is a deliberately hard starting point, because it hides its wiring in two ways. Its components are addressed through a registry rather than direct references, and the Pool's admin is baked into an immutable proxy (InitializableImmutableAdminUpgradeabilityProxy) instead of a standard 1967 slot. So the naive moves fail: there is no owner() to follow, and deterministic authority- and deployer-closures find nothing to anchor on. Start there and you get two nodes - the proxy and its implementation - and a dead end.

One Aave Pool address expands to a 14-node core with 10 members through getter expansion, with the oracle correctly left external and reserves folded into a clone-class
One Aave Pool address expands to a 14-node core with 10 members through getter expansion, with the oracle correctly left external and reserves folded into a clone-class

What breaks it open is the purpose-driven getter walk, and it is fully deterministic - no LLM required. The Pool exposes its own parts through no-argument, address-returning view getters. ADDRESSES_PROVIDER() returns the registry hub, which in turn exposes the rest of the core through more getters. dandelion calls each one and follows the returned address. Two nodes become fourteen, ten of them members: the Pool proxy and implementation, the PoolAddressesProvider, the interest-rate strategy, five logic libraries and the PayloadsController. The oracle is reached as well but held external. The registry gap and the immutable-admin gap are both closed by structure alone.

Then reserve-keyed expansion takes over the long tail. getReserveData(asset) over the reserve list surfaces, for each accepted asset, its aToken, its debt token and its rate strategy. Those run into the thousands across all reserves, so they collapse into a single clone-class instead of bloating the graph. The price oracle is reached too, but it is correctly held as external, joined by a reads_price_from edge rather than pulled into the cluster - a reference is not membership. The accepted assets are tagged reserved, not own.

How much does the AI do on this run? Very little, which is what you would expect - Aave has verified source. With --enrich on, the LLM does propose the ADDRESSES_PROVIDER() read, a nice confirmation that the proposer finds the right lead, but the deterministic getter walk already had it. Enrichment adds no new nodes here and changes no classifications, because the membrane can only add validated leads, never remove or relabel what determinism found. So on Aave the LLM's job is semantic - labels and a summary - not structural. The membership judge is calibrated against human labels, but on this graph only a small subset of members was present to score: PoolAddressesProvider came out a member, and tokens like LINK, UNI and MKR external, matching the human call. Treat that as a spot-check, not a large-sample precision guarantee - most of Aave's members were not in the scored set.

The result on Aave

From one address, you get a named, typed graph of the Aave v3 core: proxies wired to implementations, the role hubs and authorities, the oracle held as an external dependency, the reserves tagged and their per-reserve triplets folded into a clone-class - and a machine-readable aave.graph.json to build on, with no external contract wrongly pulled into the cluster. Recall from a single seed is 6 of 10 key components, and the misses are the expected ones: unconnected subsystems like GHO, the AAVE token, stkGHO and the Gateway are not structurally reachable from the Pool. Feed the tool the full scope list of addresses instead - its intended on-chain-first mode - and recall goes to 11 of 11.

There is a forensic payoff on top. --block N pins every read to a historical block, so you can reconstruct the architecture exactly as it was during an incident. --anomalies ranks structural risks straight from the graph - single-key upgrade control, funds under an EOA, unverified core. --audit runs a leak audit over external assets and operator keys. And every skipped read is counted and surfaced in the graph's diagnostics, so the gaps are visible rather than hidden behind a map that only looks finished.

How we tested and validated it

We did not want to trust our gut on whether this worked. The whole thing was driven by a measurable loop: run against live fixtures, compare to ground truth, find the bottleneck, fix it, repeat. Ground truth is curated by hand from real Immunefi scopes.

  • A real fixture set. The eval harness (evals/, public) replays golden fixtures against live RPC and scores a spread of real protocols: Aave, Fluid, Morpho, Velodrome, deBridge, Liquity, GMX and mETH. Each stresses something different - a simple 1967 proxy, ACL roles, factory clones, a cross-chain bridge, an immutable "no proxy here" negative.
  • The metrics are specific. Proxy-kind accuracy, implementation/admin correctness, node recall and precision, and - importantly - membership precision, a leak audit that gates false positives on external assets and operator keys. Plus mirror coverage, clone-collapse correctness, cost in RPC calls, and wall-time.
  • 155 unit tests on a pure, network-free core, with CI, ruff and mypy on top.
  • Generalization on a held-out set. Metrics on the tuning set alone would be circular, so evals/run_evals.py --set holdout scores protocols the heuristics were not built against. On the first run, with no protocol-specific tuning, the generic engine mapped 3 of 4: Spark Lend (an Aave fork at entirely different addresses), Balancer V2's singleton Vault, and Compound V3's Comet - each cleanly, with no outside contract pulled into the cluster. The one gap is documented: Curve's Vyper coins(i) token-list idiom without a verified ABI.
  • The AI, ablated. As above, evals/ablation.py measures the LLM's contribution rather than assuming it. And a safety check runs the whole AI layer present-but-off: all 8 golden fixtures still pass with zero membership leaks, and the deterministic behavior is unchanged.

One concrete example of the loop in action: on deBridge, an early run showed clone-collapse wrongly folding cross-chain mirrors into a "clone-class", and mirror edges multiplying O(n²) to 55. The fixes - a per-chain grouping key, and probing each address once with directed edges - took false clone-classes from 2 to 0 and mirror edges from 55 to 8. Every fix like that landed with a regression test.

Try it

dandelion is free, open-source and MIT-licensed. Point it at an address you care about:

git clone https://github.com/danogurtsov/dandelion
cd dandelion
pip install -e ".[dev]"

A word on the limits. The deterministic discovery and the precision are the strong part; the weaker frontier is enumerating every factory-deployed instance and reading genuinely opaque, source-less contracts - which is where the AI proposer has the most room to grow. If you run it on something interesting - a fork, an incident, a protocol we have never tested - tell us what it got right and, more usefully, what it missed. Open an issue on GitHub, or get in touch.