Skip to main content

degenbot_cli_core/
exchange.rs

1//! The `exchange` command arms (ADR-051 D5) — data, not 34 verbs.
2//!
3//! The Python-era click tree declared one verb per `(chain, DEX)` pair
4//! (`base_aerodrome_v2`, `ethereum_uniswap_v3`, … — 17 pairs, each with an
5//! activate + deactivate handler). Those verbs are now ONE constructor-parsed
6//! command, `exchange activate|deactivate --chain <slug|id> --name <dex>`,
7//! resolving `(chain, name)` through the [`RetiredExchange`] table and the
8//! CREATE2 identity (factory/deployer) through
9//! [`degenbot_uniswap::deployments`] — the byte-compared single source.
10//!
11//! The write path is `degenbot-db`'s discovery primitives:
12//! [`upsert_exchange`](degenbot_db::DegenbotDb::upsert_exchange) (get-or-create,
13//! `active = false`), [`set_exchange_active`](degenbot_db::DegenbotDb::set_exchange_active),
14//! and — for the Uniswap V4 singleton — [`upsert_pool_manager`](degenbot_db::DegenbotDb::upsert_pool_manager).
15//!
16//! The V4 `pool_manager`/`state_view` are the SAME literals the Python module
17//! carries (`deployments.py` documents them as "singleton addresses with no
18//! factory path in the registry yet"); they are the only table entries that do
19//! not resolve to a registry record.
20
21use alloy::primitives::{address, Address};
22use degenbot_db::DegenbotDb;
23use degenbot_uniswap::deployments;
24
25use crate::context::CliContext;
26use crate::error::CliError;
27use crate::prompt::{PromptPlan, Prompter};
28use crate::report::{
29    ActivateOutcome, DeactivateOutcome, ExchangeActiveState, ExchangeListRow, ExchangeReport,
30};
31
32/// The `exchange` command group.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ExchangeCommand {
35    /// Activate the exchange (get-or-create the row, flip `active = true`).
36    Activate {
37        /// The chain selector (`base`, `ethereum`, or a numeric id).
38        chain: String,
39        /// The DEX name slug (`aerodrome_v2`, `uniswap_v3`, …).
40        name: String,
41    },
42    /// Deactivate the exchange (resolve the row by name, flip `active = false`).
43    Deactivate {
44        /// The chain selector.
45        chain: String,
46        /// The DEX name slug.
47        name: String,
48    },
49    /// List every supported `(chain, DEX)` pair and its DB activation state.
50    List {
51        /// The optional chain filter (a chain slug or numeric id).
52        chain: Option<String>,
53    },
54}
55
56impl ExchangeCommand {
57    /// The arm's confirmation policy: the Python exchange handlers never prompt.
58    #[must_use]
59    pub const fn prompt_plan(&self, _ctx: &CliContext<'_>) -> PromptPlan {
60        PromptPlan::None
61    }
62
63    /// The `(chain, name)` pair the command addresses (`None` for the filterless
64    /// `List` arm).
65    #[must_use]
66    pub fn selector(&self) -> Option<(&str, &str)> {
67        match self {
68            Self::Activate { chain, name } | Self::Deactivate { chain, name } => {
69                Some((chain, name))
70            }
71            Self::List { .. } => None,
72        }
73    }
74}
75
76/// A deployed pool manager singleton (Uniswap V4).
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct PoolManagerDeployment {
79    /// The `PoolManager` address (the DB `exchanges.factory`).
80    pub address: Address,
81    /// The companion `StateView` contract.
82    pub state_view: Address,
83}
84
85/// A resolved exchange deployment.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ExchangeDeployment {
88    /// The resolved chain id.
89    pub chain_id: u64,
90    /// The human chain label (`Base`, `Ethereum`).
91    pub chain_label: &'static str,
92    /// The DEX name slug stored in the DB `exchanges.name`.
93    pub dex_slug: &'static str,
94    /// The human DEX label used in the report lines.
95    pub display_name: &'static str,
96    /// The DB `exchanges.factory` value.
97    pub factory: Address,
98    /// The DB `exchanges.deployer` value (the JSON `deployer`; `None` = factory).
99    pub deployer: Option<Address>,
100    /// The V4 `pool_managers` row to upsert on activate, when applicable.
101    pub pool_manager: Option<PoolManagerDeployment>,
102}
103
104/// Where a retired exchange's identity comes from.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum DeploymentSource {
107    /// A `(chain, factory)` row in the shipped `deployments.json` registry.
108    Registry {
109        /// The factory address (the registry lookup key).
110        factory: Address,
111    },
112    /// A Uniswap V4 singleton (no registry factory path yet).
113    V4Singleton {
114        /// The `PoolManager` address.
115        pool_manager: Address,
116        /// The `StateView` address.
117        state_view: Address,
118    },
119}
120
121/// One retired Python verb pair's `(chain, DEX)` identity.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct RetiredExchange {
124    /// The Python verb's chain prefix (`base`, `ethereum`).
125    pub chain_slug: &'static str,
126    /// The resolved chain id.
127    pub chain_id: u64,
128    /// The human chain label.
129    pub chain_label: &'static str,
130    /// The DEX name slug.
131    pub dex_slug: &'static str,
132    /// The human DEX label.
133    pub display_name: &'static str,
134    /// The identity source.
135    pub source: DeploymentSource,
136}
137
138/// Every retired `exchange activate|deactivate` verb, collapsed to its
139/// `(chain, dex)` identity. This is the data-driven replacement for the 34
140/// click handlers; the coverage test asserts each entry resolves.
141pub const RETIRED_EXCHANGES: &[RetiredExchange] = &[
142    // ── Base mainnet ─────────────────────────────────────────────────────
143    RetiredExchange {
144        chain_slug: "base",
145        chain_id: 8453,
146        chain_label: "Base",
147        dex_slug: "aerodrome_v2",
148        display_name: "Aerodrome V2",
149        source: DeploymentSource::Registry {
150            factory: address!("420DD381b31aEf6683db6B902084cB0FFECe40Da"),
151        },
152    },
153    RetiredExchange {
154        chain_slug: "base",
155        chain_id: 8453,
156        chain_label: "Base",
157        dex_slug: "aerodrome_v3",
158        display_name: "Aerodrome V3",
159        source: DeploymentSource::Registry {
160            factory: address!("5e7BB104d84c7CB9B682AaC2F3d509f5F406809A"),
161        },
162    },
163    RetiredExchange {
164        chain_slug: "base",
165        chain_id: 8453,
166        chain_label: "Base",
167        dex_slug: "pancakeswap_v2",
168        display_name: "Pancakeswap V2",
169        source: DeploymentSource::Registry {
170            factory: address!("02a84c1b3BBD7401a5f7fa98a384EBC70bB5749E"),
171        },
172    },
173    RetiredExchange {
174        chain_slug: "base",
175        chain_id: 8453,
176        chain_label: "Base",
177        dex_slug: "pancakeswap_v3",
178        display_name: "Pancakeswap V3",
179        source: DeploymentSource::Registry {
180            factory: address!("0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865"),
181        },
182    },
183    RetiredExchange {
184        chain_slug: "base",
185        chain_id: 8453,
186        chain_label: "Base",
187        dex_slug: "swapbased_v2",
188        display_name: "SwapBased V2",
189        source: DeploymentSource::Registry {
190            factory: address!("04C9f118d21e8B767D2e50C946f0cC9F6C367300"),
191        },
192    },
193    RetiredExchange {
194        chain_slug: "base",
195        chain_id: 8453,
196        chain_label: "Base",
197        dex_slug: "sushiswap_v2",
198        display_name: "Sushiswap V2",
199        source: DeploymentSource::Registry {
200            factory: address!("71524B4f93c58fcbF659783284E38825f0622859"),
201        },
202    },
203    RetiredExchange {
204        chain_slug: "base",
205        chain_id: 8453,
206        chain_label: "Base",
207        dex_slug: "sushiswap_v3",
208        display_name: "Sushiswap V3",
209        source: DeploymentSource::Registry {
210            factory: address!("c35DADB65012eC5796536bD9864eD8773aBc74C4"),
211        },
212    },
213    RetiredExchange {
214        chain_slug: "base",
215        chain_id: 8453,
216        chain_label: "Base",
217        dex_slug: "uniswap_v2",
218        display_name: "Uniswap V2",
219        source: DeploymentSource::Registry {
220            factory: address!("8909Dc15e40173Ff4699343b6eB8132c65e18eC6"),
221        },
222    },
223    RetiredExchange {
224        chain_slug: "base",
225        chain_id: 8453,
226        chain_label: "Base",
227        dex_slug: "uniswap_v3",
228        display_name: "Uniswap V3",
229        source: DeploymentSource::Registry {
230            factory: address!("33128a8fC17869897dcE68Ed026d694621f6FDfD"),
231        },
232    },
233    RetiredExchange {
234        chain_slug: "base",
235        chain_id: 8453,
236        chain_label: "Base",
237        dex_slug: "uniswap_v4",
238        display_name: "Uniswap V4",
239        source: DeploymentSource::V4Singleton {
240            pool_manager: address!("498581fF718922c3f8e6A244956aF099B2652b2b"),
241            state_view: address!("A3c0c9b65baD0b08107Aa264b0f3dB444b867A71"),
242        },
243    },
244    // ── Ethereum mainnet ─────────────────────────────────────────────────
245    RetiredExchange {
246        chain_slug: "ethereum",
247        chain_id: 1,
248        chain_label: "Ethereum",
249        dex_slug: "pancakeswap_v2",
250        display_name: "Pancakeswap V2",
251        source: DeploymentSource::Registry {
252            factory: address!("1097053Fd2ea711dad45caCcc45EfF7548fCB362"),
253        },
254    },
255    RetiredExchange {
256        chain_slug: "ethereum",
257        chain_id: 1,
258        chain_label: "Ethereum",
259        dex_slug: "pancakeswap_v3",
260        display_name: "Pancakeswap V3",
261        source: DeploymentSource::Registry {
262            factory: address!("0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865"),
263        },
264    },
265    RetiredExchange {
266        chain_slug: "ethereum",
267        chain_id: 1,
268        chain_label: "Ethereum",
269        dex_slug: "sushiswap_v2",
270        display_name: "Sushiswap V2",
271        source: DeploymentSource::Registry {
272            factory: address!("C0AEe478e3658e2610c5F7A4A2E1777cE9e4f2Ac"),
273        },
274    },
275    RetiredExchange {
276        chain_slug: "ethereum",
277        chain_id: 1,
278        chain_label: "Ethereum",
279        dex_slug: "sushiswap_v3",
280        display_name: "Sushiswap V3",
281        source: DeploymentSource::Registry {
282            factory: address!("bACEB8eC6b9355Dfc0269C18bac9d6E2Bdc29C4F"),
283        },
284    },
285    RetiredExchange {
286        chain_slug: "ethereum",
287        chain_id: 1,
288        chain_label: "Ethereum",
289        dex_slug: "uniswap_v2",
290        display_name: "Uniswap V2",
291        source: DeploymentSource::Registry {
292            factory: address!("5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f"),
293        },
294    },
295    RetiredExchange {
296        chain_slug: "ethereum",
297        chain_id: 1,
298        chain_label: "Ethereum",
299        dex_slug: "uniswap_v3",
300        display_name: "Uniswap V3",
301        source: DeploymentSource::Registry {
302            factory: address!("1F98431c8aD98523631AE4a59f267346ea31F984"),
303        },
304    },
305    RetiredExchange {
306        chain_slug: "ethereum",
307        chain_id: 1,
308        chain_label: "Ethereum",
309        dex_slug: "uniswap_v4",
310        display_name: "Uniswap V4",
311        source: DeploymentSource::V4Singleton {
312            pool_manager: address!("000000000004444c5dc75cB358380D2e3dE08A90"),
313            state_view: address!("7fFE42C4a5DEeA5b0feC41C94C136Cf115597227"),
314        },
315    },
316];
317
318/// Resolve `(chain_id, dex_slug)` to a deployment, reading the registry record
319/// (factory/deployer) where one exists.
320///
321/// # Errors
322///
323/// [`CliError::UnknownDeployment`] when the pair is not a retired exchange, or
324/// when a registry-backed entry has no shipped `deployments.json` record.
325pub fn resolve_deployment(chain_id: u64, dex_slug: &str) -> Result<ExchangeDeployment, CliError> {
326    let entry = RETIRED_EXCHANGES
327        .iter()
328        .find(|e| e.chain_id == chain_id && e.dex_slug == dex_slug)
329        .ok_or_else(|| CliError::UnknownDeployment {
330            chain_id,
331            name: dex_slug.to_string(),
332        })?;
333    match entry.source {
334        DeploymentSource::Registry { factory } => {
335            let record = deployments::lookup(chain_id, factory).ok_or_else(|| {
336                CliError::UnknownDeployment {
337                    chain_id,
338                    name: dex_slug.to_string(),
339                }
340            })?;
341            Ok(ExchangeDeployment {
342                chain_id: entry.chain_id,
343                chain_label: entry.chain_label,
344                dex_slug: entry.dex_slug,
345                display_name: entry.display_name,
346                factory: record.factory,
347                deployer: record.deployer,
348                pool_manager: None,
349            })
350        }
351        DeploymentSource::V4Singleton {
352            pool_manager,
353            state_view,
354        } => Ok(ExchangeDeployment {
355            chain_id: entry.chain_id,
356            chain_label: entry.chain_label,
357            dex_slug: entry.dex_slug,
358            display_name: entry.display_name,
359            factory: pool_manager,
360            deployer: None,
361            pool_manager: Some(PoolManagerDeployment {
362                address: pool_manager,
363                state_view,
364            }),
365        }),
366    }
367}
368
369/// Execute an `exchange` command.
370///
371/// # Errors
372///
373/// [`CliError::UnknownChain`] / [`CliError::UnknownDeployment`] for an
374/// unresolvable selector, or a DB failure from the discovery primitives.
375pub(crate) fn execute(
376    command: &ExchangeCommand,
377    ctx: &CliContext<'_>,
378    _prompter: &dyn Prompter,
379) -> Result<ExchangeReport, CliError> {
380    let path = ctx.database_path()?.value;
381    match command {
382        ExchangeCommand::Activate { chain, name } => activate(&deployment_for(chain, name)?, &path),
383        ExchangeCommand::Deactivate { chain, name } => {
384            deactivate(&deployment_for(chain, name)?, &path)
385        }
386        ExchangeCommand::List { chain } => list(chain.as_deref(), &path),
387    }
388}
389
390/// Resolve an activate/deactivate arm's `(chain, name)` pair to its deployment.
391fn deployment_for(chain: &str, name: &str) -> Result<ExchangeDeployment, CliError> {
392    let chain_id = crate::block::resolve_chain_selector(chain)?;
393    resolve_deployment(chain_id, name)
394}
395
396/// List every supported `(chain, DEX)` pair (optionally one chain's), joined
397/// with its DB activation state.
398fn list(chain_filter: Option<&str>, path: &std::path::Path) -> Result<ExchangeReport, CliError> {
399    let chain_id = chain_filter
400        .map(crate::block::resolve_chain_selector)
401        .transpose()?;
402    let (db, _state) = DegenbotDb::open(path)?;
403    let mut rows = Vec::new();
404    for entry in RETIRED_EXCHANGES {
405        if chain_id.is_some_and(|filtered| filtered != entry.chain_id) {
406            continue;
407        }
408        let row = db.fetch_exchange_by_name(
409            i64::try_from(entry.chain_id).map_err(|_| {
410                CliError::InvalidArgument(format!("chain id {} is out of range", entry.chain_id))
411            })?,
412            entry.dex_slug,
413        )?;
414        let state = match row {
415            Some(row) if row.active => ExchangeActiveState::Active,
416            Some(_) => ExchangeActiveState::Inactive,
417            None => ExchangeActiveState::NoEntry,
418        };
419        rows.push(ExchangeListRow {
420            chain_id: entry.chain_id,
421            chain_label: entry.chain_label,
422            display_name: entry.display_name,
423            dex_slug: entry.dex_slug,
424            factory: resolve_deployment(entry.chain_id, entry.dex_slug)?
425                .factory
426                .to_string(),
427            state,
428        });
429    }
430    Ok(ExchangeReport::Listed { rows })
431}
432
433/// Activate (get-or-create the row, flip active true, upsert the V4 manager).
434fn activate(
435    deployment: &ExchangeDeployment,
436    path: &std::path::Path,
437) -> Result<ExchangeReport, CliError> {
438    let chain = i64::try_from(deployment.chain_id).map_err(|_| {
439        CliError::InvalidArgument(format!("chain id {} is out of range", deployment.chain_id))
440    })?;
441    let (db, _state) = DegenbotDb::open_for_writes(path)?;
442    let row = db.upsert_exchange(
443        chain,
444        deployment.dex_slug,
445        deployment.factory,
446        deployment.deployer,
447    )?;
448    if row.active {
449        return Ok(ExchangeReport::Activated {
450            chain_id: deployment.chain_id,
451            chain_label: deployment.chain_label,
452            display_name: deployment.display_name,
453            dex_slug: deployment.dex_slug,
454            outcome: ActivateOutcome::AlreadyActive,
455        });
456    }
457    db.set_exchange_active(row.id, true)?;
458    if let Some(manager) = deployment.pool_manager {
459        db.upsert_pool_manager(
460            manager.address,
461            chain,
462            deployment.dex_slug,
463            Some(manager.state_view),
464            row.id,
465        )?;
466    }
467    tracing::info!(
468        chain_id = deployment.chain_id,
469        name = deployment.dex_slug,
470        "activated exchange"
471    );
472    Ok(ExchangeReport::Activated {
473        chain_id: deployment.chain_id,
474        chain_label: deployment.chain_label,
475        display_name: deployment.display_name,
476        dex_slug: deployment.dex_slug,
477        outcome: ActivateOutcome::Activated,
478    })
479}
480
481/// Deactivate (resolve by name, flip active false).
482fn deactivate(
483    deployment: &ExchangeDeployment,
484    path: &std::path::Path,
485) -> Result<ExchangeReport, CliError> {
486    let chain = i64::try_from(deployment.chain_id).map_err(|_| {
487        CliError::InvalidArgument(format!("chain id {} is out of range", deployment.chain_id))
488    })?;
489    let (db, _state) = DegenbotDb::open_for_writes(path)?;
490    let Some(row) = db.fetch_exchange_by_name(chain, deployment.dex_slug)? else {
491        return Ok(ExchangeReport::Deactivated {
492            chain_id: deployment.chain_id,
493            chain_label: deployment.chain_label,
494            display_name: deployment.display_name,
495            dex_slug: deployment.dex_slug,
496            outcome: DeactivateOutcome::NoEntry,
497        });
498    };
499    if !row.active {
500        return Ok(ExchangeReport::Deactivated {
501            chain_id: deployment.chain_id,
502            chain_label: deployment.chain_label,
503            display_name: deployment.display_name,
504            dex_slug: deployment.dex_slug,
505            outcome: DeactivateOutcome::AlreadyDeactivated,
506        });
507    }
508    db.set_exchange_active(row.id, false)?;
509    tracing::info!(
510        chain_id = deployment.chain_id,
511        name = deployment.dex_slug,
512        "deactivated exchange"
513    );
514    Ok(ExchangeReport::Deactivated {
515        chain_id: deployment.chain_id,
516        chain_label: deployment.chain_label,
517        display_name: deployment.display_name,
518        dex_slug: deployment.dex_slug,
519        outcome: DeactivateOutcome::Deactivated,
520    })
521}