Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Construct Coin from String #1684

Merged
merged 6 commits into from
May 22, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions packages/std/src/coin.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::{fmt, str::FromStr};

use crate::math::Uint128;
use crate::{errors::CoinFromStrError, math::Uint128};

#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq, JsonSchema)]
pub struct Coin {
Expand All @@ -19,6 +19,22 @@ impl Coin {
}
}

impl FromStr for Coin {
type Err = CoinFromStrError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let pos = s
.find(|c: char| !c.is_ascii_digit())
.ok_or(CoinFromStrError::MissingDenom)?;
let (amount, denom) = s.split_at(pos);

Ok(Coin {
amount: amount.parse::<u128>()?.into(),
denom: denom.to_string(),
})
}
}

impl fmt::Display for Coin {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// We use the formatting without a space between amount and denom,
Expand Down Expand Up @@ -166,4 +182,41 @@ mod tests {
// less than same type
assert!(has_coins(&wallet, &coin(777, "ETH")));
}

#[test]
fn parse_coin() {
let expected = Coin::new(123, "ucosm");
assert_eq!("123ucosm".parse::<Coin>().unwrap(), expected);
// leading zeroes should be ignored
assert_eq!("00123ucosm".parse::<Coin>().unwrap(), expected);
// 0 amount parses correctly
assert_eq!("0ucosm".parse::<Coin>().unwrap(), Coin::new(0, "ucosm"));
// ibc denom should work
let ibc_str = "11111ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2";
let ibc_coin = Coin::new(
11111,
"ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2",
);
assert_eq!(ibc_str.parse::<Coin>().unwrap(), ibc_coin);

// error cases
assert_eq!(
Coin::from_str("123").unwrap_err(),
CoinFromStrError::MissingDenom
);
assert_eq!(
Coin::from_str("ucosm").unwrap_err().to_string(),
"Invalid amount: cannot parse integer from empty string"
);
assert_eq!(
Coin::from_str("-123ucosm").unwrap_err().to_string(),
"Invalid amount: cannot parse integer from empty string"
);
Copy link
Member

@webmaster128 webmaster128 May 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting test cases here.

Can we have a dedicated check for cases where the number does not start with a digit? Otherwise the error message is more misleasing than helping. The basic idea would be

  • Ensure we start reading digits (not yet implemented)
  • Once we get a non-digit, mark as separation point to denom (already implemented)

This would better cover cases like those:

Coin::from_str("ucosm") // no amount
Coin::from_str("") // empty input
Coin::from_str("-1ucosm") // negative amount
Coin::from_str(" 1ucosm") // unsupported whitespace
Coin::from_str("�1ucosm") // other broken data

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, basically, there would be another error variant, something like "MissingAmount" that would throw in all those cases?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about using InvalidAmount for the new check, but given the error payload type and the separate check time, it is probably better to have MissingAmount or AmountNotFound separately.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a check for this now. Only the empty one still throws MissingDenom, because that way the check becomes much more elegant (and I think both errors make sense here)

assert_eq!(
Coin::from_str("340282366920938463463374607431768211456ucosm")
.unwrap_err()
.to_string(),
"Invalid amount: number too large to fit in target type"
);
webmaster128 marked this conversation as resolved.
Show resolved Hide resolved
}
}
2 changes: 1 addition & 1 deletion packages/std/src/errors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ mod verification_error;
pub use recover_pubkey_error::RecoverPubkeyError;
pub use std_error::{
CheckedFromRatioError, CheckedMultiplyFractionError, CheckedMultiplyRatioError,
ConversionOverflowError, DivideByZeroError, OverflowError, OverflowOperation,
CoinFromStrError, ConversionOverflowError, DivideByZeroError, OverflowError, OverflowOperation,
RoundUpOverflowError, StdError, StdResult,
};
pub use system_error::SystemError;
Expand Down
20 changes: 20 additions & 0 deletions packages/std/src/errors/std_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,26 @@ pub enum CheckedFromRatioError {
#[error("Round up operation failed because of overflow")]
pub struct RoundUpOverflowError;

#[derive(Error, Debug, PartialEq, Eq)]
pub enum CoinFromStrError {
#[error("Missing denominator")]
MissingDenom,
#[error("Invalid amount: {0}")]
InvalidAmount(std::num::ParseIntError),
}

impl From<std::num::ParseIntError> for CoinFromStrError {
fn from(value: std::num::ParseIntError) -> Self {
Self::InvalidAmount(value)
}
}

impl From<CoinFromStrError> for StdError {
fn from(value: CoinFromStrError) -> Self {
Self::generic_err(format!("Parsing Coin: {}", value))
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down