Skip to content

Commit 1a49ad4

Browse files
Toby1009claude
andcommitted
Draw funders to the left of the seed, where money comes from
"I still cannot see who transferred to that address on the front end." The funders were on screen the whole time. They were drawn in the *rightmost* column, which on a left-to-right flow diagram is where the money ends up. `layer_nodes` walked forwards only. An address that paid the seed is not reachable by following edges forwards, so it fell through to the "unreachable, put it in the last column" clause --- past every address the money actually reached. Both addresses that staked the LpdFi attacker, one of them 689,529 USDC, sat downstream of him in the position a reader takes to mean "where it went". That is worse than leaving them out: an absent funder is a gap somebody notices, a funder drawn as a recipient is a wrong answer shaped like a picture. Depth is now signed --- negative upstream, positive downstream --- and the frontend needed no change, because it already sorted depths numerically before laying them out. Upstream wins a tie, which the first version got backwards. Both of those funders were also paid by the attacker (689,429 back to one, 116,495 to the other), so with downstream winning they landed on the right again and the funding was still invisible. For an address on both sides no column is true: the arrows carry the direction and the column carries the emphasis, and the emphasis belongs to the half a reader cannot otherwise see. Outbound is already obvious --- it is the direction a trace fans out in. The key now says which side means what, since a picture whose encoding is undocumented gets read wrong, confidently. Three of the new tests fail against the old function, checked by reverting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXWQ25VgFeMuuMhGWjvdof
1 parent f962a48 commit 1a49ad4

4 files changed

Lines changed: 145 additions & 22 deletions

File tree

src/chainscope/render/flow.py

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,41 @@
6767

6868

