Skip to content

Commit 8df812a

Browse files
committed
feat(wallet): strict address validation in forest-wallet CLI
Mirrors the `forest-cli` treatment introduced in #6011 for the `forest-wallet` binary. Address arguments to `balance`, `export`, `has`, `delete`, `set-default`, `sign`, `verify` and the `--from` option of `send` are now parsed into `StrictAddress` at CLI-parse time. clap surfaces a `ValueValidation` error on a malformed input instead of deferring to a runtime `StrictAddress::from_str(...)?` fallback, and each handler simply destructures the typed value via `field: StrictAddress(inner)`. Network detection is moved ahead of `Cli::parse_from` so that clap-time `StrictAddress` validation accepts testnet (`t0...`) addresses when the daemon reports a testnet chain. Client construction errors propagate (matching `forest-cli`); an unreachable daemon leaves the global `CurrentNetwork` at its mainnet default. `wallet_default_address()` now returns `Option<Address>` rather than `Option<String>`, removing a stringify-then-reparse round trip through `StrictAddress::from_str` in both the `list` and `send` handlers. `Send.target_address` is intentionally kept as a `String` because it accepts ETH (`0x...`) recipients, which `StrictAddress` rejects; `resolve_target_address` continues to handle the dispatch. `ValidateAddress.address` also stays a `String`, since its purpose is to validate arbitrary user input. Two clap-level regression tests guard against an accidental revert of the `StrictAddress` typing. Refs #6012
1 parent 97cea94 commit 8df812a

4 files changed

Lines changed: 69 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929

3030
### Added
3131

