Skip to content

Commit dad8f7a

Browse files
Merge pull request #485 from EbukaMoses/Interactive_Contract2
Interactive Contract Dependency Graph
2 parents 55574a8 + eb2b2dd commit dad8f7a

File tree

5 files changed

+135
-53
lines changed

5 files changed

+135
-53
lines changed

frontend/app/components/CallGraph.stories.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,20 @@ export const WithSeverities: Story = {
8484
],
8585
},
8686
};
87+
88+
/** Shows internal (same-project) calls in green curves vs external calls in purple dashes. */
89+
export const InternalVsExternal: Story = {
90+
args: {
91+
nodes: [
92+
{ id: "fn-TokenA", label: "TokenA", type: "function" },
93+
{ id: "fn-TokenB", label: "TokenB", type: "function" },
94+
{ id: "fn-AmmPool", label: "AmmPool", type: "function" },
95+
{ id: "external-PriceOracle", label: "PriceOracle", type: "external" },
96+
],
97+
edges: [
98+
{ source: "fn-TokenA", target: "fn-AmmPool", type: "internal", label: "add_liquidity" },
99+
{ source: "fn-TokenB", target: "fn-AmmPool", type: "internal", label: "swap" },
100+
{ source: "fn-AmmPool", target: "external-PriceOracle", type: "calls", label: "get_price" },
101+
],
102+
},
103+
};

frontend/app/components/CallGraph.tsx

