Skip to content

Commit 9219e89

Browse files
Blackman99claude
andcommitted
feat: enhance graph analysis, multi-chain support, and interaction
Phase 1 - Data & Analysis: - Dynamic token filter extracted from graph edges - ENS reverse resolution for on-chain EVM queries - Multi-address batch exploration with graph merging - Client-side query cache with 5-minute TTL Phase 2 - Chain Support: - Generalize EtherscanAdapter for BSC, Polygon, Arbitrum - Add SUPPORTED_CHAINS registry and chain presets - Smart address detection (0x no longer forces Ethereum) Phase 3 - Rendering & Interaction: - Export graph as PNG/JSON/CSV - In-graph search by address or ENS name - Node annotation in selected node panel - Auto-switch to Sigma renderer for large graphs (>200 nodes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f088041 commit 9219e89

9 files changed

Lines changed: 486 additions & 70 deletions

File tree

examples/local-demo/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"@types/react": "^18",
1212
"@types/react-dom": "^18",
1313
"@vitejs/plugin-react": "^4",
14+
"html-to-image": "^1.11.13",
1415
"typescript": "^5",
1516
"vite": "^5"
1617
},

examples/local-demo/src/App.tsx

Lines changed: 152 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
1-
import React, { useState, useCallback, useEffect } from 'react'
1+
import React, { useState, useCallback, useEffect, useMemo } from 'react'
22
import { GraphExplorer, GraphExplorerSigma } from '@trustin/txgraph'
33
import type { TxNode, TxGraph } from '@trustin/txgraph'
4+
import { SUPPORTED_CHAINS, type ChainName } from '@trustin/txgraph-core'
45
import { exploreGraph, expandNode } from './api'
56
import type { DataSourceType } from './api'
67

78
type Renderer = 'reactflow' | 'sigma'
8-
type Chain = 'Ethereum' | 'Tron'
99

