diff --git a/integration/src/erc20_helper.rs b/integration/src/erc20_helper.rs index 58955310c0..309cb83ba6 100644 --- a/integration/src/erc20_helper.rs +++ b/integration/src/erc20_helper.rs @@ -305,6 +305,38 @@ impl Token { .await } + pub async fn freeze_partial_tokens( + &self, + caller: &mut dyn ContractCaller, + account: Address, + amount: u128, + ) -> Result> { + self.send( + caller, + ierc20::freezePartialTokensCall { + account, + amount: U256::from(amount), + }, + ) + .await + } + + pub async fn unfreeze_partial_tokens( + &self, + caller: &mut dyn ContractCaller, + account: Address, + amount: u128, + ) -> Result> { + self.send( + caller, + ierc20::unfreezePartialTokensCall { + account, + amount: U256::from(amount), + }, + ) + .await + } + pub async fn set_symbol( &self, caller: &mut dyn ContractCaller, diff --git a/integration/tests/revive_erc3643.rs b/integration/tests/revive_erc3643.rs index d4119f4c33..e23ae5b9b4 100644 --- a/integration/tests/revive_erc3643.rs +++ b/integration/tests/revive_erc3643.rs @@ -83,6 +83,78 @@ async fn erc3643_set_name() -> Result<()> { Ok(()) } +#[tokio::test] +#[test_log::test] +async fn erc3643_freeze_partial_tokens() -> Result<()> { + let (mut tester, node) = revive_tester().await?; + let mut users = tester.users(&["Erc3643Issuer", "Erc3643Holder"]).await?; + let api = tester.api.clone(); + let (issuers, holders) = users.split_at_mut(1); + let issuer = &mut issuers[0]; + let holder = &mut holders[0]; + + let (_, erc3643) = create_erc20_asset(&api, &node, issuer, "ERC3643 Freeze", MINT).await?; + + let holder_address = eth_address_of(&api, holder).await?; + let mut caller = SubstrateCaller::new(&api, issuer).await?; + + assert_eq!(erc3643.get_frozen_tokens(holder_address).await.unwrap(), 0); + + erc3643 + .freeze_partial_tokens(&mut caller, holder_address, 100) + .await?; + + assert_eq!( + erc3643.get_frozen_tokens(holder_address).await.unwrap(), + 100 + ); + + // Freezing again adds to the amount already frozen, rather than replacing it. + erc3643 + .freeze_partial_tokens(&mut caller, holder_address, 50) + .await?; + + assert_eq!( + erc3643.get_frozen_tokens(holder_address).await.unwrap(), + 150 + ); + + Ok(()) +} + +#[tokio::test] +#[test_log::test] +async fn erc3643_unfreeze_partial_tokens() -> Result<()> { + let (mut tester, node) = revive_tester().await?; + let mut users = tester.users(&["Erc3643Issuer", "Erc3643Holder"]).await?; + let api = tester.api.clone(); + let (issuers, holders) = users.split_at_mut(1); + let issuer = &mut issuers[0]; + let holder = &mut holders[0]; + + let (_, erc3643) = create_erc20_asset(&api, &node, issuer, "ERC3643 Unfreeze", MINT).await?; + + let holder_address = eth_address_of(&api, holder).await?; + let mut caller = SubstrateCaller::new(&api, issuer).await?; + + erc3643 + .freeze_partial_tokens(&mut caller, holder_address, 100) + .await?; + + assert_eq!( + erc3643.get_frozen_tokens(holder_address).await.unwrap(), + 100 + ); + + erc3643 + .unfreeze_partial_tokens(&mut caller, holder_address, 40) + .await?; + + assert_eq!(erc3643.get_frozen_tokens(holder_address).await.unwrap(), 60); + + Ok(()) +} + #[tokio::test] #[test_log::test] async fn erc3643_pause_unpause() -> Result<()> { diff --git a/pallets/asset/src/benchmarking.rs b/pallets/asset/src/benchmarking.rs index 063bc061c5..3073da4c3e 100644 --- a/pallets/asset/src/benchmarking.rs +++ b/pallets/asset/src/benchmarking.rs @@ -563,6 +563,38 @@ benchmarks! { ); } + controller_transfer_to { + let bob = UserBuilder::::default().generate_did().build("Bob"); + let alice = UserBuilder::::default().generate_did().build("Alice"); + let asset_id = create_sample_asset::(&alice, true); + + let alice_holdings = AssetHolder::from(PortfolioId::default_portfolio(alice.did())); + let bob_holdings = AssetHolder::from(PortfolioId::default_portfolio(bob.did())); + + Pallet::::issue( + alice.origin.clone().into(), + asset_id, + 1_000_000, + alice_holdings.clone().into() + ) + .unwrap(); + + let auth_id = pallet_identity::Pallet::::add_auth( + alice.did(), + Signatory::from(bob.did()), + AuthorizationData::BecomeAgent(asset_id, AgentGroup::Full), + None, + ) + .unwrap(); + pallet_external_agents::Pallet::::accept_become_agent(bob.origin().into(), auth_id)?; + }: _(bob.origin.clone(), asset_id, 1_000, alice_holdings, bob_holdings) + verify { + assert_eq!( + BalanceOf::::get(asset_id, bob.did()), + 1_000 + ); + } + register_custom_asset_type { let n in 1 .. T::MaxLen::get() as u32; @@ -1018,4 +1050,36 @@ benchmarks! { let bob_portfolio = create_portfolio::(&bob, "SenderPortfolio"); let asset_id = create_sample_asset::(&alice, true); }: _(alice.origin, bob_portfolio.into(), asset_id, true) + + freeze_partial_tokens { + let alice = UserBuilder::::default().generate_did().build("Alice"); + let asset_id = create_sample_asset::(&alice, true); + let alice_portfolio = create_portfolio::(&alice, "SenderPortfolio"); + }: _(alice.origin, asset_id, alice_portfolio.clone(), ONE_UNIT) + verify { + assert_eq!( + Pallet::::get_holders_frozen_balance(&alice_portfolio, &asset_id), + ONE_UNIT + ); + } + + unfreeze_partial_tokens { + let alice = UserBuilder::::default().generate_did().build("Alice"); + let asset_id = create_sample_asset::(&alice, true); + let alice_portfolio = create_portfolio::(&alice, "SenderPortfolio"); + + Pallet::::freeze_partial_tokens( + alice.origin().into(), + asset_id, + alice_portfolio.clone(), + ONE_UNIT, + ) + .unwrap(); + }: _(alice.origin, asset_id, alice_portfolio.clone(), ONE_UNIT) + verify { + assert_eq!( + Pallet::::get_holders_frozen_balance(&alice_portfolio, &asset_id), + 0 + ); + } } diff --git a/pallets/asset/src/lib.rs b/pallets/asset/src/lib.rs index 6e574c2023..2ffe893d18 100644 --- a/pallets/asset/src/lib.rs +++ b/pallets/asset/src/lib.rs @@ -373,6 +373,14 @@ pub mod pallet { asset_id: AssetId, freeze: bool, }, + /// Event for when a controller transfers assets from one holder to another. + ControllerTransferTo { + caller_did: IdentityId, + asset_id: AssetId, + source: AssetHolder, + destination: AssetHolder, + amount: Balance, + }, } /// Map each [`Ticker`] to its registration details ([`TickerRegistration`]). @@ -1857,6 +1865,75 @@ pub mod pallet { ) -> DispatchResult { Self::base_set_holder_frozen(origin, asset_holder, asset_id, freeze) } + + /// Forces a transfer of tokens from `source` to `destination`. + /// + /// Unlike [`Self::controller_transfer`], which always sends the funds to the caller, + /// this extrinsic lets the caller name an arbitrary [`AssetHolder`] as the destination. + /// + /// # Arguments + /// * `origin` - The origin of the call, which can be the primary or secondary key of an identity. + /// * `asset_id` - The [`AssetId`] associated to the asset. + /// * `value` - The [`Balance`] of tokens that will be transferred. + /// * `source` - The [`AssetHolder`] that will have its balance reduced. + /// * `destination` - The [`AssetHolder`] that will have its balance increased. + /// + /// # Permissions + /// * Asset + /// + /// # Events + /// * `ControllerTransfer` - When tokens are successfully transferred. + /// + /// # Errors + /// * `UnexpectedNonFungibleToken` - If the asset is a non-fungible token. + /// * `InvalidGranularity` - If the amount to transfer does not meet the granularity requirements. + /// * `TotalSupplyOverflow` - If the total supply exceeds the maximum allowed limit. + /// * `ReceiverAffirmationRequired` - If `destination` requires receiver affirmation for the asset. + #[pallet::call_index(40)] + #[pallet::weight(::WeightInfo::controller_transfer_to())] + pub fn controller_transfer_to( + origin: OriginFor, + asset_id: AssetId, + value: Balance, + source: AssetHolder, + destination: AssetHolder, + ) -> DispatchResult { + let mut weight_meter = WeightMeter::max_limit_no_minimum(); + Self::base_controller_transfer_to( + origin, + asset_id, + value, + source, + destination, + &mut weight_meter, + ) + } + + /// Freezes an additional `amount` of `asset_id` tokens from `asset_holder`, on top of + /// any tokens already frozen. + #[pallet::call_index(41)] + #[pallet::weight(::WeightInfo::freeze_partial_tokens())] + pub fn freeze_partial_tokens( + origin: OriginFor, + asset_id: AssetId, + asset_holder: AssetHolder, + amount: Balance, + ) -> DispatchResult { + Self::base_freeze_partial_tokens(origin, asset_id, asset_holder, amount) + } + + /// Unfreezes `amount` of `asset_id` tokens from `asset_holder`, reducing the amount + /// currently frozen. + #[pallet::call_index(42)] + #[pallet::weight(::WeightInfo::unfreeze_partial_tokens())] + pub fn unfreeze_partial_tokens( + origin: OriginFor, + asset_id: AssetId, + asset_holder: AssetHolder, + amount: Balance, + ) -> DispatchResult { + Self::base_unfreeze_partial_tokens(origin, asset_id, asset_holder, amount) + } } #[pallet::error] @@ -1973,6 +2050,10 @@ pub mod pallet { WeightLimitExceeded, /// The sender is frozen and cannot transfer assets. InvalidTransferSenderIsFrozen, + /// The destination requires receiver affirmation before assets can be moved into it. + ReceiverAffirmationRequired, + /// Attempt to unfreeze more tokens than are currently frozen for the asset holder. + InsufficientFrozenBalance, } pub trait WeightInfo { @@ -1991,6 +2072,7 @@ pub mod pallet { fn set_funding_round(f: u32) -> Weight; fn update_identifiers(i: u32) -> Weight; fn controller_transfer() -> Weight; + fn controller_transfer_to() -> Weight; fn register_custom_asset_type(n: u32) -> Weight; fn set_asset_metadata() -> Weight; fn set_asset_metadata_details() -> Weight; @@ -2021,6 +2103,8 @@ pub mod pallet { fn transfer_is_allowed_for_holder_best_case() -> Weight; fn transfer_is_allowed_for_holder_worst_case() -> Weight; fn set_holder_frozen() -> Weight; + fn freeze_partial_tokens() -> Weight; + fn unfreeze_partial_tokens() -> Weight; } } @@ -2414,6 +2498,55 @@ impl Pallet { Ok(()) } + /// Same as [`Self::base_controller_transfer`], but `destination` is given explicitly + /// instead of being derived from the caller's identity. The transfer is rejected if + /// `destination` requires receiver affirmation for `asset_id`. + fn base_controller_transfer_to( + origin: T::RuntimeOrigin, + asset_id: AssetId, + transfer_value: Balance, + source: AssetHolder, + destination: AssetHolder, + weight_meter: &mut WeightMeter, + ) -> DispatchResult { + let caller_data = ExternalAgents::::ensure_agent_asset_perms(origin, &asset_id)?; + + Self::ensure_valid_holder(&destination)?; + ensure!( + Self::skip_asset_holder_affirmation(&destination, &asset_id)?, + Error::::ReceiverAffirmationRequired + ); + + Self::validate_asset_transfer( + asset_id, + &source, + &destination, + transfer_value, + true, + weight_meter, + )?; + Self::unverified_transfer_asset( + source.clone(), + destination.clone(), + asset_id, + transfer_value, + None, + None, + caller_data.primary_did, + true, + weight_meter, + )?; + + Self::deposit_event(Event::ControllerTransferTo { + caller_did: caller_data.primary_did, + asset_id, + source, + destination, + amount: transfer_value, + }); + Ok(()) + } + /// Registers a new custom asset type. fn base_register_custom_asset_type( origin: T::RuntimeOrigin, @@ -3022,6 +3155,64 @@ impl Pallet { Ok(()) } + /// Freezes an additional `amount` of `asset_id` tokens from `asset_holder`, on top of + /// any tokens already frozen. + fn base_freeze_partial_tokens( + origin: T::RuntimeOrigin, + asset_id: AssetId, + asset_holder: AssetHolder, + amount: Balance, + ) -> DispatchResult { + let caller_did = ExternalAgents::::ensure_perms(origin, &asset_id)?; + + let asset_details = Self::try_get_asset_details(&asset_id)?; + ensure!( + asset_details.asset_type.is_fungible(), + Error::::UnexpectedNonFungibleToken + ); + + if let AssetHolder::Portfolio(receiver_portfolio_id) = &asset_holder { + PortfolioPallet::::ensure_portfolio_validity(receiver_portfolio_id)?; + } + + let current_frozen_balance = Self::get_holders_frozen_balance(&asset_holder, &asset_id); + let new_frozen_balance = current_frozen_balance + .checked_add(amount) + .ok_or(Error::::BalanceOverflow)?; + + Self::unverified_set_frozen_tokens(caller_did, asset_holder, asset_id, new_frozen_balance); + Ok(()) + } + + /// Unfreezes `amount` of `asset_id` tokens from `asset_holder`, reducing the amount + /// currently frozen. + fn base_unfreeze_partial_tokens( + origin: T::RuntimeOrigin, + asset_id: AssetId, + asset_holder: AssetHolder, + amount: Balance, + ) -> DispatchResult { + let caller_did = ExternalAgents::::ensure_perms(origin, &asset_id)?; + + let asset_details = Self::try_get_asset_details(&asset_id)?; + ensure!( + asset_details.asset_type.is_fungible(), + Error::::UnexpectedNonFungibleToken + ); + + if let AssetHolder::Portfolio(receiver_portfolio_id) = &asset_holder { + PortfolioPallet::::ensure_portfolio_validity(receiver_portfolio_id)?; + } + + let current_frozen_balance = Self::get_holders_frozen_balance(&asset_holder, &asset_id); + let new_frozen_balance = current_frozen_balance + .checked_sub(amount) + .ok_or(Error::::InsufficientFrozenBalance)?; + + Self::unverified_set_frozen_tokens(caller_did, asset_holder, asset_id, new_frozen_balance); + Ok(()) + } + /// Sets whether `account` is frozen for transfers of `asset_id`. fn base_set_holder_frozen( origin: T::RuntimeOrigin, @@ -4207,13 +4398,18 @@ impl Pallet { if is_controller_transfer { let frozen_balance = Self::get_holders_frozen_balance(&sender, &asset_id); if frozen_balance > 0 { - let new_frozen_balance = frozen_balance.saturating_sub(transfer_value); - Self::unverified_set_frozen_tokens( - caller_did, - sender.clone(), - asset_id, - new_frozen_balance, - ); + // Only unfreeze tokens if there's not enough free_balance + let free_balance = sender_current_balance.saturating_sub(frozen_balance); + if transfer_value > free_balance { + let tokens_to_unfreeze = transfer_value - free_balance; + let new_frozen_balance = frozen_balance.saturating_sub(tokens_to_unfreeze); + Self::unverified_set_frozen_tokens( + caller_did, + sender.clone(), + asset_id, + new_frozen_balance, + ); + } } } diff --git a/pallets/nft/src/benchmarking.rs b/pallets/nft/src/benchmarking.rs index 22cb329b4e..a484fd46a5 100644 --- a/pallets/nft/src/benchmarking.rs +++ b/pallets/nft/src/benchmarking.rs @@ -263,6 +263,42 @@ benchmarks! { assert_eq!(NFTsInCollection::::get(nfts.asset_id()), n as u64); } + controller_transfer_to { + let n in 1..T::MaxNumberOfNFTsCount::get(); + + let alice = UserBuilder::::default().generate_did().build("Alice"); + let bob = UserBuilder::::default().generate_did().build("Bob"); + let mut weight_meter = WeightMeter::max_limit_no_minimum(); + + let (asset_id, alice_holdings, bob_holdings, _) = + setup_nft_transfer::(&alice, &bob, n, None, None, true, 0, false); + let nfts = NFTs::new_unverified(asset_id, (0..n).map(|i| NFTId((i + 1) as u64)).collect()); + with_transaction(|| { + Pallet::::base_nft_transfer( + alice_holdings.clone(), + bob_holdings.clone(), + nfts.clone(), + InstructionId(1), + None, + IdentityId::default(), + &mut weight_meter + ) + }) + .unwrap(); + // Before the controller transfer all NFTs belong to bob + assert_eq!(NumberOfNFTs::::get(nfts.asset_id(), bob.did()), n as u64); + assert_eq!(NumberOfNFTs::::get(nfts.asset_id(), alice.did()), 0); + }: _(alice.origin.clone(), nfts.clone(), bob_holdings.clone(), alice_holdings.clone()) + verify { + assert_eq!(NumberOfNFTs::::get(nfts.asset_id(), bob.did()), 0); + assert_eq!(NumberOfNFTs::::get(nfts.asset_id(), alice.did()), n as u64); + for i in 1..n + 1 { + assert!(Pallet::::is_holder_of_nft(&asset_id, &NFTId(i.into()), &alice_holdings)); + assert!(!Pallet::::is_holder_of_nft(&asset_id, &NFTId(i.into()), &bob_holdings)); + } + assert_eq!(NFTsInCollection::::get(nfts.asset_id()), n as u64); + } + approve { let alice = UserBuilder::::default().generate_did().build("Alice"); let bob = UserBuilder::::default().generate_did().build("Bob"); diff --git a/pallets/nft/src/lib.rs b/pallets/nft/src/lib.rs index 37960c0378..cff30e2721 100644 --- a/pallets/nft/src/lib.rs +++ b/pallets/nft/src/lib.rs @@ -43,6 +43,7 @@ pub trait WeightInfo { fn redeem_nft(n: u32) -> Weight; fn base_nft_transfer(n: u32) -> Weight; fn controller_transfer(n: u32) -> Weight; + fn controller_transfer_to(n: u32) -> Weight; fn approve() -> Weight; fn set_approval_for_all() -> Weight; fn spend_nft_approval(n: u32) -> Weight; @@ -457,6 +458,30 @@ pub mod pallet { ) -> DispatchResult { Self::base_set_approval_for_all(origin, asset_id, operator, approved) } + + /// Forces the transfer of NFTs from a given portfolio to `destination`. + /// + /// Unlike [`Self::controller_transfer`], which always sends the NFTs to the caller's, + /// this extrinsic lets the caller name an arbitrary [`AssetHolder`] as the destination. + /// + /// # Arguments + /// * `origin` - is a signer that has permissions to act as an agent of `asset_id`. + /// * `nfts` - the [`NFTs`] to be transferred. + /// * `source` - the [`AssetHolder`] that currently holds the NFTs. + /// * `destination` - the [`AssetHolder`] that will receive the NFTs. + /// + /// # Permissions + /// * Asset + #[pallet::weight(::WeightInfo::controller_transfer_to(nfts.len() as u32))] + #[pallet::call_index(7)] + pub fn controller_transfer_to( + origin: OriginFor, + nfts: NFTs, + source: AssetHolder, + destination: AssetHolder, + ) -> DispatchResult { + Self::base_controller_transfer_to(origin, nfts, source, destination) + } } #[pallet::error] @@ -526,6 +551,8 @@ pub mod pallet { NFTApprovalNotAuthorized, /// The spender has no approval to transfer this NFT. InsufficientNFTApproval, + /// The destination requires receiver affirmation before assets can be moved into it. + ReceiverAffirmationRequired, } } @@ -1098,6 +1125,39 @@ impl Pallet { Ok(()) } + /// Same as [`Self::base_controller_transfer`], but `destination` is given explicitly + /// instead of being derived from the caller's identity. The transfer is rejected if + /// `destination` requires receiver affirmation for the NFT's asset. + pub fn base_controller_transfer_to( + origin: T::RuntimeOrigin, + nfts: NFTs, + source: AssetHolder, + destination: AssetHolder, + ) -> DispatchResult { + // Ensure origin is an agent with permissions for the asset. + let caller_data = ExternalAgents::::ensure_agent_asset_perms(origin, nfts.asset_id())?; + + AssetPallet::::ensure_valid_holder(&destination)?; + ensure!( + AssetPallet::::skip_asset_holder_affirmation(&destination, nfts.asset_id())?, + Error::::ReceiverAffirmationRequired + ); + + // Verifies if all rules for transfering the NFTs are being respected + Self::validate_nft_transfer(&source, &destination, &nfts, true, None)?; + // Transfer ownership of the NFTs + Self::unverified_nfts_transfer(&source, destination.clone(), &nfts)?; + + Self::deposit_event(Event::NFTHoldingsUpdated( + caller_data.primary_did, + nfts, + Some(source), + Some(destination), + HoldingsUpdateReason::ControllerTransfer, + )); + Ok(()) + } + /// Returns a vector containing all errors for the transfer. An empty vec means there's no error. pub fn nft_transfer_report( sender: &AssetHolder, diff --git a/pallets/precompiles/src/interface/fungible_asset/erc3643.rs b/pallets/precompiles/src/interface/fungible_asset/erc3643.rs index 0bbf1674a5..680876479f 100644 --- a/pallets/precompiles/src/interface/fungible_asset/erc3643.rs +++ b/pallets/precompiles/src/interface/fungible_asset/erc3643.rs @@ -150,4 +150,68 @@ impl FungibleAssetInterface { )?; Ok(Vec::new()) } + + /// Freezes an additional amount of tokens for a specific address, on top of any tokens + /// already frozen. Only an agent of the token can call this function. + pub(crate) fn freeze_partial_tokens( + asset_id: AssetId, + call: &IFungibleAsset::freezePartialTokensCall, + env: &mut impl Ext, + ) -> Result, Error> { + let caller = Common::::caller(env)?; + + let acc_to_freeze = Common::::asset_holder(env, call.account)?; + let amount = Common::::to_balance(call.amount)?; + + Common::::call_runtime( + env, + caller.runtime_origin(), + pallet_asset::Call::::freeze_partial_tokens { + asset_id, + asset_holder: acc_to_freeze, + amount, + }, + )?; + + Common::::deposit_event( + env, + IFungibleAssetEvents::TokensFrozen(IFungibleAsset::TokensFrozen { + account: call.account, + amount: call.amount, + }), + )?; + Ok(Vec::new()) + } + + /// Unfreezes an amount of tokens for a specific address, reducing the amount currently + /// frozen. Only an agent of the token can call this function. + pub(crate) fn unfreeze_partial_tokens( + asset_id: AssetId, + call: &IFungibleAsset::unfreezePartialTokensCall, + env: &mut impl Ext, + ) -> Result, Error> { + let caller = Common::::caller(env)?; + + let acc_to_unfreeze = Common::::asset_holder(env, call.account)?; + let amount = Common::::to_balance(call.amount)?; + + Common::::call_runtime( + env, + caller.runtime_origin(), + pallet_asset::Call::::unfreeze_partial_tokens { + asset_id, + asset_holder: acc_to_unfreeze, + amount, + }, + )?; + + Common::::deposit_event( + env, + IFungibleAssetEvents::TokensUnfrozen(IFungibleAsset::TokensUnfrozen { + account: call.account, + amount: call.amount, + }), + )?; + Ok(Vec::new()) + } } diff --git a/pallets/precompiles/src/interface/fungible_asset/erc7943.rs b/pallets/precompiles/src/interface/fungible_asset/erc7943.rs index 236494b5a3..cfea51eb7b 100644 --- a/pallets/precompiles/src/interface/fungible_asset/erc7943.rs +++ b/pallets/precompiles/src/interface/fungible_asset/erc7943.rs @@ -21,7 +21,7 @@ use pallet_revive::precompiles::Ext; use pallet_asset::WeightInfo; use polymesh_precompiles::{IFungibleAsset, IFungibleAssetEvents}; -use polymesh_primitives::asset::{AssetHolderKind, AssetId}; +use polymesh_primitives::asset::AssetId; use polymesh_primitives::WeightMeter; use crate::common::Common; @@ -67,7 +67,9 @@ impl FungibleAssetInterface { )) } - /// Takes tokens from one address and transfers them to the caller's account. + /// Takes tokens from one address and transfers them to another. + /// + /// Only an asset holder account is supported as the destination for now. pub(crate) fn forced_transfer( asset_id: AssetId, call: &IFungibleAsset::forcedTransferCall, @@ -75,16 +77,17 @@ impl FungibleAssetInterface { ) -> Result, Error> { let caller = Common::::caller(env)?; let source = Common::::asset_holder(env, call.from)?; + let destination = Common::::asset_holder(env, call.to)?; let value = Common::::to_balance(call.amount)?; Common::::call_runtime( env, caller.runtime_origin(), - pallet_asset::Call::::controller_transfer { + pallet_asset::Call::::controller_transfer_to { asset_id, value, source, - destination_kind: AssetHolderKind::Account, + destination, }, )?; @@ -92,7 +95,7 @@ impl FungibleAssetInterface { env, IFungibleAssetEvents::ForcedTransfer(IFungibleAsset::ForcedTransfer { from: call.from.into(), - to: caller.address.0.into(), + to: call.to.into(), amount: call.amount, }), )?; diff --git a/pallets/precompiles/src/interface/fungible_asset/mod.rs b/pallets/precompiles/src/interface/fungible_asset/mod.rs index cbb2687651..1ce717f315 100644 --- a/pallets/precompiles/src/interface/fungible_asset/mod.rs +++ b/pallets/precompiles/src/interface/fungible_asset/mod.rs @@ -131,6 +131,12 @@ impl Precompile for FungibleAssetInterface { IFungibleAssetCalls::setAddressFrozen(call) => { Self::set_address_frozen(asset_id, call, env) } + IFungibleAssetCalls::freezePartialTokens(call) => { + Self::freeze_partial_tokens(asset_id, call, env) + } + IFungibleAssetCalls::unfreezePartialTokens(call) => { + Self::unfreeze_partial_tokens(asset_id, call, env) + } } } } diff --git a/pallets/runtime/tests/src/asset_pallet/controller_transfer.rs b/pallets/runtime/tests/src/asset_pallet/controller_transfer.rs index 1ac11b3ad5..1bdd97de40 100644 --- a/pallets/runtime/tests/src/asset_pallet/controller_transfer.rs +++ b/pallets/runtime/tests/src/asset_pallet/controller_transfer.rs @@ -7,7 +7,7 @@ use polymesh_primitives::settlement::{ InstructionId, Leg, SettlementType, VenueDetails, VenueId, VenueType, }; use polymesh_primitives::{ - AssetHolderKind, AuthorizationData, PortfolioId, PortfolioKind, Signatory, + AssetHolder, AssetHolderKind, AuthorizationData, PortfolioId, PortfolioKind, Signatory, }; use super::setup::{create_and_issue_sample_asset, ISSUE_AMOUNT}; @@ -23,6 +23,20 @@ type Portfolio = pallet_portfolio::Pallet; type Settlement = pallet_settlement::Pallet; type System = frame_system::Pallet; +fn make_full_agent(owner: &User, agent: &User, asset_id: polymesh_primitives::asset::AssetId) { + let authorization_id = Identity::add_auth( + owner.did, + Signatory::from(agent.did), + AuthorizationData::BecomeAgent(asset_id, AgentGroup::Full), + None, + ) + .unwrap(); + assert_ok!(ExternalAgents::accept_become_agent( + agent.origin(), + authorization_id + )); +} + #[test] fn controller_transfer_locked_asset() { ExtBuilder::default().build().execute_with(|| { @@ -109,3 +123,75 @@ fn controller_self_transfer_rejected() { ); }); } + +#[test] +fn controller_transfer_within_free_balance_does_not_unfreeze() { + ExtBuilder::default().build().execute_with(|| { + let bob = User::new(Sr25519Keyring::Bob); + let alice = User::new(Sr25519Keyring::Alice); + let alice_default_portfolio = PortfolioId::default_portfolio(alice.did); + let alice_holder = AssetHolder::from(alice_default_portfolio.clone()); + + let asset_id = create_and_issue_sample_asset(&alice); + make_full_agent(&alice, &bob, asset_id); + + let frozen_amount = ISSUE_AMOUNT / 2; + assert_ok!(Asset::set_frozen_tokens( + alice.origin(), + asset_id, + alice_holder.clone(), + frozen_amount, + )); + + let free_balance = ISSUE_AMOUNT - frozen_amount; + assert_ok!(Asset::controller_transfer( + bob.origin(), + asset_id, + free_balance, + alice_default_portfolio.into(), + AssetHolderKind::DefaultPortfolio, + )); + + assert_eq!( + Asset::get_holders_frozen_balance(&alice_holder, &asset_id), + frozen_amount + ); + }); +} + +#[test] +fn controller_transfer_exceeding_free_balance_unfreezes_shortfall() { + ExtBuilder::default().build().execute_with(|| { + let bob = User::new(Sr25519Keyring::Bob); + let alice = User::new(Sr25519Keyring::Alice); + let alice_default_portfolio = PortfolioId::default_portfolio(alice.did); + let alice_holder = AssetHolder::from(alice_default_portfolio.clone()); + + let asset_id = create_and_issue_sample_asset(&alice); + make_full_agent(&alice, &bob, asset_id); + + let frozen_amount = ISSUE_AMOUNT / 2; + assert_ok!(Asset::set_frozen_tokens( + alice.origin(), + asset_id, + alice_holder.clone(), + frozen_amount, + )); + + let free_balance = ISSUE_AMOUNT - frozen_amount; + let shortfall = 100; + let transfer_value = free_balance + shortfall; + assert_ok!(Asset::controller_transfer( + bob.origin(), + asset_id, + transfer_value, + alice_default_portfolio.into(), + AssetHolderKind::DefaultPortfolio, + )); + + assert_eq!( + Asset::get_holders_frozen_balance(&alice_holder, &asset_id), + frozen_amount - shortfall + ); + }); +} diff --git a/pallets/weights/src/pallet_asset.rs b/pallets/weights/src/pallet_asset.rs index af2274c583..baf048d68c 100644 --- a/pallets/weights/src/pallet_asset.rs +++ b/pallets/weights/src/pallet_asset.rs @@ -947,4 +947,82 @@ impl pallet_asset::WeightInfo for SubstrateWeight { .saturating_add(DbWeight::get().reads(4)) .saturating_add(DbWeight::get().writes(1)) } + // Storage: `Identity::KeyRecords` (r:1 w:0) + // Proof: `Identity::KeyRecords` (`max_values`: None, `max_size`: Some(73), added: 2548, mode: `MaxEncodedLen`) + // Storage: `ExternalAgents::GroupOfAgent` (r:1 w:0) + // Proof: `ExternalAgents::GroupOfAgent` (`max_values`: None, `max_size`: Some(77), added: 2552, mode: `MaxEncodedLen`) + // Storage: `Permissions::CurrentPalletName` (r:1 w:0) + // Proof: `Permissions::CurrentPalletName` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + // Storage: `Permissions::CurrentDispatchableName` (r:1 w:0) + // Proof: `Permissions::CurrentDispatchableName` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + // Storage: `Identity::DidRecords` (r:1 w:0) + // Proof: `Identity::DidRecords` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) + // Storage: `Portfolio::PortfolioCustodian` (r:1 w:0) + // Proof: `Portfolio::PortfolioCustodian` (`max_values`: None, `max_size`: Some(81), added: 2556, mode: `MaxEncodedLen`) + // Storage: `Settlement::MandatoryReceiverAffirmation` (r:1 w:0) + // Proof: `Settlement::MandatoryReceiverAffirmation` (`max_values`: None, `max_size`: Some(33), added: 2508, mode: `MaxEncodedLen`) + // Storage: `Asset::Assets` (r:1 w:0) + // Proof: `Asset::Assets` (`max_values`: None, `max_size`: None, mode: `Measured`) + // Storage: `Asset::BalanceOf` (r:2 w:2) + // Proof: `Asset::BalanceOf` (`max_values`: None, `max_size`: Some(80), added: 2555, mode: `MaxEncodedLen`) + // Storage: `Portfolio::PortfolioAssetBalances` (r:2 w:2) + // Proof: `Portfolio::PortfolioAssetBalances` (`max_values`: None, `max_size`: Some(97), added: 2572, mode: `MaxEncodedLen`) + // Storage: `Portfolio::PortfolioLockedAssets` (r:1 w:0) + // Proof: `Portfolio::PortfolioLockedAssets` (`max_values`: None, `max_size`: Some(97), added: 2572, mode: `MaxEncodedLen`) + // Storage: `Checkpoint::CachedNextCheckpoints` (r:1 w:0) + // Proof: `Checkpoint::CachedNextCheckpoints` (`max_values`: None, `max_size`: None, mode: `Measured`) + // Storage: `Checkpoint::CheckpointIdSequence` (r:1 w:0) + // Proof: `Checkpoint::CheckpointIdSequence` (`max_values`: None, `max_size`: Some(40), added: 2515, mode: `MaxEncodedLen`) + // Storage: `Portfolio::PortfolioFrozenAssets` (r:1 w:0) + // Proof: `Portfolio::PortfolioFrozenAssets` (`max_values`: None, `max_size`: Some(97), added: 2572, mode: `MaxEncodedLen`) + // Storage: `Portfolio::PortfolioAssetCount` (r:1 w:1) + // Proof: `Portfolio::PortfolioAssetCount` (`max_values`: None, `max_size`: Some(57), added: 2532, mode: `MaxEncodedLen`) + // Storage: `Statistics::ActiveAssetStats` (r:1 w:0) + // Proof: `Statistics::ActiveAssetStats` (`max_values`: None, `max_size`: Some(2373), added: 4848, mode: `MaxEncodedLen`) + fn controller_transfer_to() -> Weight { + // Minimum execution time: 144_398 nanoseconds. + Weight::from_parts(149_141_000, 0) + .saturating_add(DbWeight::get().reads(18)) + .saturating_add(DbWeight::get().writes(5)) + } + // Storage: `Identity::KeyRecords` (r:1 w:0) + // Proof: `Identity::KeyRecords` (`max_values`: None, `max_size`: Some(73), added: 2548, mode: `MaxEncodedLen`) + // Storage: `ExternalAgents::GroupOfAgent` (r:1 w:0) + // Proof: `ExternalAgents::GroupOfAgent` (`max_values`: None, `max_size`: Some(77), added: 2552, mode: `MaxEncodedLen`) + // Storage: `Permissions::CurrentPalletName` (r:1 w:0) + // Proof: `Permissions::CurrentPalletName` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + // Storage: `Permissions::CurrentDispatchableName` (r:1 w:0) + // Proof: `Permissions::CurrentDispatchableName` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + // Storage: `Asset::Assets` (r:1 w:0) + // Proof: `Asset::Assets` (`max_values`: None, `max_size`: None, mode: `Measured`) + // Storage: `Portfolio::Portfolios` (r:1 w:0) + // Proof: `Portfolio::Portfolios` (`max_values`: None, `max_size`: None, mode: `Measured`) + // Storage: `Portfolio::PortfolioFrozenAssets` (r:1 w:1) + // Proof: `Portfolio::PortfolioFrozenAssets` (`max_values`: None, `max_size`: Some(97), added: 2572, mode: `MaxEncodedLen`) + fn freeze_partial_tokens() -> Weight { + // Minimum execution time: 67_006 nanoseconds. + Weight::from_parts(68_972_000, 0) + .saturating_add(DbWeight::get().reads(7)) + .saturating_add(DbWeight::get().writes(1)) + } + // Storage: `Identity::KeyRecords` (r:1 w:0) + // Proof: `Identity::KeyRecords` (`max_values`: None, `max_size`: Some(73), added: 2548, mode: `MaxEncodedLen`) + // Storage: `ExternalAgents::GroupOfAgent` (r:1 w:0) + // Proof: `ExternalAgents::GroupOfAgent` (`max_values`: None, `max_size`: Some(77), added: 2552, mode: `MaxEncodedLen`) + // Storage: `Permissions::CurrentPalletName` (r:1 w:0) + // Proof: `Permissions::CurrentPalletName` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + // Storage: `Permissions::CurrentDispatchableName` (r:1 w:0) + // Proof: `Permissions::CurrentDispatchableName` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + // Storage: `Asset::Assets` (r:1 w:0) + // Proof: `Asset::Assets` (`max_values`: None, `max_size`: None, mode: `Measured`) + // Storage: `Portfolio::Portfolios` (r:1 w:0) + // Proof: `Portfolio::Portfolios` (`max_values`: None, `max_size`: None, mode: `Measured`) + // Storage: `Portfolio::PortfolioFrozenAssets` (r:1 w:1) + // Proof: `Portfolio::PortfolioFrozenAssets` (`max_values`: None, `max_size`: Some(97), added: 2572, mode: `MaxEncodedLen`) + fn unfreeze_partial_tokens() -> Weight { + // Minimum execution time: 68_742 nanoseconds. + Weight::from_parts(70_849_000, 0) + .saturating_add(DbWeight::get().reads(7)) + .saturating_add(DbWeight::get().writes(1)) + } } diff --git a/pallets/weights/src/pallet_nft.rs b/pallets/weights/src/pallet_nft.rs index eade472377..75726fe10b 100644 --- a/pallets/weights/src/pallet_nft.rs +++ b/pallets/weights/src/pallet_nft.rs @@ -256,4 +256,43 @@ impl pallet_nft::WeightInfo for SubstrateWeight { .saturating_add(DbWeight::get().reads((1_u64).saturating_mul(n.into()))) .saturating_add(DbWeight::get().writes((1_u64).saturating_mul(n.into()))) } + // Storage: `Identity::KeyRecords` (r:1 w:0) + // Proof: `Identity::KeyRecords` (`max_values`: None, `max_size`: Some(73), added: 2548, mode: `MaxEncodedLen`) + // Storage: `ExternalAgents::GroupOfAgent` (r:1 w:0) + // Proof: `ExternalAgents::GroupOfAgent` (`max_values`: None, `max_size`: Some(77), added: 2552, mode: `MaxEncodedLen`) + // Storage: `Permissions::CurrentPalletName` (r:1 w:0) + // Proof: `Permissions::CurrentPalletName` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + // Storage: `Permissions::CurrentDispatchableName` (r:1 w:0) + // Proof: `Permissions::CurrentDispatchableName` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + // Storage: `Identity::DidRecords` (r:1 w:0) + // Proof: `Identity::DidRecords` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) + // Storage: `Portfolio::Portfolios` (r:1 w:0) + // Proof: `Portfolio::Portfolios` (`max_values`: None, `max_size`: None, mode: `Measured`) + // Storage: `Portfolio::PortfolioCustodian` (r:1 w:0) + // Proof: `Portfolio::PortfolioCustodian` (`max_values`: None, `max_size`: Some(81), added: 2556, mode: `MaxEncodedLen`) + // Storage: `Settlement::MandatoryReceiverAffirmation` (r:1 w:0) + // Proof: `Settlement::MandatoryReceiverAffirmation` (`max_values`: None, `max_size`: Some(33), added: 2508, mode: `MaxEncodedLen`) + // Storage: `Nft::CollectionAsset` (r:1 w:0) + // Proof: `Nft::CollectionAsset` (`max_values`: None, `max_size`: Some(40), added: 2515, mode: `MaxEncodedLen`) + // Storage: `Nft::NumberOfNFTs` (r:2 w:2) + // Proof: `Nft::NumberOfNFTs` (`max_values`: None, `max_size`: Some(72), added: 2547, mode: `MaxEncodedLen`) + // Storage: `Portfolio::PortfolioNFT` (r:20 w:20) + // Proof: `Portfolio::PortfolioNFT` (`max_values`: None, `max_size`: Some(106), added: 2581, mode: `MaxEncodedLen`) + // Storage: `Portfolio::PortfolioLockedNFT` (r:10 w:0) + // Proof: `Portfolio::PortfolioLockedNFT` (`max_values`: None, `max_size`: Some(90), added: 2565, mode: `MaxEncodedLen`) + // Storage: `Portfolio::PortfolioNFTCount` (r:2 w:2) + // Proof: `Portfolio::PortfolioNFTCount` (`max_values`: None, `max_size`: Some(89), added: 2564, mode: `MaxEncodedLen`) + // Storage: `Nft::Owner` (r:0 w:10) + // Proof: `Nft::Owner` (`max_values`: None, `max_size`: Some(98), added: 2573, mode: `MaxEncodedLen`) + /// The range of component `n` is `[1, 10]`. + fn controller_transfer_to(n: u32) -> Weight { + // Minimum execution time: 149_180 nanoseconds. + Weight::from_parts(112_021_213, 0) + // Standard Error: 32_083 + .saturating_add(Weight::from_parts(43_672_613, 0).saturating_mul(n.into())) + .saturating_add(DbWeight::get().reads(13)) + .saturating_add(DbWeight::get().reads((3_u64).saturating_mul(n.into()))) + .saturating_add(DbWeight::get().writes(4)) + .saturating_add(DbWeight::get().writes((3_u64).saturating_mul(n.into()))) + } } diff --git a/precompiles/src/interfaces/FungibleAssetStub.bin b/precompiles/src/interfaces/FungibleAssetStub.bin index ab8cb3b0ce..381d5e8c87 100644 Binary files a/precompiles/src/interfaces/FungibleAssetStub.bin and b/precompiles/src/interfaces/FungibleAssetStub.bin differ diff --git a/precompiles/src/interfaces/FungibleAssetStub.sol b/precompiles/src/interfaces/FungibleAssetStub.sol index 422533d7d8..9879de07d3 100644 --- a/precompiles/src/interfaces/FungibleAssetStub.sol +++ b/precompiles/src/interfaces/FungibleAssetStub.sol @@ -177,12 +177,13 @@ interface IFungibleAsset { /// @return True if the transfer is allowed, false otherwise. function canTransfer(address from, address to, uint256 value) external view returns (bool); - /// @notice Takes tokens from one address and transfers them to the caller's account. + /// @notice Takes tokens from one address and transfers them to another. /// @dev Requires specific authorization. Used for regulatory compliance or recovery scenarios. /// @param from The address from which `amount` is taken. + /// @param to The address which receives the seized tokens. /// @param amount The amount to force transfer. /// @return True if the transfer executed correctly. Reverts on failure. - function forcedTransfer(address from, uint256 amount) external returns (bool); + function forcedTransfer(address from, address to, uint256 amount) external returns (bool); /// @notice Changes the frozen status of `amount` tokens belonging to `account`. /// @dev Overwrites the current value, similar to an `approve` function. @@ -223,6 +224,12 @@ interface IFungibleAsset { /// @notice Emitted when the account of an investor is frozen or unfrozen. event AddressFrozen(address indexed account, bool freeze, address indexed owner); + /// @notice Emitted when `amount` tokens are frozen for `account`, on top of any tokens already frozen. + event TokensFrozen(address indexed account, uint256 amount); + + /// @notice Emitted when `amount` tokens are unfrozen for `account`. + event TokensUnfrozen(address indexed account, uint256 amount); + /// @notice Sets the token name. Only the owner of the token contract can call this function. function setName(string calldata name) external; @@ -237,6 +244,14 @@ interface IFungibleAsset { /// @notice Sets the frozen status of a specific address. Only an agent of the token can call this function. function setAddressFrozen(address account, bool freeze) external; + + /// @notice Freezes an additional `amount` of tokens for `account`, on top of any tokens already frozen. + /// Only an agent of the token can call this function. + function freezePartialTokens(address account, uint256 amount) external; + + /// @notice Unfreezes `amount` of tokens for `account`, reducing the amount currently frozen. + /// Only an agent of the token can call this function. + function unfreezePartialTokens(address account, uint256 amount) external; } contract FungibleAssetStub is IFungibleAsset { @@ -339,8 +354,9 @@ contract FungibleAssetStub is IFungibleAsset { revert NotExecutable(); } - function forcedTransfer(address from, uint256 amount) external override returns (bool) { + function forcedTransfer(address from, address to, uint256 amount) external override returns (bool) { from; + to; amount; revert NotExecutable(); } @@ -394,4 +410,20 @@ contract FungibleAssetStub is IFungibleAsset { freeze; revert NotExecutable(); } + + /// @notice Freezes an additional `amount` of tokens for `account`, on top of any tokens already frozen. + /// Only an agent of the token can call this function. + function freezePartialTokens(address account, uint256 amount) external override { + account; + amount; + revert NotExecutable(); + } + + /// @notice Unfreezes `amount` of tokens for `account`, reducing the amount currently frozen. + /// Only an agent of the token can call this function. + function unfreezePartialTokens(address account, uint256 amount) external override { + account; + amount; + revert NotExecutable(); + } }