6969
def layer_nodes(graph: Graph) -> dict[str, int]:
70-
"""Hop distance from the nearest seed, for every node.
70+
"""Signed hop distance from the nearest seed, for every node.
7171
7272
Breadth-first over directed edges, so a column is "how many hops the money
7373
travelled", not "how far apart these are in the drawing".
7474
75-
Nodes unreachable from any seed get the last column rather than being
76-
dropped. They are usually inbound counterparties of a seed --- money coming
77-
*in* --- and a flow view that silently omits them shows an address that only
78-
ever sent.
75+
**Negative upstream, positive downstream, and the sign is the fix.** A
76+
renderer lays these out left to right, so the sign is what puts the people
77+
who *paid* the seed on its left and the people it paid on its right --- the
78+
way money is read on every diagram anybody has seen.
79+
80+
Without it, a funder is not reachable from the seed by following edges
81+
forwards, so it fell through to the ``furthest + 1`` clause below and was
82+
drawn in the **rightmost** column: the picture said the seed's money ended
83+
up at the address that had started it. Verified on the LpdFi case, where
84+
both addresses that staked the attacker --- one of them 689,529 USDC --- sat
85+
to the right of him, in the position a reader takes to mean "where it went".
86+
That is worse than omitting them. An absent funder is a gap somebody can
87+
notice; a funder drawn as a recipient is a wrong answer that looks like a
88+
picture.
89+
90+
**Upstream wins a tie**, and the LpdFi case is why. Both addresses that
91+
staked the attacker were also paid by him --- 689,429 USDC back to one,
92+
116,495 to the other --- so with forward winning they both landed
93+
downstream again and the funding was still invisible. For an address on
94+
both sides no single column is true; the *arrows* carry the direction and
95+
the column carries the emphasis, and the emphasis belongs to the half a
96+
reader cannot otherwise see. Outbound is already obvious --- it is the
97+
direction a trace fans out in --- while "who paid this address" is the
98+
question that goes unanswered. So a box on the left paid the seed at least
99+
once, and may also have been paid by it.
100+
101+
Anything reachable in neither direction still gets the last column. That is
102+
now genuinely rare --- the graph is built from the seed's own edges --- and
103+
it stays rather than being dropped, because a node with no path to the seed
104+
is exactly the thing a reader should be able to see and ask about.
79105
"""
80106
# Keyed by `address_key`, matching `_node_payload`'s ids. These were
81107
# `.lower()`, which on Solana, Sui or Bitcoin produced depth keys that
@@ -86,23 +112,30 @@ def layer_nodes(graph: Graph) -> dict[str, int]:
86112
for s in graph.seeds
87113
]
88114
outgoing: dict[str, list[str]] = defaultdict(list)
115+
incoming: dict[str, list[str]] = defaultdict(list)
89116
for edge in graph.edges.values():
90-
outgoing[address_key(edge.chain, edge.source)].append(
91-
address_key(edge.chain, edge.target)
92-
)
117+
source = address_key(edge.chain, edge.source)
118+
target = address_key(edge.chain, edge.target)
119+
outgoing[source].append(target)
120+
incoming[target].append(source)
93121

94122
depth: dict[str, int] = {}
95-
queue: deque[tuple[str, int]] = deque()
96-
for seed in seeds:
97-
depth[seed] = 0
98-
queue.append((seed, 0))
99-
100-
while queue:
101-
address, level = queue.popleft()
102-
for nxt in outgoing.get(address, ()):
103-
if nxt not in depth:
104-
depth[nxt] = level + 1
105-
queue.append((nxt, level + 1))
123+
124+
def walk(adjacent: dict[str, list[str]], step: int) -> None:
125+
queue: deque[tuple[str, int]] = deque()
126+
for seed in seeds:
127+
depth[seed] = 0
128+
queue.append((seed, 0))
129+
while queue:
130+
address, level = queue.popleft()
131+
for nxt in adjacent.get(address, ()):
132+
if nxt not in depth:
133+
depth[nxt] = level + step
134+
queue.append((nxt, level + step))
135+
136+
# Upstream first, so it wins the tie described above.
137+
walk(incoming, -1)
138+
walk(outgoing, 1)
106139

107140
furthest = max(depth.values(), default=0)
108141
for node in graph.nodes.values():

tests/unit/test_flow_render.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,83 @@ def test_dragged_positions_survive_a_redraw(self):
237237
arranged."""
238238
html = to_flow_html(chain_graph())
239239
assert "keeps what the reader arranged" in html
240+
241+
242+
class TestWhichSideOfTheSeedAFunderLandsOn:
243+
"""A funder drawn to the right of the seed is a wrong answer, not a gap.
244+
245+
`layer_nodes` walked forwards only, so an address that *paid* the seed was
246+
unreachable and fell through to the "put it in the last column" clause ---
247+
the rightmost one, which on a left-to-right flow diagram is where the money
248+
ended up. On the LpdFi case both addresses that staked the attacker, one of
249+
them with 689,529 USDC, were drawn downstream of him.
250+
251+
An omitted funder is something a reader can notice is missing. A funder in
252+
the recipient's position is a picture that reads cleanly and says the
253+
opposite of what happened.
254+
"""
255+
256+
def _funded(self):
257+
"""F → A → B. A is the seed; F paid it; B was paid by it."""
258+
funder = "0x" + "f" * 40
259+
g = chain_graph()
260+
g.add_node(Node(address=funder, chain=str(ETHEREUM), expanded=True))
261+
g.add_edge(
262+
Edge(
263+
source=funder,
264+
target=A,
265+
chain=str(ETHEREUM),
266+
symbol="ETH",
267+
decimals=18,
268+
total_raw=10**20,
269+
transfer_count=1,
270+
)
271+
)
272+
return g, funder
273+
274+
def test_a_funder_sits_upstream_of_the_seed(self):
275+
g, funder = self._funded()
276+
depth = layer_nodes(g)
277+
assert depth[funder] < depth[A], (
278+
"the address that paid the seed has to be drawn on the side a "
279+
"reader takes to mean 'before'"
280+
)
281+
282+
def test_a_funder_is_not_parked_in_the_last_column(self):
283+
"""The specific old behaviour, pinned so it cannot come back."""
284+
g, funder = self._funded()
285+
depth = layer_nodes(g)
286+
assert depth[funder] < max(depth.values()), (
287+
"it used to get `furthest + 1`, which put it further downstream "
288+
"than every address the money actually reached"
289+
)
290+
291+
def test_hop_distance_is_still_the_magnitude(self):
292+
g, funder = self._funded()
293+
depth = layer_nodes(g)
294+
assert depth[funder] == -1
295+
assert [depth[x] for x in (A, B, C, D)] == [0, 1, 2, 3]
296+
297+
def test_an_address_on_both_sides_is_placed_upstream(self):
298+
"""B is paid by the seed and also pays it.
299+
300+
No column is true for such an address --- the arrows carry the
301+
direction, the column carries the emphasis --- and the emphasis goes to
302+
the half a reader cannot otherwise see. This is not hypothetical: both
303+
of the LpdFi attacker's funders were also paid by him, so with
304+
downstream winning they landed on the right again and the funding
305+
stayed invisible, which is the whole complaint this fixes.
306+
"""
307+
g = chain_graph()
308+
g.add_edge(
309+
Edge(
310+
source=B,
311+
target=A,
312+
chain=str(ETHEREUM),
313+
symbol="ETH",
314+
decimals=18,
315+
total_raw=10**18,
316+
transfer_count=1,
317+
)
318+
)
319+
assert layer_nodes(g)[B] == -1

web/src/components/graph.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,9 @@ export function Graph({
398398
</dd>
399399
<dt className="wide" />
400400
<dd className="wide">
401-
Columns are <b>hops</b>, not time. A box further right is further
402-
from the seed, not later.
401+
<b>Left of the seed paid it; right of it was paid.</b> Columns are{" "}
402+
<b>hops</b>, not time — a box further out is further from the seed,
403+
not later.
403404
</dd>
404405
</dl>
405406
) : null}

web/src/lib/layout.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,16 @@ export const GUTTER = 300;
2727
export type Placed = GraphNode & { x: number; y: number };
2828

2929
/**
30-
* Columns by hop depth, left to right.
30+
* Columns by signed hop depth, left to right.
31+
*
32+
* The sign carries the direction of the money: `layer_nodes` gives an address
33+
* that **paid** the seed a negative depth and one the seed paid a positive one,
34+
* so sorting numerically puts funders to the left of the seed and recipients to
35+
* the right. That is the only arrangement in which the picture agrees with how
36+
* everybody already reads a flow diagram — and before the sign existed, a
37+
* funder was unreachable going forwards and landed in the *rightmost* column,
38+
* so the graph showed the address that staked an attacker 689,529 USDC sitting
39+
* where the money ended up.
3140
*
3241
* Deliberately not a force layout. Force arranges by connectivity, and then a
3342
* five-hop laundering chain and a five-way split look identical — the one

0 commit comments

Comments
 (0)