Skip to content

Commit bf7a269

Browse files
0xrinegadeclaude
andcommitted
feat(research): Add convergence-aware path visualization
CONVERGENCE DETECTION: - Wallets that receive from multiple sources now shown ONCE - Marker shows "[×N PATHS CONVERGE HERE]" on convergence nodes - Second+ paths show "↗ (converges with path above)" instead of duplicating - Visual clarity: no more duplicate wallets cluttering the graph EXAMPLE OUTPUT: ◉ [DEX Trader 1] Output_L3_1_1 │ └─→ [130,000.00 USDT] ──→ FinalDestination_COLD [Consolidation 1] │ ◉ [Cold Storage Vault] FinalDestination_COLD [×2 PATHS CONVERGE HERE] ... later in graph ... └─→ [130,000.00 USDT] ──→ FinalDestination_COLD [Consolidation 2] ↗ (converges with path above) IMPROVEMENTS: - Full wallet addresses (no truncation) - Continuous pipe guides - Labels prominently displayed - Convergence detection prevents duplicate nodes - All 10 tests passing This solves the "same wallet appearing multiple times" problem for complex money laundering networks with consolidation points. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent f81502a commit bf7a269

File tree

1 file changed

+42
-24
lines changed

1 file changed

+42
-24
lines changed

src/services/research_agent.rs

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,8 @@ impl TransferGraph {
347347
self.render_node_with_prefix(output, addr, depth, visited, is_origin, String::new());
348348
}
349349

350+
/// Build a convergence-aware visualization
351+
/// Shows all paths with proper merging when multiple paths lead to same wallet
350352
fn render_node_with_prefix(
351353
&self,
352354
output: &mut String,
@@ -356,37 +358,53 @@ impl TransferGraph {
356358
is_origin: bool,
357359
parent_prefix: String,
358360
) {
361+
// Check if this wallet has already been rendered (convergence point)
359362
if visited.contains(addr) {
363+
// This is a convergence - show a reference instead of duplicating
364+
output.push_str(&format!("{} ↗ CONVERGES TO: {} (shown above)\n", parent_prefix, addr));
360365
return;
361366
}
362367
visited.insert(addr.to_string());
363368

364369
let node = self.nodes.get(addr);
365370
let cfg = &self.render_config;
366371

367-
// Node header - NO TRUNCATION, show full address + label if available
372+
// Check how many incoming transfers this wallet has (convergence indicator)
373+
let incoming_count = self.nodes.values()
374+
.flat_map(|n| &n.outgoing)
375+
.filter(|t| t.to == addr)
376+
.count();
377+
378+
let convergence_marker = if incoming_count > 1 {
379+
format!(" [×{} PATHS CONVERGE HERE]", incoming_count)
380+
} else {
381+
String::new()
382+
};
383+
384+
// Node header - NO TRUNCATION, show full address + label
368385
let label_text = node.and_then(|n| n.label.as_ref()).map(|l| format!(" [{}]", l)).unwrap_or_default();
369386

370387
if is_origin {
371-
output.push_str(&format!("{}{}\n{}\n",
388+
output.push_str(&format!("{}{}\n",
372389
cfg.origin_icon,
373-
label_text,
374-
addr // FULL ADDRESS
390+
label_text
375391
));
392+
output.push_str(&format!(" {}{}\n", addr, convergence_marker));
376393
} else if Some(addr) == self.target.as_deref() {
377-
output.push_str(&format!("{}{}{}\n{}{}\n",
394+
output.push_str(&format!("{}{}{}{}\n",
378395
parent_prefix,
379396
cfg.target_icon,
380397
label_text,
381-
parent_prefix,
382-
addr // FULL ADDRESS
398+
convergence_marker
383399
));
400+
output.push_str(&format!("{} {}\n", parent_prefix, addr));
384401
} else {
385-
output.push_str(&format!("{}{}{} {}\n",
402+
output.push_str(&format!("{}{}{} {}{}\n",
386403
parent_prefix,
387404
cfg.node_icon,
388405
label_text,
389-
addr // FULL ADDRESS
406+
addr,
407+
convergence_marker
390408
));
391409
}
392410

@@ -398,35 +416,35 @@ impl TransferGraph {
398416

399417
// Build the prefix that will be passed to ALL children of this branch
400418
let child_prefix = if is_last {
401-
// Last branch - use spaces (no more siblings)
402-
format!("{} ", parent_prefix)
419+
format!("{} ", parent_prefix)
403420
} else {
404-
// Not last - keep the pipe going for siblings below
405-
format!("{}│ ", parent_prefix)
421+
format!("{}│ ", parent_prefix)
406422
};
407423

408-
// Transfer line
424+
// Show connector line before transfer
425+
output.push_str(&format!("{}│\n", parent_prefix));
426+
427+
// Transfer line with amount and metadata
409428
let branch_char = if is_last { "└─" } else { "├─" };
429+
let note_text = transfer.note.as_deref().unwrap_or("transfer");
430+
410431
output.push_str(&format!(
411-
"{}{}→ {} {} [{:?}]\n",
432+
"{}{}→ [{} {}] ──→ {} [{}]\n",
412433
parent_prefix,
413434
branch_char,
414435
self.format_amount(transfer.amount),
415436
transfer.token_symbol,
416-
transfer.note.as_deref().unwrap_or("SimpleTransfer")
417-
));
418-
419-
// Show full destination address with proper prefix
420-
output.push_str(&format!(
421-
"{} ↓ TO: {}\n",
422-
if is_last { format!("{} ", parent_prefix) } else { format!("{}│ ", parent_prefix) },
423-
transfer.to // FULL ADDRESS
437+
transfer.to,
438+
note_text
424439
));
425440

426441
// Recurse to child with the continuous prefix
427442
if !visited.contains(&transfer.to) {
428-
output.push_str(&format!("{} │\n", if is_last { format!("{} ", parent_prefix) } else { format!("{}│ ", parent_prefix) }));
443+
output.push_str(&format!("{} \n", if is_last { parent_prefix.clone() } else { format!("{}│", parent_prefix) }));
429444
self.render_node_with_prefix(output, &transfer.to, depth + 1, visited, false, child_prefix);
445+
} else {
446+
// Already visited - this is a convergence point, show reference
447+
output.push_str(&format!("{} ↗ (converges with path above)\n", if is_last { parent_prefix.clone() } else { format!("{}│", parent_prefix) }));
430448
}
431449
}
432450
}

0 commit comments

Comments
 (0)