Skip to content

Commit be68f44

Browse files
Hiksangclaude
andauthored
Add portfolio, monitor, and Morpho IRM rates (#1)
* Add portfolio dashboard, health monitor, and fix Morpho IRM rates New commands: - `defi portfolio --address 0x...` — aggregate positions via Multicall3 (token balances, lending positions, health factors in single RPC call) - `defi monitor --address 0x... --threshold 1.5` — poll health factors with alerts when below threshold (supports --once and --interval) Multicall3 read helper: - New `multicall_read()` function batches arbitrary eth_call queries into a single Multicall3 aggregate3 call - Reduces RPC calls from N to 1 for portfolio/monitor queries Fix Morpho Blue IRM rates: - Call `idToMarketParams()` to get IRM contract address - Call `borrowRateView(MarketParams, Market)` on the IRM for accurate per-second borrow rate, convert to APY - Before: supply=12.55% (rough util*15 estimate) - After: supply=1.22% (actual IRM on-chain calculation) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add yield optimize auto strategy with vault + Morpho + diversification - New 'auto' strategy: collects yields from lending, Morpho Blue, and ERC-4626 vaults, sorts by APY, recommends allocation - --amount flag enables diversification (60/30/10 split across top 3) - Queries 6 yield sources: HypurrFi, HyperLend, Felix Morpho, vaults - Example: USDC → HypurrFi 6.62% (60%), HyperLend 3.92% (10%) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f6ef3c1 commit be68f44

6 files changed

Lines changed: 622 additions & 31 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ pub mod cdp;
22
pub mod dex;
33
pub mod gauge;
44
pub mod lending;
5+
pub mod monitor;
6+
pub mod portfolio;
57
pub mod price;
68
pub mod schema;
79
pub mod staking;
@@ -65,6 +67,10 @@ pub enum Commands {
6567
Vault(vault::VaultArgs),
6668
/// Yield operations: compare, optimize
6769
Yield(yield_cmd::YieldArgs),
70+
/// Portfolio: aggregate positions across all protocols
71+
Portfolio(portfolio::PortfolioArgs),
72+
/// Monitor health factor with alerts
73+
Monitor(monitor::MonitorArgs),
6874
/// Query asset prices from oracles and DEXes
6975
Price(price::PriceArgs),
7076
/// Wallet management
@@ -95,6 +101,8 @@ pub async fn run(cli: Cli) -> Result<(), DefiError> {
95101
}
96102
Commands::Vault(args) => vault::run(args, &registry, chain, &executor, &output_mode).await,
97103
Commands::Yield(args) => yield_cmd::run(args, &registry, chain, &output_mode).await,
104+
Commands::Portfolio(args) => portfolio::run(args, &registry, chain, &output_mode).await,
105+
Commands::Monitor(args) => monitor::run(args, &registry, chain, &output_mode).await,
98106
Commands::Price(args) => price::run(args, &registry, chain, &output_mode).await,
99107
Commands::Wallet(args) => wallet::run(args, &registry, &output_mode).await,
100108
Commands::Token(args) => token::run(args, &registry, chain, &executor, &output_mode).await,
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
use alloy::primitives::{Address, U256};
2+
use alloy::sol_types::SolCall;
3+
use clap::Args;
4+
5+
use defi_core::error::{DefiError, Result};
6+
use defi_core::multicall::multicall_read;
7+
use defi_core::registry::{ChainConfig, Registry};
8+
9+
use crate::output::OutputMode;
10+
11+
#[derive(Args)]
12+
pub struct MonitorArgs {
13+
/// Wallet address to monitor
14+
#[arg(long)]
15+
pub address: String,
16+
17+
/// Health factor alert threshold (default: 1.5)
18+
#[arg(long, default_value = "1.5")]
19+
pub threshold: f64,
20+
21+
/// Polling interval in seconds (default: 60)
22+
#[arg(long, default_value = "60")]
23+
pub interval: u64,
24+
25+
/// Single check then exit
26+
#[arg(long)]
27+
pub once: bool,
28+
}
29+
30+
alloy::sol! {
31+
interface IPool {
32+
function getUserAccountData(address user) external view returns (
33+
uint256 totalCollateralBase,
34+
uint256 totalDebtBase,
35+
uint256 availableBorrowsBase,
36+
uint256 currentLiquidationThreshold,
37+
uint256 ltv,
38+
uint256 healthFactor
39+
);
40+
}
41+
}
42+
43+
struct LendingPool {
44+
name: &'static str,
45+
pool: Address,
46+
}
47+
48+
const LENDING_POOLS: &[LendingPool] = &[
49+
LendingPool {
50+
name: "HyperLend",
51+
pool: alloy::primitives::address!("00A89d7a5A02160f20150EbEA7a2b5E4879A1A8b"),
52+
},
53+
LendingPool {
54+
name: "HypurrFi",
55+
pool: alloy::primitives::address!("ceCcE0EB9DD2Ef7996e01e25DD70e461F918A14b"),
56+
},
57+
];
58+
59+
pub async fn run(
60+
args: MonitorArgs,
61+
_registry: &Registry,
62+
chain: &ChainConfig,
63+
output: &OutputMode,
64+
) -> Result<()> {
65+
let user: Address = args
66+
.address
67+
.parse()
68+
.map_err(|e| DefiError::InvalidParam(format!("Invalid address: {e}")))?;
69+
70+
let rpc = chain.effective_rpc_url();
71+
72+
loop {
73+
let timestamp = std::time::SystemTime::now()
74+
.duration_since(std::time::UNIX_EPOCH)
75+
.unwrap_or_default()
76+
.as_secs();
77+
78+
// Build multicall: getUserAccountData for each lending pool
79+
let calls: Vec<(Address, Vec<u8>)> = LENDING_POOLS
80+
.iter()
81+
.map(|p| {
82+
let calldata = IPool::getUserAccountDataCall { user }.abi_encode();
83+
(p.pool, calldata)
84+
})
85+
.collect();
86+
87+
let results = multicall_read(&rpc, calls).await?;
88+
89+
let mut positions = Vec::new();
90+
let mut any_alert = false;
91+
92+
for (i, pool) in LENDING_POOLS.iter().enumerate() {
93+
if let Some(data) = &results[i]
94+
&& data.len() >= 192
95+
{
96+
let collateral = U256::from_be_slice(&data[0..32]).to::<u128>() as f64 / 1e8;
97+
let debt = U256::from_be_slice(&data[32..64]).to::<u128>() as f64 / 1e8;
98+
let hf_raw = U256::from_be_slice(&data[160..192]);
99+
100+
let hf = if hf_raw > U256::from(u128::MAX) {
101+
None
102+
} else {
103+
let v = hf_raw.to::<u128>() as f64 / 1e18;
104+
if v > 1e10 { None } else { Some(v) }
105+
};
106+
107+
let below = matches!(hf, Some(h) if h < args.threshold);
108+
any_alert |= below;
109+
110+
if collateral > 0.0 || debt > 0.0 {
111+
positions.push(serde_json::json!({
112+
"protocol": pool.name,
113+
"collateral_usd": format!("{:.2}", collateral),
114+
"debt_usd": format!("{:.2}", debt),
115+
"health_factor": hf,
116+
"below_threshold": below,
117+
}));
118+
}
119+
}
120+
}
121+
122+
let check = serde_json::json!({
123+
"timestamp": timestamp,
124+
"address": format!("{}", user),
125+
"threshold": args.threshold,
126+
"alert": any_alert,
127+
"positions": positions,
128+
});
129+
130+
if any_alert {
131+
eprintln!(
132+
"⚠ ALERT: Health factor below {} for {}",
133+
args.threshold, user
134+
);
135+
}
136+
137+
output.print(&check)?;
138+
139+
if args.once {
140+
break;
141+
}
142+
143+
tokio::time::sleep(std::time::Duration::from_secs(args.interval)).await;
144+
}
145+
146+
Ok(())
147+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
use alloy::primitives::{Address, U256};
2+
use alloy::sol_types::SolCall;
3+
use clap::Args;
4+
5+
use defi_core::error::{DefiError, Result};
6+
use defi_core::multicall::{decode_u256, multicall_read};
7+
use defi_core::registry::{ChainConfig, Registry};
8+
9+
use crate::output::OutputMode;
10+
11+
#[derive(Args)]
12+
pub struct PortfolioArgs {
13+
/// Wallet address to query
14+
#[arg(long)]
15+
pub address: String,
16+
}
17+
18+
alloy::sol! {
19+
interface IERC20 {
20+
function balanceOf(address owner) external view returns (uint256);
21+
}
22+
23+
interface IPool {
24+
function getUserAccountData(address user) external view returns (
25+
uint256 totalCollateralBase,
26+
uint256 totalDebtBase,
27+
uint256 availableBorrowsBase,
28+
uint256 currentLiquidationThreshold,
29+
uint256 ltv,
30+
uint256 healthFactor
31+
);
32+
}
33+
34+
interface IAaveOracle {
35+
function getAssetPrice(address asset) external view returns (uint256);
36+
}
37+
}
38+
39+
pub async fn run(
40+
args: PortfolioArgs,
41+
registry: &Registry,
42+
chain: &ChainConfig,
43+
output: &OutputMode,
44+
) -> Result<()> {
45+
let user: Address = args
46+
.address
47+
.parse()
48+
.map_err(|e| DefiError::InvalidParam(format!("Invalid address: {e}")))?;
49+
50+
let rpc = chain.effective_rpc_url();
51+
let chain_key = chain.name.to_lowercase();
52+
53+
// === Build multicall batch ===
54+
let mut calls: Vec<(Address, Vec<u8>)> = Vec::new();
55+
let mut call_labels: Vec<String> = Vec::new();
56+
57+
// 1. Token balances
58+
let tokens = [
59+
"WHYPE", "USDC", "USDT0", "WETH", "kHYPE", "wstHYPE", "feUSD",
60+
];
61+
for symbol in &tokens {
62+
if let Ok(entry) = registry.resolve_token(&chain_key, symbol) {
63+
let calldata = IERC20::balanceOfCall { owner: user }.abi_encode();
64+
calls.push((entry.address, calldata));
65+
call_labels.push(format!("balance:{}", symbol));
66+
}
67+
}
68+
69+
// 2. Lending positions (HyperLend, HypurrFi)
70+
let lending_pools: Vec<(&str, Address)> = vec![
71+
(
72+
"HyperLend",
73+
"0x00A89d7a5A02160f20150EbEA7a2b5E4879A1A8b"
74+
.parse()
75+
.unwrap(),
76+
),
77+
(
78+
"HypurrFi",
79+
"0xceCcE0EB9DD2Ef7996e01e25DD70e461F918A14b"
80+
.parse()
81+
.unwrap(),
82+
),
83+
];
84+
for (name, pool) in &lending_pools {
85+
let calldata = IPool::getUserAccountDataCall { user }.abi_encode();
86+
calls.push((*pool, calldata));
87+
call_labels.push(format!("lending:{}", name));
88+
}
89+
90+
// 3. HYPE price from oracle
91+
let oracle: Address = "0xC9Fb4fbE842d57EAc1dF3e641a281827493A630e"
92+
.parse()
93+
.unwrap();
94+
let whype: Address = "0x5555555555555555555555555555555555555555"
95+
.parse()
96+
.unwrap();
97+
let calldata = IAaveOracle::getAssetPriceCall { asset: whype }.abi_encode();
98+
calls.push((oracle, calldata));
99+
call_labels.push("price:HYPE".to_string());
100+
101+
// === Execute multicall ===
102+
let results = multicall_read(&rpc, calls).await?;
103+
104+
// === Parse results ===
105+
let mut token_balances = Vec::new();
106+
let mut total_value_usd = 0.0_f64;
107+
let mut idx = 0;
108+
109+
// Get HYPE price first (last call in batch minus 1)
110+
let hype_price_idx = results.len() - 1;
111+
let hype_price = decode_u256(&results[hype_price_idx]).to::<u128>() as f64 / 1e8;
112+
113+
// Token balances
114+
for symbol in &tokens {
115+
if let Ok(entry) = registry.resolve_token(&chain_key, symbol) {
116+
let balance = decode_u256(&results[idx]);
117+
if !balance.is_zero() {
118+
let decimals = entry.decimals;
119+
let bal_f64 = balance.to::<u128>() as f64 / 10f64.powi(decimals as i32);
120+
let value_usd = match *symbol {
121+
"WHYPE" | "kHYPE" | "wstHYPE" => bal_f64 * hype_price,
122+
"USDC" | "USDT0" | "feUSD" => bal_f64,
123+
"WETH" => bal_f64 * 2250.0, // approximate
124+
_ => 0.0,
125+
};
126+
total_value_usd += value_usd;
127+
token_balances.push(serde_json::json!({
128+
"symbol": symbol,
129+
"balance": format!("{:.4}", bal_f64),
130+
"value_usd": format!("{:.2}", value_usd),
131+
}));
132+
}
133+
idx += 1;
134+
}
135+
}
136+
137+
// Lending positions
138+
let mut lending_positions = Vec::new();
139+
for (name, _pool) in &lending_pools {
140+
if let Some(data) = &results[idx]
141+
&& data.len() >= 192
142+
{
143+
let collateral = U256::from_be_slice(&data[0..32]).to::<u128>() as f64 / 1e8;
144+
let debt = U256::from_be_slice(&data[32..64]).to::<u128>() as f64 / 1e8;
145+
let hf_raw = U256::from_be_slice(&data[160..192]);
146+
let hf = if hf_raw > U256::from(u128::MAX) {
147+
None
148+
} else {
149+
let v = hf_raw.to::<u128>() as f64 / 1e18;
150+
if v > 1e10 { None } else { Some(v) }
151+
};
152+
153+
if collateral > 0.0 || debt > 0.0 {
154+
total_value_usd += collateral - debt;
155+
lending_positions.push(serde_json::json!({
156+
"protocol": name,
157+
"collateral_usd": format!("{:.2}", collateral),
158+
"debt_usd": format!("{:.2}", debt),
159+
"health_factor": hf,
160+
}));
161+
}
162+
}
163+
idx += 1;
164+
}
165+
166+
let portfolio = serde_json::json!({
167+
"address": format!("{}", user),
168+
"total_value_usd": format!("{:.2}", total_value_usd),
169+
"hype_price_usd": format!("{:.2}", hype_price),
170+
"token_balances": token_balances,
171+
"lending_positions": lending_positions,
172+
});
173+
174+
output.print(&portfolio)?;
175+
Ok(())
176+
}

0 commit comments

Comments
 (0)