use std::fmt;
use degenbot_aave::RunError as AaveRunError;
use degenbot_config::ConfigError;
use degenbot_db::DbError;
use degenbot_pool_updater::RunError as PoolRunError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitCode {
Success,
Failure,
Config,
}
impl ExitCode {
#[must_use]
pub const fn code(self) -> i32 {
match self {
Self::Success => 0,
Self::Failure => 1,
Self::Config => 78,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownVariant {
pub raw: String,
pub known: Vec<&'static str>,
}
impl fmt::Display for UnknownVariant {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"unknown variant {:?}: expected one of {}",
self.raw,
self.known.join(", ")
)
}
}
impl std::error::Error for UnknownVariant {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExchangeResumeState {
pub name: String,
pub last_update_block: Option<i64>,
}
#[derive(Debug)]
pub struct PoolUpdateFailure {
pub error: PoolRunError,
pub rpc_url: String,
pub chain_id: i64,
pub from_block: u64,
pub to_block: Option<u64>,
pub resume: Option<Vec<ExchangeResumeState>>,
}
impl PoolUpdateFailure {
#[must_use]
pub fn message(&self) -> String {
let error_text = self.error.to_string();
let range = self.range_text();
let mut lines = if self.is_rpc_connection_failure(&error_text) {
vec![
format!(
"Chain {}: the RPC connection to {} dropped mid-run while advancing blocks \
{}; chunks already committed are kept. Rerunning resumes from the recorded \
per-exchange cursors.",
self.chain_id, self.rpc_url, range
),
format!(" underlying error: {error_text}"),
]
} else {
vec![format!(
"Chain {}: pool update against {} failed while advancing blocks {}: {error_text}",
self.chain_id, self.rpc_url, range
)]
};
lines.extend(self.resume_lines());
lines.join("\n")
}
fn range_text(&self) -> String {
match self.to_block {
Some(to) => format!("{}-{to}", self.from_block),
None => format!("{} onward (the chain tip)", self.from_block),
}
}
fn is_rpc_connection_failure(&self, error_text: &str) -> bool {
matches!(&self.error, PoolRunError::Provider(_))
&& (error_text.starts_with("rpc error: Connection failed: ")
|| error_text.starts_with("rpc error: Request timeout: "))
}
fn resume_lines(&self) -> Vec<String> {
let Some(rows) = &self.resume else {
return vec![
" exchange cursor state unavailable: the read-only resume check failed"
.to_string(),
];
};
if rows.is_empty() {
return vec![format!(
" no active exchanges were registered for chain {}.",
self.chain_id
)];
}
let mut current = Vec::new();
let mut behind = Vec::new();
let mut never_updated = Vec::new();
for row in rows {
match (row.last_update_block, self.to_block) {
(None, _) => never_updated.push(row.name.clone()),
(Some(block), None) => {
behind.push(format!("{} (block {block})", row.name));
}
(Some(block), Some(to)) => {
let to = i64::try_from(to).unwrap_or(i64::MAX);
if block >= to {
current.push(row.name.clone());
} else {
behind.push(format!("{} (block {block})", row.name));
}
}
}
}
let mut lines = Vec::new();
if !current.is_empty() {
lines.push(format!(
" current at the requested target: {}",
current.join(", ")
));
}
if !behind.is_empty() {
lines.push(format!(
" behind (last committed block): {}",
behind.join(", ")
));
}
if !never_updated.is_empty() {
lines.push(format!(" never updated: {}", never_updated.join(", ")));
}
lines
}
}
#[derive(Debug)]
pub enum CliError {
BootRefused(String),
Aborted,
DatabaseUpgradeRetired,
DatabaseForeign,
DatabaseNothingToDo,
Database(DbError),
Io(std::io::Error),
Config(ConfigError),
UnknownChain {
chain: String,
},
UnknownDeployment {
chain_id: u64,
name: String,
},
InvalidBlockTag(String),
BlockResolution(String),
InvalidAddress(String),
InvalidArgument(String),
NoActiveAaveMarkets,
PoolUpdate(Box<PoolUpdateFailure>),
AaveUpdate(AaveRunError),
RuntimeNested,
OperatorRefused(String),
OperatorProtocol(String),
OperatorHygiene(String),
}
impl CliError {
#[must_use]
pub fn message(&self) -> String {
match self {
Self::BootRefused(message)
| Self::BlockResolution(message)
| Self::InvalidArgument(message)
| Self::OperatorRefused(message)
| Self::OperatorProtocol(message)
| Self::OperatorHygiene(message) => message.clone(),
Self::Aborted => "Aborted!".to_string(),
Self::DatabaseUpgradeRetired => {
"the database upgrades itself at open; for an explicit repair, run \
`degenbot database heal`"
.to_string()
}
Self::DatabaseForeign => {
"The database is unrecognized (a foreign SQLite file); refused.".to_string()
}
Self::DatabaseNothingToDo => {
"The database has no legacy history; there is nothing to cut over.".to_string()
}
Self::Database(err) => err.to_string(),
Self::Io(err) => err.to_string(),
Self::Config(err) => err.to_string(),
Self::UnknownChain { chain } => format!(
"Unknown chain {chain:?}: expected a chain slug (base, ethereum) or a numeric \
chain id."
),
Self::UnknownDeployment { chain_id, name } => {
format!("The deployments registry has no record for {name:?} on chain {chain_id}.")
}
Self::InvalidBlockTag(tag) => format!("Invalid block tag: {tag}"),
Self::InvalidAddress(address) => format!("Invalid address: {address}"),
Self::NoActiveAaveMarkets => "No active Aave markets found.".to_string(),
Self::PoolUpdate(failure) => failure.message(),
Self::AaveUpdate(err) => err.to_string(),
Self::RuntimeNested => "the command arms own their tokio runtime; do not run them \
from inside an existing runtime"
.to_string(),
}
}
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message())
}
}
impl std::error::Error for CliError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Database(err) => Some(err),
Self::Config(err) => Some(err),
Self::PoolUpdate(failure) => Some(&failure.error),
Self::AaveUpdate(err) => Some(err),
Self::Io(err) => Some(err),
_ => None,
}
}
}
impl From<DbError> for CliError {
fn from(err: DbError) -> Self {
match err {
DbError::UnrecognizedSchema => Self::DatabaseForeign,
other => Self::Database(other),
}
}
}
impl From<ConfigError> for CliError {
fn from(err: ConfigError) -> Self {
Self::Config(err)
}
}
impl From<&CliError> for ExitCode {
fn from(err: &CliError) -> Self {
match err {
CliError::BootRefused(_) => Self::Config,
CliError::Aborted
| CliError::DatabaseUpgradeRetired
| CliError::DatabaseForeign
| CliError::DatabaseNothingToDo
| CliError::Database(_)
| CliError::Io(_)
| CliError::Config(_)
| CliError::UnknownChain { .. }
| CliError::UnknownDeployment { .. }
| CliError::InvalidBlockTag(_)
| CliError::BlockResolution(_)
| CliError::InvalidAddress(_)
| CliError::InvalidArgument(_)
| CliError::NoActiveAaveMarkets
| CliError::PoolUpdate(_)
| CliError::AaveUpdate(_)
| CliError::RuntimeNested
| CliError::OperatorRefused(_)
| CliError::OperatorProtocol(_)
| CliError::OperatorHygiene(_) => Self::Failure,
}
}
}
impl From<CliError> for ExitCode {
fn from(err: CliError) -> Self {
Self::from(&err)
}
}
#[cfg(test)]
mod tests {
use super::*;
use degenbot_db::DbError;
#[test]
fn non_connection_failure_names_endpoint_chain_range_and_error() {
let failure = PoolUpdateFailure {
error: PoolRunError::Db(DbError::MissingRow("chunk row".to_string())),
rpc_url: "http://reth.local:8545".to_string(),
chain_id: 8453,
from_block: 26_055_206,
to_block: Some(26_059_263),
resume: None,
};
let message = failure.message();
assert!(message.contains("http://reth.local:8545"), "{message}");
assert!(message.contains("8453"), "{message}");
assert!(message.contains("blocks 26055206-26059263"), "{message}");
assert!(
message.contains("required row not found: chunk row"),
"{message}"
);
assert!(!message.contains("dropped mid-run"), "{message}");
}
#[test]
fn tip_run_reports_an_open_ended_range() {
let failure = PoolUpdateFailure {
error: PoolRunError::Db(DbError::MissingRow("tip".to_string())),
rpc_url: "http://reth.local:8545".to_string(),
chain_id: 1,
from_block: 5,
to_block: None,
resume: None,
};
assert!(failure
.message()
.contains("blocks 5 onward (the chain tip)"));
}
#[test]
fn resume_groups_render_current_behind_and_never_updated() {
let failure = PoolUpdateFailure {
error: PoolRunError::Db(DbError::MissingRow("row".to_string())),
rpc_url: "http://reth.local:8545".to_string(),
chain_id: 8453,
from_block: 1,
to_block: Some(100),
resume: Some(vec![
ExchangeResumeState {
name: "uniswap_v2".to_string(),
last_update_block: Some(100),
},
ExchangeResumeState {
name: "uniswap_v3".to_string(),
last_update_block: Some(50),
},
ExchangeResumeState {
name: "uniswap_v4".to_string(),
last_update_block: None,
},
]),
};
let message = failure.message();
assert!(
message.contains("current at the requested target: uniswap_v2"),
"{message}"
);
assert!(
message.contains("behind (last committed block): uniswap_v3 (block 50)"),
"{message}"
);
assert!(message.contains("never updated: uniswap_v4"), "{message}");
}
#[test]
fn a_tip_run_leaves_no_exchange_current_at_the_target() {
let failure = PoolUpdateFailure {
error: PoolRunError::Db(DbError::MissingRow("row".to_string())),
rpc_url: "http://reth.local:8545".to_string(),
chain_id: 8453,
from_block: 1,
to_block: None,
resume: Some(vec![ExchangeResumeState {
name: "uniswap_v2".to_string(),
last_update_block: Some(26_059_263),
}]),
};
let message = failure.message();
assert!(
!message.contains("current at the requested target"),
"{message}"
);
assert!(
message.contains("behind (last committed block): uniswap_v2 (block 26059263)"),
"{message}"
);
}
#[test]
fn an_unavailable_snapshot_is_said_so() {
let failure = PoolUpdateFailure {
error: PoolRunError::Db(DbError::MissingRow("row".to_string())),
rpc_url: "http://reth.local:8545".to_string(),
chain_id: 8453,
from_block: 1,
to_block: Some(100),
resume: None,
};
assert!(failure
.message()
.contains("exchange cursor state unavailable"),);
}
}