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