From edd06a337f03e496ec6602c02a54f33bc13f7cc9 Mon Sep 17 00:00:00 2001 From: larry <26318510+larry0x@users.noreply.github.com> Date: Mon, 14 Nov 2022 15:50:18 +0000 Subject: [PATCH 01/19] add `Coins` struct --- packages/std/src/coins.rs | 246 ++++++++++++++++++++++++++++++++++++++ packages/std/src/lib.rs | 2 + 2 files changed, 248 insertions(+) create mode 100644 packages/std/src/coins.rs diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs new file mode 100644 index 0000000000..826982d7ab --- /dev/null +++ b/packages/std/src/coins.rs @@ -0,0 +1,246 @@ +use std::any::type_name; +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; + +use crate::{Coin, StdError, StdResult, Uint128}; + +/// A collection of coins, similar to Cosmos SDK's `sdk.Coins` struct. +/// +/// Differently from `sdk.Coins`, which is a vector of `sdk.Coin`, here we +/// implement Coins as a BTreeMap that maps from coin denoms to amounts. +/// This has a number of advantages: +/// +/// - coins are naturally sorted alphabetically by denom +/// - duplicate denoms are automatically removed +/// - cheaper for searching/inserting/deleting: O(log(n)) compared to O(n) +#[derive(Clone, Default, Debug, PartialEq, Eq)] +pub struct Coins(BTreeMap); + +// Casting a Vec to Coins. +// The Vec can be out of order, but must not contain duplicate denoms or zero amounts. +impl TryFrom> for Coins { + type Error = StdError; + + fn try_from(vec: Vec) -> StdResult { + let vec_len = vec.len(); + + let map = vec + .into_iter() + .filter(|coin| !coin.amount.is_zero()) + .map(|coin| (coin.denom, coin.amount)) + .collect::>(); + + // the map having a different length from the vec means the vec must either + // 1) contain duplicate denoms, or 2) contain zero amounts + if map.len() != vec_len { + return Err(StdError::parse_err( + type_name::(), + "duplicate denoms or zero amount", + )); + } + + Ok(Self(map)) + } +} + +impl TryFrom<&[Coin]> for Coins { + type Error = StdError; + + fn try_from(slice: &[Coin]) -> StdResult { + slice.to_vec().try_into() + } +} + +impl FromStr for Coins { + type Err = StdError; + + fn from_str(s: &str) -> StdResult { + // Parse a string into a `Coin`. + // + // Parsing the string with regex doesn't work, because the resulting + // wasm binary would be too big from including the `regex` library. + // + // We opt for the following solution: enumerate characters in the string, + // and break before the first non-number character. Split the string at + // that index. + // + // This assumes the denom never starts with a number, which is the case: + // https://github.com/cosmos/cosmos-sdk/blob/v0.46.0/types/coin.go#L854-L856 + let parse_coin_str = |s: &str| -> StdResult { + for (i, c) in s.chars().enumerate() { + if !c.is_ascii_digit() { + let amount = Uint128::from_str(&s[..i])?; + let denom = String::from(&s[i..]); + return Ok(Coin { amount, denom }); + } + } + + Err(StdError::parse_err( + type_name::(), + format!("invalid coin string: {s}"), + )) + }; + + s.split(',') + .into_iter() + .map(parse_coin_str) + .collect::>>()? + .try_into() + } +} + +impl fmt::Display for Coins { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = self + .0 + .iter() + .map(|(denom, amount)| format!("{amount}{denom}")) + .collect::>() + .join(","); + write!(f, "{s}") + } +} + +impl Coins { + /// Cast to Vec, while NOT consuming the original object + pub fn to_vec(&self) -> Vec { + self.0 + .iter() + .map(|(denom, amount)| Coin { + denom: denom.clone(), + amount: *amount, + }) + .collect() + } + + /// Cast to Vec, consuming the original object + pub fn into_vec(self) -> Vec { + self.0 + .into_iter() + .map(|(denom, amount)| Coin { denom, amount }) + .collect() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Return the denoms as a vector of strings. + /// The vector is guaranteed to not contain duplicates and sorted alphabetically. + pub fn denoms(&self) -> Vec { + self.0.keys().cloned().collect() + } + + pub fn add(&mut self, coin: &Coin) -> StdResult<()> { + let amount = self + .0 + .entry(coin.denom.clone()) + .or_insert_with(Uint128::zero); + *amount = amount.checked_add(coin.amount)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::coin; + + /// Sort a Vec by denom alphabetically + fn sort_by_denom(vec: &mut [Coin]) { + vec.sort_by(|a, b| a.denom.cmp(&b.denom)); + } + + /// Returns a mockup Vec. In this example, the coins are not in order + fn mock_vec() -> Vec { + vec![ + coin(12345, "uatom"), + coin(69420, "ibc/1234ABCD"), + coin(88888, "factory/osmo1234abcd/subdenom"), + ] + } + + /// Return a mockup Coins that contains the same coins as in `mock_vec` + fn mock_coins() -> Coins { + let mut coins = Coins::default(); + for coin in mock_vec() { + coins.add(&coin).unwrap(); + } + coins + } + + #[test] + fn casting_vec() { + let mut vec = mock_vec(); + let coins = mock_coins(); + + // &[Coin] --> Coins + assert_eq!(Coins::try_from(vec.as_slice()).unwrap(), coins); + // Vec --> Coins + assert_eq!(Coins::try_from(vec.clone()).unwrap(), coins); + + sort_by_denom(&mut vec); + + // &Coins --> Vec + // NOTE: the returned vec should be sorted + assert_eq!(coins.to_vec(), vec); + // Coins --> Vec + // NOTE: the returned vec should be sorted + assert_eq!(coins.into_vec(), vec); + } + + #[test] + fn casting_str() { + // not in order + let s1 = "88888factory/osmo1234abcd/subdenom,12345uatom,69420ibc/1234ABCD"; + // in order + let s2 = "88888factory/osmo1234abcd/subdenom,69420ibc/1234ABCD,12345uatom"; + + let coins = mock_coins(); + + // &str --> Coins + // NOTE: should generate the same Coins, regardless of input order + assert_eq!(Coins::from_str(s1).unwrap(), coins); + assert_eq!(Coins::from_str(s2).unwrap(), coins); + + // Coins --> String + // NOTE: the generated string should be sorted + assert_eq!(coins.to_string(), s2); + } + + #[test] + fn handling_duplicates() { + // create a Vec that contains duplicate denoms + let mut vec = mock_vec(); + vec.push(coin(67890, "uatom")); + + let err = Coins::try_from(vec).unwrap_err(); + assert!(err.to_string().contains("duplicate denoms")); + } + + #[test] + fn handling_zero_amount() { + // create a Vec that contains zero amounts + let mut vec = mock_vec(); + vec[0].amount = Uint128::zero(); + + let err = Coins::try_from(vec).unwrap_err(); + assert!(err.to_string().contains("zero amount")); + } + + #[test] + fn length() { + let coins = Coins::default(); + assert_eq!(coins.len(), 0); + assert!(coins.is_empty()); + + let coins = mock_coins(); + assert_eq!(coins.len(), 3); + assert!(!coins.is_empty()); + } +} diff --git a/packages/std/src/lib.rs b/packages/std/src/lib.rs index 7f26868498..98e8e45f9b 100644 --- a/packages/std/src/lib.rs +++ b/packages/std/src/lib.rs @@ -7,6 +7,7 @@ mod addresses; mod assertions; mod binary; mod coin; +mod coins; mod conversion; mod deps; mod errors; @@ -31,6 +32,7 @@ mod types; pub use crate::addresses::{instantiate2_address, Addr, CanonicalAddr, Instantiate2AddressError}; pub use crate::binary::Binary; pub use crate::coin::{coin, coins, has_coins, Coin}; +pub use crate::coins::Coins; pub use crate::deps::{Deps, DepsMut, OwnedDeps}; pub use crate::errors::{ CheckedFromRatioError, CheckedMultiplyFractionError, CheckedMultiplyRatioError, From c5b82d33df7684a340e16825874be60706c10296 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Fri, 5 May 2023 13:33:48 +0200 Subject: [PATCH 02/19] Ignore zero amount when adding coin to coins --- packages/std/src/coins.rs | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 826982d7ab..43489dd746 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -136,11 +136,19 @@ impl Coins { self.0.keys().cloned().collect() } - pub fn add(&mut self, coin: &Coin) -> StdResult<()> { - let amount = self - .0 - .entry(coin.denom.clone()) - .or_insert_with(Uint128::zero); + /// Returns the amount of the given denom or zero if the denom is not present. + pub fn amount_of(&self, denom: &str) -> Uint128 { + self.0.get(denom).copied().unwrap_or_else(Uint128::zero) + } + + /// Adds the given coin to the collection. + /// This errors in case of overflow. + pub fn add(&mut self, coin: Coin) -> StdResult<()> { + if coin.amount.is_zero() { + return Ok(()); + } + + let amount = self.0.entry(coin.denom).or_insert_with(Uint128::zero); *amount = amount.checked_add(coin.amount)?; Ok(()) } @@ -169,7 +177,7 @@ mod tests { fn mock_coins() -> Coins { let mut coins = Coins::default(); for coin in mock_vec() { - coins.add(&coin).unwrap(); + coins.add(coin).unwrap(); } coins } @@ -231,6 +239,11 @@ mod tests { let err = Coins::try_from(vec).unwrap_err(); assert!(err.to_string().contains("zero amount")); + + // adding a coin with zero amount should not be added + let mut coins = Coins::default(); + coins.add(coin(0, "uusd")).unwrap(); + assert!(coins.is_empty()); } #[test] @@ -243,4 +256,16 @@ mod tests { assert_eq!(coins.len(), 3); assert!(!coins.is_empty()); } + + #[test] + fn add_coin() { + let mut coins = mock_coins(); + coins.add(coin(12345, "uatom")).unwrap(); + + assert_eq!(coins.len(), 3); + assert_eq!(coins.amount_of("uatom").u128(), 24690); + + coins.add(coin(123, "uusd")).unwrap(); + assert_eq!(coins.len(), 4); + } } From 2da843212087fc734cdd654db21beea7d5f3c9b7 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Fri, 5 May 2023 15:13:37 +0200 Subject: [PATCH 03/19] Add extend method for Coins --- packages/std/src/coins.rs | 74 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 43489dd746..d2ab0f16a4 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -1,7 +1,7 @@ -use std::any::type_name; use std::collections::BTreeMap; use std::fmt; use std::str::FromStr; +use std::{any::type_name, collections::btree_map}; use crate::{Coin, StdError, StdResult, Uint128}; @@ -52,6 +52,16 @@ impl TryFrom<&[Coin]> for Coins { } } +impl TryFrom for Coins { + type Error = StdError; + + fn try_from(coin: Coin) -> StdResult { + let mut coins = Coins::default(); + coins.add(coin)?; + Ok(coins) + } +} + impl FromStr for Coins { type Err = StdError; @@ -130,7 +140,7 @@ impl Coins { self.0.is_empty() } - /// Return the denoms as a vector of strings. + /// Returns the denoms as a vector of strings. /// The vector is guaranteed to not contain duplicates and sorted alphabetically. pub fn denoms(&self) -> Vec { self.0.keys().cloned().collect() @@ -152,6 +162,51 @@ impl Coins { *amount = amount.checked_add(coin.amount)?; Ok(()) } + + /// Adds the given coins to the collection. + /// This takes anything that yields `(denom, amount)` tuples when iterated over. + /// + /// # Examples + /// + /// ```rust + /// use cosmwasm_std::{Coin, Coins, Uint128, coin}; + /// + /// let mut coins = Coins::default(); + /// let new_coins: Coins = coin(123u128, "ucosm").try_into()?; + /// coins.extend(new_coins.clone())?; + /// assert_eq!(coins, new_coins); + /// # cosmwasm_std::StdResult::Ok(()) + /// ``` + pub fn extend(&mut self, others: C) -> StdResult<()> + where + C: IntoIterator, + { + for (denom, amount) in others { + self.add(Coin { denom, amount })?; + } + Ok(()) + } +} + +impl IntoIterator for Coins { + type Item = (String, Uint128); + // TODO: do we want to wrap the iterator type with our own to avoid exposing BTreeMap? + // also: for the owned version we could return Coins instead of (String, Uint128), + // but not for the borrowed version, so it would feel inconsistent + type IntoIter = btree_map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl<'a> IntoIterator for &'a Coins { + type Item = (&'a String, &'a Uint128); + type IntoIter = btree_map::Iter<'a, String, Uint128>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } } #[cfg(test)] @@ -268,4 +323,19 @@ mod tests { coins.add(coin(123, "uusd")).unwrap(); assert_eq!(coins.len(), 4); } + + #[test] + fn extend_coins() { + let mut coins: Coins = coin(12345, "uatom").try_into().unwrap(); + + coins.extend(mock_coins()).unwrap(); + assert_eq!(coins.len(), 3); + assert_eq!(coins.amount_of("uatom").u128(), 24690); + + coins + .extend([("uusd".to_string(), Uint128::new(123u128))]) + .unwrap(); + assert_eq!(coins.len(), 4); + assert_eq!(coins.amount_of("uusd").u128(), 123) + } } From ea2aab14a7d9d88e2b2abe18869290ac13ae4db7 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Fri, 5 May 2023 15:16:43 +0200 Subject: [PATCH 04/19] Fix lint --- packages/std/src/coins.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index d2ab0f16a4..1c8c263673 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -93,7 +93,6 @@ impl FromStr for Coins { }; s.split(',') - .into_iter() .map(parse_coin_str) .collect::>>()? .try_into() From b9635086f715c69292a768f0aa677b0e186dfb5b Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Fri, 5 May 2023 16:58:29 +0200 Subject: [PATCH 05/19] Add more trait impls for Coins --- packages/std/src/coins.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 1c8c263673..87cda52a65 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -52,6 +52,14 @@ impl TryFrom<&[Coin]> for Coins { } } +impl TryFrom<[Coin; N]> for Coins { + type Error = StdError; + + fn try_from(slice: [Coin; N]) -> StdResult { + slice.to_vec().try_into() + } +} + impl TryFrom for Coins { type Error = StdError; @@ -62,6 +70,12 @@ impl TryFrom for Coins { } } +impl From for Vec { + fn from(value: Coins) -> Self { + value.into_vec() + } +} + impl FromStr for Coins { type Err = StdError; @@ -111,6 +125,12 @@ impl fmt::Display for Coins { } } +impl PartialEq for Coins { + fn eq(&self, other: &Coin) -> bool { + self.0.len() == 1 && self.amount_of(&other.denom) == other.amount + } +} + impl Coins { /// Cast to Vec, while NOT consuming the original object pub fn to_vec(&self) -> Vec { From 15c60acf87e6c71219aa6cac75cb35cb735dee99 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Fri, 5 May 2023 17:05:37 +0200 Subject: [PATCH 06/19] Add helper to Coins --- packages/std/src/coins.rs | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 87cda52a65..6be7af358a 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -80,6 +80,8 @@ impl FromStr for Coins { type Err = StdError; fn from_str(s: &str) -> StdResult { + // TODO: use FromStr impl for Coin once it's merged + // Parse a string into a `Coin`. // // Parsing the string with regex doesn't work, because the resulting @@ -170,6 +172,33 @@ impl Coins { self.0.get(denom).copied().unwrap_or_else(Uint128::zero) } + /// Returns the amount of the given denom if and only if this collection contains only + /// the given denom. Otherwise `None` is returned. + /// + /// # Examples + /// + /// ```rust + /// use cosmwasm_std::{Coin, Coins, coin}; + /// + /// let coins: Coins = coin(100, "uatom").try_into().unwrap(); + /// assert_eq!(coins.contains_only("uatom").unwrap().u128(), 100); + /// assert_eq!(coins.contains_only("uluna"), None); + /// ``` + /// + /// ```rust + /// use cosmwasm_std::{Coin, Coins, coin}; + /// + /// let coins: Coins = [coin(100, "uatom"), coin(200, "uusd")].try_into().unwrap(); + /// assert_eq!(coins.contains_only("uatom"), None); + /// ``` + pub fn contains_only(&self, denom: &str) -> Option { + if self.len() == 1 { + self.0.get(denom).copied() + } else { + None + } + } + /// Adds the given coin to the collection. /// This errors in case of overflow. pub fn add(&mut self, coin: Coin) -> StdResult<()> { @@ -188,7 +217,7 @@ impl Coins { /// # Examples /// /// ```rust - /// use cosmwasm_std::{Coin, Coins, Uint128, coin}; + /// use cosmwasm_std::{Coin, Coins, coin}; /// /// let mut coins = Coins::default(); /// let new_coins: Coins = coin(123u128, "ucosm").try_into()?; @@ -357,4 +386,12 @@ mod tests { assert_eq!(coins.len(), 4); assert_eq!(coins.amount_of("uusd").u128(), 123) } + + #[test] + fn equality() { + let coin = coin(54321, "uatom"); + let coins = Coins::try_from(coin.clone()).unwrap(); + + assert_eq!(coins, coin); + } } From dab3146776119cc6cfca628e86eb862f671724a7 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Mon, 22 May 2023 10:26:28 +0200 Subject: [PATCH 07/19] Use Coin's FromStr impl in FromStr impl of Coins --- packages/std/src/coins.rs | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 6be7af358a..27b3196009 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -80,37 +80,9 @@ impl FromStr for Coins { type Err = StdError; fn from_str(s: &str) -> StdResult { - // TODO: use FromStr impl for Coin once it's merged - - // Parse a string into a `Coin`. - // - // Parsing the string with regex doesn't work, because the resulting - // wasm binary would be too big from including the `regex` library. - // - // We opt for the following solution: enumerate characters in the string, - // and break before the first non-number character. Split the string at - // that index. - // - // This assumes the denom never starts with a number, which is the case: - // https://github.com/cosmos/cosmos-sdk/blob/v0.46.0/types/coin.go#L854-L856 - let parse_coin_str = |s: &str| -> StdResult { - for (i, c) in s.chars().enumerate() { - if !c.is_ascii_digit() { - let amount = Uint128::from_str(&s[..i])?; - let denom = String::from(&s[i..]); - return Ok(Coin { amount, denom }); - } - } - - Err(StdError::parse_err( - type_name::(), - format!("invalid coin string: {s}"), - )) - }; - s.split(',') - .map(parse_coin_str) - .collect::>>()? + .map(Coin::from_str) + .collect::, _>>()? .try_into() } } From 4e38bcfe494ccc58dc299551ecd284204bb4821f Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Mon, 22 May 2023 17:12:07 +0200 Subject: [PATCH 08/19] Remove IntoIterator impls for now --- packages/std/src/coins.rs | 35 ++++++----------------------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 27b3196009..175a024e2a 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -1,7 +1,7 @@ +use std::any::type_name; use std::collections::BTreeMap; use std::fmt; use std::str::FromStr; -use std::{any::type_name, collections::btree_map}; use crate::{Coin, StdError, StdResult, Uint128}; @@ -199,36 +199,15 @@ impl Coins { /// ``` pub fn extend(&mut self, others: C) -> StdResult<()> where - C: IntoIterator, + C: IntoIterator, { - for (denom, amount) in others { - self.add(Coin { denom, amount })?; + for c in others { + self.add(c)?; } Ok(()) } } -impl IntoIterator for Coins { - type Item = (String, Uint128); - // TODO: do we want to wrap the iterator type with our own to avoid exposing BTreeMap? - // also: for the owned version we could return Coins instead of (String, Uint128), - // but not for the borrowed version, so it would feel inconsistent - type IntoIter = btree_map::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.0.into_iter() - } -} - -impl<'a> IntoIterator for &'a Coins { - type Item = (&'a String, &'a Uint128); - type IntoIter = btree_map::Iter<'a, String, Uint128>; - - fn into_iter(self) -> Self::IntoIter { - self.0.iter() - } -} - #[cfg(test)] mod tests { use super::*; @@ -348,13 +327,11 @@ mod tests { fn extend_coins() { let mut coins: Coins = coin(12345, "uatom").try_into().unwrap(); - coins.extend(mock_coins()).unwrap(); + coins.extend(mock_coins().to_vec()).unwrap(); assert_eq!(coins.len(), 3); assert_eq!(coins.amount_of("uatom").u128(), 24690); - coins - .extend([("uusd".to_string(), Uint128::new(123u128))]) - .unwrap(); + coins.extend([coin(123, "uusd")]).unwrap(); assert_eq!(coins.len(), 4); assert_eq!(coins.amount_of("uusd").u128(), 123) } From 324289f99dbb21390de1af5bf3722e1d27a34f09 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Tue, 23 May 2023 08:51:54 +0200 Subject: [PATCH 09/19] Fix test --- packages/std/src/coins.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 175a024e2a..b6ec3380d6 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -193,7 +193,7 @@ impl Coins { /// /// let mut coins = Coins::default(); /// let new_coins: Coins = coin(123u128, "ucosm").try_into()?; - /// coins.extend(new_coins.clone())?; + /// coins.extend(new_coins.to_vec())?; /// assert_eq!(coins, new_coins); /// # cosmwasm_std::StdResult::Ok(()) /// ``` From 24e4d7a48d4cf75d9bc11e5673e2d44adf1af1b9 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Tue, 23 May 2023 17:48:31 +0200 Subject: [PATCH 10/19] Apply suggestions from code review Co-authored-by: Simon Warta <2603011+webmaster128@users.noreply.github.com> --- packages/std/src/coins.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index b6ec3380d6..abc9c11c02 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -106,7 +106,7 @@ impl PartialEq for Coins { } impl Coins { - /// Cast to Vec, while NOT consuming the original object + /// Conversion to Vec, while NOT consuming the original object pub fn to_vec(&self) -> Vec { self.0 .iter() @@ -117,7 +117,7 @@ impl Coins { .collect() } - /// Cast to Vec, consuming the original object + /// Conversion to Vec, consuming the original object pub fn into_vec(self) -> Vec { self.0 .into_iter() From 0e3bc7e5ab4582adf2424e8fba164f9a6a71fe4b Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Wed, 24 May 2023 10:08:57 +0200 Subject: [PATCH 11/19] Add custom error type for Coins conversion --- packages/std/src/coins.rs | 34 +++++++++++++++------------- packages/std/src/errors/mod.rs | 4 ++-- packages/std/src/errors/std_error.rs | 14 ++++++++++++ 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index abc9c11c02..c6fdee61c1 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -1,9 +1,8 @@ -use std::any::type_name; use std::collections::BTreeMap; use std::fmt; use std::str::FromStr; -use crate::{Coin, StdError, StdResult, Uint128}; +use crate::{errors::CoinsError, Coin, StdError, StdResult, Uint128}; /// A collection of coins, similar to Cosmos SDK's `sdk.Coins` struct. /// @@ -20,9 +19,15 @@ pub struct Coins(BTreeMap); // Casting a Vec to Coins. // The Vec can be out of order, but must not contain duplicate denoms or zero amounts. impl TryFrom> for Coins { - type Error = StdError; + type Error = CoinsError; + + fn try_from(vec: Vec) -> Result { + if let Some(coin) = vec.iter().find(|c| c.amount.is_zero()) { + return Err(CoinsError::ZeroAmount { + denom: coin.denom.clone(), + }); + } - fn try_from(vec: Vec) -> StdResult { let vec_len = vec.len(); let map = vec @@ -31,13 +36,10 @@ impl TryFrom> for Coins { .map(|coin| (coin.denom, coin.amount)) .collect::>(); - // the map having a different length from the vec means the vec must either - // 1) contain duplicate denoms, or 2) contain zero amounts + // the map having a different length from the vec means the vec must either contain + // duplicate denoms if map.len() != vec_len { - return Err(StdError::parse_err( - type_name::(), - "duplicate denoms or zero amount", - )); + return Err(CoinsError::DuplicateDenom); } Ok(Self(map)) @@ -45,17 +47,17 @@ impl TryFrom> for Coins { } impl TryFrom<&[Coin]> for Coins { - type Error = StdError; + type Error = CoinsError; - fn try_from(slice: &[Coin]) -> StdResult { + fn try_from(slice: &[Coin]) -> Result { slice.to_vec().try_into() } } impl TryFrom<[Coin; N]> for Coins { - type Error = StdError; + type Error = CoinsError; - fn try_from(slice: [Coin; N]) -> StdResult { + fn try_from(slice: [Coin; N]) -> Result { slice.to_vec().try_into() } } @@ -80,10 +82,10 @@ impl FromStr for Coins { type Err = StdError; fn from_str(s: &str) -> StdResult { - s.split(',') + Ok(s.split(',') .map(Coin::from_str) .collect::, _>>()? - .try_into() + .try_into()?) } } diff --git a/packages/std/src/errors/mod.rs b/packages/std/src/errors/mod.rs index 5535479bdf..8c41d36540 100644 --- a/packages/std/src/errors/mod.rs +++ b/packages/std/src/errors/mod.rs @@ -6,8 +6,8 @@ mod verification_error; pub use recover_pubkey_error::RecoverPubkeyError; pub use std_error::{ CheckedFromRatioError, CheckedMultiplyFractionError, CheckedMultiplyRatioError, - CoinFromStrError, ConversionOverflowError, DivideByZeroError, OverflowError, OverflowOperation, - RoundUpOverflowError, StdError, StdResult, + CoinFromStrError, CoinsError, ConversionOverflowError, DivideByZeroError, OverflowError, + OverflowOperation, RoundUpOverflowError, StdError, StdResult, }; pub use system_error::SystemError; pub use verification_error::VerificationError; diff --git a/packages/std/src/errors/std_error.rs b/packages/std/src/errors/std_error.rs index d90171c74c..119732eff7 100644 --- a/packages/std/src/errors/std_error.rs +++ b/packages/std/src/errors/std_error.rs @@ -590,6 +590,20 @@ pub enum CheckedFromRatioError { #[error("Round up operation failed because of overflow")] pub struct RoundUpOverflowError; +#[derive(Error, Debug, PartialEq, Eq)] +pub enum CoinsError { + #[error("Duplicate denom")] + DuplicateDenom, + #[error("Coin with zero amount: {denom}")] + ZeroAmount { denom: String }, +} + +impl From for StdError { + fn from(value: CoinsError) -> Self { + Self::generic_err(format!("Creating Coins: {}", value)) + } +} + #[derive(Error, Debug, PartialEq, Eq)] pub enum CoinFromStrError { #[error("Missing denominator")] From b202f79a3e147861ed7e52da4c6fad8a68136382 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Wed, 24 May 2023 10:10:10 +0200 Subject: [PATCH 12/19] Remove Coin to Coins conversion --- packages/std/src/coins.rs | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index c6fdee61c1..3dc1ce8186 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -62,16 +62,6 @@ impl TryFrom<[Coin; N]> for Coins { } } -impl TryFrom for Coins { - type Error = StdError; - - fn try_from(coin: Coin) -> StdResult { - let mut coins = Coins::default(); - coins.add(coin)?; - Ok(coins) - } -} - impl From for Vec { fn from(value: Coins) -> Self { value.into_vec() @@ -154,7 +144,7 @@ impl Coins { /// ```rust /// use cosmwasm_std::{Coin, Coins, coin}; /// - /// let coins: Coins = coin(100, "uatom").try_into().unwrap(); + /// let coins: Coins = [coin(100, "uatom")].try_into().unwrap(); /// assert_eq!(coins.contains_only("uatom").unwrap().u128(), 100); /// assert_eq!(coins.contains_only("uluna"), None); /// ``` @@ -194,7 +184,7 @@ impl Coins { /// use cosmwasm_std::{Coin, Coins, coin}; /// /// let mut coins = Coins::default(); - /// let new_coins: Coins = coin(123u128, "ucosm").try_into()?; + /// let new_coins: Coins = [coin(123u128, "ucosm")].try_into()?; /// coins.extend(new_coins.to_vec())?; /// assert_eq!(coins, new_coins); /// # cosmwasm_std::StdResult::Ok(()) @@ -284,7 +274,7 @@ mod tests { vec.push(coin(67890, "uatom")); let err = Coins::try_from(vec).unwrap_err(); - assert!(err.to_string().contains("duplicate denoms")); + assert_eq!(err, CoinsError::DuplicateDenom); } #[test] @@ -327,7 +317,7 @@ mod tests { #[test] fn extend_coins() { - let mut coins: Coins = coin(12345, "uatom").try_into().unwrap(); + let mut coins: Coins = [coin(12345, "uatom")].try_into().unwrap(); coins.extend(mock_coins().to_vec()).unwrap(); assert_eq!(coins.len(), 3); @@ -341,7 +331,7 @@ mod tests { #[test] fn equality() { let coin = coin(54321, "uatom"); - let coins = Coins::try_from(coin.clone()).unwrap(); + let coins = Coins::try_from([coin.clone()]).unwrap(); assert_eq!(coins, coin); } From 94f5748cbba0b9235426d9daa5f898ca538455c3 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Wed, 24 May 2023 10:21:54 +0200 Subject: [PATCH 13/19] Remove equality impl between Coin and Coins --- packages/std/src/coins.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 3dc1ce8186..43491edd17 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -91,12 +91,6 @@ impl fmt::Display for Coins { } } -impl PartialEq for Coins { - fn eq(&self, other: &Coin) -> bool { - self.0.len() == 1 && self.amount_of(&other.denom) == other.amount - } -} - impl Coins { /// Conversion to Vec, while NOT consuming the original object pub fn to_vec(&self) -> Vec { @@ -327,12 +321,4 @@ mod tests { assert_eq!(coins.len(), 4); assert_eq!(coins.amount_of("uusd").u128(), 123) } - - #[test] - fn equality() { - let coin = coin(54321, "uatom"); - let coins = Coins::try_from([coin.clone()]).unwrap(); - - assert_eq!(coins, coin); - } } From 23d5a45cf77250769d6b8b1fba92376ee5ccc20a Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Wed, 24 May 2023 10:50:34 +0200 Subject: [PATCH 14/19] Handle empty string when parsing Coins --- packages/std/src/coins.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 43491edd17..b756bff329 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -72,6 +72,10 @@ impl FromStr for Coins { type Err = StdError; fn from_str(s: &str) -> StdResult { + if s.is_empty() { + return Ok(Self::default()); + } + Ok(s.split(',') .map(Coin::from_str) .collect::, _>>()? @@ -223,7 +227,7 @@ mod tests { } #[test] - fn casting_vec() { + fn converting_vec() { let mut vec = mock_vec(); let coins = mock_coins(); @@ -243,22 +247,30 @@ mod tests { } #[test] - fn casting_str() { + fn converting_str() { // not in order let s1 = "88888factory/osmo1234abcd/subdenom,12345uatom,69420ibc/1234ABCD"; // in order let s2 = "88888factory/osmo1234abcd/subdenom,69420ibc/1234ABCD,12345uatom"; + let invalid = "12345uatom,noamount"; + let coins = mock_coins(); // &str --> Coins // NOTE: should generate the same Coins, regardless of input order assert_eq!(Coins::from_str(s1).unwrap(), coins); assert_eq!(Coins::from_str(s2).unwrap(), coins); + assert_eq!(Coins::from_str("").unwrap(), Coins::default()); // Coins --> String // NOTE: the generated string should be sorted assert_eq!(coins.to_string(), s2); + assert_eq!(Coins::default().to_string(), ""); + assert_eq!( + Coins::from_str(invalid).unwrap_err().to_string(), + "Generic error: Parsing Coin: Missing amount or non-digit characters in amount" + ); } #[test] From e9772297bd25d28942c1e52027cd4c40ca013764 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Tue, 6 Jun 2023 10:28:41 +0200 Subject: [PATCH 15/19] Ignore zero values when creating Coins --- packages/std/src/coins.rs | 64 +++++++++++++++++----------- packages/std/src/errors/std_error.rs | 2 - 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index b756bff329..1f8356ddf6 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -16,30 +16,23 @@ use crate::{errors::CoinsError, Coin, StdError, StdResult, Uint128}; #[derive(Clone, Default, Debug, PartialEq, Eq)] pub struct Coins(BTreeMap); -// Casting a Vec to Coins. -// The Vec can be out of order, but must not contain duplicate denoms or zero amounts. +/// Casting a Vec to Coins. +/// The Vec can be out of order, but must not contain duplicate denoms. +/// If you want to sum up duplicates, create an empty instance using [Coins::default] and use `Coins::extend`. impl TryFrom> for Coins { type Error = CoinsError; fn try_from(vec: Vec) -> Result { - if let Some(coin) = vec.iter().find(|c| c.amount.is_zero()) { - return Err(CoinsError::ZeroAmount { - denom: coin.denom.clone(), - }); - } - - let vec_len = vec.len(); - - let map = vec - .into_iter() - .filter(|coin| !coin.amount.is_zero()) - .map(|coin| (coin.denom, coin.amount)) - .collect::>(); - - // the map having a different length from the vec means the vec must either contain - // duplicate denoms - if map.len() != vec_len { - return Err(CoinsError::DuplicateDenom); + let mut map = BTreeMap::new(); + for Coin { amount, denom } in vec { + if amount.is_zero() { + continue; + } + + // if the insertion returns a previous value, we have a duplicate denom + if map.insert(denom, amount).is_some() { + return Err(CoinsError::DuplicateDenom); + } } Ok(Self(map)) @@ -54,6 +47,15 @@ impl TryFrom<&[Coin]> for Coins { } } +impl From for Coins { + fn from(value: Coin) -> Self { + let mut coins = Coins::default(); + // this can never overflow (because there are no coins in there yet), so we can unwrap + coins.add(value).unwrap(); + coins + } +} + impl TryFrom<[Coin; N]> for Coins { type Error = CoinsError; @@ -173,7 +175,8 @@ impl Coins { Ok(()) } - /// Adds the given coins to the collection. + /// Adds the given coins to the collection, ignoring any zero coins and summing up + /// duplicate denoms. /// This takes anything that yields `(denom, amount)` tuples when iterated over. /// /// # Examples @@ -226,6 +229,10 @@ mod tests { coins } + fn mock_duplicate() -> Vec { + vec![coin(12345, "uatom"), coin(6789, "uatom")] + } + #[test] fn converting_vec() { let mut vec = mock_vec(); @@ -289,8 +296,13 @@ mod tests { let mut vec = mock_vec(); vec[0].amount = Uint128::zero(); - let err = Coins::try_from(vec).unwrap_err(); - assert!(err.to_string().contains("zero amount")); + let coins = Coins::try_from(vec).unwrap(); + assert_eq!(coins.len(), 2); + assert_ne!(coins.amount_of("ibc/1234ABCD"), Uint128::zero()); + assert_ne!( + coins.amount_of("factory/osmo1234abcd/subdenom"), + Uint128::zero() + ); // adding a coin with zero amount should not be added let mut coins = Coins::default(); @@ -331,6 +343,10 @@ mod tests { coins.extend([coin(123, "uusd")]).unwrap(); assert_eq!(coins.len(), 4); - assert_eq!(coins.amount_of("uusd").u128(), 123) + assert_eq!(coins.amount_of("uusd").u128(), 123); + + // duplicate handling + coins.extend(mock_duplicate()).unwrap(); + assert_eq!(coins.amount_of("uatom").u128(), 24690 + 12345 + 6789); } } diff --git a/packages/std/src/errors/std_error.rs b/packages/std/src/errors/std_error.rs index 119732eff7..d30fec6a71 100644 --- a/packages/std/src/errors/std_error.rs +++ b/packages/std/src/errors/std_error.rs @@ -594,8 +594,6 @@ pub struct RoundUpOverflowError; pub enum CoinsError { #[error("Duplicate denom")] DuplicateDenom, - #[error("Coin with zero amount: {denom}")] - ZeroAmount { denom: String }, } impl From for StdError { From f4ead00e547a82a8c06275c7b3082bac9fd806a2 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Tue, 6 Jun 2023 12:57:00 +0200 Subject: [PATCH 16/19] Add Coin subtraction --- packages/std/src/coins.rs | 76 ++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 1f8356ddf6..8b383a70a0 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -3,6 +3,7 @@ use std::fmt; use std::str::FromStr; use crate::{errors::CoinsError, Coin, StdError, StdResult, Uint128}; +use crate::{OverflowError, OverflowOperation}; /// A collection of coins, similar to Cosmos SDK's `sdk.Coins` struct. /// @@ -18,7 +19,8 @@ pub struct Coins(BTreeMap); /// Casting a Vec to Coins. /// The Vec can be out of order, but must not contain duplicate denoms. -/// If you want to sum up duplicates, create an empty instance using [Coins::default] and use `Coins::extend`. +/// If you want to sum up duplicates, create an empty instance using `Coins::default` and +/// use `Coins::add` to add your coins. impl TryFrom> for Coins { type Error = CoinsError; @@ -175,28 +177,27 @@ impl Coins { Ok(()) } - /// Adds the given coins to the collection, ignoring any zero coins and summing up - /// duplicate denoms. - /// This takes anything that yields `(denom, amount)` tuples when iterated over. - /// - /// # Examples - /// - /// ```rust - /// use cosmwasm_std::{Coin, Coins, coin}; - /// - /// let mut coins = Coins::default(); - /// let new_coins: Coins = [coin(123u128, "ucosm")].try_into()?; - /// coins.extend(new_coins.to_vec())?; - /// assert_eq!(coins, new_coins); - /// # cosmwasm_std::StdResult::Ok(()) - /// ``` - pub fn extend(&mut self, others: C) -> StdResult<()> - where - C: IntoIterator, - { - for c in others { - self.add(c)?; + /// Subtracts the given coin or collection of coins from this `Coins` instance. + /// Errors in case of overflow or if one of the coins is not present. + pub fn sub(&mut self, coin: Coin) -> StdResult<()> { + match self.0.get_mut(&coin.denom) { + Some(v) => { + *v = v.checked_sub(coin.amount)?; + // make sure to remove zero coins + if v.is_zero() { + self.0.remove(&coin.denom); + } + } + None => { + return Err(OverflowError::new( + OverflowOperation::Sub, + Uint128::zero(), + coin.amount, + ) + .into()) + } } + Ok(()) } } @@ -229,10 +230,6 @@ mod tests { coins } - fn mock_duplicate() -> Vec { - vec![coin(12345, "uatom"), coin(6789, "uatom")] - } - #[test] fn converting_vec() { let mut vec = mock_vec(); @@ -334,19 +331,24 @@ mod tests { } #[test] - fn extend_coins() { - let mut coins: Coins = [coin(12345, "uatom")].try_into().unwrap(); + fn sub_coins() { + let mut coins: Coins = coin(12345, "uatom").into(); - coins.extend(mock_coins().to_vec()).unwrap(); - assert_eq!(coins.len(), 3); - assert_eq!(coins.amount_of("uatom").u128(), 24690); + // sub more than available + let err = coins.sub(coin(12346, "uatom")).unwrap_err(); + assert!(matches!(err, StdError::Overflow { .. })); - coins.extend([coin(123, "uusd")]).unwrap(); - assert_eq!(coins.len(), 4); - assert_eq!(coins.amount_of("uusd").u128(), 123); + // sub non-existent denom + let err = coins.sub(coin(12345, "uusd")).unwrap_err(); + assert!(matches!(err, StdError::Overflow { .. })); - // duplicate handling - coins.extend(mock_duplicate()).unwrap(); - assert_eq!(coins.amount_of("uatom").u128(), 24690 + 12345 + 6789); + // partial sub + coins.sub(coin(1, "uatom")).unwrap(); + assert_eq!(coins.len(), 1); + assert_eq!(coins.amount_of("uatom").u128(), 12344); + + // full sub + coins.sub(coin(12344, "uatom")).unwrap(); + assert!(coins.is_empty()); } } From c02a2f1cfbc71776659a07ae8a9902415b067c08 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Tue, 6 Jun 2023 14:35:12 +0200 Subject: [PATCH 17/19] Fix Coin subtraction --- packages/std/src/coins.rs | 40 ++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 8b383a70a0..0c0877ddbe 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -119,10 +119,12 @@ impl Coins { .collect() } + /// Returns the number of different denoms in this collection. pub fn len(&self) -> usize { self.0.len() } + /// Returns `true` if this collection contains no coins. pub fn is_empty(&self) -> bool { self.0.is_empty() } @@ -165,8 +167,8 @@ impl Coins { } } - /// Adds the given coin to the collection. - /// This errors in case of overflow. + /// Adds the given coin to this `Coins` instance. + /// Errors in case of overflow. pub fn add(&mut self, coin: Coin) -> StdResult<()> { if coin.amount.is_zero() { return Ok(()); @@ -177,24 +179,28 @@ impl Coins { Ok(()) } - /// Subtracts the given coin or collection of coins from this `Coins` instance. - /// Errors in case of overflow or if one of the coins is not present. + /// Subtracts the given coin from this `Coins` instance. + /// Errors in case of overflow or if the denom is not present. pub fn sub(&mut self, coin: Coin) -> StdResult<()> { match self.0.get_mut(&coin.denom) { Some(v) => { *v = v.checked_sub(coin.amount)?; - // make sure to remove zero coins + // make sure to remove zero coin if v.is_zero() { self.0.remove(&coin.denom); } } None => { + // ignore zero subtraction + if coin.amount.is_zero() { + return Ok(()); + } return Err(OverflowError::new( OverflowOperation::Sub, Uint128::zero(), coin.amount, ) - .into()) + .into()); } } @@ -321,13 +327,23 @@ mod tests { #[test] fn add_coin() { let mut coins = mock_coins(); - coins.add(coin(12345, "uatom")).unwrap(); + // existing denom + coins.add(coin(12345, "uatom")).unwrap(); assert_eq!(coins.len(), 3); assert_eq!(coins.amount_of("uatom").u128(), 24690); + // new denom coins.add(coin(123, "uusd")).unwrap(); assert_eq!(coins.len(), 4); + + // zero amount + coins.add(coin(0, "uusd")).unwrap(); + assert_eq!(coins.amount_of("uusd").u128(), 123); + + // zero amount, new denom + coins.add(coin(0, "utest")).unwrap(); + assert_eq!(coins.len(), 4); } #[test] @@ -350,5 +366,15 @@ mod tests { // full sub coins.sub(coin(12344, "uatom")).unwrap(); assert!(coins.is_empty()); + + // sub zero, existing denom + coins.sub(coin(0, "uusd")).unwrap(); + assert!(coins.is_empty()); + let mut coins: Coins = coin(12345, "uatom").into(); + + // sub zero, non-existent denom + coins.sub(coin(0, "uatom")).unwrap(); + assert_eq!(coins.len(), 1); + assert_eq!(coins.amount_of("uatom").u128(), 12345); } } From 3f5ea72a6425021f4ac02648b3694025558a7ac4 Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Wed, 7 Jun 2023 12:17:05 +0200 Subject: [PATCH 18/19] Add Coin to Coins conversion test --- packages/std/src/coins.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/std/src/coins.rs b/packages/std/src/coins.rs index 0c0877ddbe..1548cfffad 100644 --- a/packages/std/src/coins.rs +++ b/packages/std/src/coins.rs @@ -377,4 +377,16 @@ mod tests { assert_eq!(coins.len(), 1); assert_eq!(coins.amount_of("uatom").u128(), 12345); } + + #[test] + fn coin_to_coins() { + // zero coin results in empty collection + let coins: Coins = coin(0, "uusd").into(); + assert!(coins.is_empty()); + + // happy path + let coins = Coins::from(coin(12345, "uatom")); + assert_eq!(coins.len(), 1); + assert_eq!(coins.amount_of("uatom").u128(), 12345); + } } From df8ff96ff885f857d7f561e6f65480047127d85e Mon Sep 17 00:00:00 2001 From: Christoph Otter Date: Mon, 19 Jun 2023 14:25:46 +0200 Subject: [PATCH 19/19] Add Coins to changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb9f1ba98..7cf92978dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ and this project adheres to ### Added - cosmwasm-std: Add `FromStr` impl for `Coin`. ([#1684]) +- cosmwasm-std: Add `Coins` helper to handle multiple coins. ([#1687]) [#1684]: https://github.com/CosmWasm/cosmwasm/pull/1684 +[#1687]: https://github.com/CosmWasm/cosmwasm/pull/1687 ### Changed