Lines changed: 98 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ interface CallGraphProps {
1010

1111
const RENDER_THRESHOLD = 100;
1212

13-
const NODE_COLORS: Record<string, { bg: string; border: string }> = {
14-
function: { bg: "#dbeafe", border: "#3b82f6" },
15-
storage: { bg: "#fef3c7", border: "#f59e0b" },
16-
external: { bg: "#f3e8ff", border: "#a855f7" },
13+
const NODE_COLORS: Record<string, { bg: string; border: string; dark: string }> = {
14+
function: { bg: "#dbeafe", border: "#3b82f6", dark: "#1e3a5f" },
15+
storage: { bg: "#fef3c7", border: "#f59e0b", dark: "#3d2e00" },
16+
external: { bg: "#f3e8ff", border: "#a855f7", dark: "#2e1a47" },
1717
};
1818

1919
const SEVERITY_RING: Record<string, string> = {
@@ -23,10 +23,12 @@ const SEVERITY_RING: Record<string, string> = {
2323
low: "#6b7280",
2424
};
2525

26-
const EDGE_COLORS: Record<string, string> = {
27-
calls: "#6b7280",
28-
mutates: "#ef4444",
29-
reads: "#3b82f6",
26+
/** Visual properties for each edge type. */
27+
const EDGE_STYLE: Record<string, { color: string; dash?: string; label: string }> = {
28+
internal: { color: "#10b981", label: "Internal call" },
29+
calls: { color: "#a855f7", dash: "6 3", label: "External call" },
30+
mutates: { color: "#ef4444", label: "Mutates" },
31+
reads: { color: "#3b82f6", label: "Reads" },
3032
};
3133

3234
interface LayoutNode extends CallGraphNode {
@@ -70,7 +72,7 @@ export const CallGraph = memo(function CallGraph({ nodes, edges }: CallGraphProp
7072
return (
7173
<div className="rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-6">
7274
<h3 className="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-4">
73-
Contract Call Graph
75+
Contract Interaction Graph
7476
</h3>
7577
<p className="text-sm text-zinc-500 dark:text-zinc-400 text-center py-8">
7678
No cross-contract call paths were reported for this scan.
@@ -90,12 +92,24 @@ export const CallGraph = memo(function CallGraph({ nodes, edges }: CallGraphProp
9092
const nodeWidth = 140;
9193
const nodeHeight = 40;
9294

95+
const internalCount = edges.filter((e) => e.type === "internal").length;
96+
const externalCount = edges.filter((e) => e.type === "calls").length;
97+
9398
return (
9499
<div className="rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-6">
95-
<div className="flex justify-between items-center mb-4">
96-
<h3 className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">
97-
Contract Call Graph ({nodes.length} nodes)
98-
</h3>
100+
<div className="flex flex-wrap justify-between items-start gap-2 mb-4">
101+
<div>
102+
<h3 className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">
103+
Contract Interaction Graph
104+
</h3>
105+
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
106+
{nodes.length} contract{nodes.length !== 1 ? "s" : ""}
107+
{" · "}
108+
<span className="text-emerald-600 dark:text-emerald-400">{internalCount} internal</span>
109+
{" · "}
110+
<span className="text-purple-600 dark:text-purple-400">{externalCount} external</span>
111+
</p>
112+
</div>
99113
</div>
100114

101115
{!shouldRender ? (
@@ -112,43 +126,63 @@ export const CallGraph = memo(function CallGraph({ nodes, edges }: CallGraphProp
112126
</div>
113127
) : (
114128
<>
115-
<div className="flex flex-wrap gap-x-4 gap-y-2 mb-4 text-[10px] sm:text-xs text-zinc-500 dark:text-zinc-400">
116-
<span className="flex items-center gap-1">
117-
<span className="inline-block w-3 h-3 rounded" style={{ background: NODE_COLORS.function.bg, border: `2px solid ${NODE_COLORS.function.border}` }} />
118-
Function
119-
</span>
120-
<span className="flex items-center gap-1">
121-
<span className="inline-block w-3 h-3 rounded" style={{ background: NODE_COLORS.storage.bg, border: `2px solid ${NODE_COLORS.storage.border}` }} />
122-
Storage
123-
</span>
124-
<span className="flex items-center gap-1">
125-
<span className="inline-block w-2 h-0.5" style={{ background: EDGE_COLORS.mutates }} />
126-
Mutates
127-
</span>
128-
<span className="flex items-center gap-1">
129-
<span className="inline-block w-2 h-0.5" style={{ background: EDGE_COLORS.calls }} />
130-
Calls
131-
</span>
129+
{/* Legend */}
130+
<div className="flex flex-wrap gap-x-5 gap-y-2 mb-4 text-[10px] sm:text-xs text-zinc-500 dark:text-zinc-400">
131+
<span className="flex items-center gap-1.5 font-medium text-zinc-600 dark:text-zinc-300">Nodes:</span>
132+
{(["function", "storage", "external"] as const).map((type) => (
133+
<span key={type} className="flex items-center gap-1">
134+
<span
135+
className="inline-block w-3 h-3 rounded"
136+
style={{
137+
background: NODE_COLORS[type].bg,
138+
border: `2px solid ${NODE_COLORS[type].border}`,
139+
}}
140+
/>
141+
{type.charAt(0).toUpperCase() + type.slice(1)}
142+
</span>
143+
))}
144+
<span className="flex items-center gap-1.5 font-medium text-zinc-600 dark:text-zinc-300 ml-2">Edges:</span>
145+
{(["internal", "calls", "mutates", "reads"] as const).map((type) => {
146+
const style = EDGE_STYLE[type];
147+
return (
148+
<span key={type} className="flex items-center gap-1">
149+
<svg width="20" height="8" aria-hidden="true">
150+
<line
151+
x1="0" y1="4" x2="20" y2="4"
152+
stroke={style.color}
153+
strokeWidth={2}
154+
strokeDasharray={style.dash}
155+
/>
156+
</svg>
157+
{style.label}
158+
</span>
159+
);
160+
})}
132161
</div>
162+
133163
<div className="overflow-auto max-h-[600px]">
134164
<svg
135165
width={svgWidth}
136166
height={svgHeight}
137167
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
138168
className="bg-zinc-50 dark:bg-zinc-950 rounded"
139169
role="img"
140-
aria-label="Contract call graph visualization"
170+
aria-label="Contract interaction graph visualization"
141171
>
142172
<defs>
143-
<marker id="arrowhead-mutates" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
144-
<polygon points="0 0, 8 3, 0 6" fill={EDGE_COLORS.mutates} />
145-
</marker>
146-
<marker id="arrowhead-calls" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
147-
<polygon points="0 0, 8 3, 0 6" fill={EDGE_COLORS.calls} />
148-
</marker>
149-
<marker id="arrowhead-reads" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
150-
<polygon points="0 0, 8 3, 0 6" fill={EDGE_COLORS.reads} />
151-
</marker>
173+
{(["internal", "calls", "mutates", "reads"] as const).map((type) => (
174+
<marker
175+
key={type}
176+
id={`arrowhead-${type}`}
177+
markerWidth="8"
178+
markerHeight="6"
179+
refX="8"
180+
refY="3"
181+
orient="auto"
182+
>
183+
<polygon points="0 0, 8 3, 0 6" fill={EDGE_STYLE[type].color} />
184+
</marker>
185+
))}
152186
</defs>
153187

154188
{edges.map((edge, i) => {
@@ -160,24 +194,43 @@ export const CallGraph = memo(function CallGraph({ nodes, edges }: CallGraphProp
160194
const y1 = source.y + nodeHeight / 2;
161195
const x2 = target.x;
162196
const y2 = target.y + nodeHeight / 2;
163-
const color = EDGE_COLORS[edge.type] || EDGE_COLORS.calls;
197+
const style = EDGE_STYLE[edge.type] ?? EDGE_STYLE.calls;
164198

165-
return (
199+
// Curved path for internal edges to distinguish from straight external ones.
200+
const isCurved = edge.type === "internal";
201+
const midX = (x1 + x2) / 2;
202+
const midY = (y1 + y2) / 2 - 30;
203+
const d = isCurved
204+
? `M ${x1} ${y1} Q ${midX} ${midY} ${x2} ${y2}`
205+
: undefined;
206+
207+
return isCurved ? (
208+
<path
209+
key={`edge-${i}`}
210+
d={d}
211+
fill="none"
212+
stroke={style.color}
213+
strokeWidth={2}
214+
strokeDasharray={style.dash}
215+
markerEnd={`url(#arrowhead-${edge.type})`}
216+
/>
217+
) : (
166218
<line
167219
key={`edge-${i}`}
168220
x1={x1}
169221
y1={y1}
170222
x2={x2}
171223
y2={y2}
172-
stroke={color}
224+
stroke={style.color}
173225
strokeWidth={2}
226+
strokeDasharray={style.dash}
174227
markerEnd={`url(#arrowhead-${edge.type})`}
175228
/>
176229
);
177230
})}
178231

179232
{layout.map((node) => {
180-
const colors = NODE_COLORS[node.type] || NODE_COLORS.function;
233+
const colors = NODE_COLORS[node.type] ?? NODE_COLORS.function;
181234
const severityColor = node.severity ? SEVERITY_RING[node.severity] : undefined;
182235

183236
return (
@@ -213,7 +266,7 @@ export const CallGraph = memo(function CallGraph({ nodes, edges }: CallGraphProp
213266
fontWeight={600}
214267
fill="#1f2937"
215268
>
216-
{node.label.length > 16 ? node.label.slice(0, 14) + "..." : node.label}
269+
{node.label.length > 16 ? node.label.slice(0, 14) + "" : node.label}
217270
</text>
218271
</g>
219272
);

frontend/app/components/FindingsList.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ function FindingCard({ finding }: { finding: Finding }) {
6969
}
7070

7171
export function FindingsList({ findings, severityFilter }: FindingsListProps) {
72-
73-
72+
const listRef = useRef<FixedSizeList>(null);
7473
const filtered = useMemo(() => {
7574
return severityFilter === "all"
7675
? findings
@@ -116,7 +115,14 @@ export function FindingsList({ findings, severityFilter }: FindingsListProps) {
116115
const listHeight = Math.min(filtered.length * ITEM_HEIGHT, MAX_LIST_HEIGHT);
117116

118117
return (
119-
120-
</div>
118+
<FixedSizeList
119+
height={listHeight}
120+
itemCount={filtered.length}
121+
itemSize={ITEM_HEIGHT}
122+
width="100%"
123+
ref={listRef}
124+
>
125+
{Row}
126+
</FixedSizeList>
121127
);
122128
}

frontend/app/lib/transform.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -402,9 +402,14 @@ function extractReportedCallGraph(
402402
const nodeMap = new Map<string, CallGraphNode>();
403403
const edges: CallGraphEdge[] = [];
404404

405+
// First pass: collect all known caller names so we can classify edges.
406+
const knownCallers = new Set(reportedEdges.map((e) => e.caller));
407+
405408
reportedEdges.forEach((edge) => {
406409
const sourceId = `fn-${edge.caller}`;
407-
const targetId = `external-${edge.callee}`;
410+
// If the callee is also a known caller in this project it's an internal call.
411+
const isInternal = knownCallers.has(edge.callee);
412+
const targetId = isInternal ? `fn-${edge.callee}` : `external-${edge.callee}`;
408413

409414
if (!nodeMap.has(sourceId)) {
410415
nodeMap.set(sourceId, {
@@ -419,7 +424,7 @@ function extractReportedCallGraph(
419424
nodeMap.set(targetId, {
420425
id: targetId,
421426
label: edge.callee,
422-
type: "external",
427+
type: isInternal ? "function" : "external",
423428
});
424429
}
425430

@@ -429,7 +434,7 @@ function extractReportedCallGraph(
429434
label: edge.function_expr
430435
? `${edge.function_expr} (${edge.file}:${edge.line})`
431436
: `${edge.file}:${edge.line}`,
432-
type: "calls",
437+
type: isInternal ? "internal" : "calls",
433438
});
434439
});
435440

frontend/app/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,5 +135,6 @@ export interface CallGraphEdge {
135135
source: string;
136136
target: string;
137137
label?: string;
138-
type: "calls" | "mutates" | "reads";
138+
/** internal = both contracts are in the same project; calls = external contract */
139+
type: "calls" | "mutates" | "reads" | "internal";
139140
}

0 commit comments

Comments
 (0)