Skip to content

Commit 2e751e0

Browse files
Hiksangclaude
andcommitted
Add cross-chain yield scan: defi yield scan --asset USDC
Scans all 11 chains in parallel for lending rates, ranks by supply APY, and identifies arb opportunities (supply high / borrow low spread). defi yield scan --asset USDC → 15 rates across 11 chains in 1.4s → Top: HypurrFi 4.66% (HyperEVM) → 10 arb opportunities (best: +2.56% cross-chain) Table output with color-coded APYs and arb spread table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c470d21 commit 2e751e0

4 files changed

Lines changed: 236 additions & 7 deletions

File tree

.omc/project-memory.json

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,18 @@
116116
"lastAccessed": 1773824621726,
117117
"type": "file"
118118
},
119+
{
120+
"path": "crates/defi-cli/src/table.rs",
121+
"accessCount": 8,
122+
"lastAccessed": 1773826378964,
123+
"type": "file"
124+
},
125+
{
126+
"path": "crates/defi-cli/src/commands/yield_cmd.rs",
127+
"accessCount": 8,
128+
"lastAccessed": 1773826425420,
129+
"type": "file"
130+
},
119131
{
120132
"path": "crates/defi-cli/src/commands/whales.rs",
121133
"accessCount": 6,
@@ -134,12 +146,6 @@
134146
"lastAccessed": 1773745726311,
135147
"type": "file"
136148
},
137-
{
138-
"path": "crates/defi-cli/src/table.rs",
139-
"accessCount": 5,
140-
"lastAccessed": 1773826170351,
141-
"type": "file"
142-
},
143149
{
144150
"path": "crates/defi-core/src/registry/mod.rs",
145151
"accessCount": 4,

.omc/state/hud-stdin-cache.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"session_id":"2c019e77-806b-4a72-b477-db1f3b13fa91","transcript_path":"/Users/hik/.claude/projects/-Users-hik-Documents-GitHub-defi-cli/2c019e77-806b-4a72-b477-db1f3b13fa91.jsonl","cwd":"/Users/hik/Documents/GitHub/defi-cli","model":{"id":"claude-opus-4-6[1m]","display_name":"Opus 4.6 (1M context)"},"workspace":{"current_dir":"/Users/hik/Documents/GitHub/defi-cli","project_dir":"/Users/hik/Documents/GitHub/defi-cli","added_dirs":[]},"version":"2.1.77","output_style":{"name":"default"},"cost":{"total_cost_usd":388.52809715000075,"total_duration_ms":181764897,"total_api_duration_ms":29799466,"total_lines_added":11098,"total_lines_removed":1738},"context_window":{"total_input_tokens":9209365,"total_output_tokens":1237334,"context_window_size":1000000,"current_usage":{"input_tokens":3,"output_tokens":1,"cache_creation_input_tokens":4113,"cache_read_input_tokens":608615},"used_percentage":61,"remaining_percentage":39},"exceeds_200k_tokens":true}
1+
{"session_id":"2c019e77-806b-4a72-b477-db1f3b13fa91","transcript_path":"/Users/hik/.claude/projects/-Users-hik-Documents-GitHub-defi-cli/2c019e77-806b-4a72-b477-db1f3b13fa91.jsonl","cwd":"/Users/hik/Documents/GitHub/defi-cli","model":{"id":"claude-opus-4-6[1m]","display_name":"Opus 4.6 (1M context)"},"workspace":{"current_dir":"/Users/hik/Documents/GitHub/defi-cli","project_dir":"/Users/hik/Documents/GitHub/defi-cli","added_dirs":[]},"version":"2.1.77","output_style":{"name":"default"},"cost":{"total_cost_usd":394.6681909000007,"total_duration_ms":181995226,"total_api_duration_ms":30017887,"total_lines_added":11283,"total_lines_removed":1743},"context_window":{"total_input_tokens":9209422,"total_output_tokens":1243110,"context_window_size":1000000,"current_usage":{"input_tokens":3,"output_tokens":1,"cache_creation_input_tokens":197,"cache_read_input_tokens":627744},"used_percentage":63,"remaining_percentage":37},"exceeds_200k_tokens":true}