32+
- [#6012](https://github.com/ChainSafe/forest/issues/6012): Stricter validation of address arguments in `forest-wallet` subcommands.
33+
3234
### Changed
3335

3436
### Removed

src/wallet/main.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ pub async fn main<ArgT>(args: impl IntoIterator<Item = ArgT>) -> anyhow::Result<
1414
where
1515
ArgT: Into<OsString> + Clone,
1616
{
17+
// Preliminary client without the token to check network. This needs to occur before parsing to ensure the `StrictAddress` validation works correctly.
18+
let client = rpc::Client::default_or_from_env(None)?;
19+
if let Ok(name) = StateNetworkName::call(&client, ()).await
20+
&& !matches!(NetworkChain::from_str(&name), Ok(NetworkChain::Mainnet))
21+
{
22+
CurrentNetwork::set_global(Network::Testnet);
23+
}
24+
1725
// Capture Cli inputs
1826
let Cli {
1927
opts,
@@ -24,11 +32,6 @@ where
2432

2533
let client = rpc::Client::default_or_from_env(opts.token.as_deref())?;
2634

27-
let name = StateNetworkName::call(&client, ()).await?;
28-
let chain = NetworkChain::from_str(&name)?;
29-
if chain.is_testnet() {
30-
CurrentNetwork::set_global(Network::Testnet);
31-
}
3235
// Run command
3336
cmd.run(client, remote_wallet, encrypt).await
3437
}

src/wallet/subcommands/mod.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,29 @@ pub struct Cli {
2929
#[command(subcommand)]
3030
pub cmd: wallet_cmd::WalletCommands,
3131
}
32+
33+
#[cfg(test)]
34+
mod tests {
35+
use super::Cli;
36+
use clap::Parser;
37+
use clap::error::ErrorKind;
38+
use rstest::rstest;
39+
40+
fn try_parse(args: &[&str]) -> Result<Cli, ErrorKind> {
41+
let argv = std::iter::once("forest-wallet").chain(args.iter().copied());
42+
Cli::try_parse_from(argv).map_err(|e| e.kind())
43+
}
44+
45+
#[rstest]
46+
#[case::balance(&["balance", "not-an-address"])]
47+
#[case::export(&["export", "not-an-address"])]
48+
#[case::has(&["has", "not-an-address"])]
49+
#[case::set_default(&["set-default", "not-an-address"])]
50+
#[case::delete(&["delete", "not-an-address"])]
51+
#[case::sign(&["sign", "-m", "de", "-a", "not-an-address"])]
52+
#[case::verify(&["verify", "-a", "not-an-address", "-m", "de", "-s", "de"])]
53+
#[case::send_from(&["send", "--from", "not-an-address", "f01234", "1"])]
54+
fn migrated_subcommands_reject_malformed_address(#[case] args: &[&str]) {
55+
assert_eq!(try_parse(args).err(), Some(ErrorKind::ValueValidation));
56+
}
57+
}

src/wallet/subcommands/wallet_cmd.rs

Lines changed: 33 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,11 @@ impl WalletBackend {
149149
}
150150
}
151151

152-
async fn wallet_default_address(&self) -> anyhow::Result<Option<String>> {
152+
async fn wallet_default_address(&self) -> anyhow::Result<Option<Address>> {
153153
if let Some(keystore) = &self.local {
154-
Ok(crate::key_management::get_default(keystore)?.map(|s| s.to_string()))
154+
Ok(crate::key_management::get_default(keystore)?)
155155
} else {
156-
Ok(WalletDefaultAddress::call(&self.remote, ())
157-
.await?
158-
.map(|it| it.to_string()))
156+
Ok(WalletDefaultAddress::call(&self.remote, ()).await?)
159157
}
160158
}
161159

@@ -211,7 +209,7 @@ pub enum WalletCommands {
211209
/// Get account balance
212210
Balance {
213211
/// The address of the account to check
214-
address: String,
212+
address: StrictAddress,
215213
/// Output is rounded to 4 significant figures by default.
216214
/// Do not round
217215
// ENHANCE(aatifsyed): add a --round/--no-round argument pair
@@ -227,12 +225,12 @@ pub enum WalletCommands {
227225
/// Export the wallet's keys
228226
Export {
229227
/// The address that contains the keys to export
230-
address: String,
228+
address: StrictAddress,
231229
},
232230
/// Check if the wallet has a key
233231
Has {
234232
/// The key to check
235-
key: String,
233+
key: StrictAddress,
236234
},
237235
/// Import keys from existing wallet
238236
Import {
@@ -254,7 +252,7 @@ pub enum WalletCommands {
254252
/// Set the default wallet address
255253
SetDefault {
256254
/// The given key to set to the default address
257-
key: String,
255+
key: StrictAddress,
258256
},
259257
/// Sign a message
260258
Sign {
@@ -263,7 +261,7 @@ pub enum WalletCommands {
263261
message: String,
264262
/// The address to be used to sign the message
265263
#[arg(short)]
266-
address: String,
264+
address: StrictAddress,
267265
},
268266
/// Validates whether a given string can be decoded as a well-formed address
269267
ValidateAddress {
@@ -275,7 +273,7 @@ pub enum WalletCommands {
275273
Verify {
276274
/// The address used to sign the message
277275
#[arg(short)]
278-
address: String,
276+
address: StrictAddress,
279277
/// The message to verify
280278
#[arg(short)]
281279
message: String,
@@ -286,14 +284,18 @@ pub enum WalletCommands {
286284
/// Deletes the wallet associated with the given address.
287285
Delete {
288286
/// The address of the wallet to delete
289-
address: String,
287+
address: StrictAddress,
290288
},
291289
/// Send funds between accounts
292290
Send {
293291
/// optionally specify the account to send funds from (otherwise the default
294292
/// one will be used)
295293
#[arg(long)]
296-
from: Option<String>,
294+
from: Option<StrictAddress>,
295+
/// The recipient address. Accepts either a FIL address (e.g.
296+
/// `f1.../t1...`) or an ETH address (e.g. `0x...`).
297+
// Kept as `String` rather than `StrictAddress` because the latter
298+
// rejects the ETH form, which `resolve_target_address` handles.
297299
target_address: String,
298300
#[arg(value_parser = humantoken::parse)]
299301
amount: TokenAmount,
@@ -329,9 +331,7 @@ impl WalletCommands {
329331
no_round,
330332
no_abbrev,
331333
} => {
332-
let StrictAddress(address) = StrictAddress::from_str(&address)
333-
.with_context(|| format!("Invalid address: {address}"))?;
334-
let balance = WalletBalance::call(&backend.remote, (address,)).await?;
334+
let balance = WalletBalance::call(&backend.remote, (address.into(),)).await?;
335335
println!("{}", format_balance(&balance, no_round, no_abbrev));
336336
Ok(())
337337
}
@@ -343,27 +343,21 @@ impl WalletCommands {
343343
println!("{default_addr}");
344344
Ok(())
345345
}
346-
Self::Export {
347-
address: address_string,
348-
} => {
349-
let StrictAddress(address) = StrictAddress::from_str(&address_string)
350-
.with_context(|| format!("Invalid address: {address_string}"))?;
351-
let key_info = backend.wallet_export(address).await?;
346+
Self::Export { address } => {
347+
let key_info = backend.wallet_export(address.into()).await?;
352348
let encoded_key = key_info.into_lotus_json_string()?;
353349
println!("{}", hex::encode(encoded_key));
354350
Ok(())
355351
}
356352
Self::Has { key } => {
357-
let StrictAddress(address) = StrictAddress::from_str(&key)
358-
.with_context(|| format!("Invalid address: {key}"))?;
359-
360-
println!("{response}", response = backend.wallet_has(address).await?);
353+
println!(
354+
"{response}",
355+
response = backend.wallet_has(key.into()).await?
356+
);
361357
Ok(())
362358
}
363359
Self::Delete { address } => {
364-
let StrictAddress(address) = StrictAddress::from_str(&address)
365-
.with_context(|| format!("Invalid address: {address}"))?;
366-
360+
let address: Address = address.into();
367361
backend.wallet_delete(address).await?;
368362
println!("deleted {address}.");
369363
Ok(())
@@ -407,13 +401,9 @@ impl WalletCommands {
407401
no_round,
408402
no_abbrev,
409403
} => {
410-
let (key_pairs, default) =
404+
let (key_pairs, default_address) =
411405
tokio::try_join!(backend.list_addrs(), backend.wallet_default_address(),)?;
412406

413-
let default_address = default
414-
.as_deref()
415-
.and_then(|s| StrictAddress::from_str(s).ok().map(Into::into));
416-
417407
let remote = &backend.remote;
418408
let results =
419409
futures::future::join_all(key_pairs.iter().copied().map(|a| async move {
@@ -455,20 +445,12 @@ impl WalletCommands {
455445
println!("{list}");
456446
Ok(())
457447
}
458-
Self::SetDefault { key } => {
459-
let StrictAddress(key) = StrictAddress::from_str(&key)
460-
.with_context(|| format!("Invalid address: {key}"))?;
461-
462-
backend.wallet_set_default(key).await
463-
}
448+
Self::SetDefault { key } => backend.wallet_set_default(key.into()).await,
464449
Self::Sign { address, message } => {
465-
let StrictAddress(address) = StrictAddress::from_str(&address)
466-
.with_context(|| format!("Invalid address: {address}"))?;
467-
468450
let message = hex::decode(message).context("Message has to be a hex string")?;
469451
let message = BASE64_STANDARD.encode(message);
470452

471-
let signature = backend.wallet_sign(address, message).await?;
453+
let signature = backend.wallet_sign(address.into(), message).await?;
472454
println!("{}", hex::encode(signature.to_bytes()));
473455
Ok(())
474456
}
@@ -484,12 +466,12 @@ impl WalletCommands {
484466
} => {
485467
let sig_bytes =
486468
hex::decode(signature).context("Signature has to be a hex string")?;
487-
let StrictAddress(address) = StrictAddress::from_str(&address)
488-
.with_context(|| format!("Invalid address: {address}"))?;
489469
let msg = hex::decode(message).context("Message has to be a hex string")?;
490470

491471
let signature = Signature::from_bytes(sig_bytes)?;
492-
let is_valid = backend.wallet_verify(address, msg, signature).await?;
472+
let is_valid = backend
473+
.wallet_verify(address.into(), msg, signature)
474+
.await?;
493475

494476
println!("{is_valid}");
495477
Ok(())
@@ -502,13 +484,11 @@ impl WalletCommands {
502484
gas_limit,
503485
gas_premium,
504486
} => {
505-
let from: Address = if let Some(from) = from {
506-
StrictAddress::from_str(&from)?.into()
507-
} else {
508-
StrictAddress::from_str(&backend.wallet_default_address().await?.context(
487+
let from: Address = match from {
488+
Some(a) => a.into(),
489+
None => backend.wallet_default_address().await?.context(
509490
"No default wallet address selected. Please set a default address.",
510-
)?)?
511-
.into()
491+
)?,
512492
};
513493

514494
let (mut to, is_0x_recipient) = resolve_target_address(&target_address)?;

0 commit comments

Comments
 (0)