10-
const SAMPLE_ADDRESSES = {
10+
const SAMPLE_ADDRESSES: Partial<Record<ChainName, string>> = {
1111
Ethereum: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
1212
Tron: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9',
13+
BSC: '0x8894e0a0c962cb723c1ef8f1d0de620172a38596',
14+
Polygon: '0x8894e0a0c962cb723c1ef8f1d0de620172a38596',
15+
Arbitrum: '0x8894e0a0c962cb723c1ef8f1d0de620172a38596',
1316
}
1417

15-
function detectChain(addr: string): Chain | null {
18+
function detectChain(addr: string): ChainName | null {
1619
const trimmed = addr.trim()
1720
if (/^0x[0-9a-fA-F]{40}$/.test(trimmed)) return 'Ethereum'
1821
if (/^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(trimmed)) return 'Tron'
@@ -66,7 +69,7 @@ export default function App() {
6669
const qp = useQueryParams()
6770
const [isDark, setIsDark] = useState(true)
6871
const [address, setAddress] = useState(qp.address)
69-
const [chain, setChain] = useState<Chain>(qp.chain || (detectChain(qp.address) ?? 'Ethereum'))
72+
const [chain, setChain] = useState<ChainName>(qp.chain as ChainName || (detectChain(qp.address) ?? 'Ethereum'))
7073
const [direction, setDirection] = useState<'in' | 'out' | 'all'>(qp.direction || 'out')
7174
const [token, setToken] = useState(qp.token)
7275
const [fromDate, setFromDate] = useState(qp.fromDate)
@@ -78,6 +81,8 @@ export default function App() {
7881
const [expandingNode, setExpandingNode] = useState<string | null>(null)
7982
const [selectedAddress, setSelectedAddress] = useState<string | null>(null)
8083
const [error, setError] = useState<string | null>(null)
84+
const [searchQuery, setSearchQuery] = useState('')
85+
const [annotations, setAnnotations] = useState<Record<string, string>>({})
8186

8287
useEffect(() => {
8388
if (isDark) {
@@ -89,6 +94,76 @@ export default function App() {
8994

9095
const c = themeColors(isDark)
9196

97+
const exportJSON = useCallback(() => {
98+
if (!graph) return
99+
const blob = new Blob([JSON.stringify(graph, null, 2)], { type: 'application/json' })
100+
const url = URL.createObjectURL(blob)
101+
const a = document.createElement('a')
102+
a.href = url
103+
a.download = 'txgraph.json'
104+
a.click()
105+
URL.revokeObjectURL(url)
106+
}, [graph])
107+
108+
const exportCSV = useCallback(() => {
109+
if (!graph) return
110+
const header = 'from,to,direction,amount,formatted_amount,token,tx_count,last_timestamp\n'
111+
const rows = graph.edges.map(e =>
112+
[e.from, e.to, e.direction, e.amount, `"${e.formatted_amount}"`, e.token || '', e.tx_count ?? '', e.last_timestamp].join(',')
113+
).join('\n')
114+
const blob = new Blob([header + rows], { type: 'text/csv' })
115+
const url = URL.createObjectURL(blob)
116+
const a = document.createElement('a')
117+
a.href = url
118+
a.download = 'txgraph.csv'
119+
a.click()
120+
URL.revokeObjectURL(url)
121+
}, [graph])
122+
123+
const exportPNG = useCallback(() => {
124+
// Capture the graph container as PNG via canvas
125+
const container = document.querySelector('.react-flow, .sigma-container')?.closest('[style]')?.parentElement
126+
if (!container) return
127+
import('html-to-image').then(({ toPng }) => {
128+
toPng(container as HTMLElement, { backgroundColor: isDark ? '#0f172a' : '#f8fafc' }).then(dataUrl => {
129+
const a = document.createElement('a')
130+
a.href = dataUrl
131+
a.download = 'txgraph.png'
132+
a.click()
133+
}).catch(console.error)
134+
}).catch(console.error)
135+
}, [isDark])
136+
137+
const LARGE_GRAPH_THRESHOLD = 200
138+
139+
// Auto-switch to Sigma for large graphs
140+
useEffect(() => {
141+
if (graph && graph.nodes.length > LARGE_GRAPH_THRESHOLD && renderer === 'reactflow') {
142+
setRenderer('sigma')
143+
}
144+
}, [graph])
145+
146+
// Search within graph: find first matching node by address or ENS tag
147+
const handleSearch = useCallback(() => {
148+
if (!graph || !searchQuery.trim()) return
149+
const q = searchQuery.trim().toLowerCase()
150+
const match = graph.nodes.find(n =>
151+
n.address.toLowerCase().includes(q) ||
152+
n.tags.some(t => (t.name || '').toLowerCase().includes(q))
153+
)
154+
if (match) setSelectedAddress(match.address)
155+
}, [graph, searchQuery])
156+
157+
// Extract unique token symbols from graph edges for dynamic filtering
158+
const availableTokens = useMemo(() => {
159+
if (!graph) return []
160+
const tokens = new Set<string>()
161+
for (const edge of graph.edges) {
162+
if (edge.token) tokens.add(edge.token)
163+
}
164+
return Array.from(tokens).sort()
165+
}, [graph])
166+
92167
const handleExplore = useCallback(async () => {
93168
// Clear query params after first explore to avoid re-triggering
94169
if (window.location.search) {
@@ -193,13 +268,15 @@ export default function App() {
193268
{/* Address input */}
194269
<input
195270
type="text"
196-
placeholder="Enter blockchain address…"
271+
placeholder="Enter address (or multiple separated by comma)…"
197272
value={address}
198273
onChange={(e) => {
199274
const val = e.target.value
200275
setAddress(val)
201276
const detected = detectChain(val)
202-
if (detected) setChain(detected)
277+
// Only auto-switch chain for Tron (unambiguous T prefix).
278+
// 0x addresses are valid on all EVM chains, so keep current selection.
279+
if (detected === 'Tron') setChain('Tron')
203280
}}
204281
onKeyDown={(e) => e.key === 'Enter' && handleExplore()}
205282
style={{
@@ -214,13 +291,14 @@ export default function App() {
214291
<select
215292
value={chain}
216293
onChange={(e) => {
217-
setChain(e.target.value as Chain)
294+
setChain(e.target.value as ChainName)
218295
setAddress('')
219296
}}
220297
style={inputStyle}
221298
>
222-
<option>Ethereum</option>
223-
<option>Tron</option>
299+
{SUPPORTED_CHAINS.map((c) => (
300+
<option key={c} value={c}>{c}</option>
301+
))}
224302
</select>
225303

226304
{/* Data source */}
@@ -251,8 +329,15 @@ export default function App() {
251329
style={inputStyle}
252330
>
253331
<option value="">All Tokens</option>
254-
<option value="usdt">USDT</option>
255-
<option value="usdc">USDC</option>
332+
{availableTokens.length > 0
333+
? availableTokens.map((t) => (
334+
<option key={t} value={t.toLowerCase()}>{t}</option>
335+
))
336+
: <>
337+
<option value="usdt">USDT</option>
338+
<option value="usdc">USDC</option>
339+
</>
340+
}
256341
</select>
257342

258343
{/* Date range */}
@@ -327,7 +412,7 @@ export default function App() {
327412
<div style={{ fontSize: 48 }}>🔍</div>
328413
<div style={{ fontSize: 15 }}>Enter an address and click <strong>Explore</strong></div>
329414
<div style={{ fontSize: 12, color: c.dimmed }}>
330-
{dataSource === 'trustin' ? 'Powered by TrustIn API' : `On-Chain via ${chain === 'Tron' ? 'Tronscan' : 'Etherscan'}`}
415+
{dataSource === 'trustin' ? 'Powered by TrustIn API' : `On-Chain via ${chain === 'Tron' ? 'Tronscan' : chain === 'BSC' ? 'BscScan' : chain === 'Polygon' ? 'PolygonScan' : chain === 'Arbitrum' ? 'Arbiscan' : 'Etherscan'}`}
331416
</div>
332417
</div>
333418
)}
@@ -379,6 +464,53 @@ export default function App() {
379464
<button onClick={() => setRenderer('sigma')} style={btnStyle(renderer === 'sigma')}>Sigma</button>
380465
</div>
381466
)}
467+
468+
{/* Search within graph */}
469+
{graph && (
470+
<div style={{
471+
position: 'absolute',
472+
top: 10,
473+
left: '50%',
474+
transform: 'translateX(-50%)',
475+
display: 'flex',
476+
gap: 4,
477+
background: c.overlayBg,
478+
borderRadius: 6,
479+
border: `1px solid ${c.border}`,
480+
padding: 2,
481+
zIndex: 10,
482+
}}>
483+
<input
484+
type="text"
485+
placeholder="Search address or ENS…"
486+
value={searchQuery}
487+
onChange={(e) => setSearchQuery(e.target.value)}
488+
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
489+
style={{ ...inputStyle, fontSize: 12, minWidth: 180, padding: '4px 8px' }}
490+
/>
491+
<button onClick={handleSearch} style={{ ...btnStyle(false), padding: '4px 10px', fontSize: 12 }}>Find</button>
492+
</div>
493+
)}
494+
495+
{/* Export buttons */}
496+
{graph && (
497+
<div style={{
498+
position: 'absolute',
499+
top: 10,
500+
right: 10,
501+
display: 'flex',
502+
gap: 2,
503+
background: c.overlayBg,
504+
borderRadius: 6,
505+
border: `1px solid ${c.border}`,
506+
padding: 2,
507+
zIndex: 10,
508+
}}>
509+
<button onClick={exportPNG} style={btnStyle(false)} title="Export as PNG">PNG</button>
510+
<button onClick={exportJSON} style={btnStyle(false)} title="Export as JSON">JSON</button>
511+
<button onClick={exportCSV} style={btnStyle(false)} title="Export as CSV">CSV</button>
512+
</div>
513+
)}
382514
</div>
383515

384516
{/* Selected node info panel */}
@@ -398,6 +530,13 @@ export default function App() {
398530
<span style={{ color: '#818cf8' }}>{node.tags.map((t) => t.name || t.primaryCategory).join(', ')}</span>
399531
)}
400532
{node.is_stopped && <span style={{ color: '#f59e0b' }}>{node.stop_reason || 'Stopped'}</span>}
533+
<input
534+
type="text"
535+
placeholder="Add note…"
536+
value={annotations[node.address] || ''}
537+
onChange={(e) => setAnnotations(prev => ({ ...prev, [node.address]: e.target.value }))}
538+
style={{ ...inputStyle, fontSize: 11, padding: '2px 8px', minWidth: 120 }}
539+
/>
401540
<button
402541
onClick={() => setSelectedAddress(null)}
403542
style={{ marginLeft: 'auto', padding: '2px 8px', borderRadius: 4, border: `1px solid ${c.border}`, background: 'transparent', color: c.dimmed, cursor: 'pointer', fontSize: 11 }}

examples/local-demo/src/api.ts

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
GraphBuilder,
44
TrustInAdapter,
55
createChainAdapter,
6+
batchResolveENS,
7+
mergeGraphs,
68
type DataSource,
79
type BuilderOptions,
810
} from '@trustin/txgraph-core'
@@ -11,7 +13,7 @@ export type DataSourceType = 'trustin' | 'onchain'
1113

1214
export interface GraphExploreParams {
1315
address: string
14-
chain: 'Ethereum' | 'Tron'
16+
chain: string
1517
direction?: 'in' | 'out' | 'all'
1618
token?: string
1719
maxDepth?: number
@@ -38,7 +40,65 @@ function getAdapter(dataSource: DataSourceType, chain: string): DataSource {
3840
})
3941
}
4042

43+
// Simple in-memory cache for explore results
44+
const graphCache = new Map<string, { graph: TxGraph; timestamp: number }>()
45+
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
46+
47+
function getCacheKey(params: GraphExploreParams): string {
48+
return JSON.stringify({
49+
address: params.address,
50+
chain: params.chain,
51+
direction: params.direction || 'out',
52+
token: params.token || '',
53+
maxDepth: params.maxDepth || 3,
54+
fromDate: params.fromDate || '',
55+
toDate: params.toDate || '',
56+
dataSource: params.dataSource || 'trustin',
57+
})
58+
}
59+
60+
function getCached(key: string): TxGraph | null {
61+
const entry = graphCache.get(key)
62+
if (!entry) return null
63+
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
64+
graphCache.delete(key)
65+
return null
66+
}
67+
return entry.graph
68+
}
69+
70+
/**
71+
* Enrich graph nodes with ENS names (EVM chains only, on-chain mode only).
72+
* Runs in background — does not block graph rendering.
73+
*/
74+
async function enrichWithENS(graph: TxGraph): Promise<TxGraph> {
75+
const evmAddresses = graph.nodes
76+
.filter(n => n.address.startsWith('0x') && n.tags.length === 0)
77+
.map(n => n.address)
78+
79+
if (evmAddresses.length === 0) return graph
80+
81+
const ensMap = await batchResolveENS(evmAddresses)
82+
if (ensMap.size === 0) return graph
83+
84+
return {
85+
...graph,
86+
nodes: graph.nodes.map(n => {
87+
const ensName = ensMap.get(n.address.toLowerCase())
88+
if (!ensName) return n
89+
return {
90+
...n,
91+
tags: [...n.tags, { primary_category: 'ENS', name: ensName }],
92+
}
93+
}),
94+
}
95+
}
96+
4197
export async function exploreGraph(params: GraphExploreParams): Promise<TxGraph> {
98+
const cacheKey = getCacheKey(params)
99+
const cached = getCached(cacheKey)
100+
if (cached) return cached
101+
42102
const dataSource = params.dataSource || 'trustin'
43103
const adapter = getAdapter(dataSource, params.chain)
44104

@@ -50,8 +110,30 @@ export async function exploreGraph(params: GraphExploreParams): Promise<TxGraph>
50110
toDate: params.toDate,
51111
}
52112

53-
const builder = new GraphBuilder(adapter, options)
54-
return builder.explore(params.address, params.chain)
113+
// Support multiple addresses separated by comma
114+
const addresses = params.address.split(',').map(a => a.trim()).filter(Boolean)
115+
116+
let graph: TxGraph
117+
if (addresses.length <= 1) {
118+
const builder = new GraphBuilder(adapter, options)
119+
graph = await builder.explore(addresses[0] || params.address, params.chain)
120+
} else {
121+
const graphs = await Promise.all(
122+
addresses.map(addr => {
123+
const builder = new GraphBuilder(adapter, options)
124+
return builder.explore(addr, params.chain)
125+
})
126+
)
127+
graph = mergeGraphs(graphs)
128+
}
129+
130+
// Enrich with ENS names for on-chain EVM queries
131+
if (dataSource === 'onchain' && params.chain !== 'Tron') {
132+
graph = await enrichWithENS(graph)
133+
}
134+
135+
graphCache.set(cacheKey, { graph, timestamp: Date.now() })
136+
return graph
55137
}
56138

57139
export async function expandNode(

0 commit comments

Comments
 (0)