Skip to main content

degenbot_cli_core/
aave.rs

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