# The contract map — how `bat-cli map` should draw a system's shape
This document is the design behind `map`: one frame that answers "which contracts talk to
which, which one everybody writes to, and what is wired to what at construction". It exists
because the first attempt (`src/batbelt/evm/miro/map.rs` on `feature/architecture-map`) was
honest about the data and unreadable as a picture: one box per contract, one arrow per relation,
the deploy's left-to-right Sugiyama, and the auditor's verdict was that arrows flew across several
boxes, interfaces stood beside the contracts that implement them, and nothing said what to look at
first. The interface half has since been fixed in `relations()` (a type name collapses into its
implementer when that is a fact). What is left — and what this document decides — is the model
behind the nodes and edges, the layout, the reading order, and what the map must refuse to draw.
It is written in three parts, in the order the decisions were reached: what the people who do this
well actually do (§1–§3), the design that follows from it against the numbers this tool's own scans
produce (§4–§10), and the implementation against the code as it is today (§11–§14). Nothing here
changes code. Line references are to `develop` at 0.27.0 plus the uncommitted `map.rs`.
---
## 1. What audit firms draw, and what they leave off
Nobody who publishes a system overview draws every contract in the repository. ConsenSys
Diligence is the only firm with a stated, repeatable legend, and its definition of the unit is the
one that matters here: the overview "describes the top-level/**deployable** contracts, their
inheritance structure and interfaces, actors, permissions and important contract interactions"
(Arbitrum audit, 2021, §3 —
<https://docs.arbitrum.io/assets/files/2021_11_05_consensys_diligence_security_audit_core_contracts_token_bridge-664fbe3e5a14a41acaee4af64ae06100.pdf>).
Inheritance is "a yellow dashed row at the top of the contract", a `using for` library "a green
dashed row" — rows *inside* the box, never edges between boxes — and access-control modifiers are
"yellow bubbles in front of methods". The figures are per subsystem ("core", "bridge
peripherals"), not one all-in picture, and the deploy wiring ("RollupCreator … configures the
NodeFactory, ChallengeFactory") is prose beside the figure.
Trail of Bits does not put architecture diagrams in code reviews at all; its threat-modelling
deliverable identifies "all in-scope system components", places "a trust boundary anywhere that
security controls gate connections between components", and enumerates threats over "connections
that cross trust boundaries" (<https://blog.trailofbits.com/2025/02/28/threat-modeling-the-trail-of-bits-way/>).
OpenZeppelin, Zellic, Sigma Prime and Spearbit write the overview as prose plus a roles table; the
thing every one of them records somewhere is the out-of-scope boundary ("all the contracts that the
in-scope contracts interact with work correctly", Across audit —
<https://www.openzeppelin.com/news/across-audit>) and who holds which admin key. Contest READMEs
(Code4rena's LoopFi, <https://github.com/code-423n4/2024-10-loopfi>) draw actors and core contracts
and list external integrations; Sherlock's guide names the pre-code step as "map out the trust
boundaries (who can call what, and under which conditions), and diagram the token flow"
(<https://sherlock.xyz/post/a-complete-guide-to-solidity-security-audits-for-web3-protocols>).
Three things to carry forward. The node is the deployable contract. Inheritance and libraries are
part of a node, not relations between nodes. And the boundary — what the audited code calls that it
does not contain — is always written down, but collapsed: the far side is a black box.
## 2. What the Solidity tools draw, and what their own flags admit
Slither's `call-graph` is a function-level DOT graph with one cluster per contract
(<https://github.com/crytic/slither/wiki/Printer-documentation>); Secureum files it under "in-depth
review" and recommends `human-summary` and `contract-summary` for first contact
(<https://secureum.substack.com/p/audit-techniques-and-tools-101>). Surya's `graph` is also
function-level, and its README is the most candid source on readability: `-l` exists "to prevent
libraries like SafeMath from polluting the call graph", and `-s/--simple` "makes the command chart
only the contract call graph, instead of the function call graph. It's super useful for
higher-level analyses!" (<https://github.com/ConsenSys/surya>). sol2uml ships a depth limit and six
`hide` flags (<https://github.com/naddison36/sol2uml>). The pattern is the same everywhere: the
tool draws everything, then grows switches to draw less, and the thing practitioners praise is the
summary, not the graph. Dreamlab's comparison singles out Slither because it lets "written variables
be seen along with the authorisation checks that evaluate msg.sender"
(<https://dreamlab.net/en/blog/post/ethereum-smart-contracts-visualisation-tools-surya-solgraph-slither-piet-sol2uml-and-sol-functi/>).
So the contract-level map is the view the field already asked for (Surya `-s`) and never got a
good drawing of, because a DOT render of a contract graph still has every library and interface as
a node. This tool has one edge over all of them, and the capability-ideas note already names it:
`contract_in_scope` resolves a name through the caller's import graph the way `solc` does, so a
repository that vendors three `BeaconProxy`s produces one node where Slither produces a wrong one.
## 3. What the layout and readability literature says
**Node-link or matrix.** Ghoniem, Fekete and Castagliola (2004/2005,
<https://datavis2020.github.io/pdfs/ghoniem2004.pdf>) tested both on random graphs of 20, 50 and
100 nodes at densities 0.2–0.6: "when graphs are bigger than twenty vertices, the matrix-based
visualization performs better than node-link diagrams on most tasks. Only path finding is
consistently in favor of node-link diagrams." The per-task numbers are what decide the design: on
"is there a link between A and B", matrices held ~98 % correct at every size while node-link fell
to 67 % on large graphs; on "which node is most connected", matrices ~98 % regardless while
node-link degraded with size and density; on "find a path", node-link scored 99 % versus 70 % on
small graphs. Two caveats apply directly. Their graphs were random at density ≥ 0.2 — a contract
graph is far sparser (§4) — and their own discussion qualifies path-finding: it "is difficult to
carry out visually when the distance between the extremities is greater than two or three arcs".
NodeTrix (Henry, Fekete, McGuffin 2007, <https://arxiv.org/pdf/0705.0599>) states the resulting
rule: "Node-link diagrams are good for sparse networks, matrices are good for dense ones", and
mixes them. Lattix's DSM paper (Sangal et al., OOPSLA 2005,
<https://groups.csail.mit.edu/sdg/pubs/2005/oopsla05-dsm.pdf>) is honest about the cost: "following
the edges of a relation transitively requires going back and forth between row and column indices
rather than simply following arrows along a path".
**Crossings.** Purchase (1997) found "reducing the number of edge crosses is by far the most
important aesthetic"; her 2001 UML study, with crossings held at zero, found bends mattered a
little and node distribution, edge length, symmetry, orthogonality not at all — and that diagrams
with most edges pointing *upward* read worse
(<https://courses.ischool.berkeley.edu/i247/f05/readings/Purchase_UML_OzIV01.pdf>). Kobourov,
Pupyrev and Saket (GD 2014, <https://www2.cs.arizona.edu/~kobourov/crossings.pdf>) found the
crossing effect "significant for small graphs but not significant for large graphs" — a 10–30 node
map is exactly where it counts.
**Levelisation.** Structure101's Levelized Structure Map puts items "into rows, or levels, so that
as far as possible every item depends on at least one item on the level immediately below it … and
items on the lowest level do not depend on any other items", and claims the payoff this map needs:
"This arrangement conveys a lot of dependency information so that the specific item-to-item
dependency arrows can be hidden without loss of context"
(<https://www.sonarsource.com/structure101/docs/java/studio5/Content/restructure101/lsm.html>).
Cycles become "feedback dependencies" drawn in red, after computing a minimum feedback set. The DSM
tradition says the same with a matrix: "the dependency relation should be acyclic … the matrix
should be lower triangular", a cycle "is literally a square block straddling the diagonal", and the
ordering pushes "elements with many inbound relationships (providers) … to the bottom, while the
elements with many outgoing relationships (consumers) have shifted to the top"
(<https://dsmsuite.github.io/dsm_overview.html>). NDepend puts a number on the alternative: its
box-and-arrow graph "was not able to show more than a few dozens boxes and then became
un-understandable" (<https://www.ndepend.com/docs/dependency-structure-matrix-dsm>).
**The dot rules worth borrowing.** Gansner et al. (1993, <https://www.graphviz.org/documentation/TSE93.pdf>)
break cycles by reversing DFS back edges rather than collapsing the cycle, because "even when a
procedure call graph has cycles, one still expects to see top-level functions near the top"; they
replace an edge spanning more than one rank by "chains of unit length edges between … virtual
nodes" so a long edge takes a slot in every rank it crosses; and `constraint=false` lets an edge be
drawn without ranking (<https://graphviz.org/docs/attrs/constraint/>). The dot guide's advice on
oversized graphs is surgery, not knobs: collapse, `tred`, then smaller separations
(<https://graphviz.org/pdf/dotguide.pdf>). dependency-cruiser's `archi` reporter "exists to make
high level dependency overviews" by collapsing modules to folders, and calls depth limits "typically
a terrible idea" because they drop edges silently
(<https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md>). Holten's
edge bundling needs waypoints this tool does not have, but its second benefit transfers: a collapsed
group "visualizes implicit adjacency edges between parent nodes"
(<https://www.cs.jhu.edu/~misha/ReadingSeminar/Papers/Holten06.pdf>).
**How many edge kinds.** Healey found five colours detected "rapidly and accurately", seven to nine
"mixed" (<https://www.cs.ubc.ca/sites/default/files/tr/1996/TR-96-10_0.pdf>); the Pearson guide warns
that more than six "can be especially problematic when marks are small"
(<https://accessibility.pearson.com/resources/dataviz/>). Connectors are small marks. madge,
dependency-cruiser and NDepend each ship one to three edge styles plus one highlight.
---
## 4. The numbers this tool actually produces
Decisions below were made against four scans on this machine, not against the literature's random
graphs. The measurements are from `jq` over `BatMetadata.json` and from `map --dry-run`.
**Everlong** (`audits/everlong/EverlongLabs-blockend-2`, scanned by 0.27.0): 61 in-scope names,
of which only **20 are `Contract`**; 25 are interfaces and 16 are libraries. The current map draws
98 relations with boundaries, 41 of them "leaves the code". Every `FLAMM*Lib` is used by exactly
one deployable contract, `FLAMM` (directly or through another `FLAMM*Lib`); `MMRouterLib` only by
`MMRouter`; `TrancheMathLib` only by `TrancheController`; `RouterDeployLib` and `PoolDeployLib` only
by `FLAMMFactory`. Folding libraries into their hosts turns "`FLAMMFlowLib` calls `MMRouter`" into
"`FLAMM` calls `MMRouter`", which is what the chain actually does. `interfaces[]` is **empty** in
this scan — interface collapsing can only come from `base_contracts` and `resolutions`. In-degree
after folding concentrates on four names: `FLAMM`, `MMRouter`, `EverlongCore`, `PriceFeed`.
**Ascend** (`audits/ascend/basedmd-ascend/contracts`): 38 in-scope names, **18 `Contract`**, 9
interfaces, 10 libraries; after the interface collapse, 13 boxes and 18 relations without
boundaries, 53 with. The 35 boundary edges are dominated by `IERC20`/`IERC20Metadata` (7 contracts)
and by four names that are not contracts at all — `PoolId` (a type alias), `CurveStep[]`,
`AccountingState`, `Limits` (structs) — which reach the map through `wired_at_construction`, whose
only filter is "starts with a capital letter". `IAscendLaunchpad` still fails to collapse because
`IListingLaunchpad`, an interface, also lists it as a base, so "exactly one implementer" counts two.
21 of the 34 in-scope relations are construction wiring; only 3 are typed calls.
**Berachain** and **evm-deps-poc**: 16 and 17 in-scope names once the flag is read under its old
serialised name, `external` (a first `jq` over `.vendored` missed the alias and reported 855 and
62). Berachain's six relations included `PendingAddress`, `PendingUint192` and
`mapping(Id => PendingUint192)` as "unresolved bindings" — struct and mapping types leaking through
the same gap as Ascend's; with the type check in place it has two relations. A scan that genuinely
predates the flag would show every `lib/` contract in scope, and that case is handled by refusal
(§13) even though no scan on this machine exhibits it.
Density, then, is nowhere near Ghoniem's 0.2: Ascend is 18 edges over 13 nodes (0.11 of n²),
Everlong after folding is on the order of 30 edges over 18 nodes (0.09). At that density and size,
node-link is the right primary artefact and the matrix is a companion for the lookups node-link is
bad at. A system of eighty deployable contracts does not appear in any scan on this machine; §10
says what to do when one does.
---
## 5. What a node is
**A node is a deployable contract**: `contract_type == Contract`, not vendored, not on the ignore
list. This is C4's container ("a deployable unit", <https://c4model.com/diagrams/container>) and
Diligence's "top-level/deployable contracts". Everything else is a part of some node or a fact
about the system's edge, and is drawn as such:
- **An interface collapses into its implementer** when that is a fact — a recorded resolution, or
exactly one deployable (non-interface, non-abstract) contract declaring it as a base. This is
what `relations()` does now, with one correction: implementers are counted among deployable
contracts only, so an interface that extends another no longer blocks the collapse. Several
deployable implementers and no resolution is a question, kept as such (§6).
- **A library folds into every deployable contract that uses it**, transitively through other
libraries. A library has no address and runs in its caller's context; a relation it makes is a
relation its host makes. Everlong's sixteen libraries become zero boxes and no information is
lost, because the deploy already draws each library function in full inside its caller's cluster.
A library that no deployable contract reaches (`EverlongStrategy`, `Mul512` via maths-only
callers) is listed in the report as unused in scope and not drawn.
- **An abstract contract folds into every deployable contract that derives from it**, for the same
reason: its code is the derived contract's bytecode. An abstract base nothing in scope derives
from is listed, not drawn.
- **A contract with no source in the repository is not a node.** It is an entry in the outside
panel (§8). The current `--with-boundaries` draws one box per interface and makes `IERC20` the
best-connected thing on the map, which teaches nothing.
- **A proxy** (`FLAMMProxy is BeaconProxy`, empty body) is a deployable contract and gets a box;
the scan records no delegation, so it has no edges and lands in the "not connected" strip (§7).
The report says so rather than pretending it is unrelated to `FLAMM`.
The node is keyed by **(file path, name)**, not by name. Two in-scope contracts can share a name in
different files (a `Treasury` in `src/` and one in `test/`); the label carries the file's last
directory only when the name is ambiguous, the way `deploy` prints `from <file>` only when it has
to.
## 6. What an edge is, and how many kinds a reader can hold
An edge is a relation between two nodes, never a count — that rule from the current `map.rs`
stands. What changes is the encoding. The current four colours (blue call, green wiring, red
unresolved, orange boundary) are one too many, and the reason is not the number but that colour
was carrying two different questions at once: *what kind of thing is at the far end* and *how sure
is the scan*. Separating them gives three line styles in one colour, one colour exception, and
one node mark:
- **Solid, black: a call the scan can pin** — a `resolved_call`, an unresolved call whose
interface collapsed (§5), a static `Contract.fn` call found in `function_dependencies`, or a
call that reaches the target through a folded library or base. This is almost every edge.
- **Dashed, black, captioned `?`: a binding the auditor has not answered** — an interface with
two or more deployable implementers and no `bat-cli resolve`. The caption is the one place a
label earns its cost: the report prints the exact `bat-cli resolve IFace Contract` to run, and
the dashed arrow is the picture of that open question. Drawn to *every* candidate, because the
honest picture of "one of these three" is three dashed arrows, not a guess.
- **Dotted, grey, captioned `wired`: wired at construction and never called.** When a pair is
both wired and called the call wins and the wiring is not drawn, exactly as today; the dotted
edge survives only for the pair that is wired and *not* called — an immutable `oracle` no function
ever uses, or a wiring the scan could not follow a call through. In Ascend 21 of 34 relations
are wiring; most will disappear under a call once libraries fold, and the few that remain are
worth a glance precisely because they are odd.
- **Red: an arrow that goes up.** A cycle, after cycle-breaking (§7). Structure101's "feedback
dependency in red" and the DSM's above-diagonal cell. A red arrow is the one mark on the map that
is itself a finding candidate — two contracts that each depend on the other is where re-entrancy
and ordering bugs live — so it gets the only colour.
- **An amber border on a box: this contract calls past the audited code.** Boundary is a property
of the node, not an edge kind, drawn in the amber the deploy already uses for "reaches outside"
(`#fff7ec` frame fill, `diagram-deploy-design.md` §19). What it calls is in the outside panel.
That is three strokes, one colour, one border, which sits under Healey's five. What is deliberately
*not* an edge: inheritance (part of the node, §5), `using for` on vendored libraries
(`SafeERC20` is not a relation between two contracts), events, function names, and the number of
calls. The deploy answers all of those for one entry point; this map is not that.
## 7. The layout: levels from the bottom, arrows pointing down
**Top-down, not left-to-right.** Every layered tool and every DSM shares one visual contract: the
reader infers "A depends on B" from vertical position, and an upward arrow is the exception. The
deploy runs left-to-right because a screenshot is wide and short and a caller's call sites read top
to bottom; a map's nodes are small boxes and the thing being read is hierarchy, for which
"down" is the convention a reader already carries from every org chart and every Graphviz render.
**Levels by longest path to a sink** (Structure101's levelisation), not longest path from a root
as `layout_graph` does. A node that calls nothing in scope is on the bottom row; a node's level is
one more than the highest level among what it calls. This has three consequences that a from-root
layering does not deliver. Every sink is on the bottom row, so the row *is* the answer to "what
does everything depend on" — `EverlongCore`, `PriceFeed`, `DepositAllowlist`, `TrancheToken` in
Everlong; `Treasury`, `StakingQuoteOracle`, `StakerDistributor` in Ascend. The levelling is stable
under rescans: adding a new periphery contract never re-levels the core, whereas from-root layering
re-levels everything when a deeper entry appears. And there is no root to choose — the current code
picks "the first contract nothing points at", which is an arbitrary name on a map that has a dozen
entry contracts.
**Cycle breaking before levelling**, as dot does: a DFS over the whole graph, started from the
sources (in-degree 0) in name order and then from every remaining node, marks back edges; those
are excluded from levelling and drawn red, upward, with Miro routing them. `layout.rs` already has
this exact routine (`split_back_edges`, `layout.rs:313`), and it already seeds its DFS with every
node after the root, so it needs only to accept "no root". Starting from the sources is what makes
the reversed edge the natural one (`MMRouter → FLAMM` is the feedback in Everlong, not the other
way), which matches dot's argument that top-level nodes should stay on top.
**Within a row, order by barycentre with crossings counted honestly**, reusing `order_layers` and
`count_crossings` (`layout.rs:441`, `:518`); the call-line fraction becomes 0.5 for every edge since
a box has no call sites. Ties are broken by the node's group (§9) and then by name, so the picture
is deterministic across runs — ELK's `considerModelOrder` makes the same choice, a stable order
over a marginally better crossing count, because the auditor will look at this map many times.
**The bottom row is ordered by in-degree from the centre outward**: the most-called contract in the
middle, then alternating left and right. Arrows then converge on the hub from both sides with the
shortest average length, and the hub's position says what its in-degree says.
**Long edges get a corridor, and are drawn as a chain.** This is the only answer to "arrows cross
several boxes", and it is the deploy's own answer (§1 of `diagram-deploy-design.md`): Miro routes,
so a crossing is removed only by changing what is near what. An edge that skips a row reserves a
slot in every row it crosses — `insert_bend_points` (`layout.rs:1401`) already does this and
`GraphLayout.routes` already returns the bend points — and is uploaded as a chain of elbowed
connectors through invisible `create_anchor_marker` shapes at those points, with `ArrowEnd::None`
on every link but the last. The intermediate boxes shift sideways by one gutter to make room; the
arrow runs down the gap instead of over a box. Arrows leave the **bottom centre** of the caller and
enter the **top** of the callee (`RelativeAnchor(0.5, 1.0)` → `(x, 0.0)`), so Miro's elbow is a
vertical–horizontal–vertical Z whose horizontal leg lies in the gutter between rows. On a hub with
many callers, the landing x is spread along the callee's top edge in caller order, so eight arrow
heads do not stack on one point.
**Rows are centred, not top-left aligned.** The deploy top-aligns because the entry point must sit
in the corner with the room below it; a map has no entry point, and a centred pyramid of three to
five rows reads as one shape. Row gap large enough for the horizontal legs — on the order of two
box heights.
**A strip on the right for the unconnected.** A deployable contract with no edges either way
(`FLAMMProxy`, `AscendCoreToken`, `BurnSink`) is not in any level. It goes in a labelled column at
the right edge, "not connected in scope", so it is visibly present and visibly apart. Hiding it
would break the auditor's rule; levelling it would put it on the bottom row beside the hubs, where
it would read as a foundation.
## 8. The outside panel and the matrix: the two things rendered as text
Both are rendered with `silicon::create_figure` the way `storage.rs` renders its slot table —
lines of text to a PNG — because that is the thing this tool draws best and because both are
tables, not graphs.
**The outside panel** sits under the bottom row: one line per external system, listing which
contracts reach it. "External system" means an interface type with no deployable implementer in
scope — in scope or vendored, it makes no difference — with the ERC-20 family (`IERC20`,
`IERC20Metadata`, `IERC20Permit`, `IERC20Errors`) merged into one line, "ERC-20 tokens", because
seven contracts touching tokens is a fact about every DeFi system and no fact about this one. A
struct, enum or type alias (`file_items`) is never an external system. The ordering is by how many
contracts reach each system, so the panel reads as "where value can leave, most-shared first". This
is STRIDE's rule applied literally: "collapse similar elements within a single trust boundary into
a single element", and "you don't trust what's on the other side"
(<https://learn.microsoft.com/en-us/archive/msdn-magazine/2006/november/uncover-security-design-flaws-using-the-stride-approach>).
Drawn by default; `--no-outside` leaves it off.
**The matrix** sits to the right of the diagram. Rows are callers and columns callees, both in the
diagram's order (top row first, left to right within a row), so the matrix reads in the same order
the diagram does; the legend states the resulting convention explicitly, since the DSM literature
warns it flips between fields: a mark **above** the diagonal is an arrow that goes down the
diagram; a mark **below** the diagonal is a red arrow. Cells are `●` call, `?` unresolved, `·`
wired-only. The last column is "called by" (column sums) and the last row "calls" (row sums); the
"called by" column is the exact, sortable answer to "everybody writes to this one". It is drawn
whenever there are at least four nodes — at three the diagram already is the matrix — and it is the
part of the map that still works at eighty nodes (§10).
## 9. Grouping, and what is left out
**Group = the first directory under the program path** (`src/core`, `src/periphery`,
`src/listing`, `src/yield`) — the author's own subsystem boundary, which is what Diligence's
per-subsystem figures draw and what dependency-cruiser's `archi` collapses to. Groups are a
categorical tint on the box fill (up to six, the Healey limit; beyond six, no tint) and a tie-break
in row ordering, never a cluster boundary that fights the levels. The alternative — clusters as
rectangles with rows inside them — is what Graphviz does and what makes its contract graphs wide
and tangled, because a cluster forces its members together even when the levels say they belong
apart.
**Deliberately left out of the picture**, each with its reason: actors and roles (the scan has
`entry_points[].access_control`, but a map of who-can-call-what is a different lens and belongs in
a `--lens roles` later, not on this frame); function names and call counts (the deploy's job);
inheritance edges (inside the node, §5); vendored libraries used through `using for`; `new X()`
creation — the scan does not record it (§13); and delegation through proxies, likewise. The frame
title says what it is: `map: <project> — who calls whom`.
## 10. One contract, eighty contracts
**One contract** is drawn: one box, the outside panel under it, no matrix, and the report says
"one deployable contract in scope; every relation is with the outside". It is not an error: the
auditor asked for the system's shape and that is the shape.
**Eighty contracts** does not fit on one node-link frame, by Ghoniem's data and NDepend's "few
dozen boxes". The rule, in order of what to try: first, the map as designed — levelling and the
corridor chains hold up longer than a force layout, and the crossing count from `count_crossings`
is printed in the report so the auditor knows what they are looking at. Above **thirty** deployable
nodes, the frame becomes one frame per group (§9) with the other groups collapsed to a single box
each carrying the implicit aggregated edges (Holten's parent edges; dependency-cruiser's
`collapsePattern`), plus the matrix of everything, which does not degrade. `--focus <Contract>`
draws one contract and its neighbours, levelled, for the reader who wants the shape around one box
(dependency-cruiser's `--focus`). What is never done is a depth cut or a node cap: the deploy
removed `--max-depth` and `--max-nodes` for hiding code, and the same rule holds here.
## 11. In what order the reader takes the information
The auditor's question — "no sé cómo priorizar la información" — gets an explicit answer, and the
legend states it in that order:
1. **The bottom row**: what everything depends on. These contracts hold the state the system is
about; their storage frames (`bat-cli storage`) are the next thing to open.
2. **The top row**: what nothing in scope calls — the entry contracts, where the outside gets in.
3. **The arrows, read downward**: a path from an entry contract to a foundation is a flow; the
`effects` report of that entry point is the detail.
4. **The red arrows**: the cycles. Each one is a pair to think about before reading either.
5. **The dashed arrows**: the questions. Each has a `bat-cli resolve` line in the report; the map
is not finished until they are solid or acknowledged.
6. **The amber borders and the outside panel**: where value leaves the code, most-shared first.
7. **The matrix**, for the exact lookups a picture is bad at: is there an edge, who is called by
the most, and the column that ranks it.
This is the same order the report prints to stdout, so the terminal and the board say one thing.
---
## 12. The implementation, against the code as it is
The current `map.rs` has three parts worth keeping — `relations()` as the source of relation
facts (with the §5 corrections), `report()` as the stdout shape, and the placement via
`ColumnLayout::open_left` — and two to replace: `layout_map`, which calls `layout_graph` with an
arbitrary root, and `draw`, which draws four legend shapes and auto-routed box-to-box connectors.
In stages:
### Stage 1 — the model, pure and tested (`map.rs`, about a day)
Split `relations()` into a model that owns the node set and a relation collector over it.
- `deployables(metadata) -> Vec<Node>`: `Contract`-typed, non-vendored, not ignored, keyed by
(file, name), with `group` from the first directory under the program path and `reaches_outside`
from any `unknown_external_calls` whose type is an interface with no deployable implementer.
- `hosts(metadata) -> BTreeMap<(file,name), BTreeSet<node>>`: for every library and abstract
contract in scope, the deployable contracts whose code it is. Bases come from `base_contracts`,
transitively through in-scope non-interface bases. Library use comes from
`function_dependencies.callees` — the strings are `Lib.fn` for static calls, as
`auto_deploy.rs:5542` documents and as the Everlong sample confirms (`FLAMMGateLib.anchor`,
`FLAMMStore.grant`) — resolved with `contract_in_scope(target, caller.file)` and iterated to a
fixpoint so a library used only by a library reaches its host. A library on the ignore list is
still folded; ignoring hides a box, never a relation.
- `resolve_type(name, from_file) -> Resolved::{Node, Candidates(Vec), Outside, NotAType}`: the
existing closure, with implementers counted among deployables, the `resolutions` map first, the
`file_items` struct/alias/enum check that returns `NotAType`, and a vendored interface with no
deployable implementer returning `Outside`. `implementations_of` (`auto_deploy.rs:6002`) encodes
the same priorities; the cleanest move is to lift it into `EvmBatMetadata` and have both call it.
- `relations()` then records, per deployable node *and per node hosting each library or base*:
`resolved_calls` (`Call`), `unresolved_calls` through `resolve_type` (`Call`, one `Unresolved`
per candidate, or `Outside`), static `Contract.fn` callees from `function_dependencies` that
resolve to a deployable (`Call`), `wired_at_construction` through `resolve_type` (`Wired`), and
`unknown_external_calls` (`Outside`, into the panel). Self-relations drop; `Call` beats `Wired`
on a pair as today; `Outside` never becomes an edge.
- The report gains sections in the §11 order, the `bat-cli resolve …` lines, the folded libraries
("FLAMMFlowLib → part of FLAMM"), the unused and the unconnected, and the crossing count.
- Tests, in `map_test`: the `IListingLaunchpad` case (an interface extending an interface does not
block a collapse); a struct-typed immutable is not an edge; a library used by a library folds to
the host; wired-and-called keeps the call; wired-only survives; two implementers and no
resolution produce two dashed edges; a vendored interface with no implementer is outside.
`--dry-run` prints the report and the matrix as text and stops, as now.
### Stage 2 — the level layout (`layout.rs`, about a day)
Add `layout_levels(nodes, edges, config) -> GraphLayout`, top-down, rootless. It reuses
`split_back_edges` (generalised to take an optional root; it already seeds every node), a new
`assign_levels` (longest path to a sink over the forward edges), `insert_bend_points` (unchanged:
it works on any layer assignment), `order_layers` and `count_crossings` (unchanged; the
`from_line_fraction` is 0.5 for every edge), and returns `PlacedNode`s in frame-local coordinates
with rows centred. `back_edges` and `routes` come back exactly as `layout_graph` returns them, so
the drawing code consumes one shape for both. The bottom-row centre-out ordering is a final pass
over level 0. `LayoutConfig` gains nothing; the map passes its own `gutter_y`.
Tests: a chain levels into one node per row; a diamond puts the shared sink on the bottom; a cycle
yields one back edge and still levels; a source that reaches only a sink lands on level 1; the
unconnected are not in `nodes` (the caller strips them first); crossings on a known
two-row graph equal the hand count.
### Stage 3 — drawing (`map.rs` + two client helpers, about a day)
- `MiroClient::create_map_node(frame, name, subtitle, fill, border, href)`: a `create_label` with
two text lines (`<p><b>name</b></p><p>called by N · calls M</p>`), a fill for the group tint, an
amber border when it reaches outside, and an `<a href>` to the contract's column label on the
board when `miro.auto.columns[]` has one (`ContractColumn.label_id`), so the map doubles as the
board's index — the same `moveToWidget` trick the link cards use.
- Legend and outside panel: two `silicon::create_figure` PNGs, uploaded with
`create_image_in_frame`; the legend text is the §11 list in the map's own words.
- Matrix: a third PNG, `.txt` so nothing is highlighted, column headers as numbers when names
would not fit, every line parsing (the storage table's lesson about an unbalanced `(`).
- Connectors: for each forward edge, markers at `routes[i]` via `create_anchor_marker`, then a
chain of `create_connector` (elbowed) through them with `ArrowEnd::End` on the last only; the
landing anchor x spread along the callee's top edge in caller order. Back edges: one red
elbowed connector from the caller's top to the callee's bottom, Miro-routed. Dashed and dotted
through `ConnectorStroke`; captions through `ConnectorStyle.caption`.
- Frame: `create_frame`, `open_left` as now, no fill. Record `{frame_id, node_ids}` under a new
`miro.map` so a later run can print the previous frame's URL; nothing is deleted, per the rule
that just removed replacement from `storage` and `effects`.
- Concurrency as today: boxes and PNGs in one `JoinSet`, then markers, then connectors.
### Stage 4 — scale (later, when a scan needs it)
`--focus <Contract>` (neighbours only, same pipeline), and above thirty nodes one frame per group
with foreign groups collapsed to one box each. Both are the same model with a filter applied before
Stage 2; neither needs new drawing code.
## 13. The edge cases, and what each one does wrong by default
- **A scan without `vendored`**: every `lib/` contract is in scope, so the map would have `Vm` and
`Test` as nodes. Detected as "nothing is vendored and some contract sits under a `lib/` segment"
(`predates_vendored`) and refused with "run `bat-cli sonar`". The field is read under both
`vendored` and its older name `external`, so a scan from 0.27.0 with the old spelling is not
refused — it was never the problem.
- **Same-named contracts from `lib/`**: never a node (vendored), but a *target*: `resolve_type`
must go through `contract_in_scope(name, caller.file)`, never `get_contract_by_name`; a vendored
`ERC20` base of `AscendCoreToken` is part of that node, not a boundary, which is what the
current dry-run draws (`AscendCoreToken → ERC20 leaves the code`).
- **Two in-scope contracts with one name**: keyed by (file, name); labelled with the directory;
the report prints both paths.
- **A contract only wired, never called**: the dotted `wired` edge, and a line in the report.
- **An interface extended by another interface** (`IAscendLaunchpad`): count deployable
implementers only.
- **A struct, enum or alias typed as a relation** (`Limits`, `PoolId`, `CurveStep[]`,
`mapping(Id => PendingUint192)`): `NotAType`; strip `[]` and `mapping(…)` before the lookup.
- **A call into an inherited vendored base** (`super._mint`): the base folds into the node; the
call is a self-call and is dropped.
- **Cycles**: broken from the sources, drawn red upward, listed in the report as pairs; a
three-node cycle reverses one edge, not three, and the matrix shows the block.
- **A hub with twenty callers**: landing anchors spread along the top edge; if the edge count on
one box exceeds what its width can space at the stroke width, the box widens — never fewer
arrows.
- **A library used by nobody deployable**: listed, not drawn. **A library used by everyone**
(`ReentrancyGuardLib`): folds into every host, producing no edge at all, which is correct — a
guard is not a relation.
- **One contract**: one box plus the outside panel; no matrix. **Zero**: stop with "no deployable
contract in scope".
- **Eighty contracts**: §10. Nothing is cut; the frame per group and the matrix carry it.
- **The ignore list**: a box is left out, its relations are not — calls *to* an ignored contract
are dropped with the box (there is nothing to land on) and the report says what was skipped,
matching `deploy`.
- **Interface collapse that lies**: a contract implementing an interface without `is IFace` is
invisible to `base_contracts`; `bat-cli resolve` is the escape hatch, and the dashed arrow with
its `resolve` line is the prompt.
- **`new X()` creation**: not in the metadata. A factory that only deploys (`FLAMMFactory` and
its `RouterDeployLib`/`PoolDeployLib`) shows calls to what it configures and nothing for what it
creates. Accepted as a gap and named in the legend until `sonar` records `new` targets.
## 14. What to measure before calling it done
Three dry runs and one real frame. The dry runs: Everlong (expect ~18 boxes, four hubs on the
bottom row, `MMRouter → FLAMM` red, zero struct-typed edges), Ascend (13 boxes, `IAscendLaunchpad`
gone, the outside panel with "ERC-20 tokens" first), and a scan with one contract. The real
frame: Everlong, checked for arrows that cross a box (should be none that skip a row), the
crossing count printed versus counted by eye, the matrix order matching the diagram, every href
landing on a column label, and the frame to the left of everything. Then a second run to confirm
nothing was deleted and the previous URL was printed.
---
## Frames, not one picture (2026-10-13)
Five passes were spent on colour, lanes, anchor spread and hub ordering, and the auditor's
verdict stayed the same: "no logro seguirlo". The defect was never the styling. One frame with
sixteen contracts and twenty relations is a mesh at any zoom, and the three things that fixed
exactly this for entry-point diagrams were sitting unused in `auto_deploy.rs`
(`docs/diagram-deploy-design.md` §4, §5, §13):
1. **Partition** — `map` now draws ONE FRAME PER TOP CALLER (a contract nothing in scope depends
on). Its frame holds everything it reaches, so the frame answers one question: what does this
contract talk to. A contract two frames both use is drawn in both. That repetition is the
deliberate trade from §4: a repeated box is cheap, a crossing arrow is not.
2. **Localize** — a shared contract whose caller sits ≥2 columns back gets a local copy
(`localize`, ids `Name#2`). §5's warning applies unchanged: the distance is only the heuristic
for WHICH box to copy.
3. **The certificate** — `GraphLayout::crossings` is now returned by the layout engine and
printed per frame by `--dry-run`. A copy that does not lower it is undone. This is the part
that stops the guessing: a change to the picture is judged by a number before anyone looks.
Measured on ascend (13 in-scope contracts, 46 relations with the outside drawn): six frames,
crossings 2 / 0 / 0 / 0 / 0 / 0 — against one frame that no ordering brought under a tangle.
Two decisions that came with it, both from the auditor:
- **The outside is drawn**, grey and named. Telling someone a contract "calls past this
repository" without naming what it calls is not information they can act on. `--no-boundaries`
replaces `--with-boundaries` — the default inverted.
- **The matrix is gone.** It made the reader learn a number per contract. The picture is the
deliverable; the terminal report carries what the picture cannot.
---
## Frames, not one picture (2026-10-13)
Five passes were spent on colour, lanes, anchor spread and hub ordering, and the auditor's
verdict stayed the same: "no logro seguirlo". The defect was never the styling. One frame with
sixteen contracts and twenty relations is a mesh at any zoom, and the three things that fixed
exactly this for entry-point diagrams were sitting unused in `auto_deploy.rs`
(`docs/diagram-deploy-design.md` §4, §5, §13):
1. **Partition** — `map` now draws ONE FRAME PER TOP CALLER (a contract nothing in scope depends
on). Its frame holds everything it reaches, so the frame answers one question: what does this
contract talk to. A contract two frames both use is drawn in both. That repetition is the
deliberate trade from §4: a repeated box is cheap, a crossing arrow is not.
2. **Localize** — a shared contract whose caller sits >= 2 columns back gets a local copy
(`localize`, ids `Name#2`). §5's warning applies unchanged: the distance is only the heuristic
for WHICH box to copy.
3. **The certificate** — `GraphLayout::crossings` is now returned by the layout engine and
printed per frame by `--dry-run`. A copy that does not lower it is undone. This is the part
that stops the guessing: a change to the picture is judged by a number before anyone looks.
Measured on ascend (13 in-scope contracts, 46 relations with the outside drawn): six frames,
crossings 2 / 0 / 0 / 0 / 0 / 0 — against one frame that no ordering brought under a tangle.
Two decisions that came with it, both from the auditor:
- **The outside is drawn**, grey and named. Telling someone a contract "calls past this
repository" without naming what it calls is not information they can act on. `--no-boundaries`
replaces `--with-boundaries` — the default inverted.
- **The matrix is gone.** It made the reader learn a number per contract. The picture is the
deliverable; the terminal report carries what the picture cannot.
---
## The frame is a tree, and there are no cards (2026-10-13)
The hub cut from earlier the same day did what it promised — no box drawn twice, 8 frames of at
most 6 boxes — and the auditor rejected it:
> "listing desk es intermedio, y tampoco es de alta jerarquía, entonces tantos frames confunden…
> esos 2 frames perfectamente podrían ir juntos en 1 solo frame… quiero menos frames"
The goal was never "fewest boxes". It is: **a frame per contract of high hierarchy, and that
frame self-contained**, so the set of frames IS the high-level view of the system.
So:
- **Roots** are the top callers (nothing in scope depends on them) plus any cycle nothing sits
above (`StakingFeeConverter`). Nothing else gets a frame. A cycle has no top caller, so one
member has to be chosen: it is **the one that calls the most**, ties by name — the member
whose frame tells the most of the story, rather than whichever the relations happened to list
first.
- **`unfold`** turns each root's reachable sub-graph into a TREE: a contract reached two ways is
drawn once per branch, its chain copied with it. A node already on the current path is a
cycle, and gets the one red arrow home instead of unfolding for ever.
- Nothing inside a frame is shared, so **nothing can cross** — `localize`, the lanes' lane
assignment and the crossing certificate all agree at 0 on every frame of ascend.
- Bounded at 400 boxes per frame: unfolding a dense DAG can blow up in principle. On the systems
this is pointed at (16 contracts, 20 relations) the worst frame is 23 boxes. Recycling a box
when the bound is hit is intense logic for a problem nobody has yet.
Ascend: **6 frames**, 83 boxes, 0 crossings anywhere — against 8 frames and a card trail.
One trap worth recording: `covered` compares relations by ORIGINAL name on both ends. A drawn
edge carries the id of a copy (`IERC20@YieldRouter`, `Treasury#2`), the raw relation carries the
name, and comparing the two made every call leaving the repository look undrawn — which turned
every contract that makes one into a root of its own: fourteen frames for five systems.