Skip to content

Commit fcfaee0

Browse files
Hiksangclaude
andcommitted
Add oracle price feed infrastructure for arbitrage detection
New components: - Oracle trait (get_price, get_prices) in defi-core - AaveOracle adapter (HyperLend, HypurrFi oracle contracts) - FelixOracle adapter (WHYPE PriceFeed only) - DexSpotPrice utility (quote-based spot pricing) - CLI `defi price --asset WHYPE` command Verified on real HyperEVM RPC: WHYPE: HyperLend=$38.31, HypurrFi=$38.30, Felix=$38.31 (spread 0.03%) WBTC: HyperLend=$73,952, HypurrFi=$73,847 (spread 0.14%) WETH: HyperLend=$2,264, HypurrFi=$2,262 (spread 0.10%) kHYPE: HyperLend=$38.87, HypurrFi=$38.82 (spread 0.13%) This provides the foundation for DEX↔Oracle arbitrage detection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3532fcd commit fcfaee0

12 files changed

Lines changed: 597 additions & 2 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod cdp;
33
pub mod dex;
44
pub mod gauge;
55
pub mod lending;
6+
pub mod price;
67
pub mod schema;
78
pub mod staking;
89
pub mod status;
@@ -67,6 +68,8 @@ pub enum Commands {
6768
Vault(vault::VaultArgs),
6869
/// Yield operations: compare, optimize
6970
Yield(yield_cmd::YieldArgs),
71+
/// Query asset prices from oracles and DEXes
72+
Price(price::PriceArgs),
7073
/// Wallet management
7174
Wallet(wallet::WalletArgs),
7275
/// Token operations: approve, allowance, transfer
@@ -96,6 +99,7 @@ pub async fn run(cli: Cli) -> Result<(), DefiError> {
9699
}
97100
Commands::Vault(args) => vault::run(args, &registry, chain, &executor, &output_mode).await,
98101
Commands::Yield(args) => yield_cmd::run(args, &registry, chain, &output_mode).await,
102+
Commands::Price(args) => price::run(args, &registry, chain, &output_mode).await,
99103
Commands::Wallet(args) => wallet::run(args, &registry, &output_mode).await,
100104
Commands::Token(args) => token::run(args, &registry, chain, &executor, &output_mode).await,
101105
Commands::Agent => crate::agent::run_agent(&registry, &executor).await,
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
use alloy::primitives::Address;
2+
use clap::Args;
3+
4+
use defi_core::error::{DefiError, Result};
5+
use defi_core::registry::{ChainConfig, ProtocolCategory, Registry};
6+
use defi_core::types::PriceData;
7+
8+
use crate::output::OutputMode;
9+
10+
#[derive(Args)]
11+
pub struct PriceArgs {
12+
/// Asset symbol (e.g. WHYPE) or address
13+
#[arg(long)]
14+
pub asset: String,
15+
16+
/// Price source filter: all, oracle, dex
17+
#[arg(long, default_value = "all")]
18+
pub source: String,
19+
}
20+
21+
#[derive(serde::Serialize)]
22+
struct PriceReport {
23+
asset: String,
24+
asset_address: String,
25+
prices: Vec<PriceEntry>,
26+
max_spread_pct: f64,
27+
oracle_vs_dex_spread_pct: f64,
28+
}
29+
30+
#[derive(serde::Serialize)]
31+
struct PriceEntry {
32+
source: String,
33+
source_type: String,
34+
price: f64,
35+
}
36+
37+
fn resolve_asset(registry: &Registry, chain: &str, asset: &str) -> Result<(Address, String, u8)> {
38+
if let Ok(addr) = asset.parse::<Address>() {
39+
return Ok((addr, asset.to_string(), 18));
40+
}
41+
let token = registry.resolve_token(chain, asset)?;
42+
Ok((token.address, token.symbol.clone(), token.decimals))
43+
}
44+
45+
pub async fn run(
46+
args: PriceArgs,
47+
registry: &Registry,
48+
chain: &ChainConfig,
49+
output: &OutputMode,
50+
) -> Result<()> {
51+
let chain_key = chain.name.to_lowercase();
52+
let rpc_url = chain.effective_rpc_url();
53+
let (asset_addr, asset_symbol, _asset_decimals) =
54+
resolve_asset(registry, &chain_key, &args.asset)?;
55+
56+
let fetch_oracle = args.source == "all" || args.source == "oracle";
57+
let fetch_dex = args.source == "all" || args.source == "dex";
58+
59+
let mut all_prices: Vec<PriceData> = Vec::new();
60+
61+
// === Oracle prices from lending protocols (Aave V3 forks) ===
62+
if fetch_oracle {
63+
let lending_protocols = registry.get_protocols_by_category(ProtocolCategory::Lending);
64+
for entry in &lending_protocols {
65+
match defi_protocols::factory::create_oracle_from_lending(entry, &rpc_url) {
66+
Ok(oracle) => match oracle.get_price(asset_addr).await {
67+
Ok(price) => all_prices.push(price),
68+
Err(e) => {
69+
eprintln!("[{}] oracle price failed: {e}", entry.name);
70+
}
71+
},
72+
Err(_) => continue, // Interface doesn't support oracle
73+
}
74+
}
75+
76+
// === Oracle prices from CDP protocols (Felix) ===
77+
// Felix PriceFeed only returns WHYPE collateral price.
78+
// Only query it when the asset is WHYPE or a WHYPE-related token.
79+
let whype_addr: Address = "0x5555555555555555555555555555555555555555"
80+
.parse()
81+
.unwrap();
82+
let is_whype = asset_addr == whype_addr
83+
|| asset_symbol.eq_ignore_ascii_case("WHYPE")
84+
|| asset_symbol.eq_ignore_ascii_case("HYPE");
85+
86+
if is_whype {
87+
let cdp_protocols = registry.get_protocols_by_category(ProtocolCategory::Cdp);
88+
for entry in &cdp_protocols {
89+
match defi_protocols::factory::create_oracle_from_cdp(entry, asset_addr, &rpc_url) {
90+
Ok(oracle) => match oracle.get_price(asset_addr).await {
91+
Ok(price) => all_prices.push(price),
92+
Err(e) => {
93+
eprintln!("[{}] oracle price failed: {e}", entry.name);
94+
}
95+
},
96+
Err(_) => continue,
97+
}
98+
}
99+
}
100+
}
101+
102+
// === DEX spot prices ===
103+
if fetch_dex {
104+
// Resolve USDC as the quote token
105+
let usdc = registry.resolve_token(&chain_key, "USDC");
106+
if let Ok(usdc_token) = usdc {
107+
let usdc_addr = usdc_token.address;
108+
let usdc_decimals = usdc_token.decimals;
109+
110+
let dex_protocols = registry.get_protocols_by_category(ProtocolCategory::Dex);
111+
for entry in &dex_protocols {
112+
match defi_protocols::factory::create_dex_with_rpc(entry, Some(&rpc_url)) {
113+
Ok(dex) => {
114+
match defi_protocols::dex::DexSpotPrice::get_price(
115+
dex.as_ref(),
116+
asset_addr,
117+
_asset_decimals,
118+
usdc_addr,
119+
usdc_decimals,
120+
)
121+
.await
122+
{
123+
Ok(price) => all_prices.push(price),
124+
Err(e) => {
125+
eprintln!("[{}] dex price failed: {e}", entry.name);
126+
}
127+
}
128+
}
129+
Err(_) => continue,
130+
}
131+
}
132+
} else {
133+
eprintln!("USDC token not found in registry — skipping DEX prices");
134+
}
135+
}
136+
137+
if all_prices.is_empty() {
138+
return Err(DefiError::Internal(
139+
"No prices could be fetched from any source".to_string(),
140+
));
141+
}
142+
143+
// Compute spreads
144+
let prices_f64: Vec<f64> = all_prices.iter().map(|p| p.price_f64).collect();
145+
let max_price = prices_f64.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
146+
let min_price = prices_f64.iter().cloned().fold(f64::INFINITY, f64::min);
147+
let max_spread_pct = if min_price > 0.0 {
148+
(max_price - min_price) / min_price * 100.0
149+
} else {
150+
0.0
151+
};
152+
153+
// Oracle vs DEX spread
154+
let oracle_prices: Vec<f64> = all_prices
155+
.iter()
156+
.filter(|p| p.source_type == "oracle")
157+
.map(|p| p.price_f64)
158+
.collect();
159+
let dex_prices: Vec<f64> = all_prices
160+
.iter()
161+
.filter(|p| p.source_type == "dex_spot")
162+
.map(|p| p.price_f64)
163+
.collect();
164+
165+
let oracle_vs_dex_spread_pct = if !oracle_prices.is_empty() && !dex_prices.is_empty() {
166+
let avg_oracle = oracle_prices.iter().sum::<f64>() / oracle_prices.len() as f64;
167+
let avg_dex = dex_prices.iter().sum::<f64>() / dex_prices.len() as f64;
168+
let min_avg = avg_oracle.min(avg_dex);
169+
if min_avg > 0.0 {
170+
(avg_oracle - avg_dex).abs() / min_avg * 100.0
171+
} else {
172+
0.0
173+
}
174+
} else {
175+
0.0
176+
};
177+
178+
let report = PriceReport {
179+
asset: asset_symbol,
180+
asset_address: asset_addr.to_string(),
181+
prices: all_prices
182+
.iter()
183+
.map(|p| PriceEntry {
184+
source: p.source.clone(),
185+
source_type: p.source_type.clone(),
186+
price: (p.price_f64 * 100.0).round() / 100.0,
187+
})
188+
.collect(),
189+
max_spread_pct: (max_spread_pct * 100.0).round() / 100.0,
190+
oracle_vs_dex_spread_pct: (oracle_vs_dex_spread_pct * 100.0).round() / 100.0,
191+
};
192+
193+
output.print(&report)?;
194+
Ok(())
195+
}

crates/defi-core/src/traits/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod gauge;
66
pub mod lending;
77
pub mod liquid_staking;
88
pub mod options;
9+
pub mod oracle;
910
pub mod vault;
1011
pub mod yield_aggregator;
1112
pub mod yield_source;
@@ -18,6 +19,7 @@ pub use gauge::{Gauge, GaugeSystem, VoteEscrow, Voter};
1819
pub use lending::Lending;
1920
pub use liquid_staking::LiquidStaking;
2021
pub use options::Options;
22+
pub use oracle::Oracle;
2123
pub use vault::Vault;
2224
pub use yield_aggregator::YieldAggregator;
2325
pub use yield_source::YieldSource;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
use alloy::primitives::Address;
2+
use async_trait::async_trait;
3+
4+
use crate::error::Result;
5+
use crate::types::PriceData;
6+
7+
/// Oracle price feed — reads prices from lending protocol oracles or price feeds
8+
#[async_trait]
9+
pub trait Oracle: Send + Sync {
10+
fn name(&self) -> &str;
11+
/// Get price for an asset from this oracle
12+
async fn get_price(&self, asset: Address) -> Result<PriceData>;
13+
/// Get prices for multiple assets
14+
async fn get_prices(&self, assets: &[Address]) -> Result<Vec<PriceData>>;
15+
}

crates/defi-core/src/types.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,27 @@ pub struct VeNftInfo {
357357
pub voting_power: U256,
358358
}
359359

360+
// === Oracle / Price Types ===
361+
362+
/// Price data from an oracle or DEX
363+
#[derive(Debug, Clone, Serialize, Deserialize)]
364+
pub struct PriceData {
365+
/// Source of the price (protocol name or "dex:protocol")
366+
pub source: String,
367+
/// Source type: "oracle", "dex_spot", "dex_twap"
368+
pub source_type: String,
369+
/// The asset being priced
370+
pub asset: Address,
371+
/// Price in USD (18 decimals)
372+
pub price_usd: U256,
373+
/// Price as f64 for display
374+
pub price_f64: f64,
375+
/// Block number when price was fetched
376+
pub block_number: Option<u64>,
377+
/// Timestamp
378+
pub timestamp: Option<u64>,
379+
}
380+
360381
// === Yield Types ===
361382

362383
#[derive(Debug, Clone, Serialize, Deserialize)]
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
use alloy::primitives::Address;
2+
use alloy::providers::ProviderBuilder;
3+
use alloy::sol;
4+
use async_trait::async_trait;
5+
6+
use defi_core::error::{DefiError, Result};
7+
use defi_core::traits::Oracle;
8+
use defi_core::types::PriceData;
9+
10+
sol! {
11+
#[sol(rpc)]
12+
interface IFelixPriceFeed {
13+
function fetchPrice() external view returns (uint256 price, bool isNewOracleFailureDetected);
14+
function lastGoodPrice() external view returns (uint256);
15+
}
16+
}
17+
18+
/// Felix price feed oracle (Liquity V2 style).
19+
/// Returns the price of the collateral asset (WHYPE) in USD with 18 decimals.
20+
pub struct FelixOracle {
21+
name: String,
22+
price_feed: Address,
23+
/// The collateral asset this price feed reports on (e.g. WHYPE)
24+
asset: Address,
25+
rpc_url: String,
26+
}
27+
28+
impl FelixOracle {
29+
pub fn new(name: String, price_feed: Address, asset: Address, rpc_url: String) -> Self {
30+
Self {
31+
name,
32+
price_feed,
33+
asset,
34+
rpc_url,
35+
}
36+
}
37+
38+
pub fn from_contracts(
39+
name: String,
40+
contracts: &std::collections::HashMap<String, Address>,
41+
asset: Address,
42+
rpc_url: String,
43+
) -> Result<Self> {
44+
let price_feed = contracts.get("price_feed").copied().ok_or_else(|| {
45+
DefiError::ContractError(format!("[{name}] Missing 'price_feed' contract address"))
46+
})?;
47+
Ok(Self {
48+
name,
49+
price_feed,
50+
asset,
51+
rpc_url,
52+
})
53+
}
54+
55+
fn rpc_url(&self) -> Result<url::Url> {
56+
self.rpc_url
57+
.parse()
58+
.map_err(|e| DefiError::RpcError(format!("Invalid RPC URL: {e}")))
59+
}
60+
}
61+
62+
#[async_trait]
63+
impl Oracle for FelixOracle {
64+
fn name(&self) -> &str {
65+
&self.name
66+
}
67+
68+
async fn get_price(&self, asset: Address) -> Result<PriceData> {
69+
if asset != self.asset {
70+
return Err(DefiError::Unsupported(format!(
71+
"[{}] Felix PriceFeed only supports asset {:?}",
72+
self.name, self.asset
73+
)));
74+
}
75+
76+
let url = self.rpc_url()?;
77+
let provider = ProviderBuilder::new().connect_http(url);
78+
let feed = IFelixPriceFeed::new(self.price_feed, &provider);
79+
80+
// Try fetchPrice first, fall back to lastGoodPrice
81+
let price_val = match feed.fetchPrice().call().await {
82+
Ok(result) => result.price,
83+
Err(_) => {
84+
// Fall back to lastGoodPrice
85+
feed.lastGoodPrice().call().await.map_err(|e| {
86+
DefiError::RpcError(format!("[{}] lastGoodPrice failed: {e}", self.name))
87+
})?
88+
}
89+
};
90+
91+
// Felix prices are already in 18-decimal USD
92+
let price_f64 = price_val.to::<u128>() as f64 / 1e18;
93+
94+
Ok(PriceData {
95+
source: "Felix PriceFeed".to_string(),
96+
source_type: "oracle".to_string(),
97+
asset,
98+
price_usd: price_val,
99+
price_f64,
100+
block_number: None,
101+
timestamp: None,
102+
})
103+
}
104+
105+
async fn get_prices(&self, assets: &[Address]) -> Result<Vec<PriceData>> {
106+
let mut results = Vec::new();
107+
for &asset in assets {
108+
match self.get_price(asset).await {
109+
Ok(price) => results.push(price),
110+
Err(_) => continue, // Skip unsupported assets
111+
}
112+
}
113+
Ok(results)
114+
}
115+
}

0 commit comments

Comments
 (0)