Skip to main content

degenbot_cli_core/
aave.rs

1//! The `aave` command arms (ADR-051 D1; ergo FTVJ6L).
2//!
3//! Ports `cli/aave.py`:
4//!
5//! - `aave activate` — [`activate_aave_market`] (the one-time market seed).
6//! - `aave deactivate` — the `(chain, name)` market read + the
7//!   [`deactivate_aave_market`] row flip.
8//! - `aave update` — the active-market walk over
9//!   [`run_aave_update`], with the post-run zero-balance cleanup and the
10//!   opt-in completion backup.
11//! - `aave position show` — the market/user scalar row reads ported onto the
12//!   `degenbot-db::aave` read surface.
13
14use std::sync::Arc;
15
16use alloy::primitives::{address, Address};
17use degenbot_aave::updater::verify::cleanup_zero_balance_positions_on_conn;
18use degenbot_aave::{
19    activate_aave_market, deactivate_aave_market, run_aave_update, NoProgress, RunError,
20};
21use degenbot_db::{ops, DbError, DegenbotDb};
22
23use crate::block::{parse_to_block, resolve_to_block};
24use crate::cancel::CancelHandle;
25use crate::context::CliContext;
26use crate::error::CliError;
27use crate::prompt::{PromptPlan, Prompter};
28use crate::report::{
29    AavePositionLine, AaveReport, AaveUpdateEntry, AaveUpdateOutcome, DeactivateOutcome,
30};
31
32/// An Aave V3 deployment (the Python `aave/deployments.py` constants).
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct AaveDeployment {
35    /// The chain id.
36    pub chain_id: u64,
37    /// The human chain label.
38    pub chain_label: &'static str,
39    /// The `PoolAddressProvider` contract.
40    pub pool_address_provider: Address,
41    /// The chain's GHO token.
42    pub gho_token_address: Address,
43}
44
45/// The shipped Aave V3 deployments (Ethereum mainnet only, mirroring Python).
46pub const AAVE_DEPLOYMENTS: &[AaveDeployment] = &[AaveDeployment {
47    chain_id: 1,
48    chain_label: "Ethereum",
49    pool_address_provider: address!("2f39d218133AFaB8F2B819B1066c7E434Ad94E9e"),
50    gho_token_address: address!("40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f"),
51}];
52
53/// The `aave` command group.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum AaveCommand {
56    /// Activate (or re-activate) the chain's Aave V3 market.
57    Activate {
58        /// The chain id.
59        chain_id: u64,
60    },
61    /// Deactivate a market by chain + name.
62    Deactivate {
63        /// The chain id.
64        chain_id: u64,
65        /// The `aave_v3_markets.name` to flip.
66        market_name: String,
67    },
68    /// Update positions for every active market.
69    Update {
70        /// Max blocks per chunk before committing.
71        chunk_size: u64,
72        /// The raw `--to-block` identifier.
73        to_block: String,
74        /// The per-chunk touched-position verify.
75        verify_chunk: bool,
76        /// The market-wide interval + completion verify.
77        verify_all: bool,
78        /// The interval for the market-wide gate.
79        verify_all_interval: u64,
80        /// Stop after the first committed chunk.
81        stop_after_one_chunk: bool,
82        /// Preview only (skips the Rust call entirely).
83        dry_run: bool,
84        /// Back up the DB once per market at the end of the run.
85        enable_backup: bool,
86    },
87    /// Display a user's collateral + debt positions.
88    PositionShow {
89        /// The user address.
90        address: String,
91        /// The market name.
92        market: String,
93        /// The chain id.
94        chain_id: u64,
95    },
96}
97
98impl AaveCommand {
99    /// The arm's confirmation policy: no Python aave handler prompts.
100    #[must_use]
101    pub const fn prompt_plan(&self, _ctx: &CliContext<'_>) -> PromptPlan {
102        PromptPlan::None
103    }
104}
105
106/// Resolve the Aave V3 deployment for `chain_id`.
107///
108/// # Errors
109///
110/// [`CliError::UnknownDeployment`] when the chain has no shipped deployment.
111pub fn resolve_aave_deployment(chain_id: u64) -> Result<&'static AaveDeployment, CliError> {
112    AAVE_DEPLOYMENTS
113        .iter()
114        .find(|d| d.chain_id == chain_id)
115        .ok_or_else(|| CliError::UnknownDeployment {
116            chain_id,
117            name: "ethereum_aave_v3".to_string(),
118        })
119}
120
121/// Execute an `aave` command.
122///
123/// # Errors
124///
125/// [`CliError::UnknownDeployment`] / [`CliError::InvalidAddress`] /
126/// [`CliError::InvalidArgument`] for bad inputs, [`CliError::Config`] for an
127/// unresolved driver-domain value, [`CliError::NoActiveAaveMarkets`] when
128/// `aave update` finds nothing, and [`CliError::AaveUpdate`] for a core
129/// failure.
130pub(crate) fn execute(
131    command: &AaveCommand,
132    ctx: &CliContext<'_>,
133    _prompter: &dyn Prompter,
134    cancel: &CancelHandle,
135) -> Result<AaveReport, CliError> {
136    match command {
137        AaveCommand::Activate { chain_id } => activate(ctx, *chain_id),
138        AaveCommand::Deactivate {
139            chain_id,
140            market_name,
141        } => deactivate(ctx, *chain_id, market_name),
142        AaveCommand::Update {
143            chunk_size,
144            to_block,
145            verify_chunk,
146            verify_all,
147            verify_all_interval,
148            stop_after_one_chunk,
149            dry_run,
150            enable_backup,
151        } => update(
152            ctx,
153            cancel,
154            &UpdateArgs {
155                chunk_size: *chunk_size,
156                to_block,
157                verify_chunk: *verify_chunk,
158                verify_all: *verify_all,
159                verify_all_interval: *verify_all_interval,
160                stop_after_one_chunk: *stop_after_one_chunk,
161                dry_run: *dry_run,
162                enable_backup: *enable_backup,
163            },
164        ),
165        AaveCommand::PositionShow {
166            address,
167            market,
168            chain_id,
169        } => position_show(ctx, address, market, *chain_id),
170    }
171}
172
173/// `aave activate`.
174fn activate(ctx: &CliContext<'_>, chain_id: u64) -> Result<AaveReport, CliError> {
175    let deployment = resolve_aave_deployment(chain_id)?;
176    let database_path = ctx.database_path().value;
177    let rpc_url = ctx.node_http_uri_for(chain_id)?.value;
178    let chain = i64::try_from(chain_id)
179        .map_err(|_| CliError::InvalidArgument(format!("chain id {chain_id} is out of range")))?;
180    let result = activate_aave_market(
181        &database_path,
182        chain,
183        &deployment.pool_address_provider.to_checksum(None),
184        &deployment.gho_token_address.to_checksum(None),
185        &rpc_url,
186    )
187    .map_err(CliError::AaveUpdate)?;
188    tracing::info!(
189        chain_id,
190        market_id = result.market_id,
191        "activated Aave V3 market"
192    );
193    Ok(AaveReport::Activated {
194        chain_id,
195        chain_label: deployment.chain_label,
196        market_id: result.market_id,
197        market_name: result.market_name,
198        created: result.created,
199    })
200}
201
202/// `aave deactivate`.
203fn deactivate(
204    ctx: &CliContext<'_>,
205    chain_id: u64,
206    market_name: &str,
207) -> Result<AaveReport, CliError> {
208    let database_path = ctx.database_path().value;
209    let chain = i64::try_from(chain_id)
210        .map_err(|_| CliError::InvalidArgument(format!("chain id {chain_id} is out of range")))?;
211    let market = {
212        let db = (DegenbotDb::open(&database_path)?).0;
213        db.fetch_aave_market_by_name(chain, market_name)?
214    };
215    let Some(market) = market else {
216        return Ok(AaveReport::Deactivated {
217            chain_id,
218            market_id: None,
219            outcome: DeactivateOutcome::NoEntry,
220        });
221    };
222    if !market.active {
223        return Ok(AaveReport::Deactivated {
224            chain_id,
225            market_id: Some(market.id),
226            outcome: DeactivateOutcome::AlreadyDeactivated,
227        });
228    }
229    deactivate_aave_market(&database_path, market.id).map_err(CliError::AaveUpdate)?;
230    tracing::info!(
231        chain_id,
232        market_id = market.id,
233        "deactivated Aave V3 market"
234    );
235    Ok(AaveReport::Deactivated {
236        chain_id,
237        market_id: Some(market.id),
238        outcome: DeactivateOutcome::Deactivated,
239    })
240}
241
242/// The `aave update` flag bundle.
243#[expect(clippy::struct_excessive_bools)]
244struct UpdateArgs<'a> {
245    chunk_size: u64,
246    to_block: &'a str,
247    verify_chunk: bool,
248    verify_all: bool,
249    verify_all_interval: u64,
250    stop_after_one_chunk: bool,
251    dry_run: bool,
252    enable_backup: bool,
253}
254
255/// `aave update`.
256#[expect(clippy::too_many_lines)]
257fn update(
258    ctx: &CliContext<'_>,
259    cancel: &CancelHandle,
260    args: &UpdateArgs<'_>,
261) -> Result<AaveReport, CliError> {
262    let database_path = ctx.database_path().value;
263    let markets = {
264        let db = (DegenbotDb::open(&database_path)?).0;
265        db.fetch_active_aave_markets()?
266    };
267    if markets.is_empty() {
268        return Err(CliError::NoActiveAaveMarkets);
269    }
270    let spec = parse_to_block(args.to_block)?;
271    let interval = if args.verify_all {
272        Some(args.verify_all_interval)
273    } else {
274        None
275    };
276    let max_chunks = if args.stop_after_one_chunk {
277        Some(1)
278    } else {
279        None
280    };
281    let mut entries: Vec<AaveUpdateEntry> = Vec::new();
282    let mut chain_ids: Vec<i64> = Vec::new();
283    for market in &markets {
284        if !chain_ids.contains(&market.chain_id) {
285            chain_ids.push(market.chain_id);
286        }
287    }
288    for chain in chain_ids {
289        if cancel.is_cancelled() {
290            break;
291        }
292        let chain_unsigned = u64::try_from(chain).map_err(|_| {
293            CliError::InvalidArgument(format!("market chain id {chain} is out of range"))
294        })?;
295        let rpc_url = ctx.node_http_uri_for(chain_unsigned)?.value;
296        let resolved = resolve_to_block(spec, &rpc_url)?;
297        for market in markets.iter().filter(|m| m.chain_id == chain) {
298            if cancel.is_cancelled() {
299                return Ok(AaveReport::Updated { entries });
300            }
301            let Some(last_update_block) = market.last_update_block else {
302                entries.push(AaveUpdateEntry {
303                    chain_id: chain,
304                    market_id: market.id,
305                    market_name: market.name.clone(),
306                    outcome: AaveUpdateOutcome::NeedsBootstrap,
307                });
308                continue;
309            };
310            if args.dry_run {
311                entries.push(AaveUpdateEntry {
312                    chain_id: chain,
313                    market_id: market.id,
314                    market_name: market.name.clone(),
315                    outcome: AaveUpdateOutcome::DryRun {
316                        last_update_block,
317                        to_block: resolved,
318                    },
319                });
320                continue;
321            }
322            match run_aave_update(
323                &database_path,
324                chain,
325                market.id,
326                resolved,
327                args.chunk_size,
328                &rpc_url,
329                cancel.flag(),
330                Arc::new(NoProgress),
331                args.verify_chunk,
332                interval,
333                args.verify_all,
334                max_chunks,
335            ) {
336                Ok(report) => {
337                    entries.push(AaveUpdateEntry {
338                        chain_id: chain,
339                        market_id: market.id,
340                        market_name: market.name.clone(),
341                        outcome: AaveUpdateOutcome::Advanced {
342                            from_block: report.from_block,
343                            to_block: report.to_block,
344                            chunks_committed: report.chunks_committed,
345                            total_events_applied: report.total_events_applied,
346                        },
347                    });
348                    cleanup_zero_balance_positions(&database_path, market.id)?;
349                    if args.enable_backup {
350                        let backup = backup_for_block(&database_path, report.to_block)?;
351                        tracing::info!(
352                            chain_id = chain,
353                            to_block = report.to_block,
354                            backup = %backup.display(),
355                            "created Aave database backup at block"
356                        );
357                    }
358                }
359                Err(RunError::Cancelled) => {
360                    entries.push(AaveUpdateEntry {
361                        chain_id: chain,
362                        market_id: market.id,
363                        market_name: market.name.clone(),
364                        outcome: AaveUpdateOutcome::Cancelled,
365                    });
366                    return Ok(AaveReport::Updated { entries });
367                }
368                Err(err) => return Err(CliError::AaveUpdate(err)),
369            }
370        }
371    }
372    Ok(AaveReport::Updated { entries })
373}
374
375/// Delete the market's zero-balance collateral + debt rows under one transaction.
376fn cleanup_zero_balance_positions(
377    database_path: &std::path::Path,
378    market_id: i64,
379) -> Result<(), CliError> {
380    let (db, _state) = DegenbotDb::open_for_writes(database_path)?;
381    let mut guard = db.lock();
382    let tx = guard.transaction().map_err(DbError::from)?;
383    cleanup_zero_balance_positions_on_conn(&tx, market_id)?;
384    tx.commit().map_err(DbError::from)?;
385    Ok(())
386}
387
388/// The `<stem>-<to_block>.db.bak` sibling the Python completion backup writes.
389fn backup_for_block(
390    database_path: &std::path::Path,
391    to_block: u64,
392) -> Result<std::path::PathBuf, CliError> {
393    let stem = database_path
394        .file_stem()
395        .map(|s| s.to_string_lossy().into_owned())
396        .unwrap_or_default();
397    let mut backup = database_path.to_path_buf();
398    backup.set_file_name(format!("{stem}-{to_block}.db.bak"));
399    ops::backup_database(database_path, &backup)?;
400    Ok(backup)
401}
402
403/// `aave position show`.
404fn position_show(
405    ctx: &CliContext<'_>,
406    raw_address: &str,
407    market: &str,
408    chain_id: u64,
409) -> Result<AaveReport, CliError> {
410    let address: Address = raw_address
411        .parse()
412        .map_err(|_| CliError::InvalidAddress(raw_address.to_string()))?;
413    let user_address = address.to_checksum(None);
414    let chain = i64::try_from(chain_id)
415        .map_err(|_| CliError::InvalidArgument(format!("chain id {chain_id} is out of range")))?;
416    let database_path = ctx.database_path().value;
417    let db = (DegenbotDb::open(&database_path)?).0;
418    let Some(market_row) = db.fetch_aave_market_by_name(chain, market)? else {
419        return Ok(AaveReport::PositionNoMarket {
420            market: market.to_string(),
421            chain_id,
422        });
423    };
424    let Some(user) = db.fetch_aave_user_by_address(market_row.id, &user_address)? else {
425        return Ok(AaveReport::PositionNoUser {
426            user_address,
427            market: market.to_string(),
428            chain_id,
429        });
430    };
431    let collateral = db
432        .fetch_aave_collateral_positions(user.id)?
433        .into_iter()
434        .map(|p| AavePositionLine {
435            symbol: p.underlying_symbol.unwrap_or_else(|| "Unknown".to_string()),
436            balance: p.balance,
437        })
438        .collect();
439    let debt = db
440        .fetch_aave_debt_positions(user.id)?
441        .into_iter()
442        .map(|p| AavePositionLine {
443            symbol: p.underlying_symbol.unwrap_or_else(|| "Unknown".to_string()),
444            balance: p.balance,
445        })
446        .collect();
447    Ok(AaveReport::Position {
448        user_address,
449        market: market.to_string(),
450        chain_id,
451        collateral,
452        debt,
453    })
454}