diff --git a/.gitignore b/.gitignore index 229420dcc5a..31e4914a423 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ rococo-local-raw.json rococo-local.json scripts/polkadot-launch/bin/polkadot scripts/polkadot-launch/*.log - +.rust-analyzer target .env diff --git a/.maintain/common/lib.sh b/.maintain/common/lib.sh index c97f5f5a591..29ae72d3ff8 100644 --- a/.maintain/common/lib.sh +++ b/.maintain/common/lib.sh @@ -51,9 +51,9 @@ has_client_changes() { # checks if the spec/impl version has increased check_runtime() { VERSIONS_FILE="$1" - add_spec_version="$(git diff "${LATEST_TAG_NAME}" "${GITHUB_BRANCH_NAME}" -- "${VERSIONS_FILE}" | + add_spec_version="$(git diff "${LATEST_TAG_NAME}" "origin/${GITHUB_BRANCH_NAME}" -- "${VERSIONS_FILE}" | sed -n -r "s/^\+[[:space:]]+spec_version: +([0-9]+),$/\1/p")" - sub_spec_version="$(git diff "${LATEST_TAG_NAME}" "${GITHUB_BRANCH_NAME}" -- "${VERSIONS_FILE}" | + sub_spec_version="$(git diff "${LATEST_TAG_NAME}" "origin/${GITHUB_BRANCH_NAME}" -- "${VERSIONS_FILE}" | sed -n -r "s/^\-[[:space:]]+spec_version: +([0-9]+),$/\1/p")" if [ "${add_spec_version}" != "${sub_spec_version}" ]; then @@ -70,9 +70,9 @@ check_runtime() { # check for impl_version updates: if only the impl versions changed, we assume # there is no consensus-critical logic that has changed. - add_impl_version="$(git diff "${LATEST_TAG_NAME}" "${GITHUB_BRANCH_NAME}" -- "${VERSIONS_FILE}" | + add_impl_version="$(git diff "${LATEST_TAG_NAME}" "origin/${GITHUB_BRANCH_NAME}" -- "${VERSIONS_FILE}" | sed -n -r 's/^\+[[:space:]]+impl_version: +([0-9]+),$/\1/p')" - sub_impl_version="$(git diff "${LATEST_TAG_NAME}" "${GITHUB_BRANCH_NAME}" -- "${VERSIONS_FILE}" | + sub_impl_version="$(git diff "${LATEST_TAG_NAME}" "origin/${GITHUB_BRANCH_NAME}" -- "${VERSIONS_FILE}" | sed -n -r 's/^\-[[:space:]]+impl_version: +([0-9]+),$/\1/p')" # see if the impl version changed diff --git a/Cargo.lock b/Cargo.lock index fa28974e717..efacee828b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,6 +1559,7 @@ dependencies = [ name = "composable-tests-helpers" version = "0.0.1" dependencies = [ + "frame-support", "parity-scale-codec", "scale-info", "serde", @@ -7079,6 +7080,31 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-mosaic" +version = "0.1.0" +dependencies = [ + "composable-tests-helpers", + "composable-traits", + "frame-benchmarking", + "frame-support", + "frame-system", + "log 0.4.14", + "num-traits", + "orml-tokens", + "orml-traits", + "pallet-balances", + "parity-scale-codec", + "plotters", + "proptest 0.9.6", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", + "xcm", +] + [[package]] name = "pallet-multisig" version = "4.0.0-dev" diff --git a/frame/composable-tests-helpers/Cargo.toml b/frame/composable-tests-helpers/Cargo.toml index 068120cf9c2..823dc8d013a 100644 --- a/frame/composable-tests-helpers/Cargo.toml +++ b/frame/composable-tests-helpers/Cargo.toml @@ -3,7 +3,7 @@ name = "composable-tests-helpers" version = "0.0.1" authors = ["Composable Developers"] homepage = "https://composable.finance" -edition = "2018" +edition = "2021" [package.metadata.docs.rs] targets = ["x86_64-unknown-linux-gnu"] @@ -14,6 +14,8 @@ serde = { version = "1", optional = true } sp-arithmetic = { default-features = false, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } sp-runtime = { default-features = false, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } sp-std = { default-features = false, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } +frame-support = { default-features = false, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } + [dependencies.codec] default-features = false @@ -28,4 +30,5 @@ std = [ "codec/std", "sp-runtime/std", "scale-info/std", + "frame-support/std", ] diff --git a/frame/composable-tests-helpers/src/test/proptest.rs b/frame/composable-tests-helpers/src/test/proptest.rs index 8bad9046d30..29eb942d7b7 100644 --- a/frame/composable-tests-helpers/src/test/proptest.rs +++ b/frame/composable-tests-helpers/src/test/proptest.rs @@ -2,7 +2,7 @@ #[macro_export] macro_rules! prop_assert_ok { ($cond:expr) => { - prop_assert_ok!($cond, concat!("assertion failed: ", stringify!($cond))) + prop_assert_ok!($cond, concat!("ok assertion failed: ", stringify!($cond))) }; ($cond:expr, $($fmt:tt)*) => { @@ -15,6 +15,38 @@ macro_rules! prop_assert_ok { }; } +#[macro_export] +macro_rules! prop_assert_err { + ($cond:expr, $err:expr) => { + composable_tests_helpers::prop_assert_err!($cond, $err, concat!("error assertion failed: ", stringify!($cond))) + }; + + ($cond:expr, $err:expr, $($fmt:tt)*) => { + + match $cond { + Result::Err(e) if e == $err.into() => {} + v => { + let message = format!($($fmt)*); + let message = format!("Expected {:?}, got {:?}, {} at {}:{}", $err, v, message, file!(), line!()); + return ::std::result::Result::Err( + proptest::test_runner::TestCaseError::fail(message)); + } + } + }; +} + +#[macro_export] +macro_rules! prop_assert_noop { + ( + $x:expr, + $y:expr $(,)? + ) => { + let h = frame_support::storage_root(); + composable_tests_helpers::prop_assert_err!($x, $y); + proptest::prop_assert_eq!(h, frame_support::storage_root()); + }; +} + /// Accept a `dust` deviation. #[macro_export] macro_rules! prop_assert_acceptable_computation_error { diff --git a/frame/mosaic/Cargo.toml b/frame/mosaic/Cargo.toml new file mode 100644 index 00000000000..6b6d6a68060 --- /dev/null +++ b/frame/mosaic/Cargo.toml @@ -0,0 +1,72 @@ +[package] +name = "pallet-mosaic" +version = "0.1.0" +authors = ["Composable Developers"] +homepage = "https://composable.finance" +edition = "2021" + +[[bin]] +name = "plot" +path = "src/plots.rs" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[package.metadata.cargo-udeps.ignore] +development = ["pallet-balances"] + +# alias "parity-scale-code" to "codec" +[dependencies.codec] +default-features = false +features = ["derive"] +package = "parity-scale-codec" +version = "2.0.0" + +[dependencies] +composable-traits = { path = "../composable-traits", default-features = false } +frame-benchmarking = { default-features = false, optional = true, git = "https://github.com/paritytech/substrate.git", branch = "polkadot-v0.9.13" } +frame-support = { default-features = false, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } +frame-system = { default-features = false, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } + +sp-core = { default-features = false, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } +sp-io = { default-features = false, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } +sp-runtime = { default-features = false, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } +sp-std = { default-features = false, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } +xcm = { git = "https://github.com/paritytech/polkadot", branch = "release-v0.9.13", default-features = false } + +log = { version = "0.4.14", default-features = false } +scale-info = { version = "1.0", default-features = false, features = ["derive"] } +num-traits = "0.2.14" +plotters = {version = "0.3.1", optional = true} + +[dev-dependencies] +pallet-balances = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } +proptest = "0.9.6" +orml-tokens = { git = "https://github.com/open-web3-stack/open-runtime-module-library", rev = "17a791edf431d7d7aee1ea3dfaeeb7bc21944301" } +orml-traits = { git = "https://github.com/open-web3-stack/open-runtime-module-library", rev = "17a791edf431d7d7aee1ea3dfaeeb7bc21944301", default-features = false } +composable-tests-helpers = { version = "0.0.1", path = "../composable-tests-helpers", default-features = false } + + +[features] +default = ["std"] +std = [ + "codec/std", + "log/std", + "composable-traits/std", + "scale-info/std", + "frame-support/std", + "frame-system/std", + "sp-runtime/std", + "sp-io/std", + "sp-core/std", + "sp-std/std", + "xcm/std", +] + +runtime-benchmarks = [ + 'frame-benchmarking', + 'frame-support/runtime-benchmarks', + 'frame-system/runtime-benchmarks', +] + +visualization = ["plotters"] diff --git a/frame/mosaic/README.md b/frame/mosaic/README.md new file mode 100644 index 00000000000..0892ff517df --- /dev/null +++ b/frame/mosaic/README.md @@ -0,0 +1,10 @@ +# Mosaic + +Pallet implementing an interface for the Mosaic Relayer. As opposed to the EVM-EVM bridge, this pallet takes a different approach and uses `mint` and `burn` operations. +Because of that it also limits the mintable amount by the relayer using a decaying penalty. + +## Decaying Penalty + +At moment N, the relayer has a maximum budget `budget`. Minting a token adds a penalty `penalty` to the relayer. The penalty decreases each block according to decay function `decayer`, +which depends on the penalty, current_block, and last_decay_block. The current maximum amount that the relayer can mint is given by `budget - decayer(penalty, current_block, last_decay_block)`. +The new penalty is the decayed previous penalty plus the minted amount. diff --git a/frame/mosaic/proptest-regressions/tests.txt b/frame/mosaic/proptest-regressions/tests.txt new file mode 100644 index 00000000000..38de93a884c --- /dev/null +++ b/frame/mosaic/proptest-regressions/tests.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 622d5f60d70240f7b19156918e818b27925f88f0440f60e4e5975def08a99b84 # shrinks to amount = 10000 +cc 1e4de59b29da912d7745cbfdc8d9893eb2d492cb817275ae19c38e6387c46a35 # shrinks to amount = 1, account_a = 1, lock_time = 1, early = 3 diff --git a/frame/mosaic/src/decay.rs b/frame/mosaic/src/decay.rs new file mode 100644 index 00000000000..6396fbc2b59 --- /dev/null +++ b/frame/mosaic/src/decay.rs @@ -0,0 +1,103 @@ +use frame_support::pallet_prelude::*; +use num_traits::{CheckedAdd, CheckedDiv, CheckedMul, CheckedSub, One, Saturating, Zero}; + +pub trait Decayer { + /// Decay the `amount` proportionally to the time elapsed `current_block - last_decay_block` + /// Returns `None` if an input value is invalid + fn checked_decay( + &self, + amount: Balance, + current_block: BlockNumber, + last_decay_block: BlockNumber, + ) -> Option; + + /// Determine how many blocks are required to pass until the `amount` fully recover from this + /// decayer. Returns `None` if the recovery period cannot be computed. + fn full_recovery_period(&self, amount: Balance) -> Option; +} + +/// Recommend type for storing the decay function of a penalty. +#[derive(Decode, Encode, TypeInfo, Debug, PartialEq, Clone)] +pub enum BudgetPenaltyDecayer { + /// Linear variant of the decay function, which decreases every block. + Linear(LinearDecay), +} + +impl BudgetPenaltyDecayer { + #[allow(dead_code)] + pub fn linear(n: Balance) -> BudgetPenaltyDecayer { + BudgetPenaltyDecayer::Linear(LinearDecay { factor: n, _marker: PhantomData }) + } +} + +impl Decayer + for BudgetPenaltyDecayer +where + BlockNumber: CheckedSub + Saturating + Into + TryFrom + One + CheckedAdd, + Balance: CheckedMul + CheckedDiv + Saturating + Zero, +{ + fn checked_decay( + &self, + amount: Balance, + current: BlockNumber, + last: BlockNumber, + ) -> Option { + match self { + BudgetPenaltyDecayer::Linear(lin) => lin.checked_decay(amount, current, last), + } + } + + fn full_recovery_period(&self, amount: Balance) -> Option { + match self { + BudgetPenaltyDecayer::Linear(lin) => lin.full_recovery_period(amount), + } + } +} + +#[derive(Decode, Encode, TypeInfo, Default, Debug, PartialEq, Clone)] +pub struct LinearDecay { + /// Factor by which we decay every block. + factor: Balance, + _marker: core::marker::PhantomData, +} + +impl Decayer for LinearDecay +where + BlockNumber: CheckedSub + Saturating + Into + TryFrom + One + CheckedAdd, + Balance: CheckedMul + CheckedDiv + Saturating + Zero, +{ + fn checked_decay( + &self, + amount: Balance, + current: BlockNumber, + last: BlockNumber, + ) -> Option { + let diff = current.saturating_sub(last); + let reduction = diff.into().checked_mul(&self.factor)?; + Some(amount.saturating_sub(reduction)) + } + + fn full_recovery_period(&self, amount: Balance) -> Option { + let full_period = amount.checked_div(&self.factor)?; + let block_full_period: BlockNumber = TryFrom::::try_from(full_period).ok()?; + let block_full_period_plus_one: BlockNumber = block_full_period.checked_add(&One::one())?; + Some(block_full_period_plus_one) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_linear_decrease() { + let mut penalty = 1000; + let prev = penalty.clone(); + let penalty_decayer = BudgetPenaltyDecayer::linear(10); + + (0..=100).for_each(|x| { + penalty = penalty_decayer.checked_decay(penalty, x, x - 1).unwrap(); + assert!(prev > penalty); + }); + } +} diff --git a/frame/mosaic/src/lib.rs b/frame/mosaic/src/lib.rs new file mode 100644 index 00000000000..b3933bfb18e --- /dev/null +++ b/frame/mosaic/src/lib.rs @@ -0,0 +1,695 @@ +// TODO +// 1. TEST! +// 2. RPCs for relayer convenience. +// 3. Refactor core logic to traits. +// 4. Benchmarks and Weights! + +#![cfg_attr(not(feature = "std"), no_std)] + +mod decay; +mod relayer; + +pub use decay::{BudgetPenaltyDecayer, Decayer}; +pub use pallet::*; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +#[frame_support::pallet] +pub mod pallet { + + use crate::{ + decay::Decayer, + relayer::{RelayerConfig, StaleRelayer}, + }; + use codec::FullCodec; + use composable_traits::math::SafeArithmetic; + use frame_support::{ + dispatch::DispatchResultWithPostInfo, + pallet_prelude::*, + traits::fungibles::{Inspect, Mutate, Transfer}, + transactional, PalletId, + }; + use frame_system::pallet_prelude::*; + use num_traits::{CheckedSub, Zero}; + use scale_info::TypeInfo; + use sp_core::H256; + use sp_runtime::{ + traits::{AccountIdConversion, Keccak256, Saturating}, + DispatchError, + }; + use sp_std::{fmt::Debug, str}; + + type AccountIdOf = ::AccountId; + type BalanceOf = <::Assets as Inspect>>::Balance; + type BlockNumberOf = ::BlockNumber; + type AssetIdOf = <::Assets as Inspect>>::AssetId; + type NetworkIdOf = ::NetworkId; + + #[pallet::config] + pub trait Config: frame_system::Config { + type Event: From> + IsType<::Event>; + + type PalletId: Get; + type Assets: Mutate> + Transfer>; + + type MinimumTTL: Get>; + type MinimumTimeLockPeriod: Get>; + + type BudgetPenaltyDecayer: Decayer, BlockNumberOf> + + Clone + + Encode + + Decode + + Debug + + TypeInfo + + PartialEq; + + type NetworkId: FullCodec + TypeInfo + Clone + Debug + PartialEq; + } + + #[pallet::pallet] + #[pallet::generate_store(pub(super) trait Store)] + pub struct Pallet(_); + + pub enum TransactionType { + Incoming, + Outgoing, + } + + #[derive(Clone, Debug, Encode, Decode, TypeInfo, PartialEq)] + pub struct AssetInfo { + pub last_mint_block: BlockNumber, + pub budget: Balance, + pub penalty: Balance, + pub penalty_decayer: Decayer, + } + + #[derive(Clone, Debug, Encode, Decode, TypeInfo, PartialEq)] + pub struct NetworkInfo { + pub enabled: bool, + pub max_transfer_size: Balance, + } + + /// User incoming/outgoing accounts, that hold the funds for transactions to happen. + pub struct SubAccount { + transaction_type: TransactionType, + account_id: AccountIdOf, + } + + impl SubAccount { + pub fn to_id(&self) -> impl Encode { + let prefix = match self.transaction_type { + TransactionType::Incoming => b"incoming________", + TransactionType::Outgoing => b"outgoing________", + }; + [prefix.to_vec(), self.account_id.encode()] + } + pub fn new_outgoing(account_id: AccountIdOf) -> Self { + SubAccount { transaction_type: TransactionType::Outgoing, account_id } + } + pub fn new_incoming(account_id: AccountIdOf) -> Self { + SubAccount { transaction_type: TransactionType::Incoming, account_id } + } + } + + #[pallet::storage] + pub type Relayer = + StorageValue<_, StaleRelayer, OptionQuery>; + + #[pallet::storage] + #[pallet::getter(fn asset_infos)] + pub type AssetsInfo = StorageMap< + _, + Twox64Concat, + AssetIdOf, + AssetInfo, BalanceOf, T::BudgetPenaltyDecayer>, + OptionQuery, + >; + + #[pallet::storage] + #[pallet::getter(fn network_infos)] + pub type NetworkInfos = + StorageMap<_, Twox64Concat, NetworkIdOf, NetworkInfo>, OptionQuery>; + + #[pallet::storage] + #[pallet::getter(fn time_lock_period)] + pub type TimeLockPeriod = + StorageValue<_, BlockNumberOf, ValueQuery, TimeLockPeriodOnEmpty>; + + #[pallet::storage] + #[pallet::getter(fn nonce)] + pub type Nonce = StorageValue<_, u128, ValueQuery>; + + #[pallet::type_value] + pub fn TimeLockPeriodOnEmpty() -> BlockNumberOf { + T::MinimumTimeLockPeriod::get() + } + + /// Locked outgoing tx out of Picasso, that a relayer needs to process. + #[pallet::storage] + #[pallet::getter(fn outgoing_transactions)] + pub type OutgoingTransactions = StorageDoubleMap< + _, + Twox64Concat, + AccountIdOf, + Twox64Concat, + AssetIdOf, + (BalanceOf, BlockNumberFor), + OptionQuery, + >; + + /// Locked incoming tx into Picasso that the user needs to claim. + #[pallet::storage] + #[pallet::getter(fn incoming_transactions)] + pub type IncomingTransactions = StorageDoubleMap< + _, + Twox64Concat, + AccountIdOf, + Twox64Concat, + AssetIdOf, + (BalanceOf, BlockNumberFor), + OptionQuery, + >; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// The account of the relayer has been set. + RelayerSet { relayer: AccountIdOf }, + /// The relayer has been rotated to `account_id`. + RelayerRotated { ttl: BlockNumberOf, account_id: AccountIdOf }, + BudgetUpdated { + asset_id: AssetIdOf, + amount: BalanceOf, + decay: T::BudgetPenaltyDecayer, + }, + /// The `NetworkInfos` `network_info` was updated for `network_id`. + NetworksUpdated { network_id: NetworkIdOf, network_info: NetworkInfo> }, + /// An outgoing tx is created, and locked in the outgoing tx pool. + TransferOut { + id: Id, + to: EthereumAddress, + amount: BalanceOf, + network_id: NetworkIdOf, + }, + /// User claimed outgoing tx that was not (yet) picked up by the relayer + StaleTxClaimed { to: AccountIdOf, by: AccountIdOf, amount: BalanceOf }, + /// An incoming tx is created and waiting for the user to claim. + TransferInto { to: AccountIdOf, amount: BalanceOf, asset_id: AssetIdOf, id: Id }, + /// When we have finality issues occur on the Ethereum chain, + /// we burn the locked `IncomingTransaction` for which we know that it is invalid. + TransferIntoRescined { + account: AccountIdOf, + amount: BalanceOf, + asset_id: AssetIdOf, + }, + /// The relayer partially accepted the user's `OutgoingTransaction`. + PartialTransferAccepted { + from: AccountIdOf, + asset_id: AssetIdOf, + amount: BalanceOf, + }, + /// The relayer accepted the user's `OutgoingTransaction`. + TransferAccepted { from: AccountIdOf, asset_id: AssetIdOf, amount: BalanceOf }, + /// The user claims his `IncomingTransaction` and unlocks the locked amount. + TransferClaimed { + by: AccountIdOf, + to: AccountIdOf, + asset_id: AssetIdOf, + amount: BalanceOf, + }, + } + + #[pallet::error] + pub enum Error { + RelayerNotSet, + BadTTL, + BadTimelockPeriod, + UnsupportedAsset, + NetworkDisabled, + UnsupportedNetwork, + Overflow, + NoStaleTransactions, + InsufficientBudget, + ExceedsMaxTransferSize, + NoClaimableTx, + TxStillLocked, + NoOutgoingTx, + AmountMismatch, + } + + #[pallet::call] + impl Pallet { + /// Sets the current relayer configuration. This is enacted immediately and invalidates + /// inflight, incoming transactions from the previous relayer. Budgets remain in place + /// however. + #[pallet::weight(10_000)] + pub fn set_relayer( + origin: OriginFor, + relayer: T::AccountId, + ) -> DispatchResultWithPostInfo { + ensure_root(origin)?; + Relayer::::set(Some(StaleRelayer::new(relayer.clone()))); + Self::deposit_event(Event::RelayerSet { relayer }); + Ok(().into()) + } + + /// Rotates the Relayer Account + /// + /// # Restrictions + /// - Only callable by the current relayer. + /// - TTL must be sufficiently long. + #[pallet::weight(10_000)] + pub fn rotate_relayer( + origin: OriginFor, + new: T::AccountId, + ttl: T::BlockNumber, + ) -> DispatchResultWithPostInfo { + ensure!(ttl > T::MinimumTTL::get(), Error::::BadTTL); + let (relayer, current_block) = Self::ensure_relayer(origin)?; + let ttl = current_block.saturating_add(ttl); + let relayer = relayer.rotate(new.clone(), ttl); + Relayer::::set(Some(relayer.into())); + Self::deposit_event(Event::RelayerRotated { account_id: new, ttl }); + Ok(().into()) + } + + /// Sets supported networks and maximum transaction sizes accepted by the relayer. + #[pallet::weight(10_000)] + pub fn set_network( + origin: OriginFor, + network_id: NetworkIdOf, + network_info: NetworkInfo>, + ) -> DispatchResultWithPostInfo { + Self::ensure_relayer(origin)?; + NetworkInfos::::insert(network_id.clone(), network_info.clone()); + Self::deposit_event(Event::NetworksUpdated { network_id, network_info }); + Ok(().into()) + } + + /// Sets the relayer budget for _incoming_ transactions for specific assets. Does not reset + /// the current `penalty`. + /// + /// # Restrictions + /// - Only callable by root + #[pallet::weight(10_000)] + #[transactional] + pub fn set_budget( + origin: OriginFor, + asset_id: AssetIdOf, + amount: BalanceOf, + decay: T::BudgetPenaltyDecayer, + ) -> DispatchResultWithPostInfo { + // Can also be token governance associated I reckon, as Angular holders should be able + // to grant mosaic permission to mint. We'll save that for phase 3. + ensure_root(origin)?; + let current_block = >::block_number(); + + AssetsInfo::::mutate(asset_id, |item| { + let new = item + .take() + .map(|mut asset_info| { + asset_info.budget = amount; + asset_info.penalty_decayer = decay.clone(); + asset_info + }) + .unwrap_or_else(|| AssetInfo { + last_mint_block: current_block, + budget: amount, + penalty: Zero::zero(), + penalty_decayer: decay.clone(), + }); + *item = Some(new); + }); + Self::deposit_event(Event::BudgetUpdated { asset_id, amount, decay }); + Ok(().into()) + } + + /// Creates an outgoing transaction request, locking the funds locally until picked up by + /// the relayer. + /// + /// # Restrictions + /// - Network must be supported. + /// - AssetId must be supported. + /// - Amount must be lower than the networks `max_transfer_size`. + /// - Origin must have sufficient funds. + /// - Transfers near Balance::max may result in overflows, which are caught and returned as + /// an error. + #[pallet::weight(10_000)] + #[transactional] + pub fn transfer_to( + origin: OriginFor, + network_id: NetworkIdOf, + asset_id: AssetIdOf, + address: EthereumAddress, + amount: BalanceOf, + keep_alive: bool, + ) -> DispatchResultWithPostInfo { + let caller = ensure_signed(origin)?; + ensure!(AssetsInfo::::contains_key(asset_id), Error::::UnsupportedAsset); + let network_info = + NetworkInfos::::get(network_id.clone()).ok_or(Error::::UnsupportedNetwork)?; + ensure!(network_info.enabled, Error::::NetworkDisabled); + ensure!(network_info.max_transfer_size >= amount, Error::::ExceedsMaxTransferSize); + + T::Assets::transfer( + asset_id, + &caller, + &Self::sub_account_id(SubAccount::new_outgoing(caller.clone())), + amount, + keep_alive, + )?; + let now = >::block_number(); + let lock_until = now.safe_add(&TimeLockPeriod::::get())?; + + OutgoingTransactions::::try_mutate( + caller.clone(), + asset_id, + |tx| -> Result<(), DispatchError> { + match tx.as_mut() { + // If we already have an outgoing tx, we update the lock_time and add the + // amount. + Some((already_locked, _)) => { + let amount = amount.safe_add(already_locked)?; + *tx = Some((amount, lock_until)) + }, + None => *tx = Some((amount, lock_until)), + } + Ok(()) + }, + )?; + + let id = generate_id::(&caller, &network_id, &asset_id, &address, &amount, &now); + Self::deposit_event(Event::::TransferOut { to: address, amount, network_id, id }); + + Ok(().into()) + } + + /// Called by the relayer to confirm that it will relay a transaction, disabling the user + /// from reclaiming their tokens. + /// + /// # Restrictions + /// - Origin must be relayer + /// - Outgoing transaction must exist for the user + /// - Amount must be equal or lower than what the user has locked + /// + /// # Note + /// - Reclaim period is not reset if not all the funds are moved; menaing that the clock + /// remains ticking for the relayer to pick up the rest of the transaction. + #[pallet::weight(10_000)] + #[transactional] + pub fn accept_transfer( + origin: OriginFor, + from: AccountIdOf, + asset_id: AssetIdOf, + amount: BalanceOf, + ) -> DispatchResultWithPostInfo { + Self::ensure_relayer(origin)?; + OutgoingTransactions::::try_mutate_exists::<_, _, _, DispatchError, _>( + from.clone(), + asset_id, + |maybe_tx| match *maybe_tx { + Some((balance, lock_period)) => { + ensure!(amount <= balance, Error::::AmountMismatch); + T::Assets::burn_from( + asset_id, + &Self::sub_account_id(SubAccount::new_outgoing(from.clone())), + amount, + )?; + + // No remaing funds need to be transferred for this asset, so we can delete + // the storage item. + if amount == balance { + *maybe_tx = None; + Self::deposit_event(Event::::TransferAccepted { + from, + asset_id, + amount, + }); + } else { + let new_balance = + balance.checked_sub(&amount).ok_or(Error::::AmountMismatch)?; + *maybe_tx = Some((new_balance, lock_period)); + Self::deposit_event(Event::::PartialTransferAccepted { + from, + asset_id, + amount, + }); + } + + Ok(()) + }, + None => Err(Error::::NoOutgoingTx.into()), + }, + )?; + Ok(().into()) + } + + /// Claims user funds from the `OutgoingTransactions`, in case that the relayer has not + /// picked them up. + #[pallet::weight(10_000)] + #[transactional] + pub fn claim_stale_to( + origin: OriginFor, + asset_id: AssetIdOf, + to: AccountIdOf, + ) -> DispatchResultWithPostInfo { + let caller = ensure_signed(origin)?; + + let now = >::block_number(); + + OutgoingTransactions::::try_mutate_exists( + caller.clone(), + asset_id, + |prev| -> Result<(), DispatchError> { + let amount = match *prev { + Some((balance, lock_time)) if lock_time < now => { + T::Assets::transfer( + asset_id, + &Self::sub_account_id(SubAccount::new_outgoing(caller.clone())), + &to, + balance, + false, + )?; + balance + }, + _ => return Err(Error::::NoStaleTransactions.into()), + }; + + *prev = None; + Self::deposit_event(Event::::StaleTxClaimed { to, by: caller, amount }); + Ok(()) + }, + )?; + Ok(().into()) + } + + /// Mints new tokens into the pallet's wallet, ready for the user to be picked up after + /// `lock_time` blocks have expired. + #[pallet::weight(10_000)] + pub fn timelocked_mint( + origin: OriginFor, + asset_id: AssetIdOf, + to: AccountIdOf, + amount: BalanceOf, + lock_time: BlockNumberOf, + id: Id, + ) -> DispatchResultWithPostInfo { + let (_caller, current_block) = Self::ensure_relayer(origin)?; + + AssetsInfo::::try_mutate_exists::<_, _, DispatchError, _>(asset_id, |info| { + let AssetInfo { last_mint_block, penalty, budget, penalty_decayer } = + info.take().ok_or(Error::::UnsupportedAsset)?; + + let new_penalty = penalty_decayer + .checked_decay(penalty, current_block, last_mint_block) + .unwrap_or_else(Zero::zero); + + let penalised_budget = budget.saturating_sub(new_penalty); + + // Check if the relayer has a sufficient budget to mint the requested amount. + ensure!(amount <= penalised_budget, Error::::InsufficientBudget); + + T::Assets::mint_into( + asset_id, + &Self::sub_account_id(SubAccount::new_incoming(to.clone())), + amount, + )?; + + let lock_at = current_block.saturating_add(lock_time); + + IncomingTransactions::::mutate(to.clone(), asset_id, |prev| match prev { + Some((balance, _)) => + *prev = Some(((*balance).saturating_add(amount), lock_at)), + _ => *prev = Some((amount, lock_at)), + }); + + *info = Some(AssetInfo { + last_mint_block: current_block, + budget, + penalty: new_penalty.saturating_add(amount), + penalty_decayer, + }); + + Self::deposit_event(Event::::TransferInto { to, asset_id, amount, id }); + Ok(()) + })?; + + Ok(().into()) + } + + #[pallet::weight(10_000)] + pub fn set_timelock_duration( + origin: OriginFor, + period: BlockNumberOf, + ) -> DispatchResultWithPostInfo { + ensure_root(origin)?; + ensure!(period > T::MinimumTimeLockPeriod::get(), Error::::BadTimelockPeriod); + TimeLockPeriod::::set(period); + Ok(().into()) + } + + /// Burns funds waiting in incoming_transactions that are still unclaimed. May be used by + /// the relayer in case of finality issues on the other side of the bridge. + #[pallet::weight(10_000)] + #[transactional] + pub fn rescind_timelocked_mint( + origin: OriginFor, + asset_id: AssetIdOf, + account: AccountIdOf, + untrusted_amount: BalanceOf, + ) -> DispatchResultWithPostInfo { + Self::ensure_relayer(origin)?; + + IncomingTransactions::::try_mutate_exists::<_, _, _, DispatchError, _>( + account.clone(), + asset_id, + |prev| { + let (balance, _) = prev.as_mut().ok_or(Error::::NoClaimableTx)?; + // Wipe the entire incoming transaction. + if *balance == untrusted_amount { + *prev = None; + } else { + *balance = balance.saturating_sub(untrusted_amount); + } + T::Assets::burn_from( + asset_id, + &Self::sub_account_id(SubAccount::new_incoming(account.clone())), + untrusted_amount, + )?; + Self::deposit_event(Event::::TransferIntoRescined { + account, + amount: untrusted_amount, + asset_id, + }); + Ok(()) + }, + )?; + + Ok(().into()) + } + + /// Collects funds deposited by the relayer into the owner's account + #[pallet::weight(10_000)] + pub fn claim_to( + origin: OriginFor, + asset_id: AssetIdOf, + to: AccountIdOf, + ) -> DispatchResultWithPostInfo { + let caller = ensure_signed(origin)?; + let now = >::block_number(); + + IncomingTransactions::::try_mutate_exists::<_, _, _, DispatchError, _>( + caller.clone(), + asset_id, + |deposit| { + let (amount, unlock_after) = deposit.ok_or(Error::::NoClaimableTx)?; + ensure!(unlock_after < now, Error::::TxStillLocked); + T::Assets::transfer( + asset_id, + &Self::sub_account_id(SubAccount::new_incoming(caller.clone())), + &to, + amount, + false, + )?; + // Delete the deposit. + deposit.take(); + Self::deposit_event(Event::::TransferClaimed { + by: caller, + to, + asset_id, + amount, + }); + Ok(()) + }, + )?; + Ok(().into()) + } + } + + #[pallet::extra_constants] + impl Pallet { + pub fn timelock_period() -> BlockNumberOf { + TimeLockPeriod::::get() + } + } + + impl Pallet { + /// AccountId of the pallet, used to store all funds before actually moving them. + pub fn sub_account_id(sub_account: SubAccount) -> AccountIdOf { + T::PalletId::get().into_sub_account(sub_account.to_id()) + } + + /// Queries storage, returning the account_id of the current relayer. + pub fn relayer_account_id() -> Result, DispatchError> { + let current_block = >::block_number(); + Ok(Relayer::::get() + .ok_or(Error::::RelayerNotSet)? + .update(current_block) + .account_id() + .clone()) + } + + pub(crate) fn ensure_relayer( + origin: OriginFor, + ) -> Result< + (RelayerConfig, BlockNumberOf>, BlockNumberOf), + DispatchError, + > { + let acc = ensure_signed(origin).map_err(|_| DispatchError::BadOrigin)?; + let current_block = >::block_number(); + let relayer = + Relayer::::get().ok_or(Error::::RelayerNotSet)?.update(current_block); + ensure!(relayer.is_relayer(&acc), DispatchError::BadOrigin); + Ok((relayer, current_block)) + } + } + + /// Convenience identifiers emitted by the pallet for relayer bookkeeping. + pub type Id = H256; + + /// Raw ethereum addresses. + pub type EthereumAddress = [u8; 20]; + + /// Uses Keccak256 to generate an identifier for + pub(crate) fn generate_id( + to: &AccountIdOf, + network_id: &NetworkIdOf, + asset_id: &AssetIdOf, + address: &EthereumAddress, + amount: &BalanceOf, + block_number: &BlockNumberOf, + ) -> Id { + use sp_runtime::traits::Hash; + + let nonce = Nonce::::mutate(|nonce| { + *nonce = nonce.wrapping_add(1); + *nonce + }); + + Keccak256::hash_of(&(to, network_id, asset_id, address, amount, &block_number, nonce)) + } +} diff --git a/frame/mosaic/src/mock.rs b/frame/mosaic/src/mock.rs new file mode 100644 index 00000000000..8afd741ab3d --- /dev/null +++ b/frame/mosaic/src/mock.rs @@ -0,0 +1,135 @@ +use crate as pallet_mosaic; +use frame_support::{ + parameter_types, + traits::{Everything, GenesisBuild}, + PalletId, +}; +use frame_system as system; + +use num_traits::Zero; +use orml_traits::parameter_type_with_key; +use sp_core::H256; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, +}; + +pub type AccountId = u128; +pub type BlockNumber = u64; +pub type NetworkId = u32; +pub type Balance = u128; +pub type Amount = i128; +pub type AssetId = u32; + +type Block = frame_system::mocking::MockBlock; +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; + +pub const ALICE: AccountId = 1_u128; +pub const BOB: AccountId = 2_u128; +pub const CHARLIE: AccountId = 3_u128; +pub const RELAYER: AccountId = 4_u128; + +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system::{Pallet, Call, Config, Storage, Event}, + Tokens: orml_tokens::{Pallet, Storage, Event, Config}, + Mosaic: pallet_mosaic::{Pallet, Storage, Event} + } +); + +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const SS58Prefix: u8 = 42; +} + +impl system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type Origin = Origin; + type Call = Call; + type Index = u64; + type BlockNumber = BlockNumber; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type Header = Header; + type Event = Event; + type BlockHashCount = BlockHashCount; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = (); + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = SS58Prefix; + type OnSetCode = (); +} + +parameter_type_with_key! { + pub ExistentialDeposits: |_currency_id: AssetId| -> Balance { + Zero::zero() + }; +} + +impl orml_tokens::Config for Test { + type Event = Event; + type Balance = Balance; + type Amount = Amount; + type CurrencyId = AssetId; + type WeightInfo = (); + type ExistentialDeposits = ExistentialDeposits; + type OnDust = (); + type MaxLocks = (); + type DustRemovalWhitelist = Everything; +} + +parameter_types! { + pub const MosaicPalletId: PalletId = PalletId(*b"plt_msac"); + pub const MinimumTTL: BlockNumber = 10; + pub const MinimumTimeLockPeriod: BlockNumber = 20; +} + +impl pallet_mosaic::Config for Test { + type Event = Event; + type PalletId = MosaicPalletId; + type Assets = Tokens; + type MinimumTTL = MinimumTTL; + type MinimumTimeLockPeriod = MinimumTimeLockPeriod; + type BudgetPenaltyDecayer = pallet_mosaic::BudgetPenaltyDecayer; + + type NetworkId = NetworkId; +} + +// Build genesis storage according to the mock runtime. +pub fn new_test_ext() -> sp_io::TestExternalities { + ExtBuilder::default().build() +} + +pub struct ExtBuilder { + pub balances: Vec<(AccountId, AssetId, Balance)>, +} + +impl Default for ExtBuilder { + fn default() -> Self { + Self { balances: vec![(ALICE, 1, 1000000)] } + } +} + +impl ExtBuilder { + pub fn build(self) -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::default().build_storage::().unwrap(); + + orml_tokens::GenesisConfig:: { balances: self.balances } + .assimilate_storage(&mut t) + .unwrap(); + + t.into() + } +} diff --git a/frame/mosaic/src/plots.rs b/frame/mosaic/src/plots.rs new file mode 100644 index 00000000000..354d1131b4a --- /dev/null +++ b/frame/mosaic/src/plots.rs @@ -0,0 +1,37 @@ +mod decay; + +fn main() { + #[cfg(feature = "visualization")] + { + plot_linear_decay(5); + } +} + +#[cfg(feature = "visualization")] +fn plot_linear_decay(n: u128) { + use decay::{BudgetPenaltyDecayer, Decayer}; + use plotters::prelude::*; + + let decay = BudgetPenaltyDecayer::linear(n); + let mut penalty = 80; + let blocks: u128 = 100; + + let area = BitMapBackend::new("./linear_decay.png", (1024, 768)).into_drawing_area(); + area.fill(&WHITE).unwrap(); + + let mut chart = ChartBuilder::on(&area) + .set_label_area_size(LabelAreaPosition::Left, 40) + .set_label_area_size(LabelAreaPosition::Bottom, 40) + .build_cartesian_2d(0.0..100.0, 0.0..100.0) + .unwrap(); + chart.configure_mesh().draw().unwrap(); + chart + .draw_series(LineSeries::new( + (1..=blocks).map(|x| { + penalty = decay.checked_decay(penalty, x - 1, x).unwrap(); + (x as f64, penalty as f64) + }), + &RED, + )) + .unwrap(); +} diff --git a/frame/mosaic/src/relayer.rs b/frame/mosaic/src/relayer.rs new file mode 100644 index 00000000000..54620e8c908 --- /dev/null +++ b/frame/mosaic/src/relayer.rs @@ -0,0 +1,79 @@ +use frame_support::pallet_prelude::*; + +/// A wrapper around the `Relayer` configuration which forces the user to respect the TTL and update +/// the relayer `AccountId` if mandated. +#[derive(Decode, Encode, TypeInfo)] +pub struct StaleRelayer { + relayer: RelayerConfig, +} + +impl StaleRelayer { + /// Create a relayer configuration, without scheduling a new `AccountId`. + pub fn new(account: AccountId) -> StaleRelayer { + StaleRelayer { relayer: RelayerConfig { current: account, next: None } } + } +} + +impl StaleRelayer { + /// Enforces Relayer TTL and returns the relayer configuration. + pub fn update(self, now: BlockNumber) -> RelayerConfig { + self.relayer.rejig(now) + } +} + +/// Configuration for the relayer account. +#[derive(PartialEq, Eq, Debug, Decode, Encode, TypeInfo)] +pub struct RelayerConfig { + /// Current AccountId used by the relayer. + current: AccountId, + /// Scheduled update of the AccountId. + next: Option>, +} + +impl RelayerConfig { + pub fn account_id(&self) -> &AccountId { + &self.current + } +} + +impl From> + for StaleRelayer +{ + fn from(relayer: RelayerConfig) -> Self { + Self { relayer } + } +} + +/// Next relayer configuration to be used. +#[derive(PartialEq, Eq, Debug, Decode, Encode, TypeInfo)] +pub struct Next { + ttl: BlockNumber, + account: AccountId, +} + +impl RelayerConfig { + pub fn is_relayer(&self, account: &AccountId) -> bool { + &self.current == account + } +} + +impl RelayerConfig { + fn rejig(self, current: BlockNumber) -> Self { + match self.next { + None => self, + Some(next) => + if next.ttl <= current { + RelayerConfig { current: next.account, next: None } + } else { + RelayerConfig { current: self.current, next: Some(next) } + }, + } + } +} + +impl RelayerConfig { + pub fn rotate(mut self, account: AccountId, ttl: BlockNumber) -> Self { + self.next = Some(Next { ttl, account }); + self + } +} diff --git a/frame/mosaic/src/tests.rs b/frame/mosaic/src/tests.rs new file mode 100644 index 00000000000..7e713af6912 --- /dev/null +++ b/frame/mosaic/src/tests.rs @@ -0,0 +1,1415 @@ +/// TODO +/// +/// 1. Test each extrinsic +/// 2. Make sure unlocks etc are respected (timing) +/// 3. Add tests for linear decay. +/// +/// +/// grouping tests +/// +/// test every failure case +/// every error that an extrinsic can return +/// +/// all the happy path cases +/// +/// +/// interaction logic between extrinsics +/// such as: +/// transfer_to -> waiting for a block (til lock_time expires) -> claiming +/// check if the funds are correctly moved to the user's account +/// +/// transfer_to -> waiting for a block -> relayer accepts transfer -> (til lock_time expires) +/// -> we should no longer be able to claim +/// +/// incoming -> waiting til lock_time expires -> claiming +/// +/// incoming -> wainting for a block -> relayer cancels transfer (finality issue) -> we should +/// no longer be able to claim +/// +/// +/// For every test, make sure that you check wether the funds moved to the correct (sub) +/// accounts. +use crate::{decay::*, mock::*, *}; +use composable_tests_helpers::{prop_assert_noop, prop_assert_ok}; +use frame_support::{ + assert_err, assert_noop, assert_ok, + traits::fungibles::{Inspect, Mutate}, +}; +use proptest::prelude::*; +use sp_runtime::{DispatchError, TokenError}; + +pub trait OriginExt { + fn relayer() -> Origin { + Origin::signed(RELAYER) + } + + fn alice() -> Origin { + Origin::signed(ALICE) + } + + fn bob() -> Origin { + Origin::signed(BOB) + } +} + +const BUDGET: Balance = 10000; + +impl OriginExt for Origin {} + +prop_compose! { + fn account_id() + (x in 1..AccountId::MAX) -> AccountId { + x + } +} + +prop_compose! { + fn amount_within_budget() + (x in 1..BUDGET) -> Balance { + x + } +} + +prop_compose! { + fn lock_time_gen() + (x in 1..10000u64) -> u64 { + x + } +} + +prop_compose! { + fn wait_after_lock_gen() + (x in 1..1000u64) -> u64 { + x + } +} + +prop_compose! { + fn budget_with_split() + (budget in 1..10_000_000u128, split in 1..100u128) -> (Balance, Balance, Balance) { + let first_part = (budget * split) / 100u128; + let second_part = budget - first_part; + + (budget, first_part, second_part) + } +} + +mod ensure_relayer { + use super::*; + + #[test] + fn ensure_relayer_is_set() { + new_test_ext().execute_with(|| { + assert_err!( + Mosaic::ensure_relayer(Origin::signed(ALICE)), + Error::::RelayerNotSet + ); + }) + } + + #[test] + fn ensure_relayer_origin_checked() { + new_test_ext().execute_with(|| { + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + assert_err!(Mosaic::ensure_relayer(Origin::signed(ALICE)), DispatchError::BadOrigin); + }) + } +} + +mod set_relayer { + use super::*; + + #[test] + fn set_relayer() { + new_test_ext().execute_with(|| { + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + assert_eq!(Mosaic::relayer_account_id(), Ok(RELAYER)); + }) + } + + #[test] + fn relayer_cannot_set_relayer() { + new_test_ext().execute_with(|| { + assert_noop!(Mosaic::set_relayer(Origin::relayer(), ALICE), DispatchError::BadOrigin); + }) + } + + #[test] + fn none_cannot_set_relayer() { + new_test_ext().execute_with(|| { + assert_noop!(Mosaic::set_relayer(Origin::none(), ALICE), DispatchError::BadOrigin); + }) + } + + #[test] + fn alice_cannot_set_relayer() { + new_test_ext().execute_with(|| { + assert_noop!( + Mosaic::set_relayer(Origin::signed(ALICE), ALICE), + DispatchError::BadOrigin + ); + }) + } +} + +mod rotate_relayer { + use super::*; + + #[test] + fn relayer_can_rotate_relayer() { + new_test_ext().execute_with(|| { + let ttl = 500; + let current_block = System::block_number(); + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + + // first rotation + assert_ok!(Mosaic::rotate_relayer(Origin::relayer(), BOB, ttl)); + System::set_block_number(current_block + ttl); + assert_eq!(Mosaic::relayer_account_id(), Ok(BOB)); + + // second rotation + assert_ok!(Mosaic::rotate_relayer(Origin::signed(BOB), CHARLIE, ttl)); + System::set_block_number(current_block + 2 * ttl); + assert_eq!(Mosaic::relayer_account_id(), Ok(CHARLIE)); + }) + } + + #[test] + fn relayer_must_not_rotate_early() { + new_test_ext().execute_with(|| { + let ttl = 500; + let current_block = System::block_number(); + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + assert_ok!(Mosaic::rotate_relayer(Origin::relayer(), BOB, ttl)); + System::set_block_number(current_block + ttl - 1); // just before the ttl + assert_eq!(Mosaic::relayer_account_id(), Ok(RELAYER)); // not BOB + }) + } + + #[test] + fn arbitrary_account_cannot_rotate_relayer() { + new_test_ext().execute_with(|| { + let ttl = 500; + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + assert_noop!( + Mosaic::rotate_relayer(Origin::signed(ALICE), BOB, ttl), + DispatchError::BadOrigin + ); + }) + } + + #[test] + fn none_cannot_rotate_relayer() { + new_test_ext().execute_with(|| { + let ttl = 500; + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + assert_noop!( + Mosaic::rotate_relayer(Origin::none(), BOB, ttl), + DispatchError::BadOrigin + ); + }) + } +} + +mod set_network { + use super::*; + + #[test] + fn relayer_can_set_network() { + let network_id = 3; + let network_info = NetworkInfo { enabled: false, max_transfer_size: 100000 }; + new_test_ext().execute_with(|| { + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + + assert_ok!(Mosaic::set_network(Origin::relayer(), network_id, network_info.clone())); + assert_eq!(Mosaic::network_infos(network_id), Some(network_info)); + }) + } + + #[test] + fn root_cannot_set_network() { + let network_id = 3; + let network_info = NetworkInfo { enabled: false, max_transfer_size: 100000 }; + new_test_ext().execute_with(|| { + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + + assert_noop!( + Mosaic::set_network(Origin::root(), network_id, network_info.clone()), + DispatchError::BadOrigin + ); + }) + } + + #[test] + fn none_cannot_set_network() { + let network_id = 3; + let network_info = NetworkInfo { enabled: false, max_transfer_size: 100000 }; + new_test_ext().execute_with(|| { + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + + assert_noop!( + Mosaic::set_network(Origin::none(), network_id, network_info.clone()), + DispatchError::BadOrigin + ); + }) + } +} + +mod budget { + use super::*; + + mod set_budget { + use super::*; + + #[test] + fn root_can_set_budget() { + new_test_ext().execute_with(|| { + assert_ok!(Mosaic::set_budget( + Origin::root(), + 1, + 1, + BudgetPenaltyDecayer::linear(5) + )); + }) + } + + #[test] + fn arbitrary_user_cannot_set_budget() { + new_test_ext().execute_with(|| { + assert_noop!( + Mosaic::set_budget( + Origin::signed(ALICE), + 1, + 1, + BudgetPenaltyDecayer::linear(5) + ), + DispatchError::BadOrigin + ); + }) + } + + #[test] + fn none_cannot_set_budget() { + new_test_ext().execute_with(|| { + assert_noop!( + Mosaic::set_budget(Origin::none(), 1, 1, BudgetPenaltyDecayer::linear(5)), + DispatchError::BadOrigin + ); + }) + } + } + + #[test] + fn budget_are_isolated() { + new_test_ext().execute_with(|| { + assert_ok!(Mosaic::set_budget( + Origin::root(), + 1, + 0xCAFEBABE, + BudgetPenaltyDecayer::linear(10) + )); + assert_ok!(Mosaic::set_budget( + Origin::root(), + 2, + 0xDEADC0DE, + BudgetPenaltyDecayer::linear(5) + )); + assert_eq!(Mosaic::asset_infos(1).expect("budget must exists").budget, 0xCAFEBABE); + assert_eq!(Mosaic::asset_infos(2).expect("budget must exists").budget, 0xDEADC0DE); + }) + } + + #[test] + fn last_deposit_does_not_change_after_updating_budget() { + new_test_ext().execute_with(|| { + let initial_block = System::block_number(); + assert_ok!(Mosaic::set_budget( + Origin::root(), + 1, + 0xCAFEBABE, + BudgetPenaltyDecayer::linear(10) + )); + assert_eq!( + Mosaic::asset_infos(1).expect("budget must exists").last_mint_block, + initial_block + ); + + System::set_block_number(initial_block + 1); + assert_ok!(Mosaic::set_budget( + Origin::root(), + 1, + 0xDEADC0DE, + BudgetPenaltyDecayer::linear(10) + )); + assert_eq!( + Mosaic::asset_infos(1).expect("budget must exists").last_mint_block, + initial_block + ); + }) + } +} + +#[test] +fn incoming_outgoing_accounts_are_isolated() { + ExtBuilder { balances: Default::default() }.build().execute_with(|| { + initialize(); + + let amount = 100; + let network_id = 1; + let asset_id = 1; + + assert_ok!(Tokens::mint_into(asset_id, &ALICE, amount)); + let account_balance = || Tokens::balance(asset_id, &ALICE); + let balance_of = |t| Tokens::balance(asset_id, &Mosaic::sub_account_id(t)); + assert_eq!(account_balance(), amount); + assert_eq!(balance_of(SubAccount::new_outgoing(ALICE)), 0); + assert_eq!(balance_of(SubAccount::new_incoming(ALICE)), 0); + assert_ok!(Mosaic::transfer_to( + Origin::signed(ALICE), + network_id, + asset_id, + [0; 20], + amount, + true + )); + assert_eq!(account_balance(), 0); + assert_eq!(balance_of(SubAccount::new_outgoing(ALICE)), amount); + assert_eq!(balance_of(SubAccount::new_incoming(ALICE)), 0); + }) +} + +fn initialize() { + System::set_block_number(1); + + Mosaic::set_relayer(Origin::root(), RELAYER).expect("root may call set_relayer"); + Mosaic::set_network( + Origin::relayer(), + 1, + NetworkInfo { enabled: true, max_transfer_size: 100000 }, + ) + .expect("relayer may set network info"); + Mosaic::set_budget(Origin::root(), 1, BUDGET, BudgetPenaltyDecayer::linear(10)) + .expect("root may set budget"); +} + +fn do_timelocked_mint(to: AccountId, asset_id: AssetId, amount: Balance, lock_time: u64) { + let initial_block = System::block_number(); + + Mosaic::timelocked_mint(Origin::relayer(), asset_id, to, amount, lock_time, Default::default()) + .expect("relayer should be able to mint"); + + assert_eq!( + Mosaic::incoming_transactions(to, asset_id), + Some((amount, initial_block + lock_time)) + ); +} + +mod transfers { + use super::*; + + #[test] + fn transfer_to() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + }) + } + + #[test] + fn accept_transfer() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + Mosaic::accept_transfer(Origin::relayer(), ALICE, 1, 100) + .expect("accepting transfer should work"); + }) + } + + #[test] + fn cannot_accept_transfer_larger_than_balance() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + assert_noop!( + Mosaic::accept_transfer(Origin::relayer(), ALICE, 1, 101), + Error::::AmountMismatch + ); + }) + } + + #[test] + fn claim_stale_to() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + let current_block = System::block_number(); + System::set_block_number(current_block + Mosaic::timelock_period() + 1); + Mosaic::claim_stale_to(Origin::signed(ALICE), 1, ALICE) + .expect("claiming an outgoing transaction should work after the timelock period"); + }) + } + + #[test] + fn cannot_claim_stale_to_early() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + let current_block = System::block_number(); + System::set_block_number(current_block + Mosaic::timelock_period() - 1); + assert_noop!( + Mosaic::claim_stale_to(Origin::signed(ALICE), 1, ALICE), + Error::::NoStaleTransactions + ); + }) + } + + #[test] + fn cannot_claim_after_relayer_accepts_transfer() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + assert_ok!(Mosaic::accept_transfer(Origin::relayer(), ALICE, 1, 100)); + let current_block = System::block_number(); + System::set_block_number(current_block + Mosaic::timelock_period() + 1); + assert_noop!( + Mosaic::claim_stale_to(Origin::signed(ALICE), 1, ALICE), + Error::::NoStaleTransactions + ); + }) + } + + #[test] + fn relayer_cannot_accept_transfer_after_claim() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + let current_block = System::block_number(); + System::set_block_number(current_block + Mosaic::timelock_period() + 1); + assert_ok!(Mosaic::claim_stale_to(Origin::signed(ALICE), 1, ALICE)); + assert_noop!( + Mosaic::accept_transfer(Origin::relayer(), ALICE, 1, 100), + Error::::NoOutgoingTx + ); + }) + } + + #[test] + fn can_claim_stale_after_partial_accept_transfer() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + let current_block = System::block_number(); + System::set_block_number(current_block + Mosaic::timelock_period() + 1); + assert_ok!(Mosaic::accept_transfer(Origin::relayer(), ALICE, 1, 20)); + // System::set_block_number(current_block + Mosaic::timelock_period() + 1); + assert_ok!(Mosaic::claim_stale_to(Origin::signed(ALICE), 1, ALICE)); + }) + } + + #[test] + fn transfer_to_exceeds_max_transfer_size() { + ExtBuilder { balances: Default::default() }.build().execute_with(|| { + let max_transfer_size = 100000; + + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + + let network_id = 1; + assert_ok!(Mosaic::set_network( + Origin::relayer(), + network_id, + NetworkInfo { enabled: true, max_transfer_size }, + )); + + let asset_id = 1; + assert_ok!(Mosaic::set_budget( + Origin::root(), + asset_id, + 10000, + BudgetPenaltyDecayer::linear(10) + )); + + // We exceed the max transfer size + let amount = max_transfer_size + 1; + assert_ok!(Tokens::mint_into(asset_id, &ALICE, amount)); + assert_noop!( + Mosaic::transfer_to( + Origin::signed(ALICE), + network_id, + asset_id, + [0; 20], + amount, + true + ), + Error::::ExceedsMaxTransferSize + ); + }) + } + + #[test] + fn transfer_to_move_funds_to_outgoing() { + ExtBuilder { balances: Default::default() }.build().execute_with(|| { + initialize(); + + let amount = 100; + let network_id = 1; + let asset_id = 1; + + assert_ok!(Tokens::mint_into(asset_id, &ALICE, amount)); + let account_balance = || Tokens::balance(asset_id, &ALICE); + let outgoing_balance = || { + Tokens::balance(asset_id, &Mosaic::sub_account_id(SubAccount::new_outgoing(ALICE))) + }; + assert_eq!(account_balance(), amount); + assert_eq!(outgoing_balance(), 0); + assert_ok!(Mosaic::transfer_to( + Origin::signed(ALICE), + network_id, + asset_id, + [0; 20], + amount, + true + )); + assert_eq!(account_balance(), 0); + assert_eq!(outgoing_balance(), amount); + }) + } + + #[test] + fn transfer_to_unsupported_asset() { + ExtBuilder { balances: Default::default() }.build().execute_with(|| { + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + assert_ok!(Mosaic::set_network( + Origin::relayer(), + 1, + NetworkInfo { enabled: true, max_transfer_size: 100000 }, + )); + + // We don't register the asset + + let amount = 100; + let network_id = 1; + let asset_id = 1; + + assert_ok!(Tokens::mint_into(asset_id, &ALICE, amount)); + assert_noop!( + Mosaic::transfer_to( + Origin::signed(ALICE), + network_id, + asset_id, + [0; 20], + amount, + true + ), + Error::::UnsupportedAsset + ); + }) + } + + fn do_transfer_to() { + let ethereum_address = [0; 20]; + let amount = 100; + let network_id = 1; + let asset_id = 1; + + Mosaic::transfer_to( + Origin::signed(ALICE), + network_id, + asset_id, + ethereum_address, + amount, + true, + ) + .expect("transfer_to should work"); + assert_eq!( + Mosaic::outgoing_transactions(&ALICE, 1), + Some((100, MinimumTimeLockPeriod::get() + System::block_number())) + ); + + // normally we don't unit test events being emitted, but in this case it is very crucial for + // the relayer to observe the events. + + // When a transfer is made, the nonce is incremented. However, nonce is one of the + // dependencies for `generate_id`, we want to check if the events match, so we decrement the + // nonce and increment it back when we're done + // TODO: this is a hack, cfr: CU-1ubrf2y + Nonce::::mutate(|nonce| { + *nonce = nonce.wrapping_sub(1); + *nonce + }); + + let id = generate_id::( + &ALICE, + &network_id, + &asset_id, + ðereum_address, + &amount, + &System::block_number(), + ); + Nonce::::mutate(|nonce| { + *nonce = nonce.wrapping_add(1); + *nonce + }); + + System::assert_last_event(mock::Event::Mosaic(crate::Event::TransferOut { + id, + to: ethereum_address, + amount, + network_id, + })); + } +} + +mod timelocked_mint { + use super::*; + + #[test] + fn timelocked_mint() { + new_test_ext().execute_with(|| { + initialize(); + do_timelocked_mint(ALICE, 1, 50, 10); + }) + } + + #[test] + fn cannot_mint_unsupported_assets() { + new_test_ext().execute_with(|| { + initialize(); + let unsupported_asset_id = 42; + assert_noop!( + Mosaic::timelocked_mint( + Origin::relayer(), + unsupported_asset_id, + ALICE, + 50, + 10, + Default::default() + ), + Error::::UnsupportedAsset + ); + }) + } + + #[test] + fn cannot_mint_more_than_budget() { + new_test_ext().execute_with(|| { + initialize(); + assert_noop!( + Mosaic::timelocked_mint(Origin::relayer(), 1, ALICE, 10001, 10, Default::default()), + Error::::InsufficientBudget + ); + }) + } + + #[test] + fn only_relayer_can_timelocked_mint() { + new_test_ext().execute_with(|| { + initialize(); + assert_noop!( + Mosaic::timelocked_mint( + Origin::signed(ALICE), + 1, + ALICE, + 50, + 10, + Default::default() + ), + DispatchError::BadOrigin + ); + }) + } + + #[test] + fn none_cannot_timelocked_mint() { + new_test_ext().execute_with(|| { + initialize(); + assert_noop!( + Mosaic::timelocked_mint(Origin::none(), 1, ALICE, 50, 10, Default::default()), + DispatchError::BadOrigin + ); + }) + } + + #[test] + fn timelocked_mint_adds_to_incoming_transactions() { + new_test_ext().execute_with(|| { + initialize(); + let amount = 50; + let lock_time = 10; + Mosaic::timelocked_mint( + Origin::relayer(), + 1, + ALICE, + amount, + lock_time, + Default::default(), + ) + .expect("timelocked_mint should work"); + assert_eq!( + Mosaic::incoming_transactions(ALICE, 1), + Some((amount, lock_time + System::block_number())) + ); + }) + } + + #[test] + fn timelocked_mint_updates_incoming_transactions() { + new_test_ext().execute_with(|| { + initialize(); + let amount = 50; + let lock_time = 10; + + Mosaic::timelocked_mint( + Origin::relayer(), + 1, + ALICE, + amount, + lock_time, + Default::default(), + ) + .expect("timelocked_mint should work"); + assert_eq!( + Mosaic::incoming_transactions(ALICE, 1), + Some((amount, lock_time + System::block_number())) + ); + + let amount_2 = 100; + let new_lock_time = 20; + + Mosaic::timelocked_mint( + Origin::relayer(), + 1, + ALICE, + amount_2, + new_lock_time, + Default::default(), + ) + .expect("timelocked_mint should work"); + + assert_eq!( + Mosaic::incoming_transactions(ALICE, 1), + Some((amount + amount_2, new_lock_time + System::block_number())) + ); + }) + } + + #[test] + fn rescind_timelocked_mint() { + new_test_ext().execute_with(|| { + initialize(); + let lock_time = 10; + do_timelocked_mint(ALICE, 1, 50, lock_time); + + let initial_block = System::block_number(); + + Mosaic::rescind_timelocked_mint(Origin::relayer(), 1, ALICE, 40) + .expect("relayer should be able to rescind transactions"); + assert_eq!( + Mosaic::incoming_transactions(ALICE, 1), + Some((10, initial_block + lock_time)) + ); + let transfer_amount = 9; + Mosaic::rescind_timelocked_mint(Origin::relayer(), 1, ALICE, transfer_amount) + .expect("relayer should be able to rescind transactions"); + assert_eq!(Mosaic::incoming_transactions(ALICE, 1), Some((1, 11))); + }) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(10000))] + + #[test] + fn can_mint_up_to_the_penalised_budget( + account_a in account_id(), + decay in 1..100u128, // todo, + max_transfer_size in 1..10_000_000u128, + asset_id in 1..100u32, + start_block in 1..10_000u64, + (budget, first_part, second_part) in budget_with_split(), + ) { + new_test_ext().execute_with(|| { + // initialize + System::set_block_number(start_block); + + prop_assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + prop_assert_ok!(Mosaic::set_network( + Origin::relayer(), + asset_id, + NetworkInfo { enabled: true, max_transfer_size }, + ), "relayer may set network info"); + prop_assert_ok!(Mosaic::set_budget(Origin::root(), asset_id, budget, BudgetPenaltyDecayer::linear(decay)), "root may set budget"); + + + // We've split the budget in two parts. Both within the budget + prop_assert_eq!(budget, first_part + second_part); + // When mint the first part of the budget, it should be fine. + prop_assert_ok!(Mosaic::timelocked_mint(Origin::relayer(), asset_id, account_a, first_part, 0, Default::default())); + // The new penalised_budget should be budget - first_part. + // Whenwe mint the second part of the budget, it should be fine because it matches the penalised_budget. + prop_assert_ok!(Mosaic::timelocked_mint(Origin::relayer(), asset_id, account_a, second_part, 0, Default::default())); + + Ok(()) + })?; + } + + #[test] + fn cannot_mint_more_than_the_penalised_budget( + account_a in account_id(), + decay in 1..100u128, // todo, + max_transfer_size in 1..10_000_000u128, + asset_id in 1..100u32, + start_block in 1..10_000u64, + (budget, first_part, second_part) in budget_with_split(), + ) { + new_test_ext().execute_with(|| { + // initialize + System::set_block_number(start_block); + + prop_assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + prop_assert_ok!(Mosaic::set_network( + Origin::relayer(), + asset_id, + NetworkInfo { enabled: true, max_transfer_size }, + ), "relayer may set network info"); + prop_assert_ok!(Mosaic::set_budget(Origin::root(), asset_id, budget, BudgetPenaltyDecayer::linear(decay)), "root may set budget"); + + + // We've split the budget in two parts. Both within the budget + prop_assert_eq!(budget, first_part + second_part); + // When mint the first part of the budget, it should be fine. + prop_assert_ok!(Mosaic::timelocked_mint(Origin::relayer(), asset_id, account_a, first_part, 0, Default::default())); + // The new penalised_budget should be budget - first_part. + // When we mint the second part of the budget, it should be fine because it matches the penalised_budget. + prop_assert_ok!(Mosaic::timelocked_mint(Origin::relayer(), asset_id, account_a, second_part, 0, Default::default())); + // When we mint more than the penalised budget, it should fail. + prop_assert_noop!(Mosaic::timelocked_mint(Origin::relayer(), asset_id, account_a, 1, 0, Default::default()), Error::::InsufficientBudget); + Ok(()) + })?; + } + + #[test] + fn should_be_able_to_mint_again_after_waiting_for_penalty_to_decay( + account_a in account_id(), + decay_factor in 1..100u128, // todo, + max_transfer_size in 1..10_000_000u128, + asset_id in 1..100u32, + start_block in 1..10_000u64, + (budget, first_part, second_part) in budget_with_split(), + iteration_count in 2..10u64, + ) { + prop_assume!(budget > decay_factor); + + new_test_ext().execute_with(|| { + + // initialize + System::set_block_number(start_block); + + let budget_penalty_decayer = BudgetPenaltyDecayer::linear(decay_factor); + + prop_assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + prop_assert_ok!(Mosaic::set_network( + Origin::relayer(), + asset_id, + NetworkInfo { enabled: true, max_transfer_size }, + ), "relayer may set network info"); + prop_assert_ok!(Mosaic::set_budget(Origin::root(), asset_id, budget, budget_penalty_decayer.clone()), "root may set budget"); + + + // We've split the budget in two parts. Both within the budget + prop_assert_eq!(budget, first_part + second_part); + + + let budget_recovery_period: BlockNumber = budget_penalty_decayer.full_recovery_period(budget) + .expect("impossible as per the prop_assume! above, qed"); + + + for _ in 0..iteration_count { + + // When mint the first part of the budget, it should be fine. + prop_assert_ok!(Mosaic::timelocked_mint(Origin::relayer(), asset_id, account_a, first_part, 0, Default::default())); + // The new penalised_budget should be budget - first_part. + // When we mint the second part of the budget, it should be fine because it matches the penalised_budget. + prop_assert_ok!(Mosaic::timelocked_mint(Origin::relayer(), asset_id, account_a, second_part, 0, Default::default())); + + + // When we mint more than the penalised budget, it should fail. + prop_assert_noop!(Mosaic::timelocked_mint(Origin::relayer(), asset_id, account_a, 1, 0, Default::default()), Error::::InsufficientBudget); + + + // We wait until the budget has recovered + System::set_block_number(System::block_number() + budget_recovery_period); + } + + Ok(()) + })?; + } + } +} + +mod rescind_timelocked_mint { + use super::*; + + #[test] + fn cannot_rescind_timelocked_mint_if_no_transaction() { + new_test_ext().execute_with(|| { + initialize(); + assert_noop!( + Mosaic::rescind_timelocked_mint(Origin::relayer(), 1, ALICE, 50), + Error::::NoClaimableTx + ); + }) + } + + #[test] + fn cannot_rescind_timelocked_mint_if_wrong_asset_id() { + new_test_ext().execute_with(|| { + initialize(); + let lock_time = 10; + do_timelocked_mint(ALICE, 1, 50, lock_time); + assert_noop!( + Mosaic::rescind_timelocked_mint(Origin::relayer(), 2, ALICE, 50), + Error::::NoClaimableTx + ); + }) + } + + #[test] + fn cannot_rescind_timelocked_mint_if_wrong_account() { + new_test_ext().execute_with(|| { + initialize(); + let lock_time = 10; + do_timelocked_mint(ALICE, 1, 50, lock_time); + assert_noop!( + Mosaic::rescind_timelocked_mint(Origin::relayer(), 1, BOB, 50), + Error::::NoClaimableTx + ); + }) + } + + #[test] + fn cannot_rescind_timelocked_mint_if_wrong_amount() { + new_test_ext().execute_with(|| { + initialize(); + let lock_time = 10; + let amount = 50; + do_timelocked_mint(ALICE, 1, amount, lock_time); + assert_noop!( + Mosaic::rescind_timelocked_mint(Origin::relayer(), 1, ALICE, amount + 1), + TokenError::NoFunds + ); + }) + } + + #[test] + fn rescind_timelocked_mint_in_two_steps() { + new_test_ext().execute_with(|| { + initialize(); + let lock_time = 10; + let start_amount = 50; + do_timelocked_mint(ALICE, 1, start_amount, lock_time); + assert_eq!( + Mosaic::incoming_transactions(ALICE, 1), + Some((start_amount, lock_time + System::block_number())) + ); + + let rescind_amount = 9; + Mosaic::rescind_timelocked_mint(Origin::relayer(), 1, ALICE, rescind_amount) + .expect("relayer should be able to rescind transactions"); + assert_eq!( + Mosaic::incoming_transactions(ALICE, 1), + Some((start_amount - rescind_amount, lock_time + System::block_number())) + ); + + Mosaic::rescind_timelocked_mint( + Origin::relayer(), + 1, + ALICE, + start_amount - rescind_amount, + ) + .expect("relayer should be able to rescind transactions"); + + assert_eq!(Mosaic::incoming_transactions(ALICE, 1), None); + }) + } +} + +mod set_timelock_duration { + use super::*; + + #[test] + fn set_timelock_duration() { + new_test_ext().execute_with(|| { + Mosaic::set_timelock_duration(Origin::root(), MinimumTimeLockPeriod::get() + 1) + .expect("root may set the timelock period"); + }) + } + + #[test] + fn set_timelock_duration_with_non_root() { + new_test_ext().execute_with(|| { + assert_noop!( + Mosaic::set_timelock_duration( + Origin::signed(ALICE), + MinimumTimeLockPeriod::get() + 1 + ), + DispatchError::BadOrigin + ); + }) + } + + #[test] + fn set_timelock_duration_with_origin_none() { + new_test_ext().execute_with(|| { + assert_noop!( + Mosaic::set_timelock_duration(Origin::none(), MinimumTimeLockPeriod::get() + 1), + DispatchError::BadOrigin + ); + }) + } + + #[test] + fn set_timelock_duration_with_invalid_period() { + new_test_ext().execute_with(|| { + assert_noop!( + Mosaic::set_timelock_duration(Origin::root(), 0), + Error::::BadTimelockPeriod + ); + }) + } + + #[test] + fn set_timelock_duration_with_invalid_period_2() { + new_test_ext().execute_with(|| { + assert_noop!( + Mosaic::set_timelock_duration(Origin::root(), MinimumTimeLockPeriod::get() - 1), + Error::::BadTimelockPeriod + ); + }) + } +} + +mod transfer_to { + use super::*; + + #[test] + fn transfer_to() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + }) + } + + #[test] + fn accept_transfer() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + Mosaic::accept_transfer(Origin::relayer(), ALICE, 1, 100) + .expect("accepting transfer should work"); + }) + } + + #[test] + fn cannot_accept_transfer_larger_than_balance() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + assert_noop!( + Mosaic::accept_transfer(Origin::relayer(), ALICE, 1, 101), + Error::::AmountMismatch + ); + }) + } + + #[test] + fn claim_stale_to() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + let current_block = System::block_number(); + System::set_block_number(current_block + Mosaic::timelock_period() + 1); + Mosaic::claim_stale_to(Origin::signed(ALICE), 1, ALICE) + .expect("claiming an outgoing transaction should work after the timelock period"); + }) + } + + #[test] + fn cannot_claim_stale_to_early() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + let current_block = System::block_number(); + System::set_block_number(current_block + Mosaic::timelock_period() - 1); + assert_noop!( + Mosaic::claim_stale_to(Origin::signed(ALICE), 1, ALICE), + Error::::NoStaleTransactions + ); + }) + } + + #[test] + fn cannot_claim_after_relayer_accepts_transfer() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + assert_ok!(Mosaic::accept_transfer(Origin::relayer(), ALICE, 1, 100)); + let current_block = System::block_number(); + System::set_block_number(current_block + Mosaic::timelock_period() + 1); + assert_noop!( + Mosaic::claim_stale_to(Origin::signed(ALICE), 1, ALICE), + Error::::NoStaleTransactions + ); + }) + } + + #[test] + fn relayer_cannot_accept_transfer_after_claim() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + let current_block = System::block_number(); + System::set_block_number(current_block + Mosaic::timelock_period() + 1); + assert_ok!(Mosaic::claim_stale_to(Origin::signed(ALICE), 1, ALICE)); + assert_noop!( + Mosaic::accept_transfer(Origin::relayer(), ALICE, 1, 100), + Error::::NoOutgoingTx + ); + }) + } + + #[test] + fn can_claim_stale_after_partial_accept_transfer() { + new_test_ext().execute_with(|| { + initialize(); + do_transfer_to(); + let current_block = System::block_number(); + System::set_block_number(current_block + Mosaic::timelock_period() + 1); + assert_ok!(Mosaic::accept_transfer(Origin::relayer(), ALICE, 1, 20)); + // System::set_block_number(current_block + Mosaic::timelock_period() + 1); + assert_ok!(Mosaic::claim_stale_to(Origin::signed(ALICE), 1, ALICE)); + }) + } + + #[test] + fn transfer_to_exceeds_max_transfer_size() { + ExtBuilder { balances: Default::default() }.build().execute_with(|| { + let max_transfer_size = 100000; + + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + + let network_id = 1; + assert_ok!(Mosaic::set_network( + Origin::relayer(), + network_id, + NetworkInfo { enabled: true, max_transfer_size }, + )); + + let asset_id = 1; + assert_ok!(Mosaic::set_budget( + Origin::root(), + asset_id, + 10000, + BudgetPenaltyDecayer::linear(10) + )); + + // We exceed the max transfer size + let amount = max_transfer_size + 1; + assert_ok!(Tokens::mint_into(asset_id, &ALICE, amount)); + assert_noop!( + Mosaic::transfer_to( + Origin::signed(ALICE), + network_id, + asset_id, + [0; 20], + amount, + true + ), + Error::::ExceedsMaxTransferSize + ); + }) + } + + #[test] + fn transfer_to_move_funds_to_outgoing() { + ExtBuilder { balances: Default::default() }.build().execute_with(|| { + initialize(); + + let amount = 100; + let network_id = 1; + let asset_id = 1; + + assert_ok!(Tokens::mint_into(asset_id, &ALICE, amount)); + let account_balance = || Tokens::balance(asset_id, &ALICE); + let outgoing_balance = || { + Tokens::balance(asset_id, &Mosaic::sub_account_id(SubAccount::new_outgoing(ALICE))) + }; + assert_eq!(account_balance(), amount); + assert_eq!(outgoing_balance(), 0); + assert_ok!(Mosaic::transfer_to( + Origin::signed(ALICE), + network_id, + asset_id, + [0; 20], + amount, + true + )); + assert_eq!(account_balance(), 0); + assert_eq!(outgoing_balance(), amount); + }) + } + + #[test] + fn transfer_to_unsupported_asset() { + ExtBuilder { balances: Default::default() }.build().execute_with(|| { + assert_ok!(Mosaic::set_relayer(Origin::root(), RELAYER)); + assert_ok!(Mosaic::set_network( + Origin::relayer(), + 1, + NetworkInfo { enabled: true, max_transfer_size: 100000 }, + )); + + // We don't register the asset + + let amount = 100; + let network_id = 1; + let asset_id = 1; + + assert_ok!(Tokens::mint_into(asset_id, &ALICE, amount)); + assert_noop!( + Mosaic::transfer_to( + Origin::signed(ALICE), + network_id, + asset_id, + [0; 20], + amount, + true + ), + Error::::UnsupportedAsset + ); + }) + } + + fn do_transfer_to() { + let ethereum_address = [0; 20]; + let amount = 100; + let network_id = 1; + let asset_id = 1; + + Mosaic::transfer_to( + Origin::signed(ALICE), + network_id, + asset_id, + ethereum_address, + amount, + true, + ) + .expect("transfer_to should work"); + assert_eq!( + Mosaic::outgoing_transactions(&ALICE, 1), + Some((100, MinimumTimeLockPeriod::get() + System::block_number())) + ); + + // normally we don't unit test events being emitted, but in this case it is very crucial for + // the relayer to observe the events. + + // When a transfer is made, the nonce is incremented. However, nonce is one of the + // dependencies for `generate_id`, we want to check if the events match, so we decrement the + // nonce and increment it back when we're done + // TODO: this is a hack, cfr: CU-1ubrf2y + Nonce::::mutate(|nonce| { + *nonce = nonce.wrapping_sub(1); + *nonce + }); + + let id = generate_id::( + &ALICE, + &network_id, + &asset_id, + ðereum_address, + &amount, + &System::block_number(), + ); + Nonce::::mutate(|nonce| { + *nonce = nonce.wrapping_add(1); + *nonce + }); + + System::assert_last_event(mock::Event::Mosaic(crate::Event::TransferOut { + id, + to: ethereum_address, + amount, + network_id, + })); + } +} + +mod accept_transfer { + use super::*; + + #[test] + fn cannot_mint_unsupported_assets() { + new_test_ext().execute_with(|| { + initialize(); + let unsupported_asset_id = 42; + assert_noop!( + Mosaic::timelocked_mint( + Origin::relayer(), + unsupported_asset_id, + ALICE, + 50, + 10, + Default::default() + ), + Error::::UnsupportedAsset + ); + }) + } + + #[test] + fn cannot_mint_more_than_budget() { + new_test_ext().execute_with(|| { + initialize(); + assert_noop!( + Mosaic::timelocked_mint(Origin::relayer(), 1, ALICE, 10001, 10, Default::default()), + Error::::InsufficientBudget + ); + }) + } + + #[test] + fn rescind_timelocked_mint() { + new_test_ext().execute_with(|| { + initialize(); + let lock_time = 10; + do_timelocked_mint(ALICE, 1, 50, lock_time); + + let initial_block = System::block_number(); + + Mosaic::rescind_timelocked_mint(Origin::relayer(), 1, ALICE, 40) + .expect("relayer should be able to rescind transactions"); + assert_eq!( + Mosaic::incoming_transactions(ALICE, 1), + Some((10, initial_block + lock_time)) + ); + let transfer_amount = 9; + Mosaic::rescind_timelocked_mint(Origin::relayer(), 1, ALICE, transfer_amount) + .expect("relayer should be able to rescind transactions"); + assert_eq!(Mosaic::incoming_transactions(ALICE, 1), Some((1, 11))); + }) + } +} + +#[test] +fn claim_to() { + new_test_ext().execute_with(|| { + initialize(); + let lock_time = 10; + do_timelocked_mint(ALICE, 1, 50, lock_time); + let current_block = System::block_number(); + Mosaic::claim_to(Origin::alice(), 1, ALICE).expect_err( + "received funds should only be claimable after waiting for the relayer mandated time", + ); + System::set_block_number(current_block + lock_time + 1); + Mosaic::claim_to(Origin::alice(), 1, ALICE) + .expect("received funds should be claimable after time has passed"); + }) +} diff --git a/runtime/composable/src/lib.rs b/runtime/composable/src/lib.rs index 306ecf946e8..e25b1f2f791 100644 --- a/runtime/composable/src/lib.rs +++ b/runtime/composable/src/lib.rs @@ -104,7 +104,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 100, + spec_version: 101, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, diff --git a/runtime/dali/src/lib.rs b/runtime/dali/src/lib.rs index 6a04547c028..e3ea818b77b 100644 --- a/runtime/dali/src/lib.rs +++ b/runtime/dali/src/lib.rs @@ -101,7 +101,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 2000, + spec_version: 2001, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, diff --git a/runtime/picasso/src/lib.rs b/runtime/picasso/src/lib.rs index 5596ddaea66..a4b43dcdb4b 100644 --- a/runtime/picasso/src/lib.rs +++ b/runtime/picasso/src/lib.rs @@ -99,7 +99,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // The version of the runtime specification. A full node will not attempt to use its native // runtime in substitute for the on-chain Wasm runtime unless all of `spec_name`, // `spec_version`, and `authoring_version` are the same between Wasm and native. - spec_version: 2000, + spec_version: 2001, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1,