Skip to main content

degenbot_cli_core/
block.rs

1//! The `--to-block` block-identifier port .
2//!
3//! Mirrors `cli/pool.py::_resolve_to_block` and its `cli/aave.py` twin exactly:
4//!
5//! - a concrete integer is used as-is;
6//! - a pure tag (`latest`, `safe`, …) with no offset resolves to `None` — the
7//!   Rust core fetches the chain tip via `eth_blockNumber` itself;
8//! - a tag with an offset (`latest:-64`, `safe:128`) or a concrete integer
9//!   resolves to a concrete block number (the tag's block + the offset).
10//!
11//! Parsing is split from resolution so the semantics are unit-testable offline:
12//! [`parse_to_block`] is pure, [`resolve_to_block`] performs the one RPC read a
13//! tag+offset needs.
14
15use std::future::Future;
16
17use alloy::eips::BlockNumberOrTag;
18use alloy::primitives::Address;
19use degenbot_rpc::provider::AlloyProvider;
20
21use crate::error::CliError;
22
23/// The `--chunk` default (`10_000`), ported from `cli/pool.py`.
24pub const DEFAULT_CHUNK_SIZE: u64 = 10_000;
25
26/// The `--to-block` default (`latest:-64`), ported from `cli/pool.py`.
27pub const DEFAULT_TO_BLOCK: &str = "latest:-64";
28
29/// The `--verify-all-interval` default (`1_000_000`), ported from `cli/pool.py`.
30pub const DEFAULT_VERIFY_ALL_INTERVAL: u64 = 1_000_000;
31
32/// The RPC retry budget the console's spot reads use.
33const RPC_MAX_RETRIES: u32 = 5;
34
35/// A block tag accepted by `--to-block`.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum BlockTag {
38    /// `latest`.
39    Latest,
40    /// `earliest`.
41    Earliest,
42    /// `pending`.
43    Pending,
44    /// `safe`.
45    Safe,
46    /// `finalized`.
47    Finalized,
48}
49
50impl BlockTag {
51    /// Parse one of the five accepted tags (`_BLOCK_TAGS` in the Python CLI).
52    #[must_use]
53    pub fn parse(raw: &str) -> Option<Self> {
54        match raw {
55            "latest" => Some(Self::Latest),
56            "earliest" => Some(Self::Earliest),
57            "pending" => Some(Self::Pending),
58            "safe" => Some(Self::Safe),
59            "finalized" => Some(Self::Finalized),
60            _ => None,
61        }
62    }
63
64    /// The tag's wire spelling.
65    #[must_use]
66    pub const fn as_str(self) -> &'static str {
67        match self {
68            Self::Latest => "latest",
69            Self::Earliest => "earliest",
70            Self::Pending => "pending",
71            Self::Safe => "safe",
72            Self::Finalized => "finalized",
73        }
74    }
75
76    /// The alloy tag.
77    #[must_use]
78    pub const fn to_alloy(self) -> BlockNumberOrTag {
79        match self {
80            Self::Latest => BlockNumberOrTag::Latest,
81            Self::Earliest => BlockNumberOrTag::Earliest,
82            Self::Pending => BlockNumberOrTag::Pending,
83            Self::Safe => BlockNumberOrTag::Safe,
84            Self::Finalized => BlockNumberOrTag::Finalized,
85        }
86    }
87}
88
89/// A parsed `--to-block` value, before the tag's block number is read.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum ToBlockSpec {
92    /// A concrete block number.
93    Number(u64),
94    /// A pure tag (or the empty effect of `offset == 0`): the core resolves the
95    /// chain tip.
96    Tip,
97    /// A tag plus a signed offset: the tag's block number plus `offset`.
98    TagOffset {
99        /// The block tag.
100        tag: BlockTag,
101        /// The signed offset.
102        offset: i64,
103    },
104}
105
106/// Parse a `--to-block` string into a [`ToBlockSpec`].
107///
108/// # Errors
109///
110/// [`CliError::InvalidBlockTag`] for a malformed tag or offset — the Python
111/// `ValueError("Invalid block tag: …")` refusal.
112pub fn parse_to_block(raw: &str) -> Result<ToBlockSpec, CliError> {
113    if !raw.is_empty() && raw.bytes().all(|b| b.is_ascii_digit()) {
114        return raw
115            .parse::<u64>()
116            .map(ToBlockSpec::Number)
117            .map_err(|_| CliError::InvalidBlockTag(raw.to_string()));
118    }
119    let (tag_raw, offset) = match raw.split_once(':') {
120        Some((tag, offset)) => (
121            tag,
122            offset
123                .trim()
124                .parse::<i64>()
125                .map_err(|_| CliError::InvalidBlockTag(tag.to_string()))?,
126        ),
127        None => (raw, 0),
128    };
129    let tag =
130        BlockTag::parse(tag_raw).ok_or_else(|| CliError::InvalidBlockTag(tag_raw.to_string()))?;
131    if offset == 0 {
132        Ok(ToBlockSpec::Tip)
133    } else {
134        Ok(ToBlockSpec::TagOffset { tag, offset })
135    }
136}
137
138/// Resolve a parsed spec to an optional concrete block number.
139///
140/// `None` means "advance to the chain tip" (the core fetches it). A tag+offset
141/// reads the tag's block number over RPC and adds the offset.
142///
143/// # Errors
144///
145/// [`CliError::BlockResolution`] when the RPC read fails, or when the resolved
146/// block number is negative / out of range.
147pub fn resolve_to_block(spec: ToBlockSpec, rpc_url: &str) -> Result<Option<u64>, CliError> {
148    match spec {
149        ToBlockSpec::Number(block) => Ok(Some(block)),
150        ToBlockSpec::Tip => Ok(None),
151        ToBlockSpec::TagOffset { tag, offset } => {
152            let base = fetch_tag_block_number(tag, rpc_url)?;
153            let signed = i128::from(base) + i128::from(offset);
154            let resolved = u64::try_from(signed).map_err(|_| {
155                CliError::BlockResolution(format!(
156                    "block tag {}:{offset} resolved out of range (tag block {base})",
157                    tag.as_str()
158                ))
159            })?;
160            Ok(Some(resolved))
161        }
162    }
163}
164
165/// Read the block number for `tag` over RPC.
166fn fetch_tag_block_number(tag: BlockTag, rpc_url: &str) -> Result<u64, CliError> {
167    let provider = match block_on(AlloyProvider::new(rpc_url, RPC_MAX_RETRIES)) {
168        Ok(Ok(provider)) => provider,
169        Ok(Err(err)) => return Err(CliError::BlockResolution(err.to_string())),
170        Err(err) => return Err(err),
171    };
172    let arc = provider.provider_arc();
173    let result = block_on(async move { arc.get_block_by_number(tag.to_alloy()).await })?;
174    let block = result
175        .map_err(|err| CliError::BlockResolution(err.to_string()))?
176        .ok_or_else(|| {
177            CliError::BlockResolution(format!(
178                "eth_getBlockByNumber({}) returned no block",
179                tag.as_str()
180            ))
181        })?;
182    Ok(block.header.number)
183}
184
185/// Drive `fut` on a self-built runtime — the updater cores own their runtime
186/// and must not nest, so the arms hold the same constraint.
187///
188/// # Errors
189///
190/// [`CliError::RuntimeNested`] when called from inside an existing runtime, or
191/// [`CliError::BlockResolution`] when the runtime cannot be built.
192pub(crate) fn block_on<F: Future>(fut: F) -> Result<F::Output, CliError> {
193    if tokio::runtime::Handle::try_current().is_ok() {
194        return Err(CliError::RuntimeNested);
195    }
196    let runtime = tokio::runtime::Builder::new_multi_thread()
197        .enable_all()
198        .build()
199        .map_err(|err| CliError::BlockResolution(err.to_string()))?;
200    Ok(runtime.block_on(fut))
201}
202
203/// Parse a chain selector (`base`, `ethereum`, `arbitrum`, or a numeric id).
204///
205/// # Errors
206///
207/// [`CliError::UnknownChain`] when the selector names no known chain slug and
208/// is not a number.
209pub fn resolve_chain_selector(selector: &str) -> Result<u64, CliError> {
210    let lower = selector.trim().to_ascii_lowercase();
211    if !lower.is_empty() {
212        if let Ok(chain_id) = lower.parse::<u64>() {
213            return Ok(chain_id);
214        }
215    }
216    match lower.as_str() {
217        "base" => Ok(8453),
218        "ethereum" | "eth" | "mainnet" => Ok(1),
219        "arbitrum" | "arb" | "arb1" => Ok(42_161),
220        _ => Err(CliError::UnknownChain {
221            chain: selector.to_string(),
222        }),
223    }
224}
225
226/// EIP-55-checksum an address for a DB key / RPC target.
227#[must_use]
228pub fn checksum(address: Address) -> String {
229    address.to_checksum(None)
230}