diff --git a/sdk/src/client/utils.rs b/sdk/src/client/utils.rs index ce7f79de12..da07957425 100644 --- a/sdk/src/client/utils.rs +++ b/sdk/src/client/utils.rs @@ -33,6 +33,7 @@ pub fn bech32_to_hex(bech32: impl ConvertTo) -> Result { Address::Nft(nft) => nft.to_string(), Address::Anchor(anchor) => anchor.to_string(), Address::ImplicitAccountCreation(implicit) => implicit.to_string(), + Address::Multi(multi) => multi.to_string(), Address::Restricted(restricted) => restricted.to_string(), }) } diff --git a/sdk/src/types/block/address/bech32.rs b/sdk/src/types/block/address/bech32.rs index dd0caa2e1e..cbc8d26d5b 100644 --- a/sdk/src/types/block/address/bech32.rs +++ b/sdk/src/types/block/address/bech32.rs @@ -7,6 +7,7 @@ use alloc::{ }; use core::str::FromStr; +use crypto::hashes::{blake2b::Blake2b256, Digest}; use derive_more::{AsRef, Deref, Display}; use packable::{ error::{UnpackError, UnpackErrorExt}, @@ -15,7 +16,10 @@ use packable::{ Packable, PackableExt, }; -use crate::types::block::{address::Address, ConvertTo, Error}; +use crate::types::block::{ + address::{Address, MultiAddress}, + ConvertTo, Error, +}; #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deref, Display)] #[repr(transparent)] @@ -172,11 +176,16 @@ impl Bech32Address { impl core::fmt::Display for Bech32Address { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!( - f, - "{}", - bech32::encode::(self.hrp.0, &self.inner.pack_to_vec(),).unwrap() - ) + let bytes = if self.inner.is_multi() { + core::iter::once(MultiAddress::KIND) + .chain(Blake2b256::digest(self.inner.pack_to_vec())) + .collect() + } else { + self.inner.pack_to_vec() + }; + + // PANIC: unwrap is fine as the Bech32Address has been validated at construction. + write!(f, "{}", bech32::encode::(self.hrp.0, &bytes).unwrap()) } } diff --git a/sdk/src/types/block/address/mod.rs b/sdk/src/types/block/address/mod.rs index fa6dda1a84..626eeb43ca 100644 --- a/sdk/src/types/block/address/mod.rs +++ b/sdk/src/types/block/address/mod.rs @@ -6,6 +6,7 @@ mod anchor; mod bech32; mod ed25519; mod implicit_account_creation; +mod multi; mod nft; mod restricted; @@ -14,12 +15,14 @@ use alloc::boxed::Box; use derive_more::{Display, From}; use packable::Packable; +pub(crate) use self::multi::WeightedAddressCount; pub use self::{ account::AccountAddress, anchor::AnchorAddress, bech32::{Bech32Address, Hrp}, ed25519::Ed25519Address, implicit_account_creation::ImplicitAccountCreationAddress, + multi::MultiAddress, nft::NftAddress, restricted::{AddressCapabilities, AddressCapabilityFlag, RestrictedAddress}, }; @@ -52,6 +55,9 @@ pub enum Address { /// An implicit account creation address. #[packable(tag = ImplicitAccountCreationAddress::KIND)] ImplicitAccountCreation(ImplicitAccountCreationAddress), + /// A multi address. + #[packable(tag = MultiAddress::KIND)] + Multi(MultiAddress), /// An address with restricted capabilities. #[packable(tag = RestrictedAddress::KIND)] #[from(ignore)] @@ -72,6 +78,7 @@ impl core::fmt::Debug for Address { Self::Nft(address) => address.fmt(f), Self::Anchor(address) => address.fmt(f), Self::ImplicitAccountCreation(address) => address.fmt(f), + Self::Multi(address) => address.fmt(f), Self::Restricted(address) => address.fmt(f), } } @@ -86,11 +93,12 @@ impl Address { Self::Nft(_) => NftAddress::KIND, Self::Anchor(_) => AnchorAddress::KIND, Self::ImplicitAccountCreation(_) => ImplicitAccountCreationAddress::KIND, + Self::Multi(_) => MultiAddress::KIND, Self::Restricted(_) => RestrictedAddress::KIND, } } - crate::def_is_as_opt!(Address: Ed25519, Account, Nft, Anchor, ImplicitAccountCreation, Restricted); + crate::def_is_as_opt!(Address: Ed25519, Account, Nft, Anchor, ImplicitAccountCreation, Multi, Restricted); /// Tries to create an [`Address`] from a bech32 encoded string. pub fn try_from_bech32(address: impl AsRef) -> Result { diff --git a/sdk/src/types/block/address/multi.rs b/sdk/src/types/block/address/multi.rs new file mode 100644 index 0000000000..4402dce12c --- /dev/null +++ b/sdk/src/types/block/address/multi.rs @@ -0,0 +1,236 @@ +// Copyright 2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use alloc::{boxed::Box, string::ToString, vec::Vec}; +use core::{fmt, ops::RangeInclusive}; + +use derive_more::{AsRef, Display, From}; +use iterator_sorted::is_unique_sorted; +use packable::{ + bounded::BoundedU8, + error::{UnpackError, UnpackErrorExt}, + packer::Packer, + prefix::BoxedSlicePrefix, + unpacker::Unpacker, + Packable, +}; + +use crate::types::block::{address::Address, Error}; + +pub(crate) type WeightedAddressCount = + BoundedU8<{ *MultiAddress::ADDRESSES_COUNT.start() }, { *MultiAddress::ADDRESSES_COUNT.end() }>; + +/// An address with an assigned weight. +#[derive(Clone, Debug, Display, Eq, PartialEq, Ord, PartialOrd, Hash, From, AsRef, Packable)] +#[display(fmt = "{address}")] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct WeightedAddress { + /// The unlocked address. + #[packable(verify_with = verify_address)] + address: Address, + /// The weight of the unlocked address. + #[packable(verify_with = verify_weight)] + weight: u8, +} + +impl WeightedAddress { + /// Creates a new [`WeightedAddress`]. + pub fn new(address: Address, weight: u8) -> Result { + verify_address::(&address, &())?; + verify_weight::(&weight, &())?; + + Ok(Self { address, weight }) + } + + /// Returns the address of the [`WeightedAddress`]. + pub fn address(&self) -> &Address { + &self.address + } + + /// Returns the weight of the [`WeightedAddress`]. + pub fn weight(&self) -> u8 { + self.weight + } +} + +fn verify_address(address: &Address, _visitor: &()) -> Result<(), Error> { + if VERIFY { + if !matches!( + address, + Address::Ed25519(_) | Address::Account(_) | Address::Nft(_) | Address::Anchor(_) + ) { + return Err(Error::InvalidAddressKind(address.kind())); + } + } + Ok(()) +} + +fn verify_weight(weight: &u8, _visitor: &()) -> Result<(), Error> { + if VERIFY && *weight == 0 { + return Err(Error::InvalidAddressWeight(*weight)); + } else { + Ok(()) + } +} + +/// An address that consists of addresses with weights and a threshold value. +/// The Multi Address can be unlocked if the cumulative weight of all unlocked addresses is equal to or exceeds the +/// threshold. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct MultiAddress { + /// The weighted unlocked addresses. + addresses: BoxedSlicePrefix, + /// The threshold that needs to be reached by the unlocked addresses in order to unlock the multi address. + threshold: u16, +} + +impl MultiAddress { + /// The [`Address`](crate::types::block::address::Address) kind of a [`MultiAddress`]. + pub const KIND: u8 = 40; + /// The allowed range of inner [`Address`]es. + pub const ADDRESSES_COUNT: RangeInclusive = 1..=10; + + /// Creates a new [`MultiAddress`]. + #[inline(always)] + pub fn new(addresses: impl IntoIterator, threshold: u16) -> Result { + let addresses = addresses.into_iter().collect::>(); + + verify_addresses::(&addresses, &())?; + verify_threshold::(&threshold, &())?; + + let addresses = BoxedSlicePrefix::::try_from(addresses) + .map_err(Error::InvalidWeightedAddressCount)?; + + verify_cumulative_weight::(&addresses, &threshold, &())?; + + Ok(Self { addresses, threshold }) + } + + /// Returns the addresses of a [`MultiAddress`]. + #[inline(always)] + pub fn addresses(&self) -> &[WeightedAddress] { + &self.addresses + } + + /// Returns the threshold of a [`MultiAddress`]. + #[inline(always)] + pub fn threshold(&self) -> u16 { + self.threshold + } +} + +impl Packable for MultiAddress { + type UnpackError = Error; + type UnpackVisitor = (); + + #[inline] + fn pack(&self, packer: &mut P) -> Result<(), P::Error> { + self.addresses.pack(packer)?; + self.threshold.pack(packer)?; + + Ok(()) + } + + #[inline] + fn unpack( + unpacker: &mut U, + visitor: &Self::UnpackVisitor, + ) -> Result> { + let addresses = + BoxedSlicePrefix::::unpack::<_, VERIFY>(unpacker, visitor) + .map_packable_err(|e| e.unwrap_item_err_or_else(|e| Error::InvalidWeightedAddressCount(e.into())))?; + + verify_addresses::(&addresses, &()).map_err(UnpackError::Packable)?; + + let threshold = u16::unpack::<_, VERIFY>(unpacker, visitor).coerce()?; + + verify_threshold::(&threshold, &()).map_err(UnpackError::Packable)?; + verify_cumulative_weight::(&addresses, &threshold, &()).map_err(UnpackError::Packable)?; + + Ok(Self { addresses, threshold }) + } +} + +fn verify_addresses(addresses: &[WeightedAddress], _visitor: &()) -> Result<(), Error> { + if VERIFY && !is_unique_sorted(addresses.iter().map(WeightedAddress::address)) { + return Err(Error::WeightedAddressesNotUniqueSorted); + } else { + Ok(()) + } +} + +fn verify_threshold(threshold: &u16, _visitor: &()) -> Result<(), Error> { + if VERIFY && *threshold == 0 { + return Err(Error::InvalidMultiAddressThreshold(*threshold)); + } else { + Ok(()) + } +} + +fn verify_cumulative_weight( + addresses: &[WeightedAddress], + threshold: &u16, + _visitor: &(), +) -> Result<(), Error> { + if VERIFY { + let cumulative_weight = addresses.iter().map(|address| address.weight as u16).sum::(); + + if cumulative_weight < *threshold { + return Err(Error::InvalidMultiAddressCumulativeWeight { + cumulative_weight, + threshold: *threshold, + }); + } + } + Ok(()) +} + +impl fmt::Display for MultiAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "[{}]", + self.addresses() + .iter() + .map(|address| address.to_string()) + .collect::>() + .join(", ") + ) + } +} + +#[cfg(feature = "serde")] +mod dto { + use serde::{Deserialize, Serialize}; + + use super::*; + + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + struct MultiAddressDto { + #[serde(rename = "type")] + kind: u8, + addresses: Vec, + threshold: u16, + } + + impl From<&MultiAddress> for MultiAddressDto { + fn from(value: &MultiAddress) -> Self { + Self { + kind: MultiAddress::KIND, + addresses: value.addresses.to_vec(), + threshold: value.threshold, + } + } + } + + impl TryFrom for MultiAddress { + type Error = Error; + + fn try_from(value: MultiAddressDto) -> Result { + Self::new(value.addresses, value.threshold) + } + } + + crate::impl_serde_typed_dto!(MultiAddress, MultiAddressDto, "multi address"); +} diff --git a/sdk/src/types/block/error.rs b/sdk/src/types/block/error.rs index cdfa903478..17a6023d38 100644 --- a/sdk/src/types/block/error.rs +++ b/sdk/src/types/block/error.rs @@ -11,6 +11,7 @@ use primitive_types::U256; use super::slot::EpochIndex; use crate::types::block::{ + address::WeightedAddressCount, context_input::RewardContextInputIndex, input::UtxoInput, mana::ManaAllotmentCount, @@ -70,6 +71,14 @@ pub enum Error { deposit: u64, required: u64, }, + InvalidAddressWeight(u8), + InvalidMultiAddressThreshold(u16), + InvalidMultiAddressCumulativeWeight { + cumulative_weight: u16, + threshold: u16, + }, + InvalidWeightedAddressCount(>::Error), + WeightedAddressesNotUniqueSorted, InvalidContextInputKind(u8), InvalidContextInputCount(>::Error), InvalidFeatureCount(>::Error), @@ -261,6 +270,21 @@ impl fmt::Display for Error { "storage deposit return of {deposit} exceeds the original output amount of {amount}" ), Self::InvalidContextInputCount(count) => write!(f, "invalid context input count: {count}"), + Self::InvalidAddressWeight(w) => write!(f, "invalid address weight: {w}"), + Self::InvalidMultiAddressThreshold(t) => write!(f, "invalid multi address threshold: {t}"), + Self::InvalidMultiAddressCumulativeWeight { + cumulative_weight, + threshold, + } => { + write!( + f, + "invalid multi address cumulative weight {cumulative_weight} < threshold {threshold}" + ) + } + Self::InvalidWeightedAddressCount(count) => write!(f, "invalid weighted address count: {count}"), + Self::WeightedAddressesNotUniqueSorted => { + write!(f, "weighted addresses are not unique and/or sorted") + } Self::InvalidContextInputKind(k) => write!(f, "invalid context input kind: {k}"), Self::InvalidFeatureCount(count) => write!(f, "invalid feature count: {count}"), Self::InvalidFeatureKind(k) => write!(f, "invalid feature kind: {k}"), diff --git a/sdk/tests/types/address/mod.rs b/sdk/tests/types/address/mod.rs index a19cff845d..fff6ac7d57 100644 --- a/sdk/tests/types/address/mod.rs +++ b/sdk/tests/types/address/mod.rs @@ -4,6 +4,7 @@ mod account; mod bech32; mod ed25519; +mod multi; mod nft; mod restricted; diff --git a/sdk/tests/types/address/multi.rs b/sdk/tests/types/address/multi.rs new file mode 100644 index 0000000000..ea767e5f69 --- /dev/null +++ b/sdk/tests/types/address/multi.rs @@ -0,0 +1,57 @@ +// Copyright 2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_sdk::types::block::address::{Address, ToBech32Ext}; + +#[test] +fn bech32() { + // Test from https://github.com/iotaledger/tips/blob/tip52/tips/TIP-0052/tip-0052.md#bech32 + + let multi_address_json = serde_json::json!({ + "type": 40, + "addresses": [ + { + "address": { + "type": 0, + "pubKeyHash": "0x52fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c649" + }, + "weight": 1 + }, + { + "address": { + "type": 0, + "pubKeyHash": "0x53fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c649" + }, + "weight": 1 + }, + { + "address": { + "type": 0, + "pubKeyHash": "0x54fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c649" + }, + "weight": 1 + }, + { + "address": { + "type": 8, + "accountId": "0x55fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c649" + }, + "weight": 2 + }, + { + "address": { + "type": 16, + "nftId": "0x56fdfc072182654f163f5f0f9a621d729566c74d10037c4d7bbb0407d1e2c649" + }, + "weight": 3 + } + ], + "threshold": 2 + }); + let multi_address = serde_json::from_value::
(multi_address_json).unwrap(); + + assert_eq!( + multi_address.to_bech32_unchecked("iota"), + "iota19qq0ezu97zl76wqnpdxxleuf55gk0eqhscjtdgqm5sqwav6gcarz6vvesnk" + ); +}