Skip to main content

solana_tools_lite_cli/shell/
error.rs

1use solana_tools_lite::errors::{
2    AsExitCode, Bip39Error, DeserializeError, ExitCode, GenError, KeypairError, SignError,
3    TransactionParseError, ToolError, VerifyError,
4};
5use std::io;
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum CliError {
10    #[error(transparent)]
11    Core(#[from] ToolError),
12    #[error("--summary-json requires --output (file) to keep signed tx off stdout")]
13    SummaryRequiresOutput,
14    #[error("Fee {fee_lamports} exceeds max-fee limit {max_lamports} lamports")]
15    FeeLimitExceeded {
16        fee_lamports: u128,
17        max_lamports: u64,
18    },
19    #[error("User rejected signing")]
20    UserRejected,
21    #[error("failed to encode summary json: {0}")]
22    SummaryEncode(String),
23    #[error("failed to encode json output: {0}")]
24    PresentationEncode(String),
25    #[error("failed to read stdin: {0}")]
26    StdinRead(String),
27}
28
29impl AsExitCode for CliError {
30    fn as_exit_code(&self) -> i32 {
31        match self {
32            CliError::Core(err) => err.as_exit_code(),
33            CliError::SummaryRequiresOutput | CliError::UserRejected => ExitCode::Usage.as_i32(),
34            CliError::FeeLimitExceeded { .. } => ExitCode::DataErr.as_i32(),
35            CliError::SummaryEncode(_) | CliError::PresentationEncode(_) => {
36                ExitCode::Software.as_i32()
37            }
38            CliError::StdinRead(_) => ExitCode::IoErr.as_i32(),
39        }
40    }
41}
42
43/// Helper for early exit when required options are missing or invalid.
44pub fn fail_invalid_input(context: &str, message: &str) -> ! {
45    eprintln!("{}: {}", context, message);
46    std::process::exit(64);
47}
48
49/// Unified CLI error reporting with user-friendly formatting.
50pub fn report_cli_error(context: &str, err: CliError) -> ! {
51    eprintln!("{}: {}", context, format_cli_error(&err));
52    std::process::exit(err.as_exit_code());
53}
54
55fn format_cli_error(err: &CliError) -> String {
56    match err {
57        CliError::Core(core) => format_user_friendly(core),
58        CliError::SummaryRequiresOutput => {
59            "--summary-json requires --output (file) to keep signed tx off stdout".to_string()
60        }
61        CliError::FeeLimitExceeded {
62            fee_lamports,
63            max_lamports,
64        } => format!(
65            "Fee {} exceeds max-fee limit {} lamports",
66            fee_lamports, max_lamports
67        ),
68        CliError::UserRejected => "User rejected signing".to_string(),
69        CliError::SummaryEncode(msg) => format!("failed to encode summary json: {msg}"),
70        CliError::PresentationEncode(msg) => format!("failed to encode json output: {msg}"),
71        CliError::StdinRead(msg) => format!("failed to read stdin: {msg}"),
72    }
73}
74
75fn format_user_friendly(err: &ToolError) -> String {
76    match err {
77        ToolError::Bip39(e) => format_bip39(e),
78        ToolError::Base58(e) => format!("Invalid Base58 encoding: {}", e),
79        ToolError::Sign(e) => format_sign(e),
80        ToolError::Keypair(e) => format_keypair(e),
81        ToolError::Gen(e) => format_gen(e),
82        ToolError::TransactionParse(e) => format_tx_parse(e),
83        ToolError::Deserialize(e) => format_deserialize(e),
84        ToolError::Verify(e) => format_verify(e),
85        ToolError::Io(io_err) => format_io(io_err),
86        ToolError::FileExists { path } => {
87            format!(
88                "Cannot create file '{}': already exists\nHint: Use --force to overwrite",
89                path
90            )
91        }
92        ToolError::InvalidInput(msg) => msg.clone(),
93        ToolError::ConfigurationError(msg) => format!("Configuration error: {}", msg),
94    }
95}
96
97fn format_bip39(e: &Bip39Error) -> String {
98    match e {
99        Bip39Error::InvalidWordCount(got) => {
100            format!(
101                "Invalid mnemonic length: got {} words, expected 12 or 24",
102                got
103            )
104        }
105        Bip39Error::Mnemonic(msg) => {
106            format!(
107                "Mnemonic validation failed: {}\nHint: Check that all words are from the BIP-39 wordlist",
108                msg
109            )
110        }
111    }
112}
113
114fn format_sign(e: &SignError) -> String {
115    match e {
116        SignError::InvalidBase58 => "Invalid Base58 encoding in secret key".to_string(),
117        SignError::InvalidPubkeyFormat => {
118            "Invalid public key format\nHint: Public keys must be valid Base58-encoded Ed25519 keys"
119                .to_string()
120        }
121        SignError::InvalidKeyLength => "Secret key must be exactly 32 bytes".to_string(),
122        SignError::SigningFailed(msg) => {
123            format!(
124                "Failed to sign transaction: {}\nHint: Verify your secret key is valid",
125                msg
126            )
127        }
128        SignError::SignerKeyNotFound => {
129            "Signer public key not found in transaction account keys\nHint: The keypair you're using doesn't match any signer in this transaction".to_string()
130        }
131        SignError::SigningNotRequiredForKey => {
132            "The provided signer is not a required signer for this transaction\nHint: Check that you're using the correct keypair".to_string()
133        }
134        SignError::JsonParse(e) => {
135            format!(
136                "Failed to parse input JSON: {}\nHint: Check that your JSON is valid",
137                e
138            )
139        }
140    }
141}
142
143fn format_keypair(e: &KeypairError) -> String {
144    match e {
145        KeypairError::SeedTooShort(got) => {
146            format!(
147                "Seed is too short: got {} bytes, expected at least 32 bytes",
148                got
149            )
150        }
151        KeypairError::SeedSlice(msg) => {
152            format!("Invalid seed data: {}", msg)
153        }
154    }
155}
156
157fn format_gen(e: &GenError) -> String {
158    match e {
159        GenError::InvalidSeedLength => {
160            "Invalid seed length: expected 64 bytes\nHint: Use a valid BIP-39 mnemonic or provide a 64-byte seed".to_string()
161        }
162        GenError::CryptoError(msg) => {
163            format!(
164                "Cryptographic error: {}\nHint: Check your mnemonic and derivation path",
165                msg
166            )
167        }
168        GenError::InvalidDerivationPath(msg) => {
169            format!(
170                "Invalid derivation path: {}\nHint: Use BIP-44 format like m/44'/501'/0'/0'",
171                msg
172            )
173        }
174    }
175}
176
177fn format_tx_parse(e: &TransactionParseError) -> String {
178    match e {
179        TransactionParseError::InvalidBase64(msg) => {
180            format!(
181                "Invalid Base64 encoding in transaction: {}\nHint: Check that the transaction is properly Base64-encoded",
182                msg
183            )
184        }
185        TransactionParseError::InvalidBase58(msg) => {
186            format!(
187                "Invalid Base58 encoding in transaction: {}\nHint: Check that the transaction is properly Base58-encoded",
188                msg
189            )
190        }
191        TransactionParseError::InvalidInstructionData(msg) => {
192            format!("Invalid instruction data: {}", msg)
193        }
194        TransactionParseError::InvalidPubkeyFormat(msg) => {
195            format!("Invalid public key in transaction: {}", msg)
196        }
197        TransactionParseError::InvalidSignatureLength(len) => {
198            format!("Invalid signature length: expected 64 bytes, got {}", len)
199        }
200        TransactionParseError::InvalidPubkeyLength(len) => {
201            format!("Invalid public key length: expected 32 bytes, got {}", len)
202        }
203        TransactionParseError::InvalidSignatureFormat(msg) => {
204            format!("Invalid signature format: {}", msg)
205        }
206        TransactionParseError::InvalidBlockhashLength(len) => {
207            format!("Invalid blockhash length: expected 32 bytes, got {}", len)
208        }
209        TransactionParseError::InvalidBlockhashFormat(msg) => {
210            format!("Invalid blockhash format: {}", msg)
211        }
212        TransactionParseError::InvalidFormat(msg) => {
213            format!(
214                "Invalid transaction format: {}\nHint: Ensure the transaction is in JSON, Base64, or Base58 format",
215                msg
216            )
217        }
218        TransactionParseError::Serialization(msg) => {
219            format!("Failed to serialize transaction: {}", msg)
220        }
221    }
222}
223
224fn format_deserialize(e: &DeserializeError) -> String {
225    match e {
226        DeserializeError::Deserialization(msg) => {
227            format!(
228                "Failed to deserialize transaction: {}\nHint: Check that the input is a valid Solana transaction",
229                msg
230            )
231        }
232    }
233}
234
235fn format_verify(e: &VerifyError) -> String {
236    match e {
237        VerifyError::Base58Decode(e) => format!("Invalid Base58 encoding: {}", e),
238        VerifyError::InvalidSignatureLength(len) => {
239            format!("Invalid signature length: expected 64 bytes, got {}", len)
240        }
241        VerifyError::InvalidPubkeyLength(len) => {
242            format!("Invalid public key length: expected 32 bytes, got {}", len)
243        }
244        VerifyError::InvalidSignatureFormat => {
245            "Invalid signature format\nHint: Signatures must be 64-byte Ed25519 signatures".to_string()
246        }
247        VerifyError::InvalidPubkeyFormat => {
248            "Invalid public key format\nHint: Public keys must be 32-byte Ed25519 public keys"
249                .to_string()
250        }
251        VerifyError::VerificationFailed => {
252            "Signature verification failed\nHint: The signature does not match the message and public key"
253                .to_string()
254        }
255    }
256}
257
258
259fn format_io(err: &solana_tools_lite::errors::IoError) -> String {
260    // Core error already formats nicely as "io(path: error)" or "io(error)"
261    // We just want to append a hint if possible.
262    
263    let hint = match err.kind() {
264        io::ErrorKind::NotFound => "\nHint: Check that the file path is correct",
265        io::ErrorKind::PermissionDenied => {
266            "\nHint: Ensure you have read/write permissions for this file"
267        }
268        io::ErrorKind::AlreadyExists => "\nHint: Use --force to overwrite existing files",
269        _ => "",
270    };
271
272    // Use the core Display repr which includes path and source error
273    format!("{}{}", err, hint)
274}