1use std::fmt;
9
10use degenbot_aave::RunError as AaveRunError;
11use degenbot_config::ConfigError;
12use degenbot_db::DbError;
13use degenbot_pool_updater::RunError as PoolRunError;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ExitCode {
18 Success,
21 Failure,
24 Config,
27}
28
29impl ExitCode {
30 #[must_use]
32 pub const fn code(self) -> i32 {
33 match self {
34 Self::Success => 0,
35 Self::Failure => 1,
36 Self::Config => 78,
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ExchangeResumeState {
45 pub name: String,
47 pub last_update_block: Option<i64>,
49}
50
51#[derive(Debug)]
63pub struct PoolUpdateFailure {
64 pub error: PoolRunError,
66 pub rpc_url: String,
68 pub chain_id: i64,
70 pub from_block: u64,
73 pub to_block: Option<u64>,
75 pub resume: Option<Vec<ExchangeResumeState>>,
79}
80
81impl PoolUpdateFailure {
82 #[must_use]
84 pub fn message(&self) -> String {
85 let error_text = self.error.to_string();
86 let range = self.range_text();
87 let mut lines = if self.is_rpc_connection_failure(&error_text) {
88 vec![
89 format!(
90 "Chain {}: the RPC connection to {} dropped mid-run while advancing blocks \
91 {}; chunks already committed are kept. Rerunning resumes from the recorded \
92 per-exchange cursors.",
93 self.chain_id, self.rpc_url, range
94 ),
95 format!(" underlying error: {error_text}"),
96 ]
97 } else {
98 vec![format!(
99 "Chain {}: pool update against {} failed while advancing blocks {}: {error_text}",
100 self.chain_id, self.rpc_url, range
101 )]
102 };
103 lines.extend(self.resume_lines());
104 lines.join("\n")
105 }
106
107 fn range_text(&self) -> String {
109 match self.to_block {
110 Some(to) => format!("{}-{to}", self.from_block),
111 None => format!("{} onward (the chain tip)", self.from_block),
112 }
113 }
114
115 fn is_rpc_connection_failure(&self, error_text: &str) -> bool {
126 matches!(&self.error, PoolRunError::Provider(_))
127 && (error_text.starts_with("rpc error: Connection failed: ")
128 || error_text.starts_with("rpc error: Request timeout: "))
129 }
130
131 fn resume_lines(&self) -> Vec<String> {
135 let Some(rows) = &self.resume else {
136 return vec![
137 " exchange cursor state unavailable: the read-only resume check failed"
138 .to_string(),
139 ];
140 };
141 if rows.is_empty() {
142 return vec![format!(
143 " no active exchanges were registered for chain {}.",
144 self.chain_id
145 )];
146 }
147 let mut current = Vec::new();
148 let mut behind = Vec::new();
149 let mut never_updated = Vec::new();
150 for row in rows {
151 match (row.last_update_block, self.to_block) {
152 (None, _) => never_updated.push(row.name.clone()),
153 (Some(block), None) => {
156 behind.push(format!("{} (block {block})", row.name));
157 }
158 (Some(block), Some(to)) => {
159 let to = i64::try_from(to).unwrap_or(i64::MAX);
160 if block >= to {
161 current.push(row.name.clone());
162 } else {
163 behind.push(format!("{} (block {block})", row.name));
164 }
165 }
166 }
167 }
168 let mut lines = Vec::new();
169 if !current.is_empty() {
170 lines.push(format!(
171 " current at the requested target: {}",
172 current.join(", ")
173 ));
174 }
175 if !behind.is_empty() {
176 lines.push(format!(
177 " behind (last committed block): {}",
178 behind.join(", ")
179 ));
180 }
181 if !never_updated.is_empty() {
182 lines.push(format!(" never updated: {}", never_updated.join(", ")));
183 }
184 lines
185 }
186}
187
188#[derive(Debug)]
194pub enum CliError {
195 BootRefused(String),
197 Aborted,
199 DatabaseUpgradeRetired,
202 DatabaseForeign,
204 DatabaseNothingToDo,
207 Database(DbError),
209 Io(std::io::Error),
212 Config(ConfigError),
214 UnknownChain {
217 chain: String,
219 },
220 UnknownDeployment {
223 chain_id: u64,
225 name: String,
227 },
228 InvalidBlockTag(String),
231 BlockResolution(String),
233 InvalidAddress(String),
236 InvalidArgument(String),
239 NoActiveAaveMarkets,
242 PoolUpdate(Box<PoolUpdateFailure>),
250 AaveUpdate(AaveRunError),
252 RuntimeNested,
257 OperatorRefused(String),
260 OperatorProtocol(String),
264 OperatorHygiene(String),
268}
269
270impl CliError {
271 #[must_use]
273 pub fn message(&self) -> String {
274 match self {
275 Self::BootRefused(message)
276 | Self::BlockResolution(message)
277 | Self::InvalidArgument(message)
278 | Self::OperatorRefused(message)
279 | Self::OperatorProtocol(message)
280 | Self::OperatorHygiene(message) => message.clone(),
281 Self::Aborted => "Aborted!".to_string(),
282 Self::DatabaseUpgradeRetired => {
283 "the database upgrades itself at open; for an explicit repair, run \
284 `degenbot database heal`"
285 .to_string()
286 }
287 Self::DatabaseForeign => {
288 "The database is unrecognized (a foreign SQLite file); refused.".to_string()
289 }
290 Self::DatabaseNothingToDo => {
291 "The database has no legacy history; there is nothing to cut over.".to_string()
292 }
293 Self::Database(err) => err.to_string(),
294 Self::Io(err) => err.to_string(),
295 Self::Config(err) => err.to_string(),
296 Self::UnknownChain { chain } => format!(
297 "Unknown chain {chain:?}: expected a chain slug (base, ethereum) or a numeric \
298 chain id."
299 ),
300 Self::UnknownDeployment { chain_id, name } => {
301 format!("The deployments registry has no record for {name:?} on chain {chain_id}.")
302 }
303 Self::InvalidBlockTag(tag) => format!("Invalid block tag: {tag}"),
304 Self::InvalidAddress(address) => format!("Invalid address: {address}"),
305 Self::NoActiveAaveMarkets => "No active Aave markets found.".to_string(),
306 Self::PoolUpdate(failure) => failure.message(),
307 Self::AaveUpdate(err) => err.to_string(),
308 Self::RuntimeNested => "the command arms own their tokio runtime; do not run them \
309 from inside an existing runtime"
310 .to_string(),
311 }
312 }
313}
314
315impl fmt::Display for CliError {
316 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317 f.write_str(&self.message())
318 }
319}
320
321impl std::error::Error for CliError {
322 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
323 match self {
324 Self::Database(err) => Some(err),
325 Self::Config(err) => Some(err),
326 Self::PoolUpdate(failure) => Some(&failure.error),
327 Self::AaveUpdate(err) => Some(err),
328 Self::Io(err) => Some(err),
329 _ => None,
330 }
331 }
332}
333
334impl From<DbError> for CliError {
339 fn from(err: DbError) -> Self {
340 match err {
341 DbError::UnrecognizedSchema => Self::DatabaseForeign,
342 other => Self::Database(other),
343 }
344 }
345}
346
347impl From<ConfigError> for CliError {
349 fn from(err: ConfigError) -> Self {
350 Self::Config(err)
351 }
352}
353
354impl From<&CliError> for ExitCode {
356 fn from(err: &CliError) -> Self {
357 match err {
358 CliError::BootRefused(_) => Self::Config,
360 CliError::Aborted
361 | CliError::DatabaseUpgradeRetired
362 | CliError::DatabaseForeign
363 | CliError::DatabaseNothingToDo
364 | CliError::Database(_)
365 | CliError::Io(_)
366 | CliError::Config(_)
367 | CliError::UnknownChain { .. }
368 | CliError::UnknownDeployment { .. }
369 | CliError::InvalidBlockTag(_)
370 | CliError::BlockResolution(_)
371 | CliError::InvalidAddress(_)
372 | CliError::InvalidArgument(_)
373 | CliError::NoActiveAaveMarkets
374 | CliError::PoolUpdate(_)
375 | CliError::AaveUpdate(_)
376 | CliError::RuntimeNested
377 | CliError::OperatorRefused(_)
378 | CliError::OperatorProtocol(_)
379 | CliError::OperatorHygiene(_) => Self::Failure,
380 }
381 }
382}
383
384impl From<CliError> for ExitCode {
385 fn from(err: CliError) -> Self {
386 Self::from(&err)
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
394 use degenbot_db::DbError;
395
396 #[test]
397 fn non_connection_failure_names_endpoint_chain_range_and_error() {
398 let failure = PoolUpdateFailure {
399 error: PoolRunError::Db(DbError::MissingRow("chunk row".to_string())),
400 rpc_url: "http://reth.local:8545".to_string(),
401 chain_id: 8453,
402 from_block: 26_055_206,
403 to_block: Some(26_059_263),
404 resume: None,
405 };
406 let message = failure.message();
407 assert!(message.contains("http://reth.local:8545"), "{message}");
408 assert!(message.contains("8453"), "{message}");
409 assert!(message.contains("blocks 26055206-26059263"), "{message}");
410 assert!(
411 message.contains("required row not found: chunk row"),
412 "{message}"
413 );
414 assert!(!message.contains("dropped mid-run"), "{message}");
415 }
416
417 #[test]
418 fn tip_run_reports_an_open_ended_range() {
419 let failure = PoolUpdateFailure {
420 error: PoolRunError::Db(DbError::MissingRow("tip".to_string())),
421 rpc_url: "http://reth.local:8545".to_string(),
422 chain_id: 1,
423 from_block: 5,
424 to_block: None,
425 resume: None,
426 };
427 assert!(failure
428 .message()
429 .contains("blocks 5 onward (the chain tip)"));
430 }
431
432 #[test]
433 fn resume_groups_render_current_behind_and_never_updated() {
434 let failure = PoolUpdateFailure {
435 error: PoolRunError::Db(DbError::MissingRow("row".to_string())),
436 rpc_url: "http://reth.local:8545".to_string(),
437 chain_id: 8453,
438 from_block: 1,
439 to_block: Some(100),
440 resume: Some(vec![
441 ExchangeResumeState {
442 name: "uniswap_v2".to_string(),
443 last_update_block: Some(100),
444 },
445 ExchangeResumeState {
446 name: "uniswap_v3".to_string(),
447 last_update_block: Some(50),
448 },
449 ExchangeResumeState {
450 name: "uniswap_v4".to_string(),
451 last_update_block: None,
452 },
453 ]),
454 };
455 let message = failure.message();
456 assert!(
457 message.contains("current at the requested target: uniswap_v2"),
458 "{message}"
459 );
460 assert!(
461 message.contains("behind (last committed block): uniswap_v3 (block 50)"),
462 "{message}"
463 );
464 assert!(message.contains("never updated: uniswap_v4"), "{message}");
465 }
466
467 #[test]
468 fn a_tip_run_leaves_no_exchange_current_at_the_target() {
469 let failure = PoolUpdateFailure {
470 error: PoolRunError::Db(DbError::MissingRow("row".to_string())),
471 rpc_url: "http://reth.local:8545".to_string(),
472 chain_id: 8453,
473 from_block: 1,
474 to_block: None,
475 resume: Some(vec![ExchangeResumeState {
476 name: "uniswap_v2".to_string(),
477 last_update_block: Some(26_059_263),
478 }]),
479 };
480 let message = failure.message();
481 assert!(
482 !message.contains("current at the requested target"),
483 "{message}"
484 );
485 assert!(
486 message.contains("behind (last committed block): uniswap_v2 (block 26059263)"),
487 "{message}"
488 );
489 }
490
491 #[test]
492 fn an_unavailable_snapshot_is_said_so() {
493 let failure = PoolUpdateFailure {
494 error: PoolRunError::Db(DbError::MissingRow("row".to_string())),
495 rpc_url: "http://reth.local:8545".to_string(),
496 chain_id: 8453,
497 from_block: 1,
498 to_block: Some(100),
499 resume: None,
500 };
501 assert!(failure
502 .message()
503 .contains("exchange cursor state unavailable"),);
504 }
505}