crates/defi-cli/src/commands/yield_cmd.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ pub enum YieldCommand {
2121
#[arg(long)]
2222
asset: String,
2323
},
24+
/// Scan ALL chains for best yield opportunities (parallel)
25+
Scan {
26+
/// Asset symbol (e.g. USDC, WETH)
27+
#[arg(long)]
28+
asset: String,
29+
},
2430
/// Suggest optimal yield strategy for an asset
2531
Optimize {
2632
/// Asset symbol or address to optimize (e.g. USDC, WHYPE)
@@ -169,6 +175,9 @@ pub async fn run(
169175
let chain_key = chain.name.to_lowercase();
170176

171177
match args.command {
178+
YieldCommand::Scan { asset } => {
179+
return run_yield_scan(registry, &asset, output).await;
180+
}
172181
YieldCommand::Compare { asset } => {
173182
let asset_addr = resolve_asset(registry, &chain_key, &asset)?;
174183
let mut results = collect_lending_rates(registry, chain, asset_addr).await;
@@ -404,3 +413,147 @@ pub async fn run(
404413

405414
Ok(())
406415
}
416+
417+
/// Scan all chains in parallel for the best yield on a given asset
418+
async fn run_yield_scan(registry: &Registry, asset: &str, output: &OutputMode) -> Result<()> {
419+
let start = std::time::Instant::now();
420+
let chain_keys: Vec<String> = registry.chains.keys().cloned().collect();
421+
422+
// Collect params for each chain
423+
struct ChainYieldParams {
424+
chain_name: String,
425+
rpc: String,
426+
asset_addr: Option<Address>,
427+
protocols: Vec<defi_core::registry::ProtocolEntry>,
428+
}
429+
430+
let mut params = Vec::new();
431+
for ck in &chain_keys {
432+
let chain = match registry.get_chain(ck) {
433+
Ok(c) => c,
434+
Err(_) => continue,
435+
};
436+
let asset_addr = registry.resolve_token(ck, asset).ok().map(|t| t.address);
437+
let protos: Vec<_> = registry
438+
.get_protocols_for_chain(ck)
439+
.iter()
440+
.filter(|p| p.category == ProtocolCategory::Lending && p.interface == "aave_v3")
441+
.cloned()
442+
.cloned()
443+
.collect();
444+
if asset_addr.is_some() && !protos.is_empty() {
445+
params.push(ChainYieldParams {
446+
chain_name: chain.name.clone(),
447+
rpc: chain.effective_rpc_url(),
448+
asset_addr,
449+
protocols: protos,
450+
});
451+
}
452+
}
453+
454+
// Spawn parallel tasks
455+
let mut join_set = tokio::task::JoinSet::new();
456+
for p in params {
457+
join_set.spawn(async move {
458+
let addr = match p.asset_addr {
459+
Some(a) => a,
460+
None => return Vec::new(),
461+
};
462+
let mut rates = Vec::new();
463+
for proto in &p.protocols {
464+
if let Ok(lending) =
465+
defi_protocols::factory::create_lending_with_rpc(proto, Some(&p.rpc))
466+
&& let Ok(r) = lending.get_rates(addr).await
467+
&& r.supply_apy > 0.0
468+
{
469+
rates.push(serde_json::json!({
470+
"chain": p.chain_name,
471+
"protocol": r.protocol,
472+
"supply_apy": r.supply_apy,
473+
"borrow_variable_apy": r.borrow_variable_apy,
474+
}));
475+
}
476+
}
477+
rates
478+
});
479+
}
480+
481+
// Collect results
482+
let mut all_rates: Vec<serde_json::Value> = Vec::new();
483+
while let Some(result) = join_set.join_next().await {
484+
if let Ok(rates) = result {
485+
all_rates.extend(rates);
486+
}
487+
}
488+
489+
// Sort by supply APY descending
490+
all_rates.sort_by(|a, b| {
491+
b["supply_apy"]
492+
.as_f64()
493+
.unwrap_or(0.0)
494+
.partial_cmp(&a["supply_apy"].as_f64().unwrap_or(0.0))
495+
.unwrap()
496+
});
497+
498+
let scan_ms = start.elapsed().as_millis();
499+
let best = all_rates.first().and_then(|r| {
500+
Some(format!(
501+
"{} on {}",
502+
r["protocol"].as_str()?,
503+
r["chain"].as_str()?
504+
))
505+
});
506+
507+
// Find arb opportunities (supply on A, borrow on B, same asset)
508+
let mut arbs = Vec::new();
509+
for s in &all_rates {
510+
for b in &all_rates {
511+
let sp = s["supply_apy"].as_f64().unwrap_or(0.0);
512+
let bp = b["borrow_variable_apy"].as_f64().unwrap_or(0.0);
513+
if sp > bp && bp > 0.0 {
514+
let spread = sp - bp;
515+
let s_chain = s["chain"].as_str().unwrap_or("?");
516+
let b_chain = b["chain"].as_str().unwrap_or("?");
517+
let s_proto = s["protocol"].as_str().unwrap_or("?");
518+
let b_proto = b["protocol"].as_str().unwrap_or("?");
519+
if s_chain != b_chain || s_proto != b_proto {
520+
let strategy = if s_chain == b_chain {
521+
"same-chain"
522+
} else {
523+
"cross-chain"
524+
};
525+
arbs.push(serde_json::json!({
526+
"spread_pct": (spread * 100.0).round() / 100.0,
527+
"supply_chain": s_chain,
528+
"supply_protocol": s_proto,
529+
"supply_apy": sp,
530+
"borrow_chain": b_chain,
531+
"borrow_protocol": b_proto,
532+
"borrow_apy": bp,
533+
"strategy": strategy,
534+
}));
535+
}
536+
}
537+
}
538+
}
539+
arbs.sort_by(|a, b| {
540+
b["spread_pct"]
541+
.as_f64()
542+
.unwrap_or(0.0)
543+
.partial_cmp(&a["spread_pct"].as_f64().unwrap_or(0.0))
544+
.unwrap()
545+
});
546+
arbs.truncate(10); // Top 10 arb opportunities
547+
548+
let result = serde_json::json!({
549+
"asset": asset,
550+
"scan_duration_ms": scan_ms,
551+
"chains_scanned": chain_keys.len(),
552+
"rates": all_rates,
553+
"best_supply": best,
554+
"arb_opportunities": arbs,
555+
});
556+
557+
output.print(&result)?;
558+
Ok(())
559+
}

crates/defi-cli/src/table.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ pub fn render(value: &Value) -> Option<String> {
1111
if value.get("holders").is_some() {
1212
return Some(render_whales(value));
1313
}
14+
if value.get("arb_opportunities").is_some() && value.get("rates").is_some() {
15+
return Some(render_yield_scan(value));
16+
}
1417
if value.get("rates").is_some() && value.get("asset").is_some() {
1518
return Some(render_yield(value));
1619
}
@@ -320,6 +323,73 @@ fn render_status(v: &Value) -> String {
320323
format!("{}\n\n{}", out, t)
321324
}
322325

326+
fn render_yield_scan(v: &Value) -> String {
327+
let asset = v["asset"].as_str().unwrap_or("?");
328+
let ms = v["scan_duration_ms"].as_u64().unwrap_or(0);
329+
let best = v["best_supply"].as_str().unwrap_or("?");
330+
331+
let mut t = Table::new();
332+
t.load_preset(UTF8_FULL_CONDENSED);
333+
t.set_header(vec!["Chain", "Protocol", "Supply APY", "Borrow APY"]);
334+
335+
for r in v["rates"].as_array().unwrap_or(&vec![]) {
336+
let supply = r["supply_apy"].as_f64().unwrap_or(0.0);
337+
let color = if supply > 3.0 {
338+
Color::Green
339+
} else if supply > 1.0 {
340+
Color::Cyan
341+
} else {
342+
Color::White
343+
};
344+
t.add_row(vec![
345+
Cell::new(r["chain"].as_str().unwrap_or("?")),
346+
Cell::new(r["protocol"].as_str().unwrap_or("?")),
347+
Cell::new(format!("{:.2}%", supply)).fg(color),
348+
Cell::new(format!(
349+
"{:.2}%",
350+
r["borrow_variable_apy"].as_f64().unwrap_or(0.0)
351+
)),
352+
]);
353+
}
354+
355+
let mut out = format!(
356+
" {} Yield Scan ({}ms) — Best: {}\n\n{}",
357+
asset, ms, best, t
358+
);
359+
360+
let empty_arbs = vec![];
361+
let arbs = v["arb_opportunities"].as_array().unwrap_or(&empty_arbs);
362+
if !arbs.is_empty() {
363+
let mut at = Table::new();
364+
at.load_preset(UTF8_FULL_CONDENSED);
365+
at.set_header(vec!["Spread", "Supply @", "Borrow @", "Type"]);
366+
for a in arbs {
367+
let spread = a["spread_pct"].as_f64().unwrap_or(0.0);
368+
let color = if spread > 1.0 {
369+
Color::Green
370+
} else {
371+
Color::Cyan
372+
};
373+
at.add_row(vec![
374+
Cell::new(format!("+{:.2}%", spread)).fg(color),
375+
Cell::new(format!(
376+
"{} ({})",
377+
a["supply_protocol"].as_str().unwrap_or("?"),
378+
a["supply_chain"].as_str().unwrap_or("?")
379+
)),
380+
Cell::new(format!(
381+
"{} ({})",
382+
a["borrow_protocol"].as_str().unwrap_or("?"),
383+
a["borrow_chain"].as_str().unwrap_or("?")
384+
)),
385+
Cell::new(a["strategy"].as_str().unwrap_or("?")),
386+
]);
387+
}
388+
out.push_str(&format!("\n Arb Opportunities\n\n{}", at));
389+
}
390+
out
391+
}
392+
323393
fn render_portfolio(v: &Value) -> String {
324394
render_positions(v) // Same format
325395
}

0 commit comments

Comments
 (0)