diff --git a/Cargo.lock b/Cargo.lock index 7cec4939eb92..8d735b47f81e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14161,6 +14161,7 @@ dependencies = [ "frame-system", "impl-trait-for-tuples", "log", + "pallet-assets", "pallet-balances", "pallet-utility", "parity-scale-codec", diff --git a/cumulus/parachains/integration-tests/emulated/tests/assets/asset-hub-westend/src/tests/fellowship_treasury.rs b/cumulus/parachains/integration-tests/emulated/tests/assets/asset-hub-westend/src/tests/fellowship_treasury.rs index 746613aafa67..35f498232fc6 100644 --- a/cumulus/parachains/integration-tests/emulated/tests/assets/asset-hub-westend/src/tests/fellowship_treasury.rs +++ b/cumulus/parachains/integration-tests/emulated/tests/assets/asset-hub-westend/src/tests/fellowship_treasury.rs @@ -65,7 +65,7 @@ fn create_and_claim_treasury_spend() { // create and approve a treasury spend. assert_ok!(FellowshipTreasury::spend( root, - Box::new(asset_kind), + Box::new(pallet_treasury::SpendAsset::Specific(asset_kind)), SPEND_AMOUNT, Box::new(Location::new(0, Into::<[u8; 32]>::into(alice.clone())).into()), None, diff --git a/cumulus/parachains/integration-tests/emulated/tests/assets/asset-hub-westend/src/tests/treasury.rs b/cumulus/parachains/integration-tests/emulated/tests/assets/asset-hub-westend/src/tests/treasury.rs index 3b53557fc05c..1a9a03f0139b 100644 --- a/cumulus/parachains/integration-tests/emulated/tests/assets/asset-hub-westend/src/tests/treasury.rs +++ b/cumulus/parachains/integration-tests/emulated/tests/assets/asset-hub-westend/src/tests/treasury.rs @@ -64,7 +64,7 @@ fn create_and_claim_treasury_spend() { // create and approve a treasury spend. assert_ok!(Treasury::spend( root, - Box::new(asset_kind), + Box::new(pallet_treasury::SpendAsset::Specific(asset_kind)), SPEND_AMOUNT, Box::new(Location::new(0, Into::<[u8; 32]>::into(alice.clone())).into()), None, diff --git a/cumulus/parachains/integration-tests/emulated/tests/collectives/collectives-westend/src/tests/fellowship_treasury.rs b/cumulus/parachains/integration-tests/emulated/tests/collectives/collectives-westend/src/tests/fellowship_treasury.rs index aeab5a1ba5f2..26bcaa12b6a2 100644 --- a/cumulus/parachains/integration-tests/emulated/tests/collectives/collectives-westend/src/tests/fellowship_treasury.rs +++ b/cumulus/parachains/integration-tests/emulated/tests/collectives/collectives-westend/src/tests/fellowship_treasury.rs @@ -111,10 +111,10 @@ fn fellowship_treasury_spend() { let native_asset = Location::parent(); let treasury_spend_call = RuntimeCall::Treasury(pallet_treasury::Call::::spend { - asset_kind: bx!(VersionedLocatableAsset::from(( + asset: bx!(pallet_treasury::SpendAsset::Specific(VersionedLocatableAsset::from(( asset_hub_location.clone(), native_asset.into() - ))), + )))), amount: fellowship_treasury_balance, beneficiary: bx!(VersionedLocation::from(fellowship_treasury_location)), valid_from: None, @@ -189,10 +189,10 @@ fn fellowship_treasury_spend() { let fellowship_treasury_spend_call = RuntimeCall::FellowshipTreasury(pallet_treasury::Call::::spend { - asset_kind: bx!(VersionedLocatableAsset::from(( + asset: bx!(pallet_treasury::SpendAsset::Specific(VersionedLocatableAsset::from(( asset_hub_location, native_asset.into() - ))), + )))), amount: fellowship_spend_balance, beneficiary: bx!(VersionedLocation::from(alice_location)), valid_from: None, diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/governance/mod.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/governance/mod.rs index 9c00123b55c8..bf9bdb441afe 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/governance/mod.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/governance/mod.rs @@ -20,7 +20,10 @@ use super::*; use crate::xcm_config::Collectives; use frame_support::{ parameter_types, - traits::{tokens::UnityOrOuterConversion, EitherOf, EitherOfDiverse, FromContains}, + traits::{ + tokens::{asset_ops::common_ops::ConfigurableAssetCategoryManager, UnityOrOuterConversion}, + EitherOf, EitherOfDiverse, FromContains, + }, }; use frame_system::EnsureRootWithSuccess; use pallet_xcm::{EnsureXcm, IsVoiceOfBody}; @@ -132,6 +135,9 @@ pub type TreasuryBalanceConverter = UnityOrOuterConversion< AssetRate, >; +pub type AHWAssetCategories = + ConfigurableAssetCategoryManager; + impl pallet_treasury::Config for Runtime { type PalletId = TreasuryPalletId; type Currency = Balances; @@ -150,6 +156,7 @@ impl pallet_treasury::Config for Runtime { type Paymaster = LocalPay; type BalanceConverter = TreasuryBalanceConverter; type PayoutPeriod = PayoutSpendPeriod; + type AssetCategories = AHWAssetCategories; type BlockNumberProvider = RelaychainDataProvider; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = parachains_common::pay::benchmarks::LocalPayArguments< diff --git a/cumulus/parachains/runtimes/collectives/collectives-westend/src/fellowship/mod.rs b/cumulus/parachains/runtimes/collectives/collectives-westend/src/fellowship/mod.rs index 50d0342f65a3..b585cd11b3cb 100644 --- a/cumulus/parachains/runtimes/collectives/collectives-westend/src/fellowship/mod.rs +++ b/cumulus/parachains/runtimes/collectives/collectives-westend/src/fellowship/mod.rs @@ -29,8 +29,8 @@ use cumulus_primitives_core::ParaId; use frame_support::{ parameter_types, traits::{ - tokens::UnityOrOuterConversion, EitherOf, EitherOfDiverse, FromContains, MapSuccess, - OriginTrait, TryWithMorphedArg, + tokens::{asset_ops::common_ops::ConfigurableAssetCategoryManager, UnityOrOuterConversion}, + EitherOf, EitherOfDiverse, FromContains, MapSuccess, OriginTrait, TryWithMorphedArg, }, PalletId, }; @@ -285,6 +285,9 @@ pub type FellowshipTreasuryPaymaster = PayOverXcm< pub type FellowshipTreasuryInstance = pallet_treasury::Instance1; +pub type CWAssetCategories = + ConfigurableAssetCategoryManager; + impl pallet_treasury::Config for Runtime { type WeightInfo = weights::pallet_treasury::WeightInfo; type PalletId = FellowshipTreasuryPalletId; @@ -329,6 +332,7 @@ impl pallet_treasury::Config for Runtime { AssetRate, >; type PayoutPeriod = ConstU32<{ 30 * DAYS }>; + type AssetCategories = CWAssetCategories; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = polkadot_runtime_common::impls::benchmarks::TreasuryArguments< sp_core::ConstU8<1>, diff --git a/polkadot/runtime/common/src/impls.rs b/polkadot/runtime/common/src/impls.rs index 3e9e5e6c04c7..20ce20bf695b 100644 --- a/polkadot/runtime/common/src/impls.rs +++ b/polkadot/runtime/common/src/impls.rs @@ -19,11 +19,11 @@ use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use frame_support::traits::{ fungible::{Balanced, Credit}, - tokens::imbalance::ResolveTo, + tokens::{asset_ops::common_ops::ConfigurableAssetCategoryManager, imbalance::ResolveTo}, Contains, ContainsPair, Imbalance, OnUnbalanced, }; use pallet_treasury::TreasuryAccountId; -use polkadot_primitives::Balance; +use polkadot_primitives::{AccountId, Balance}; use sp_runtime::{traits::TryConvert, Perquintill}; use xcm::VersionedLocation; @@ -293,6 +293,8 @@ pub mod benchmarks { } } +pub type MockAssetCategoryManager = ConfigurableAssetCategoryManager; + #[cfg(test)] mod tests { use super::*; @@ -400,6 +402,7 @@ mod tests { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<0>; type BlockNumberProvider = System; + type AssetCategories = MockAssetCategoryManager; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } diff --git a/polkadot/runtime/rococo/src/lib.rs b/polkadot/runtime/rococo/src/lib.rs index b5bbda7ca6d4..6c827953cb53 100644 --- a/polkadot/runtime/rococo/src/lib.rs +++ b/polkadot/runtime/rococo/src/lib.rs @@ -42,7 +42,7 @@ use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use core::cmp::Ordering; use frame_support::{ dynamic_params::{dynamic_pallet_params, dynamic_params}, - traits::FromContains, + traits::{tokens::asset_ops::common_ops::ConfigurableAssetCategoryManager, FromContains}, }; use pallet_balances::WeightInfo; use pallet_nis::WithMaximumOf; @@ -330,6 +330,9 @@ impl EnsureOriginWithArg for DynamicParamet } } +pub type RococoAssetCategories = + ConfigurableAssetCategoryManager; + impl pallet_scheduler::Config for Runtime { type RuntimeOrigin = RuntimeOrigin; type RuntimeEvent = RuntimeEvent; @@ -561,6 +564,7 @@ impl pallet_treasury::Config for Runtime { >; type PayoutPeriod = PayoutSpendPeriod; type BlockNumberProvider = System; + type AssetCategories = RococoAssetCategories; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = polkadot_runtime_common::impls::benchmarks::TreasuryArguments; } diff --git a/polkadot/runtime/westend/src/lib.rs b/polkadot/runtime/westend/src/lib.rs index 7d3ba68430e8..2a0d3da1d2ec 100644 --- a/polkadot/runtime/westend/src/lib.rs +++ b/polkadot/runtime/westend/src/lib.rs @@ -35,10 +35,11 @@ use frame_support::{ genesis_builder_helper::{build_state, get_preset}, parameter_types, traits::{ - fungible::HoldConsideration, tokens::UnityOrOuterConversion, AsEnsureOriginWithArg, - ConstU32, Contains, EitherOf, EitherOfDiverse, EnsureOriginWithArg, FromContains, - InstanceFilter, KeyOwnerProofSystem, LinearStoragePrice, Nothing, ProcessMessage, - ProcessMessageError, VariantCountOf, WithdrawReasons, + fungible::HoldConsideration, + tokens::{asset_ops::common_ops::ConfigurableAssetCategoryManager, UnityOrOuterConversion}, + AsEnsureOriginWithArg, ConstU32, Contains, EitherOf, EitherOfDiverse, EnsureOriginWithArg, + FromContains, InstanceFilter, KeyOwnerProofSystem, LinearStoragePrice, Nothing, + ProcessMessage, ProcessMessageError, VariantCountOf, WithdrawReasons, }, weights::{ConstantMultiplier, WeightMeter}, PalletId, @@ -893,6 +894,9 @@ impl ah_client::SendToAssetHub for StakingXcmToAssetHub { } } +pub type WestendAssetCategories = + ConfigurableAssetCategoryManager; + impl ah_client::Config for Runtime { type CurrencyBalance = Balance; type AssetHubOrigin = @@ -975,6 +979,7 @@ impl pallet_treasury::Config for Runtime { >; type PayoutPeriod = PayoutSpendPeriod; type BlockNumberProvider = System; + type AssetCategories = WestendAssetCategories; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = polkadot_runtime_common::impls::benchmarks::TreasuryArguments; } diff --git a/prdoc/pr_10381.prdoc b/prdoc/pr_10381.prdoc new file mode 100644 index 000000000000..47b10bf87fa4 --- /dev/null +++ b/prdoc/pr_10381.prdoc @@ -0,0 +1,44 @@ +# Schema: Polkadot SDK PRDoc Schema (prdoc) v1.0.0 +# See doc at https://raw.githubusercontent.com/paritytech/polkadot-sdk/master/prdoc/schema_user.json + +title: Improve Treasury Payout of Stables + +doc: + - audience: Runtime Devs + description: | + This PR introduces category-based treasury spending, allowing the treasury to request payment from a category of assets (e.g., USD* stablecoins) rather than a single specific asset. + On payout, the requested amount is distributed across available assets within that category, optimizing for successful payment execution. + +crates: + - name: pallet-treasury + bump: major + - name: frame-support + bump: minor + - name: pallet-assets + bump: minor + - name: pallet-bounties + bump: patch + - name: pallet-child-bounties + bump: patch + - name: pallet-tips + bump: patch + - name: staking-async-parachain-runtime + bump: minor + - name: staking-async-rc-runtime + bump: minor + - name: polkadot-runtime-common + bump: minor + - name: asset-hub-westend-runtime + bump: minor + - name: collectives-westend-runtime + bump: minor + - name: westend-runtime + bump: minor + - name: rococo-runtime + bump: minor + - name: kitchensink-runtime + bump: minor + - name: asset-hub-westend-integration-tests + bump: patch + - name: collectives-westend-integration-tests + bump: patch diff --git a/substrate/bin/node/runtime/src/lib.rs b/substrate/bin/node/runtime/src/lib.rs index 8238a593d76f..ed8fddc4f4f2 100644 --- a/substrate/bin/node/runtime/src/lib.rs +++ b/substrate/bin/node/runtime/src/lib.rs @@ -31,7 +31,6 @@ use pallet_multi_asset_bounties::ArgumentsFactory as PalletMultiAssetBountiesArg use pallet_treasury::ArgumentsFactory as PalletTreasuryArgumentsFactory; #[cfg(feature = "runtime-benchmarks")] use polkadot_sdk::sp_core::crypto::FromEntropy; - use polkadot_sdk::*; use alloc::{vec, vec::Vec}; @@ -54,6 +53,7 @@ use frame_support::{ Balanced, Credit, HoldConsideration, ItemOf, NativeFromLeft, NativeOrWithId, UnionOf, }, tokens::{ + asset_ops::common_ops::AssetCategoryManager, imbalance::{ResolveAssetTo, ResolveTo}, nonfungibles_v2::Inspect, pay::PayAssetFromAccount, @@ -266,6 +266,31 @@ impl Contains> for TxPauseWhitelistedCalls { } } +pub struct CombinedAssetManager; + +impl AssetCategoryManager for CombinedAssetManager { + type AssetKind = NativeOrWithId; + type Balance = Balance; + + fn assets_in_category(category: &[u8]) -> Vec { + Assets::assets_in_category(category) + .into_iter() + .map(|asset_id| NativeOrWithId::WithId(asset_id)) + .collect() + } + + fn available_balance(asset: Self::AssetKind, owner: AccountId) -> Option { + match asset { + NativeOrWithId::Native => { + Some(Balances::free_balance(&owner)) + } + NativeOrWithId::WithId(asset_id) => { + Assets::available_balance(asset_id, owner) + } + } + } +} + #[cfg(feature = "runtime-benchmarks")] pub struct AssetRateArguments; #[cfg(feature = "runtime-benchmarks")] @@ -1332,6 +1357,7 @@ impl pallet_treasury::Config for Runtime { type BalanceConverter = AssetRate; type PayoutPeriod = SpendPayoutPeriod; type BlockNumberProvider = System; + type AssetCategories = CombinedAssetManager; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = PalletTreasuryArguments; } diff --git a/substrate/frame/assets/src/lib.rs b/substrate/frame/assets/src/lib.rs index 80576cd1a5d0..d73c906dbffa 100644 --- a/substrate/frame/assets/src/lib.rs +++ b/substrate/frame/assets/src/lib.rs @@ -186,6 +186,7 @@ use frame_support::{ storage::KeyPrefixIterator, traits::{ tokens::{ + asset_ops::common_ops::AssetCategoryManager, fungibles, DepositConsequence, Fortitude, Preservation::{Expendable, Preserve}, WithdrawConsequence, @@ -487,6 +488,15 @@ pub mod pallet { ValueQuery, >; + #[pallet::storage] + pub type AssetCategories, I: 'static = ()> = StorageMap< + _, + Blake2_128Concat, + BoundedVec>, // Category name + BoundedVec>, // Assets in this category + ValueQuery, + >; + /// The asset ID enforced for the next asset creation, if any present. Otherwise, this storage /// item has no effect. /// @@ -1973,6 +1983,23 @@ pub mod pallet { Reserves::::get(id).into_inner() } } + + impl, I: 'static> AssetCategoryManager for Pallet { + type AssetKind = T::AssetId; + type Balance = T::Balance; + + /// Get all assets in a category + fn assets_in_category(category: &[u8]) -> Vec { + BoundedVec::>::try_from(category.to_vec()) + .map(|bounded_category| AssetCategories::::get(bounded_category).into_inner()) + .unwrap_or_default() + } + + /// Get available balance of a specific asset in treasury + fn available_balance(asset: Self::AssetKind, owner: T::AccountId) -> Option { + Self::balance_of(owner, asset) + } + } } #[cfg(any(feature = "try-runtime", test))] diff --git a/substrate/frame/bounties/Cargo.toml b/substrate/frame/bounties/Cargo.toml index b777e9ce226e..3fe756d59d6d 100644 --- a/substrate/frame/bounties/Cargo.toml +++ b/substrate/frame/bounties/Cargo.toml @@ -50,7 +50,7 @@ runtime-benchmarks = [ "frame-benchmarking/runtime-benchmarks", "frame-support/runtime-benchmarks", "frame-system/runtime-benchmarks", - "pallet-assets/runtime-benchmarks", + "pallet-assets/runtime-benchmarks", "pallet-balances/runtime-benchmarks", "pallet-treasury/runtime-benchmarks", "sp-runtime/runtime-benchmarks", @@ -58,7 +58,7 @@ runtime-benchmarks = [ try-runtime = [ "frame-support/try-runtime", "frame-system/try-runtime", - "pallet-assets/try-runtime", + "pallet-assets/try-runtime", "pallet-balances/try-runtime", "pallet-treasury/try-runtime", "sp-runtime/try-runtime", diff --git a/substrate/frame/bounties/src/tests.rs b/substrate/frame/bounties/src/tests.rs index 812ae5b8e10a..f9782d737748 100644 --- a/substrate/frame/bounties/src/tests.rs +++ b/substrate/frame/bounties/src/tests.rs @@ -31,7 +31,10 @@ use frame_support::{ traits::{ fungible, fungible::{NativeFromLeft, NativeOrWithId}, - tokens::{PayFromAccount, UnityAssetBalanceConversion}, + tokens::{ + asset_ops::common_ops::ConfigurableAssetCategoryManager, PayFromAccount, + UnityAssetBalanceConversion, + }, AsEnsureOriginWithArg, ConstU32, ConstU64, Currency, Imbalance, OnInitialize, }, PalletId, @@ -94,6 +97,9 @@ parameter_types! { pub TreasuryInstance1Account: u128 = Treasury1::account_id(); } +pub type MockAssetCategoryManager = + ConfigurableAssetCategoryManager<::AccountId, (), u64>; + impl pallet_treasury::Config for Test { type PalletId = TreasuryPalletId; type Currency = pallet_balances::Pallet; @@ -113,6 +119,7 @@ impl pallet_treasury::Config for Test { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<10>; type BlockNumberProvider = System; + type AssetCategories = MockAssetCategoryManager; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } @@ -136,6 +143,7 @@ impl pallet_treasury::Config for Test { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<10>; type BlockNumberProvider = System; + type AssetCategories = MockAssetCategoryManager; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } diff --git a/substrate/frame/child-bounties/src/tests.rs b/substrate/frame/child-bounties/src/tests.rs index e0bb1ac6f75a..48c3d33cffae 100644 --- a/substrate/frame/child-bounties/src/tests.rs +++ b/substrate/frame/child-bounties/src/tests.rs @@ -25,20 +25,22 @@ use crate as pallet_child_bounties; use frame_support::{ assert_noop, assert_ok, derive_impl, parameter_types, traits::{ - tokens::{PayFromAccount, UnityAssetBalanceConversion}, + tokens::{ + asset_ops::common_ops::ConfigurableAssetCategoryManager, PayFromAccount, + UnityAssetBalanceConversion, + }, ConstU32, ConstU64, OnInitialize, }, weights::Weight, PalletId, }; +use super::Event as ChildBountiesEvent; use sp_runtime::{ traits::{BadOrigin, IdentityLookup}, BuildStorage, Perbill, Permill, TokenError, }; -use super::Event as ChildBountiesEvent; - type Block = frame_system::mocking::MockBlock; type BountiesError = pallet_bounties::Error; @@ -92,6 +94,9 @@ parameter_types! { pub const SpendLimit: Balance = u64::MAX; } +pub type MockAssetCategoryManager = + ConfigurableAssetCategoryManager<::AccountId, (), u64>; + impl pallet_treasury::Config for Test { type PalletId = TreasuryPalletId; type Currency = pallet_balances::Pallet; @@ -111,6 +116,7 @@ impl pallet_treasury::Config for Test { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<10>; type BlockNumberProvider = System; + type AssetCategories = MockAssetCategoryManager; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } diff --git a/substrate/frame/staking-async/runtimes/parachain/src/governance/mod.rs b/substrate/frame/staking-async/runtimes/parachain/src/governance/mod.rs index 476e3897b269..d1926640967d 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/governance/mod.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/governance/mod.rs @@ -22,7 +22,7 @@ use crate::xcm_config::Collectives; use frame_support::{ parameter_types, traits::{ - fungible::HoldConsideration, tokens::UnityOrOuterConversion, EitherOf, EitherOfDiverse, + fungible::HoldConsideration, tokens::{UnityOrOuterConversion, asset_ops::common_ops::ConfigurableAssetCategoryManager}, EitherOf, EitherOfDiverse, FromContains, LinearStoragePrice, }, }; @@ -128,6 +128,8 @@ parameter_types! { pub const MaxBalance: Balance = Balance::max_value(); } +pub type MockAssetCategoryManager = ConfigurableAssetCategoryManager; + pub type TreasurySpender = EitherOf, Spender>; impl pallet_treasury::Config for Runtime { @@ -166,6 +168,7 @@ impl pallet_treasury::Config for Runtime { >; type PayoutPeriod = PayoutSpendPeriod; type BlockNumberProvider = RelayChainBlockNumberProvider; + type AssetCategories = MockAssetCategoryManager; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = polkadot_runtime_common::impls::benchmarks::TreasuryArguments; } diff --git a/substrate/frame/staking-async/runtimes/rc/src/lib.rs b/substrate/frame/staking-async/runtimes/rc/src/lib.rs index 38ad0a81bd07..497cb6035891 100644 --- a/substrate/frame/staking-async/runtimes/rc/src/lib.rs +++ b/substrate/frame/staking-async/runtimes/rc/src/lib.rs @@ -37,7 +37,7 @@ use frame_support::{ genesis_builder_helper::{build_state, get_preset}, parameter_types, traits::{ - fungible::HoldConsideration, tokens::UnityOrOuterConversion, ConstBool, ConstU32, Contains, + fungible::HoldConsideration, tokens::{UnityOrOuterConversion, asset_ops::common_ops::ConfigurableAssetCategoryManager}, ConstBool, ConstU32, Contains, EitherOf, EitherOfDiverse, EnsureOriginWithArg, EverythingBut, FromContains, InstanceFilter, KeyOwnerProofSystem, LinearStoragePrice, Nothing, ProcessMessage, ProcessMessageError, VariantCountOf, WithdrawReasons, @@ -1017,6 +1017,10 @@ parameter_types! { pub const MaxBalance: Balance = Balance::max_value(); } + +pub type MockAssetCategoryManager = ConfigurableAssetCategoryManager; + + impl pallet_treasury::Config for Runtime { type PalletId = TreasuryPalletId; type Currency = Balances; @@ -1053,6 +1057,7 @@ impl pallet_treasury::Config for Runtime { >; type PayoutPeriod = PayoutSpendPeriod; type BlockNumberProvider = System; + type AssetCategories = MockAssetCategoryManager; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = polkadot_runtime_common::impls::benchmarks::TreasuryArguments; } diff --git a/substrate/frame/support/src/traits/tokens/asset_ops/common_ops.rs b/substrate/frame/support/src/traits/tokens/asset_ops/common_ops.rs index a1f898b2491e..0d1fdde2a3f9 100644 --- a/substrate/frame/support/src/traits/tokens/asset_ops/common_ops.rs +++ b/substrate/frame/support/src/traits/tokens/asset_ops/common_ops.rs @@ -18,7 +18,7 @@ use super::{common_strategies::*, *}; use crate::{ dispatch::DispatchResult, - sp_runtime::traits::Convert, + sp_runtime::traits::{Convert, Zero, Saturating}, traits::{misc::TypedGet, EnsureOriginWithArg}, }; @@ -348,3 +348,35 @@ impl Destroy for DisabledOps { Err(DispatchError::Other("Disabled")) } } + +/// Category Ops +pub trait AssetCategoryManager { + type AssetKind; + type Balance: Zero + PartialOrd + Copy + Saturating + sp_runtime::traits::AtLeast32BitUnsigned; + + fn assets_in_category(category: &[u8]) -> Vec; + + fn available_balance(asset: Self::AssetKind, owner: AccountId) -> Option; +} + +pub struct ConfigurableAssetCategoryManager { + _phantom: core::marker::PhantomData<(AccountId, AssetKind, Balance)>, +} + +impl AssetCategoryManager + for ConfigurableAssetCategoryManager +where + AssetKind: Clone, + Balance: PartialOrd + Copy + Saturating + sp_runtime::traits::AtLeast32BitUnsigned, +{ + type AssetKind = AssetKind; + type Balance = Balance; + + fn assets_in_category(_category: &[u8]) -> Vec { + Vec::new() + } + + fn available_balance(_asset: Self::AssetKind, _owner: AccountId) -> Option { + None + } +} diff --git a/substrate/frame/tips/src/lib.rs b/substrate/frame/tips/src/lib.rs index f0b0adab4f9a..b2b3c40f3a62 100644 --- a/substrate/frame/tips/src/lib.rs +++ b/substrate/frame/tips/src/lib.rs @@ -78,7 +78,6 @@ use frame_support::{ Parameter, }; use frame_system::pallet_prelude::BlockNumberFor; - #[cfg(any(feature = "try-runtime", test))] use sp_runtime::TryRuntimeError; diff --git a/substrate/frame/tips/src/tests.rs b/substrate/frame/tips/src/tests.rs index 052a425981ed..a5e06ae93a9f 100644 --- a/substrate/frame/tips/src/tests.rs +++ b/substrate/frame/tips/src/tests.rs @@ -25,12 +25,11 @@ use sp_runtime::{ BuildStorage, Perbill, Permill, }; use sp_storage::Storage; - use frame_support::{ assert_noop, assert_ok, derive_impl, parameter_types, storage::StoragePrefixedMap, traits::{ - tokens::{PayFromAccount, UnityAssetBalanceConversion}, + tokens::{PayFromAccount, UnityAssetBalanceConversion, asset_ops::common_ops::ConfigurableAssetCategoryManager}, ConstU32, ConstU64, IntegrityTest, SortedMembers, StorageVersion, }, PalletId, @@ -101,6 +100,8 @@ parameter_types! { pub TreasuryInstance1Account: u128 = Treasury1::account_id(); } +pub type MockAssetCategoryManager = ConfigurableAssetCategoryManager<::AccountId, (), u64>; + impl pallet_treasury::Config for Test { type PalletId = TreasuryPalletId; type Currency = pallet_balances::Pallet; @@ -120,6 +121,7 @@ impl pallet_treasury::Config for Test { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<10>; type BlockNumberProvider = System; + type AssetCategories = MockAssetCategoryManager; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } @@ -143,6 +145,7 @@ impl pallet_treasury::Config for Test { type BalanceConverter = UnityAssetBalanceConversion; type PayoutPeriod = ConstU64<10>; type BlockNumberProvider = System; + type AssetCategories = MockAssetCategoryManager; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } diff --git a/substrate/frame/treasury/Cargo.toml b/substrate/frame/treasury/Cargo.toml index c52a98b26dcf..ba886f4a18d8 100644 --- a/substrate/frame/treasury/Cargo.toml +++ b/substrate/frame/treasury/Cargo.toml @@ -24,6 +24,7 @@ frame-system = { workspace = true } impl-trait-for-tuples = { workspace = true } log = { workspace = true } pallet-balances = { workspace = true } +pallet-assets = { workspace = true } scale-info = { features = ["derive"], workspace = true } serde = { features = ["derive"], optional = true, workspace = true, default-features = true } sp-core = { optional = true, workspace = true } @@ -41,6 +42,7 @@ std = [ "frame-support/std", "frame-system/std", "log/std", + "pallet-assets/std", "pallet-balances/std", "pallet-utility/std", "scale-info/std", @@ -54,6 +56,7 @@ runtime-benchmarks = [ "frame-benchmarking/runtime-benchmarks", "frame-support/runtime-benchmarks", "frame-system/runtime-benchmarks", + "pallet-assets/runtime-benchmarks", "pallet-balances/runtime-benchmarks", "pallet-utility/runtime-benchmarks", "sp-runtime/runtime-benchmarks", @@ -61,6 +64,7 @@ runtime-benchmarks = [ try-runtime = [ "frame-support/try-runtime", "frame-system/try-runtime", + "pallet-assets/try-runtime", "pallet-balances/try-runtime", "pallet-utility/try-runtime", "sp-runtime/try-runtime", diff --git a/substrate/frame/treasury/src/benchmarking.rs b/substrate/frame/treasury/src/benchmarking.rs index a11723a27b2c..15d01967df5c 100644 --- a/substrate/frame/treasury/src/benchmarking.rs +++ b/substrate/frame/treasury/src/benchmarking.rs @@ -192,7 +192,7 @@ mod benchmarks { #[extrinsic_call] _( origin as T::RuntimeOrigin, - Box::new(asset_kind.clone()), + Box::new(SpendAsset::Specific(asset_kind.clone())), amount, Box::new(beneficiary_lookup), None, @@ -203,7 +203,7 @@ mod benchmarks { assert_last_event::( Event::AssetSpendApproved { index: 0, - asset_kind, + asset: SpendAsset::Specific(asset_kind), amount, beneficiary, valid_from, @@ -223,7 +223,7 @@ mod benchmarks { let spend_exists = if let Ok(origin) = T::SpendOrigin::try_successful_origin() { Treasury::::spend( origin, - Box::new(asset_kind.clone()), + Box::new(SpendAsset::Specific(asset_kind.clone())), amount, Box::new(beneficiary_lookup), None, @@ -234,7 +234,7 @@ mod benchmarks { false }; - T::Paymaster::ensure_successful(&beneficiary, asset_kind, amount); + T::Paymaster::ensure_successful(&beneficiary, asset_kind.clone(), amount); let caller: T::AccountId = account("caller", 0, SEED); #[block] @@ -250,13 +250,19 @@ mod benchmarks { if spend_exists { let id = match Spends::::get(0).unwrap().status { - PaymentState::Attempted { id, .. } => { - assert_ne!(T::Paymaster::check_payment(id), PaymentStatus::Failure); - id - }, + PaymentState::Attempted { executions, .. } => + executions.first().expect("No executions found in Attempted state").payment_id, _ => panic!("No payout attempt made"), }; - assert_last_event::(Event::Paid { index: 0, payment_id: id }.into()); + assert_ne!(T::Paymaster::check_payment(id), PaymentStatus::Failure); + + assert_last_event::( + Event::Paid { + index: 0, + execution: PaymentExecution { asset: asset_kind, amount, payment_id: id }, + } + .into(), + ); assert!(Treasury::::payout(RawOrigin::Signed(caller).into(), 0u32).is_err()); } @@ -275,7 +281,7 @@ mod benchmarks { let spend_exists = if let Ok(origin) = T::SpendOrigin::try_successful_origin() { Treasury::::spend( origin, - Box::new(asset_kind), + Box::new(SpendAsset::Specific(asset_kind)), amount, Box::new(beneficiary_lookup), None, @@ -283,7 +289,11 @@ mod benchmarks { Treasury::::payout(RawOrigin::Signed(caller.clone()).into(), 0u32)?; match Spends::::get(0).unwrap().status { - PaymentState::Attempted { id, .. } => { + PaymentState::Attempted { executions, .. } => { + let id = executions + .first() + .expect("No executions found in Attempted state") + .payment_id; T::Paymaster::ensure_concluded(id); }, _ => panic!("No payout attempt made"), @@ -320,7 +330,7 @@ mod benchmarks { let spend_exists = if let Ok(origin) = T::SpendOrigin::try_successful_origin() { Treasury::::spend( origin, - Box::new(asset_kind.clone()), + Box::new(SpendAsset::Specific(asset_kind.clone())), amount, Box::new(beneficiary_lookup), None, diff --git a/substrate/frame/treasury/src/lib.rs b/substrate/frame/treasury/src/lib.rs index 91c58e14e52e..05a489a3c6d3 100644 --- a/substrate/frame/treasury/src/lib.rs +++ b/substrate/frame/treasury/src/lib.rs @@ -94,12 +94,19 @@ use sp_runtime::{ Debug, PerThing, Permill, }; +use sp_runtime::traits::ConstU32; + +use alloc::vec::Vec; use frame_support::{ dispatch::{DispatchResult, DispatchResultWithPostInfo}, - ensure, print, + ensure, + pallet_prelude::DispatchError, + print, traits::{ - tokens::Pay, Currency, ExistenceRequirement::KeepAlive, Get, Imbalance, OnUnbalanced, - ReservableCurrency, WithdrawReasons, + tokens::{asset_ops::common_ops::AssetCategoryManager, Pay}, + Currency, + ExistenceRequirement::KeepAlive, + Get, Imbalance, OnUnbalanced, ReservableCurrency, WithdrawReasons, }, weights::Weight, BoundedVec, PalletId, @@ -168,13 +175,43 @@ pub struct Proposal { #[derive( Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, MaxEncodedLen, Debug, TypeInfo, )] -pub enum PaymentState { +pub enum PaymentState { /// Pending claim. Pending, - /// Payment attempted with a payment identifier. - Attempted { id: Id }, - /// Payment failed. - Failed, + /// Payment attempted with payment identifiers for each payment execution + Attempted { + executions: BoundedVec, ConstU32<32>>, + remaining_amount: Balance, + }, + /// Record of all failed payments to retry + Failed(BoundedVec, ConstU32<32>>), + /// Attempt incomplete payment balance from spend + Partial(Balance), +} + +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[derive( + Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, Debug, TypeInfo, MaxEncodedLen, +)] +pub enum SpendAsset { + /// Spend a specific asset + Specific(AssetKind), + /// Spend from a category of assets + Category(BoundedVec>), +} + +/// Represents a partial payment using a specific asset from a category +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[derive( + Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, Debug, TypeInfo, MaxEncodedLen, +)] +pub struct PaymentExecution { + /// The asset used for this partial payment + pub asset: AssetKind, + /// Amount paid using this asset + pub amount: Balance, + /// Payment identifier for this partial payment + pub payment_id: PaymentId, } /// Info regarding an approved treasury spend. @@ -183,8 +220,7 @@ pub enum PaymentState { Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, MaxEncodedLen, Debug, TypeInfo, )] pub struct SpendStatus { - // The kind of asset to be spent. - pub asset_kind: AssetKind, + pub asset: SpendAsset, /// The asset amount of the spend. pub amount: AssetBalance, /// The beneficiary of the spend. @@ -194,7 +230,7 @@ pub struct SpendStatus, + pub status: PaymentState, } /// Index of an approved treasury spend. @@ -203,6 +239,7 @@ pub type SpendIndex = u32; #[frame_support::pallet] pub mod pallet { use super::*; + use alloc::vec; use frame_support::{ dispatch_context::with_context, pallet_prelude::*, @@ -292,6 +329,12 @@ pub mod pallet { /// Provider for the block number. Normally this is the `frame_system` pallet. type BlockNumberProvider: BlockNumberProvider; + + type AssetCategories: AssetCategoryManager< + Self::AccountId, + AssetKind = Self::AssetKind, + Balance = AssetBalanceOf, + >; } #[pallet::extra_constants] @@ -403,7 +446,7 @@ pub mod pallet { /// A new asset spend proposal has been approved. AssetSpendApproved { index: SpendIndex, - asset_kind: T::AssetKind, + asset: SpendAsset, amount: AssetBalanceOf, beneficiary: T::Beneficiary, valid_from: BlockNumberFor, @@ -412,7 +455,11 @@ pub mod pallet { /// An approved spend was voided. AssetSpendVoided { index: SpendIndex }, /// A payment happened. - Paid { index: SpendIndex, payment_id: ::Id }, + Paid { + index: SpendIndex, + execution: + PaymentExecution, ::Id>, + }, /// A payment failed and can be retried. PaymentFailed { index: SpendIndex, payment_id: ::Id }, /// A spend was processed and removed from the storage. It might have been successfully @@ -433,7 +480,7 @@ pub mod pallet { /// Proposal has not been approved. ProposalNotApproved, /// The balance of the asset kind is not convertible to the balance of the native asset. - FailedToConvertBalance, + BalanceConversionFailed, /// The spend has expired and cannot be claimed. SpendExpired, /// The spend is not yet eligible for payout. @@ -446,6 +493,26 @@ pub mod pallet { NotAttempted, /// The payment has neither failed nor succeeded yet. Inconclusive, + + LowBalance, + + EmptyAssetCategory, + + InvalidPaymentState, + + ExecutionRateLimit, + + TooManyFailedPayments, + + AssetNotFound, + + ConversionFailed, + + ArithmeticOverflow, + + ConversionRateMismatch, + + InvalidConversionRate, } #[pallet::hooks] @@ -650,7 +717,7 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::spend())] pub fn spend( origin: OriginFor, - asset_kind: Box, + asset: Box>, #[pallet::compact] amount: AssetBalanceOf, beneficiary: Box>, valid_from: Option>, @@ -663,10 +730,90 @@ pub mod pallet { let expire_at = valid_from.saturating_add(T::PayoutPeriod::get()); ensure!(expire_at > now, Error::::SpendExpired); - let native_amount = - T::BalanceConverter::from_asset_balance(amount, *asset_kind.clone()) - .map_err(|_| Error::::FailedToConvertBalance)?; + if let SpendAsset::Category(ref category) = *asset { + let assets = Self::validate_category_spend(category, amount)?; + + if let Some(base) = assets.first() { + let native = + T::BalanceConverter::from_asset_balance(amount, base.clone()) + .or_else(|_| { + log::error!( + "Failed to convert first asset in {:?} to native balance", + category + ); + Err(Error::::ConversionFailed) + })?; + + for (index, asset_kind) in assets.iter().enumerate().skip(1) { + let candidate = T::BalanceConverter::from_asset_balance(amount, asset_kind.clone(), + ).or_else(|_| { + log::error!("Failed to convert asset {:?} (index {}) in category {:?} to native balance", asset_kind, index, category); + Err(Error::::ConversionFailed)})?; + + // Convert to u128 + let first_u128: u128 = native.unique_saturated_into(); + let current_u128: u128 = candidate.unique_saturated_into(); + + let (max, min) = if first_u128 > current_u128 { + (first_u128, current_u128) + } else { + (current_u128, first_u128) + }; + + if max > 0 { + let diff = + max.checked_sub(min).ok_or(Error::::ConversionFailed)?; + + let multiplied_diff = + diff.checked_mul(100).ok_or(Error::::ConversionFailed)?; + + let diff_percent = multiplied_diff.checked_div(max).unwrap_or(0); + + if diff_percent > 5 { + log::warn!( + "Asset {:?} in category {:?} has significantly different conversion rate ({}% diff)", + asset_kind, + category, + diff_percent + ); + return Err(Error::::ConversionRateMismatch.into()); + } + } else if max == 0 && min == 0 { + // Both are zero, which is acceptable + continue; + } else { + // max == 0 but min > 0, which should't happen + log::error!( + "Zero conversion rate detected for asset {:?} in category {:?}", + if max == first_u128 { base } else { asset_kind }, + category + ); + + return Err(Error::::InvalidConversionRate.into()); + } + } + } + } + + let native_amount = match *asset { + SpendAsset::Specific(ref asset_kind) => + T::BalanceConverter::from_asset_balance(amount, asset_kind.clone()) + .map_err(|_| Error::::BalanceConversionFailed)?, + SpendAsset::Category(ref category) => { + let assets = T::AssetCategories::assets_in_category(&category); + + if assets.is_empty() { + return Err(Error::::EmptyAssetCategory.into()); + } + assets + .iter() + .find_map(|asset_kind| { + T::BalanceConverter::from_asset_balance(amount, asset_kind.clone()).ok() + }) + .ok_or(Error::::BalanceConversionFailed)? + }, + }; ensure!(native_amount <= max_amount, Error::::InsufficientPermission); with_context::>, _>(|v| { @@ -691,7 +838,7 @@ pub mod pallet { Spends::::insert( index, SpendStatus { - asset_kind: *asset_kind.clone(), + asset: *asset.clone(), amount, beneficiary: beneficiary.clone(), valid_from, @@ -703,7 +850,7 @@ pub mod pallet { Self::deposit_event(Event::AssetSpendApproved { index, - asset_kind: *asset_kind, + asset: *asset, amount, beneficiary, valid_from, @@ -738,21 +885,91 @@ pub mod pallet { let mut spend = Spends::::get(index).ok_or(Error::::InvalidIndex)?; let now = T::BlockNumberProvider::current_block_number(); ensure!(now >= spend.valid_from, Error::::EarlyPayout); - ensure!(spend.expire_at > now, Error::::SpendExpired); - ensure!( - matches!(spend.status, PaymentState::Pending | PaymentState::Failed), - Error::::AlreadyAttempted - ); - - let id = T::Paymaster::pay(&spend.beneficiary, spend.asset_kind.clone(), spend.amount) - .map_err(|_| Error::::PayoutError)?; - - spend.status = PaymentState::Attempted { id }; + ensure!(spend.expire_at > now, Error::::SpendExpired); // when this expires we + // can't executed the same + // partial payment? + + match spend.asset { + SpendAsset::Specific(ref asset_kind) => match spend.status { + PaymentState::Pending | PaymentState::Failed(_) => { + let id = + T::Paymaster::pay(&spend.beneficiary, asset_kind.clone(), spend.amount) + .map_err(|_| Error::::PayoutError)?; + + spend.status = PaymentState::Attempted { + executions: BoundedVec::try_from(vec![PaymentExecution { + asset: asset_kind.clone(), + amount: spend.amount, + payment_id: id, + }]) + .map_err(|_| Error::::ExecutionRateLimit)?, + + remaining_amount: Zero::zero(), + }; + }, + _ => return Err(Error::::AlreadyAttempted.into()), + }, + SpendAsset::Category(ref category) => match spend.status { + PaymentState::Pending => { + let (executions, remaining_amount) = + Self::pay_from_category(category, &spend.beneficiary, spend.amount)?; + + if remaining_amount.is_zero() { + spend.status = PaymentState::Attempted { + executions, + remaining_amount: Zero::zero(), + }; + } else { + spend.status = PaymentState::Partial(remaining_amount); + } + }, + PaymentState::Failed(ref failed_payments) => { + let (executions, retry_amount) = Self::retry_failed_category_payments( + category, + &spend.beneficiary, + failed_payments, + )?; + + if retry_amount.is_zero() { + spend.status = PaymentState::Attempted { + executions, + remaining_amount: Zero::zero(), + }; + } else { + spend.status = PaymentState::Partial(retry_amount); + } + }, + + PaymentState::Partial(unpaid) => { + let (executions, remaining_amount) = + Self::pay_from_category(category, &spend.beneficiary, unpaid)?; + if remaining_amount.is_zero() { + spend.status = PaymentState::Attempted { + executions, + remaining_amount: Zero::zero(), + }; + } else { + spend.status = PaymentState::Partial(remaining_amount); + } + }, + + PaymentState::Attempted { .. } => { + return Err(Error::::AlreadyAttempted.into()); + }, + }, + } spend.expire_at = now.saturating_add(T::PayoutPeriod::get()); - Spends::::insert(index, spend); - - Self::deposit_event(Event::::Paid { index, payment_id: id }); - + Spends::::insert(index, spend.clone()); + + // Emit events for each payment execution + if let PaymentState::Attempted { executions, .. } = &spend.status { + for execution in executions.iter() { + Self::deposit_event(Event::::Paid { + index, + execution: execution.clone(), + }); + } + } Ok(()) } @@ -792,25 +1009,64 @@ pub mod pallet { return Ok(Pays::No.into()); } - let payment_id = match spend.status { - State::Attempted { id } => id, - _ => return Err(Error::::NotAttempted.into()), - }; + match &spend.status { + State::Attempted { executions, remaining_amount } => { + // Check payment status + let results: Vec<_> = executions + .iter() + .map(|exec| (exec, T::Paymaster::check_payment(exec.payment_id))) + .collect(); + + // In-progress payments + if results.iter().any(|(_, status)| matches!(status, Status::InProgress)) { + return Err(Error::::Inconclusive.into()); + } - match T::Paymaster::check_payment(payment_id) { - Status::Failure => { - spend.status = PaymentState::Failed; - Spends::::insert(index, spend); - Self::deposit_event(Event::::PaymentFailed { index, payment_id }); - }, - Status::Success | Status::Unknown => { - Spends::::remove(index); - Self::deposit_event(Event::::SpendProcessed { index }); - return Ok(Pays::No.into()); + // Collect failed payments + let failed: Vec<_> = results + .iter() + .filter(|(_, status)| matches!(status, Status::Failure)) + .map(|(exec, _)| (*exec).clone()) + .collect(); + + if !failed.is_empty() { + let bounded = BoundedVec::try_from(failed.clone()) + .map_err(|_| Error::::TooManyFailedPayments)?; + + spend.status = State::Failed(bounded); + Spends::::insert(index, spend); + + for exec in failed { + Self::deposit_event(Event::::PaymentFailed { + index, + payment_id: exec.payment_id, + }); + } + return Ok(Pays::Yes.into()); + } + + // Check if all succeeded + let all_succeeded = results + .iter() + .all(|(_, status)| matches!(status, Status::Success | Status::Unknown)); + + if all_succeeded && remaining_amount.is_zero() { + Spends::::remove(index); + Self::deposit_event(Event::::SpendProcessed { index }); + return Ok(Pays::No.into()); + } + + if all_succeeded && !remaining_amount.is_zero() { + spend.status = State::Partial(*remaining_amount); + spend.expire_at = now.saturating_add(T::PayoutPeriod::get()); + Spends::::insert(index, spend); + return Ok(Pays::Yes.into()); + } + + Ok(Pays::Yes.into()) }, - Status::InProgress => return Err(Error::::Inconclusive.into()), + _ => return Err(Error::::NotAttempted.into()), } - return Ok(Pays::Yes.into()); } /// Void previously approved spend. @@ -835,7 +1091,10 @@ pub mod pallet { T::RejectOrigin::ensure_origin(origin)?; let spend = Spends::::get(index).ok_or(Error::::InvalidIndex)?; ensure!( - matches!(spend.status, PaymentState::Pending | PaymentState::Failed), + matches!( + spend.status, + PaymentState::Pending | PaymentState::Failed(_) | PaymentState::Partial(_) + ), Error::::AlreadyAttempted ); @@ -857,6 +1116,23 @@ impl, I: 'static> Pallet { T::PalletId::get().into_account_truncating() } + /// Ensure this asset has sufficient balance + fn ensure_sufficient_balance( + asset_kind: &T::AssetKind, + account: &T::AccountId, + required: AssetBalanceOf, + ) -> Result, DispatchError> { + T::AssetCategories::available_balance(asset_kind.clone(), account.clone()) + .ok_or(Error::::AssetNotFound.into()) + .and_then(|balance| { + if balance >= required { + Ok(balance) + } else { + Err(Error::::LowBalance.into()) + } + }) + } + // Backfill the `LastSpendPeriod` storage, assuming that no configuration has changed // since introducing this code. Used specifically for a migration-less switch to populate // `LastSpendPeriod`. @@ -1002,6 +1278,158 @@ impl, I: 'static> Pallet { .saturating_sub(T::Currency::minimum_balance()) } + fn pay_from_category( + category: &BoundedVec>, + beneficiary: &T::Beneficiary, + amount: AssetBalanceOf, + ) -> Result< + ( + BoundedVec< + PaymentExecution, ::Id>, + ConstU32<32>, + >, + AssetBalanceOf, + ), + DispatchError, + > { + let assets = T::AssetCategories::assets_in_category(category.as_slice()); + ensure!(!assets.is_empty(), Error::::EmptyAssetCategory); + + let mut executions = BoundedVec::< + PaymentExecution, ::Id>, + ConstU32<32>, + >::default(); + + let mut remaining_amount = amount; + + for asset_kind in assets { + if remaining_amount.is_zero() { + break; + } + + let available = + match T::AssetCategories::available_balance(asset_kind.clone(), Self::account_id()) + { + Some(balance) if balance > Zero::zero() => balance, + _ => continue, + }; + + let pay_amount = + if available >= remaining_amount { remaining_amount } else { available }; + + match T::Paymaster::pay(beneficiary, asset_kind.clone(), pay_amount) { + Ok(payment_id) => { + executions + .try_push(PaymentExecution { + asset: asset_kind.clone(), + amount: pay_amount, + payment_id, + }) + .map_err(|_| Error::::ExecutionRateLimit)?; + remaining_amount = remaining_amount.saturating_sub(pay_amount); + }, + Err(_) => continue, + } + } + Ok((executions, remaining_amount)) + } + + fn retry_failed_category_payments( + category: &BoundedVec>, + beneficiary: &T::Beneficiary, + failed_payments: &BoundedVec< + PaymentExecution, ::Id>, + ConstU32<32>, + >, + ) -> Result< + ( + BoundedVec< + PaymentExecution, ::Id>, + ConstU32<32>, + >, + AssetBalanceOf, + ), + DispatchError, + > { + let assets = T::AssetCategories::assets_in_category(category.as_slice()); + ensure!(!assets.is_empty(), Error::::EmptyAssetCategory); + + let mut executions = BoundedVec::< + PaymentExecution, ::Id>, + ConstU32<32>, + >::default(); + + let mut retry_amount: AssetBalanceOf = failed_payments + .iter() + .fold(Zero::zero(), |acc, exec| acc.saturating_add(exec.amount)); + let used_assets: Vec<_> = failed_payments.iter().map(|exec| exec.asset.clone()).collect(); + + for exec in failed_payments.iter() { + if retry_amount.is_zero() { + break; + } + + let mut available = + match T::AssetCategories::available_balance(exec.asset.clone(), Self::account_id()) + { + Some(balance) if balance >= exec.amount => balance, + _ => Zero::zero(), + }; + + let asset_kind = if available.is_zero() { + match assets.iter().find(|&ak| !used_assets.contains(ak)) { + Some(ak) => { + available = + Self::ensure_sufficient_balance(ak, &Self::account_id(), exec.amount)?; + ak.clone() + }, + None => continue, + } + } else { + exec.asset.clone() + }; + + let pay_amount = if available >= retry_amount { retry_amount } else { available }; + match T::Paymaster::pay(beneficiary, asset_kind.clone(), pay_amount) { + Ok(payment_id) => { + executions + .try_push(PaymentExecution { + asset: asset_kind.clone(), + amount: pay_amount, + payment_id, + }) + .map_err(|_| Error::::ExecutionRateLimit)?; + retry_amount = retry_amount.saturating_sub(pay_amount); + }, + Err(_) => continue, + } + } + Ok((executions, retry_amount)) + } + + pub fn validate_category_spend( + category: &BoundedVec>, + amount: AssetBalanceOf, + ) -> Result, DispatchError> { + let assets = T::AssetCategories::assets_in_category(category.as_slice()); + ensure!(!assets.is_empty(), Error::::EmptyAssetCategory); + + let mut accumulated: AssetBalanceOf = Zero::zero(); + + for asset_kind in &assets { + if let Some(balance) = + T::AssetCategories::available_balance(asset_kind.clone(), Self::account_id()) + { + accumulated = accumulated.saturating_add(balance); + if accumulated >= amount { + return Ok(assets); + } + } + } + + Err(Error::::LowBalance.into()) + } + /// Ensure the correctness of the state of this pallet. #[cfg(any(feature = "try-runtime", test))] fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { diff --git a/substrate/frame/treasury/src/tests.rs b/substrate/frame/treasury/src/tests.rs index 41a37cf98918..1b0df68a70c3 100644 --- a/substrate/frame/treasury/src/tests.rs +++ b/substrate/frame/treasury/src/tests.rs @@ -38,6 +38,7 @@ use frame_support::{ use super::*; use crate as treasury; +use frame_support::traits::AsEnsureOriginWithArg; type Block = frame_system::mocking::MockBlock; type UtilityCall = pallet_utility::Call; @@ -48,6 +49,7 @@ frame_support::construct_runtime!( { System: frame_system, Balances: pallet_balances, + Assets: pallet_assets, Treasury: treasury, Utility: pallet_utility, } @@ -77,6 +79,7 @@ thread_local! { pub static PAID: RefCell> = RefCell::new(BTreeMap::new()); pub static STATUS: RefCell> = RefCell::new(BTreeMap::new()); pub static LAST_ID: RefCell = RefCell::new(0u64); + pub static ASSET_BALANCES: RefCell> = RefCell::new(BTreeMap::new()); #[cfg(feature = "runtime-benchmarks")] pub static TEST_SPEND_ORIGIN_TRY_SUCCESFUL_ORIGIN_ERR: RefCell = RefCell::new(false); @@ -177,6 +180,41 @@ impl> ConversionFromAssetBalance for MulBy { fn ensure_successful(_: u32) {} } +parameter_types! { + pub const AssetDeposit: u64 = 1; + pub const AssetAccountDeposit: u64 = 1; + pub const MetadataDepositBase: u64 = 1; + pub const MetadataDepositPerByte: u64 = 1; + pub const ApprovalDeposit: u64 = 1; + pub const StringLimit: u32 = 50; + pub const MaxReserves: u32 = 5; +} + +impl pallet_assets::Config for Test { + type RuntimeEvent = RuntimeEvent; + type Balance = u64; + type AssetId = u32; + type AssetIdParameter = u32; + type Currency = Balances; + type CreateOrigin = AsEnsureOriginWithArg>; + type ForceOrigin = frame_system::EnsureRoot; + type AssetDeposit = AssetDeposit; + type AssetAccountDeposit = AssetAccountDeposit; + type MetadataDepositBase = MetadataDepositBase; + type MetadataDepositPerByte = MetadataDepositPerByte; + type ApprovalDeposit = ApprovalDeposit; + type StringLimit = StringLimit; + type Freezer = (); + type Extra = (); + type CallbackHandle = (); + type RemoveItemsLimit = ConstU32<5>; + type Holder = (); + type WeightInfo = (); + type ReserveData = (); + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = (); +} + impl Config for Test { type PalletId = TreasuryPalletId; type Currency = pallet_balances::Pallet; @@ -196,6 +234,7 @@ impl Config for Test { type BalanceConverter = MulBy>; type PayoutPeriod = SpendPayoutPeriod; type BlockNumberProvider = System; + type AssetCategories = Assets; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } @@ -207,6 +246,15 @@ impl Default for ExtBuilder { #[cfg(feature = "runtime-benchmarks")] TEST_SPEND_ORIGIN_TRY_SUCCESFUL_ORIGIN_ERR.with(|i| *i.borrow_mut() = false); + ASSET_BALANCES.with(|b| { + let mut map = b.borrow_mut(); + map.insert(1, 100); + map.insert(2, 100); + map.insert(3, 100); + map.insert(4, 100); + map.insert(5, 100); + }); + Self {} } } @@ -220,6 +268,42 @@ impl ExtBuilder { pub fn build(self) -> sp_io::TestExternalities { let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + let assets_config: pallet_assets::GenesisConfig = pallet_assets::GenesisConfig { + assets: vec![ + // id, owner, is_sufficient, min_balance + (1, 0, true, 1), + (2, 0, true, 1), + (3, 0, true, 1), + (4, 0, true, 1), + (5, 0, true, 1), + (10, 0, true, 1), + (11, 0, true, 1), + ], + + metadata: vec![ + // id name, symbol, decimals + (1, "Asset 1".into(), "A1".into(), 10), + (2, "Asset 2".into(), "A2".into(), 10), + (3, "Asset 3".into(), "A3".into(), 10), + (4, "Asset 4".into(), "A4".into(), 10), + (5, "Asset 5".into(), "A5".into(), 10), + (10, "Asset 10".into(), "A10".into(), 10), + (11, "Asset 11".into(), "A11".into(), 10), + ], + + accounts: vec![ + // id, account_id, balance + (1, Treasury::account_id(), 20), + (2, Treasury::account_id(), 20), + (3, Treasury::account_id(), 20), + (4, Treasury::account_id(), 25), + (5, Treasury::account_id(), 50), + (10, Treasury::account_id(), 50), + (11, Treasury::account_id(), 30), + ], + next_asset_id: None, + reserves: vec![], + }; pallet_balances::GenesisConfig:: { // Total issuance will be 200 with treasury account initialized at ED. balances: vec![(0, 100), (1, 98), (2, 1)], @@ -227,9 +311,32 @@ impl ExtBuilder { } .assimilate_storage(&mut t) .unwrap(); + + assets_config.assimilate_storage(&mut t).unwrap(); crate::GenesisConfig::::default().assimilate_storage(&mut t).unwrap(); + let mut ext = sp_io::TestExternalities::new(t); - ext.execute_with(|| System::set_block_number(1)); + ext.execute_with(|| { + System::set_block_number(1); + + let usd_category: BoundedVec> = + BoundedVec::try_from(b"USD".to_vec()).unwrap(); + let stable_category: BoundedVec> = + BoundedVec::try_from(b"STABLE".to_vec()).unwrap(); + + // TODO: Create assets in USD category? + + // Set up categories + pallet_assets::AssetCategories::::insert( + &usd_category, + BoundedVec::try_from(vec![1u32, 2u32, 3u32]).unwrap(), + ); + + pallet_assets::AssetCategories::::insert( + &stable_category, + BoundedVec::try_from(vec![10u32, 11u32]).unwrap(), + ); + }); ext } } @@ -237,11 +344,21 @@ impl ExtBuilder { fn get_payment_id(i: SpendIndex) -> Option { let spend = Spends::::get(i).expect("no spend"); match spend.status { - PaymentState::Attempted { id } => Some(id), + PaymentState::Attempted { executions, .. } => + executions.first().map(|exec| exec.payment_id), _ => None, } } +fn get_all_payment_ids(i: SpendIndex) -> Vec { + let spend = Spends::::get(i).expect("no spend"); + match &spend.status { + PaymentState::Attempted { executions, .. } => + executions.iter().map(|exec| exec.payment_id).collect(), + _ => vec![], + } +} + #[test] fn genesis_config_works() { ExtBuilder::default().build().execute_with(|| { @@ -534,13 +651,13 @@ fn spending_in_batch_respects_max_total() { assert_ok!(RuntimeCall::from(UtilityCall::batch_all { calls: vec![ RuntimeCall::from(TreasuryCall::spend { - asset_kind: Box::new(1), + asset: Box::new(SpendAsset::Specific(1)), amount: 1, beneficiary: Box::new(100), valid_from: None, }), RuntimeCall::from(TreasuryCall::spend { - asset_kind: Box::new(1), + asset: Box::new(SpendAsset::Specific(1)), amount: 1, beneficiary: Box::new(101), valid_from: None, @@ -553,13 +670,13 @@ fn spending_in_batch_respects_max_total() { RuntimeCall::from(UtilityCall::batch_all { calls: vec![ RuntimeCall::from(TreasuryCall::spend { - asset_kind: Box::new(1), + asset: Box::new(SpendAsset::Specific(1)), amount: 2, beneficiary: Box::new(100), valid_from: None, }), RuntimeCall::from(TreasuryCall::spend { - asset_kind: Box::new(1), + asset: Box::new(SpendAsset::Specific(1)), amount: 2, beneficiary: Box::new(101), valid_from: None, @@ -575,20 +692,62 @@ fn spending_in_batch_respects_max_total() { #[test] fn spend_origin_works() { ExtBuilder::default().build().execute_with(|| { - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(6), None)); - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 1, + Box::new(6), + None + )); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + None + )); assert_noop!( - Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 3, Box::new(6), None), + Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 3, + Box::new(6), + None + ), Error::::InsufficientPermission ); - assert_ok!(Treasury::spend(RuntimeOrigin::signed(11), Box::new(1), 5, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(11), + Box::new(SpendAsset::Specific(1)), + 5, + Box::new(6), + None + )); assert_noop!( - Treasury::spend(RuntimeOrigin::signed(11), Box::new(1), 6, Box::new(6), None), + Treasury::spend( + RuntimeOrigin::signed(11), + Box::new(SpendAsset::Specific(1)), + 6, + Box::new(6), + None + ), Error::::InsufficientPermission ); - assert_ok!(Treasury::spend(RuntimeOrigin::signed(12), Box::new(1), 10, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(12), + Box::new(SpendAsset::Specific(1)), + 10, + Box::new(6), + None + )); assert_noop!( - Treasury::spend(RuntimeOrigin::signed(12), Box::new(1), 11, Box::new(6), None), + Treasury::spend( + RuntimeOrigin::signed(12), + Box::new(SpendAsset::Specific(1)), + 11, + Box::new(6), + None + ), Error::::InsufficientPermission ); @@ -601,13 +760,19 @@ fn spend_origin_works() { fn spend_works() { ExtBuilder::default().build().execute_with(|| { System::set_block_number(1); - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + None + )); assert_eq!(SpendCount::::get(), 1); assert_eq!( Spends::::get(0).unwrap(), SpendStatus { - asset_kind: 1, + asset: SpendAsset::Specific(1), amount: 2, beneficiary: 6, valid_from: 1, @@ -618,7 +783,7 @@ fn spend_works() { System::assert_last_event( Event::::AssetSpendApproved { index: 0, - asset_kind: 1, + asset: SpendAsset::Specific(1), amount: 2, beneficiary: 6, valid_from: 1, @@ -636,13 +801,25 @@ fn spend_expires() { // spend `0` expires in 5 blocks after the creating. System::set_block_number(1); - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + None + )); System::set_block_number(6); assert_noop!(Treasury::payout(RuntimeOrigin::signed(1), 0), Error::::SpendExpired); // spend cannot be approved since its already expired. assert_noop!( - Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), Some(0)), + Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + Some(0) + ), Error::::SpendExpired ); }); @@ -654,14 +831,26 @@ fn spend_payout_works() { ExtBuilder::default().build().execute_with(|| { System::set_block_number(1); // approve a `2` coins spend of asset `1` to beneficiary `6`, the spend valid from now. - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + None + )); // payout the spend. assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); // beneficiary received `2` coins of asset `1`. assert_eq!(paid(6, 1), 2); assert_eq!(SpendCount::::get(), 1); let payment_id = get_payment_id(0).expect("no payment attempt"); - System::assert_last_event(Event::::Paid { index: 0, payment_id }.into()); + System::assert_last_event( + Event::::Paid { + index: 0, + execution: PaymentExecution { asset: 1, amount: 2, payment_id }, + } + .into(), + ); set_status(payment_id, PaymentStatus::Success); // the payment succeed. assert_ok!(Treasury::check_status(RuntimeOrigin::signed(1), 0)); @@ -677,7 +866,13 @@ fn payout_extends_expiry() { assert_eq!(::PayoutPeriod::get(), 5); System::set_block_number(1); - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + None + )); // Fail a payout at block 4 System::set_block_number(4); assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); @@ -704,7 +899,13 @@ fn payout_extends_expiry() { fn payout_retry_works() { ExtBuilder::default().build().execute_with(|| { System::set_block_number(1); - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + None + )); assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); assert_eq!(paid(6, 1), 2); let payment_id = get_payment_id(0).expect("no payment attempt"); @@ -734,7 +935,7 @@ fn spend_valid_from_works() { // spend valid from block `2`. assert_ok!(Treasury::spend( RuntimeOrigin::signed(10), - Box::new(1), + Box::new(SpendAsset::Specific(1)), 2, Box::new(6), Some(2) @@ -747,7 +948,7 @@ fn spend_valid_from_works() { // spend approved even if `valid_from` in the past since the payout period has not passed. assert_ok!(Treasury::spend( RuntimeOrigin::signed(10), - Box::new(1), + Box::new(SpendAsset::Specific(1)), 2, Box::new(6), Some(4) @@ -764,7 +965,7 @@ fn void_spend_works() { // spend cannot be voided if already attempted. assert_ok!(Treasury::spend( RuntimeOrigin::signed(10), - Box::new(1), + Box::new(SpendAsset::Specific(1)), 2, Box::new(6), Some(1) @@ -778,7 +979,7 @@ fn void_spend_works() { // void spend. assert_ok!(Treasury::spend( RuntimeOrigin::signed(10), - Box::new(1), + Box::new(SpendAsset::Specific(1)), 2, Box::new(6), Some(10) @@ -795,14 +996,26 @@ fn check_status_works() { System::set_block_number(1); // spend `0` expired and can be removed. - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + None + )); System::set_block_number(7); let info = Treasury::check_status(RuntimeOrigin::signed(1), 0).unwrap(); assert_eq!(info.pays_fee, Pays::No); System::assert_last_event(Event::::SpendProcessed { index: 0 }.into()); // spend `1` payment failed and expired hence can be removed. - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + None + )); assert_noop!( Treasury::check_status(RuntimeOrigin::signed(1), 1), Error::::NotAttempted @@ -820,7 +1033,13 @@ fn check_status_works() { System::assert_last_event(Event::::SpendProcessed { index: 1 }.into()); // spend `2` payment succeed. - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + None + )); assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 2)); let payment_id = get_payment_id(2).expect("no payment attempt"); set_status(payment_id, PaymentStatus::Success); @@ -829,7 +1048,13 @@ fn check_status_works() { System::assert_last_event(Event::::SpendProcessed { index: 2 }.into()); // spend `3` payment in process. - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + None + )); assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 3)); let payment_id = get_payment_id(3).expect("no payment attempt"); set_status(payment_id, PaymentStatus::InProgress); @@ -839,7 +1064,13 @@ fn check_status_works() { ); // spend `4` removed since the payment status is unknown. - assert_ok!(Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 2, Box::new(6), None)); + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 2, + Box::new(6), + None + )); assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 4)); let payment_id = get_payment_id(4).expect("no payment attempt"); set_status(payment_id, PaymentStatus::Unknown); @@ -939,7 +1170,13 @@ fn try_state_spends_invariant_1_works() { use frame_support::pallet_prelude::DispatchError::Other; // Propose and approve a spend assert_ok!({ - Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(6), None) + Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 1, + Box::new(6), + None, + ) }); assert_eq!(Spends::::iter().count(), 1); assert_eq!(SpendCount::::get(), 1); @@ -961,7 +1198,7 @@ fn try_state_spends_invariant_2_works() { use frame_support::pallet_prelude::DispatchError::Other; // Propose and approve a spend assert_ok!({ - Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(6), None) + Treasury::spend(RuntimeOrigin::signed(10), Box::new(SpendAsset::Specific(1)), 1, Box::new(6), None) }); assert_eq!(Spends::::iter().count(), 1); let current_spend_count = SpendCount::::get(); @@ -990,7 +1227,13 @@ fn try_state_spends_invariant_3_works() { use frame_support::pallet_prelude::DispatchError::Other; // Propose and approve a spend assert_ok!({ - Treasury::spend(RuntimeOrigin::signed(10), Box::new(1), 1, Box::new(6), None) + Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Specific(1)), + 1, + Box::new(6), + None, + ) }); assert_eq!(Spends::::iter().count(), 1); let current_spend_count = SpendCount::::get(); @@ -1046,3 +1289,439 @@ fn multiple_spend_periods_work() { assert_eq!(LastSpendPeriod::::get(), Some(8)); }); } + +#[test] +fn category_spend_works() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + let category = b"USD".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Category(bounded_category.clone())), + 2, + Box::new(6), + None + )); + + assert_eq!(SpendCount::::get(), 1); + let spend = Spends::::get(0).unwrap(); + + match spend.asset { + SpendAsset::Category(cat) => assert_eq!(cat, bounded_category), + _ => panic!("Expected Category asset"), + } + + assert_eq!(spend.amount, 2); + assert_eq!(spend.beneficiary, 6); + assert_eq!(spend.status, PaymentState::Pending); + + System::assert_last_event( + Event::::AssetSpendApproved { + index: 0, + asset: SpendAsset::Category(bounded_category), + amount: 2, + beneficiary: 6, + valid_from: 1, + expire_at: 6, + } + .into(), + ); + }); +} + +#[test] +fn category_payout_distributes_across_assets() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + let category = b"USD".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(14), + Box::new(SpendAsset::Category(bounded_category)), + 50, + Box::new(6), + None + )); + + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + + assert_eq!(paid(6, 1), 20); + assert_eq!(paid(6, 2), 20); + assert_eq!(paid(6, 3), 10); + + let spend = Spends::::get(0).unwrap(); + match &spend.status { + PaymentState::Attempted { executions, remaining_amount } => { + assert_eq!(executions.len(), 3); + assert_eq!(*remaining_amount, 0); + + let mut asset_payments = std::collections::BTreeMap::new(); + for exec in executions.iter() { + asset_payments.insert(exec.asset, exec.amount); + } + + assert_eq!(asset_payments.get(&1), Some(&20)); + assert_eq!(asset_payments.get(&2), Some(&20)); + assert_eq!(asset_payments.get(&3), Some(&10)); + }, + _ => panic!("Expected Attempted status"), + } + }); +} + +#[test] +fn category_payout_partial_fulfillment() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + let category = b"USD".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(14), + Box::new(SpendAsset::Category(bounded_category)), + 50, + Box::new(6), + None + )); + + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + + assert_eq!(paid(6, 1), 20); + assert_eq!(paid(6, 2), 20); + assert_eq!(paid(6, 3), 10); + + let spend = Spends::::get(0).unwrap(); + match &spend.status { + PaymentState::Attempted { executions, remaining_amount } => { + assert_eq!(executions.len(), 3); + assert_eq!(*remaining_amount, 0); + + let mut asset_payments = std::collections::BTreeMap::new(); + for exec in executions.iter() { + asset_payments.insert(exec.asset, exec.amount); + } + + assert_eq!(asset_payments.get(&1), Some(&20)); + assert_eq!(asset_payments.get(&2), Some(&20)); + assert_eq!(asset_payments.get(&3), Some(&10)); + }, + _ => panic!("Expected Attempted status"), + } + }); +} + +#[test] +fn category_payout_skips_assets_with_no_balance() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + let category = b"USD".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(14), + Box::new(SpendAsset::Category(bounded_category)), + 30, + Box::new(6), + None + )); + + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + + assert_eq!(paid(6, 1), 20); + assert_eq!(paid(6, 2), 10); + assert_eq!(paid(6, 3), 0); + + let spend = Spends::::get(0).unwrap(); + match &spend.status { + PaymentState::Attempted { executions, remaining_amount } => { + assert_eq!(executions.len(), 2); + assert_eq!(*remaining_amount, 0); + + assert_eq!(executions[0].asset, 1); + assert_eq!(executions[0].amount, 20); + }, + _ => panic!("Expected Attempted status"), + } + }); +} + +#[test] +fn category_spend_with_unknown_category() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + let category = b"GBP".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + assert_noop!( + Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Category(bounded_category)), + 10, + Box::new(6), + None + ), + Error::::EmptyAssetCategory + ); + }); +} + +#[test] +fn mixed_specific_and_category_spends() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(14), + Box::new(SpendAsset::Specific(4)), + 25, + Box::new(7), + None + )); + + let category = b"USD".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(14), + Box::new(SpendAsset::Category(bounded_category)), + 30, + Box::new(8), + None + )); + + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + assert_eq!(paid(7, 4), 25); + + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 1)); + + let paid_usd = paid(8, 1) + paid(8, 2); + assert_eq!(paid_usd, 30); + + assert_eq!(SpendCount::::get(), 2); + assert_eq!(Spends::::iter().count(), 2); + }); +} + +#[test] +fn category_check_status_with_multiple_executions() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + let category = b"USD".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(14), + Box::new(SpendAsset::Category(bounded_category)), + 40, + Box::new(6), + None + )); + + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + + let payment_ids = get_all_payment_ids(0); + assert_eq!(payment_ids.len(), 2); + + assert_ok!(Treasury::check_status(RuntimeOrigin::signed(1), 0)); + }); +} + +#[test] +fn category_spend_with_custom_category() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + let category = b"STABLE".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(14), + Box::new(SpendAsset::Category(bounded_category)), + 80, + Box::new(6), + None + )); + + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + + assert_eq!(paid(6, 10), 50); + assert_eq!(paid(6, 11), 30); + assert_eq!(paid(6, 12), 0); + + let spend = Spends::::get(0).unwrap(); + match &spend.status { + PaymentState::Attempted { executions, remaining_amount } => { + assert_eq!(executions.len(), 2); + assert_eq!(*remaining_amount, 0); + }, + _ => panic!("Expected Attempted status"), + } + }); +} + +#[test] +fn category_spend_respects_spend_origin_limit() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + let category = b"USD".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Category(bounded_category.clone())), + 2, + Box::new(6), + None + )); + + assert_noop!( + Treasury::spend( + RuntimeOrigin::signed(10), + Box::new(SpendAsset::Category(bounded_category)), + 3, + Box::new(6), + None + ), + Error::::InsufficientPermission + ); + }); +} + +#[test] +fn category_spend_with_empty_category_assets() { + ExtBuilder::default() + // .with_category_assets(b"EMPTY*", vec![]) // Empty category + .build() + .execute_with(|| { + System::set_block_number(1); + + let category = b"EMPTY".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + assert_noop!( + Treasury::spend( + RuntimeOrigin::signed(14), + Box::new(SpendAsset::Category(bounded_category)), + 10, + Box::new(6), + None + ), + Error::::EmptyAssetCategory + ); + }); +} + +#[test] +fn category_spend_cannot_void_after_payout() { + ExtBuilder::default() // .with_asset_balance(1, 50) + .build() + .execute_with(|| { + System::set_block_number(1); + + let category = b"USD".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(14), + Box::new(SpendAsset::Category(bounded_category)), + 50, + Box::new(6), + None + )); + + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + + // Cannot void after payout + assert_noop!( + Treasury::void_spend(RuntimeOrigin::root(), 0), + Error::::AlreadyAttempted + ); + }); +} + +#[test] +fn category_spend_expiry_works() { + ExtBuilder::default().build().execute_with(|| { + assert_eq!(::PayoutPeriod::get(), 5); + + System::set_block_number(1); + let category = b"USD".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + // Create category spend + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(14), + Box::new(SpendAsset::Category(bounded_category)), + 50, + Box::new(6), + None + )); + + // Should expire after 5 blocks + System::set_block_number(6); + assert_noop!(Treasury::payout(RuntimeOrigin::signed(1), 0), Error::::SpendExpired); + }); +} + +#[test] +fn category_spend_valid_from_works() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(1); + + let category = b"USD".to_vec(); + let bounded_category: BoundedVec> = + BoundedVec::try_from(category.clone()).unwrap(); + + // Create category spend valid from block 3 + assert_ok!(Treasury::spend( + RuntimeOrigin::signed(14), + Box::new(SpendAsset::Category(bounded_category)), + 50, + Box::new(6), + Some(3) + )); + + // Cannot payout before valid_from + System::set_block_number(2); + assert_noop!(Treasury::payout(RuntimeOrigin::signed(1), 0), Error::::EarlyPayout); + + // Can payout at valid_from + System::set_block_number(3); + + // set_asset_balance(1, 50); + assert_ok!(Treasury::payout(RuntimeOrigin::signed(1), 0)); + + assert_eq!(paid(6, 1), 20); + + let spend = Spends::::get(0).unwrap(); + match &spend.status { + PaymentState::Attempted { executions, remaining_amount } => { + assert_eq!(executions.len(), 3); + assert_eq!(*remaining_amount, 0); + }, + _ => panic!("Expected Attempted status"), + } + }); +}