Skip to content

Commit b387c5d

Browse files
committed
test(graph-builder): match live api schemas in tests and fix missing field fallbacks for edges
1 parent f817c99 commit b387c5d

2 files changed

Lines changed: 67 additions & 52 deletions

File tree

src/lib/graph-builder.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,18 @@ async function fetchConnections(address: string, options: InvestigationOptions):
135135

136136
if (traceResult.success && traceResult.data) {
137137
const data = traceResult.data as any;
138-
if (data.connections && Array.isArray(data.connections)) {
139-
return data.connections.slice(0, options.width).map((c: any) => ({
140-
address: c.address,
141-
volume_usd: c.volume_usd || 0,
142-
tx_count: c.tx_count || 0,
143-
direction: c.direction || 'both',
144-
label: c.label,
145-
primary_token: c.primary_token,
146-
}));
138+
if (data.edges && Array.isArray(data.edges)) {
139+
return data.edges.slice(0, options.width).map((e: any) => {
140+
const isFrom = e.from ? (e.from.toLowerCase() === address.toLowerCase()) : false;
141+
return {
142+
address: e.address || (isFrom ? e.to : e.from),
143+
volume_usd: e.volume_usd || 0,
144+
tx_count: e.tx_count || 0,
145+
direction: e.direction || (isFrom ? 'out' : 'in'),
146+
label: e.label,
147+
primary_token: e.primary_token,
148+
};
149+
});
147150
}
148151
}
149152

@@ -152,11 +155,11 @@ async function fetchConnections(address: string, options: InvestigationOptions):
152155

153156
if (cpResult.success && Array.isArray(cpResult.data)) {
154157
return (cpResult.data as any[]).slice(0, options.width).map((c: any) => ({
155-
address: c.address,
156-
volume_usd: c.volume_usd || 0,
157-
tx_count: c.tx_count || 0,
158+
address: c.counterparty_address || c.address,
159+
volume_usd: c.total_volume_usd || c.volume_usd || 0,
160+
tx_count: c.interaction_count || c.tx_count || 0,
158161
direction: c.direction || 'both',
159-
label: c.label,
162+
label: c.counterparty_address_label && c.counterparty_address_label.length > 0 ? c.counterparty_address_label[0] : c.label,
160163
}));
161164
}
162165

tests/lib/graph-builder.test.ts

Lines changed: 51 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ describe('graph-builder', () => {
4343

4444
it('should discover connections at depth 1', async () => {
4545
mockTrace.mockResolvedValue({ success: true, data: {
46-
connections: [
47-
{ address: '0xA', volume_usd: 1000, tx_count: 5, direction: 'out' },
48-
{ address: '0xB', volume_usd: 2000, tx_count: 3, direction: 'in' },
46+
edges: [
47+
{ from: '0xseed', to: '0xA', volume_usd: 1000, tx_count: 5, direction: 'out' },
48+
{ from: '0xB', to: '0xseed', volume_usd: 2000, tx_count: 3, direction: 'in' },
4949
],
5050
}});
5151
const g = await buildGraph({ ...baseOpts, depth: 1 });
@@ -55,17 +55,17 @@ describe('graph-builder', () => {
5555

5656
it('should not revisit nodes', async () => {
5757
mockTrace.mockImplementation(async (addr: string) => {
58-
if (addr === '0xseed') return { success: true, data: { connections: [{ address: '0xA', volume_usd: 100, tx_count: 1, direction: 'out' }] } };
59-
if (addr === '0xA') return { success: true, data: { connections: [{ address: '0xseed', volume_usd: 100, tx_count: 1, direction: 'in' }] } };
60-
return { success: true, data: { connections: [] } };
58+
if (addr === '0xseed') return { success: true, data: { edges: [{ from: '0xseed', to: '0xA', volume_usd: 100, tx_count: 1, direction: 'out' }] } };
59+
if (addr === '0xA') return { success: true, data: { edges: [{ from: '0xseed', to: '0xA', volume_usd: 100, tx_count: 1, direction: 'in' }] } };
60+
return { success: true, data: { edges: [] } };
6161
});
6262
const g = await buildGraph({ ...baseOpts, depth: 3 });
6363
expect(g.nodes.length).toBe(2);
6464
});
6565

6666
it('should respect depth guard', async () => {
6767
mockTrace.mockResolvedValue({ success: true, data: {
68-
connections: [{ address: '0xdeep', volume_usd: 1000, tx_count: 1, direction: 'out' }],
68+
edges: [{ from: '0xseed', to: '0xdeep', volume_usd: 1000, tx_count: 1, direction: 'out' }],
6969
}});
7070
const g = await buildGraph({ ...baseOpts, depth: 1 });
7171
const deepNode = g.nodes.find(n => n.id === '0xdeep');
@@ -74,9 +74,9 @@ describe('graph-builder', () => {
7474

7575
it('should filter by minVolume', async () => {
7676
mockTrace.mockResolvedValue({ success: true, data: {
77-
connections: [
78-
{ address: '0xbig', volume_usd: 50000, tx_count: 10, direction: 'out' },
79-
{ address: '0xsmall', volume_usd: 100, tx_count: 1, direction: 'out' },
77+
edges: [
78+
{ from: '0xseed', to: '0xbig', volume_usd: 50000, tx_count: 10, direction: 'out' },
79+
{ from: '0xseed', to: '0xsmall', volume_usd: 100, tx_count: 1, direction: 'out' },
8080
],
8181
}});
8282
const g = await buildGraph({ ...baseOpts, depth: 1, minVolume: 1000 });
@@ -87,7 +87,7 @@ describe('graph-builder', () => {
8787
it('should fallback to counterparties when trace fails', async () => {
8888
mockTrace.mockResolvedValue({ success: false });
8989
mockCp.mockResolvedValue({ success: true, data: [
90-
{ address: '0xcp1', volume_usd: 3000, tx_count: 2, direction: 'both', label: 'Binance' },
90+
{ counterparty_address: '0xcp1', total_volume_usd: 3000, interaction_count: 2, direction: 'both', counterparty_address_label: ['Binance'] },
9191
]});
9292
const g = await buildGraph({ ...baseOpts, depth: 1 });
9393
expect(g.nodes.length).toBe(2);
@@ -96,9 +96,9 @@ describe('graph-builder', () => {
9696

9797
it('should deduplicate edges', async () => {
9898
mockTrace.mockImplementation(async (addr: string) => {
99-
if (addr === '0xseed') return { success: true, data: { connections: [{ address: '0xA', volume_usd: 1000, tx_count: 5, direction: 'out' }] } };
100-
if (addr === '0xa') return { success: true, data: { connections: [{ address: '0xseed', volume_usd: 1000, tx_count: 5, direction: 'in' }] } };
101-
return { success: true, data: { connections: [] } };
99+
if (addr === '0xseed') return { success: true, data: { edges: [{ from: '0xseed', to: '0xA', volume_usd: 1000, tx_count: 5, direction: 'out' }] } };
100+
if (addr === '0xa') return { success: true, data: { edges: [{ from: '0xseed', to: '0xA', volume_usd: 1000, tx_count: 5, direction: 'in' }] } };
101+
return { success: true, data: { edges: [] } };
102102
});
103103
const g = await buildGraph({ ...baseOpts, depth: 2 });
104104
const edgeCount = g.links.filter(e =>
@@ -108,15 +108,15 @@ describe('graph-builder', () => {
108108
});
109109

110110
it('should enrich SM nodes', async () => {
111-
mockTrace.mockResolvedValue({ success: true, data: { connections: [{ address: '0xsm', volume_usd: 1000, tx_count: 1, direction: 'out' }] } });
111+
mockTrace.mockResolvedValue({ success: true, data: { edges: [{ from: '0xseed', to: '0xsm', volume_usd: 1000, tx_count: 1, direction: 'out' }] } });
112112
mockEnrich.mockResolvedValue({ labels: ['Fund'], sm_labels: ['Smart Money'], balance_usd: 1e6, pnl_30d: 50000, defi_protocols: 3 });
113113
const g = await buildGraph({ ...baseOpts, depth: 1 });
114114
const smNode = g.nodes.find(n => n.id === '0xsm');
115115
expect(smNode?.type).toBe('smart-money');
116116
});
117117

118118
it('should set label from enrichment', async () => {
119-
mockTrace.mockResolvedValue({ success: true, data: { connections: [{ address: '0xlabel', volume_usd: 1000, tx_count: 1, direction: 'out' }] } });
119+
mockTrace.mockResolvedValue({ success: true, data: { edges: [{ from: '0xseed', to: '0xlabel', volume_usd: 1000, tx_count: 1, direction: 'out' }] } });
120120
mockEnrich.mockImplementation(async (addr: string) => {
121121
if (addr === '0xlabel') return { labels: ['Binance'], sm_labels: [], balance_usd: 0, pnl_30d: 0, defi_protocols: 0 };
122122
return { labels: [], sm_labels: [], balance_usd: 0, pnl_30d: 0, defi_protocols: 0 };
@@ -127,7 +127,7 @@ describe('graph-builder', () => {
127127
});
128128

129129
it('should classify contract nodes', async () => {
130-
mockTrace.mockResolvedValue({ success: true, data: { connections: [{ address: '0xc', volume_usd: 1000, tx_count: 1, direction: 'out' }] } });
130+
mockTrace.mockResolvedValue({ success: true, data: { edges: [{ from: '0xseed', to: '0xc', volume_usd: 1000, tx_count: 1, direction: 'out' }] } });
131131
mockEnrich.mockImplementation(async (addr: string) => {
132132
if (addr === '0xc') return { labels: ['Uniswap Router'], sm_labels: [], balance_usd: 0, pnl_30d: 0, defi_protocols: 0 };
133133
return { labels: [], sm_labels: [], balance_usd: 0, pnl_30d: 0, defi_protocols: 0 };
@@ -146,10 +146,10 @@ describe('graph-builder', () => {
146146
});
147147

148148
it('should normalize edge directions', async () => {
149-
mockTrace.mockResolvedValue({ success: true, data: { connections: [
150-
{ address: '0xin', volume_usd: 100, tx_count: 1, direction: 'in' },
151-
{ address: '0xout', volume_usd: 100, tx_count: 1, direction: 'out' },
152-
{ address: '0xboth', volume_usd: 100, tx_count: 1, direction: 'both' },
149+
mockTrace.mockResolvedValue({ success: true, data: { edges: [
150+
{ from: '0xin', to: '0xseed', volume_usd: 100, tx_count: 1, direction: 'in' },
151+
{ from: '0xseed', to: '0xout', volume_usd: 100, tx_count: 1, direction: 'out' },
152+
{ from: '0xseed', to: '0xboth', volume_usd: 100, tx_count: 1, direction: 'both' },
153153
]}});
154154
const g = await buildGraph({ ...baseOpts, depth: 1 });
155155
expect(g.links.find(e => e.target === '0xin')?.direction).toBe('inflow');
@@ -166,8 +166,8 @@ describe('graph-builder', () => {
166166
});
167167

168168
it('should apply conn.label to node', async () => {
169-
mockTrace.mockResolvedValue({ success: true, data: { connections: [
170-
{ address: '0xlbl', volume_usd: 100, tx_count: 1, direction: 'out', label: 'Known Whale' },
169+
mockTrace.mockResolvedValue({ success: true, data: { edges: [
170+
{ from: '0xseed', to: '0xlbl', volume_usd: 100, tx_count: 1, direction: 'out', label: 'Known Whale' },
171171
]}});
172172
const g = await buildGraph({ ...baseOpts, depth: 1 });
173173
const node = g.nodes.find(n => n.id === '0xlbl');
@@ -178,53 +178,65 @@ describe('graph-builder', () => {
178178
it('should fallback counterparty fields to defaults when missing', async () => {
179179
mockTrace.mockResolvedValue({ success: false });
180180
mockCp.mockResolvedValue({ success: true, data: [
181-
{ address: '0xno_fields' }, // Missing volume_usd, tx_count, direction
181+
{ counterparty_address: '0xno_fields' }, // Missing volume_usd, tx_count, direction
182+
{ address: '0xcp_address_fallback', volume_usd: 100, tx_count: 1, direction: 'out' } // tests c.address fallback on line 158
182183
]});
183184
const g = await buildGraph({ ...baseOpts, depth: 1 });
184185
const edge = g.links.find(e => e.target === '0xno_fields');
185186
expect(edge?.volume_usd).toBe(0);
186187
expect(edge?.tx_count).toBe(0);
187188
expect(edge?.direction).toBe('bidirectional');
189+
190+
// Check fallback
191+
expect(g.nodes.find(n => n.id === '0xcp_address_fallback')).toBeDefined();
188192
});
189193

190194
it('should handle trace connections with missing optional fields', async () => {
191-
mockTrace.mockResolvedValue({ success: true, data: { connections: [
192-
{ address: '0xbare' }, // No volume_usd, tx_count, direction
195+
mockTrace.mockResolvedValue({ success: true, data: { edges: [
196+
{ from: '0xseed', to: '0xbare' }, // No volume_usd, tx_count, direction
197+
{ from: '0xother', to: '0xseed' }, // isFrom is false, tests line 145 'in'
198+
{ address: '0xnofrom', to: '0xseed' } // e.from is undefined, tests line 140 fallback
193199
]}});
194200
const g = await buildGraph({ ...baseOpts, depth: 1 });
195-
const edge = g.links.find(e => e.target === '0xbare');
196-
expect(edge?.volume_usd).toBe(0);
197-
expect(edge?.tx_count).toBe(0);
198-
expect(edge?.direction).toBe('bidirectional');
201+
const edgeOut = g.links.find(e => e.target === '0xbare');
202+
expect(edgeOut?.volume_usd).toBe(0);
203+
expect(edgeOut?.tx_count).toBe(0);
204+
expect(edgeOut?.direction).toBe('outflow');
205+
206+
const edgeIn = g.links.find(e => e.target === '0xother');
207+
expect(edgeIn?.direction).toBe('inflow');
208+
209+
const edgeNoFrom = g.links.find(e => e.target === '0xnofrom');
210+
expect(edgeNoFrom?.direction).toBe('inflow');
199211
});
200212

201213
it('should normalize "inflow" direction alias', async () => {
202-
mockTrace.mockResolvedValue({ success: true, data: { connections: [
203-
{ address: '0xinflow', volume_usd: 100, tx_count: 1, direction: 'inflow' },
214+
mockTrace.mockResolvedValue({ success: true, data: { edges: [
215+
{ from: '0xinflow', to: '0xseed', volume_usd: 100, tx_count: 1, direction: 'inflow' },
204216
]}});
205217
const g = await buildGraph({ ...baseOpts, depth: 1 });
206218
expect(g.links.find(e => e.target === '0xinflow')?.direction).toBe('inflow');
207219
});
208220

209221
it('should normalize "outflow" direction alias', async () => {
210-
mockTrace.mockResolvedValue({ success: true, data: { connections: [
211-
{ address: '0xoutflow', volume_usd: 100, tx_count: 1, direction: 'outflow' },
222+
mockTrace.mockResolvedValue({ success: true, data: { edges: [
223+
{ from: '0xseed', to: '0xoutflow', volume_usd: 100, tx_count: 1, direction: 'outflow' },
212224
]}});
213225
const g = await buildGraph({ ...baseOpts, depth: 1 });
214226
expect(g.links.find(e => e.target === '0xoutflow')?.direction).toBe('outflow');
215227
});
216228

217229
it('should pass primary_token through to edge', async () => {
218-
mockTrace.mockResolvedValue({ success: true, data: { connections: [
219-
{ address: '0xtoken', volume_usd: 100, tx_count: 1, direction: 'out', primary_token: 'USDC' },
230+
mockTrace.mockResolvedValue({ success: true, data: { edges: [
231+
{ from: '0xseed', to: '0xtoken', volume_usd: 100, tx_count: 1, direction: 'out', primary_token: 'USDC' },
220232
]}});
221233
const g = await buildGraph({ ...baseOpts, depth: 1 });
222234
expect(g.links.find(e => e.target === '0xtoken')?.primary_token).toBe('USDC');
223235
});
224236

225237
it('should not override label if enrichment gives labels but label is already custom', async () => {
226-
mockTrace.mockResolvedValue({ success: true, data: { connections: [
227-
{ address: '0xcustom', volume_usd: 100, tx_count: 1, direction: 'out', label: 'My Label' },
238+
mockTrace.mockResolvedValue({ success: true, data: { edges: [
239+
{ from: '0xseed', to: '0xcustom', volume_usd: 100, tx_count: 1, direction: 'out', label: 'My Label' },
228240
]}});
229241
mockEnrich.mockImplementation(async (addr: string) => {
230242
if (addr === '0xcustom') return { labels: ['Enriched'], sm_labels: [], balance_usd: 0, pnl_30d: 0, defi_protocols: 0 };
@@ -239,7 +251,7 @@ describe('graph-builder', () => {
239251
it('should handle trace data without connections array', async () => {
240252
mockTrace.mockResolvedValue({ success: true, data: { result: 'ok' } }); // No connections key
241253
mockCp.mockResolvedValue({ success: true, data: [
242-
{ address: '0xfallback', volume_usd: 500, tx_count: 2, direction: 'out' },
254+
{ counterparty_address: '0xfallback', total_volume_usd: 500, interaction_count: 2, direction: 'out' },
243255
]});
244256
const g = await buildGraph({ ...baseOpts, depth: 1 });
245257
expect(g.nodes.find(n => n.id === '0xfallback')).toBeDefined();

0 commit comments

Comments
 (0)