diff --git a/xtokens/src/lib.rs b/xtokens/src/lib.rs index b75dbf8cf..f511be7e5 100644 --- a/xtokens/src/lib.rs +++ b/xtokens/src/lib.rs @@ -104,8 +104,12 @@ pub mod module { pub enum Event { /// Transferred. \[sender, currency_id, amount, dest\] Transferred(T::AccountId, T::CurrencyId, T::Balance, MultiLocation), + /// Transferred with fee. \[sender, currency_id, amount, fee, dest\] + TransferredWithFee(T::AccountId, T::CurrencyId, T::Balance, T::Balance, MultiLocation), /// Transferred `MultiAsset`. \[sender, asset, dest\] TransferredMultiAsset(T::AccountId, MultiAsset, MultiLocation), + /// Transferred `MultiAsset` with fee. \[sender, asset, fee, dest\] + TransferredMultiAssetWithFee(T::AccountId, MultiAsset, MultiAsset, MultiLocation), } #[pallet::error] @@ -134,6 +138,11 @@ pub mod module { /// The version of the `Versioned` value used is not able to be /// interpreted. BadVersion, + /// The fee MultiAsset is of different type than the asset to transfer. + DistincAssetAndFeeId, + /// The fee amount was zero when the fee specification extrinsic is + /// being used. + FeeCannotBeZero, } #[pallet::hooks] @@ -195,6 +204,89 @@ pub mod module { let dest: MultiLocation = (*dest).try_into().map_err(|()| Error::::BadVersion)?; Self::do_transfer_multiasset(who, asset, dest, dest_weight, true) } + + /// Transfer native currencies specifying the fee and amount as + /// separate. + /// + /// `dest_weight` is the weight for XCM execution on the dest chain, and + /// it would be charged from the transferred assets. If set below + /// requirements, the execution may fail and assets wouldn't be + /// received. + /// + /// `fee` is the amount to be spent to pay for execution in destination + /// chain. Both fee and amount will be subtracted form the callers + /// balance. + /// + /// If `fee` is not high enough to cover for the execution costs in the + /// destination chain, then the assets will be trapped in the + /// destination chain + /// + /// It's a no-op if any error on local XCM execution or message sending. + /// Note sending assets out per se doesn't guarantee they would be + /// received. Receiving depends on if the XCM message could be delivered + /// by the network, and if the receiving chain would handle + /// messages correctly. + #[pallet::weight(Pallet::::weight_of_transfer(currency_id.clone(), *amount, dest))] + #[transactional] + pub fn transfer_with_fee( + origin: OriginFor, + currency_id: T::CurrencyId, + amount: T::Balance, + fee: T::Balance, + dest: Box, + dest_weight: Weight, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + let dest: MultiLocation = (*dest).try_into().map_err(|()| Error::::BadVersion)?; + // Zero fee is an error + if fee.is_zero() { + return Err(Error::::FeeCannotBeZero.into()); + } + + Self::do_transfer_with_fee(who, currency_id, amount, fee, dest, dest_weight) + } + + /// Transfer `MultiAsset` specifying the fee and amount as separate. + /// + /// `dest_weight` is the weight for XCM execution on the dest chain, and + /// it would be charged from the transferred assets. If set below + /// requirements, the execution may fail and assets wouldn't be + /// received. + /// + /// `fee` is the multiasset to be spent to pay for execution in + /// destination chain. Both fee and amount will be subtracted form the + /// callers balance For now we only accept fee and asset having the same + /// `MultiLocation` id. + /// + /// If `fee` is not high enough to cover for the execution costs in the + /// destination chain, then the assets will be trapped in the + /// destination chain + /// + /// It's a no-op if any error on local XCM execution or message sending. + /// Note sending assets out per se doesn't guarantee they would be + /// received. Receiving depends on if the XCM message could be delivered + /// by the network, and if the receiving chain would handle + /// messages correctly. + #[pallet::weight(Pallet::::weight_of_transfer_multiasset(asset, dest))] + #[transactional] + pub fn transfer_multiasset_with_fee( + origin: OriginFor, + asset: Box, + fee: Box, + dest: Box, + dest_weight: Weight, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + let asset: MultiAsset = (*asset).try_into().map_err(|()| Error::::BadVersion)?; + let fee: MultiAsset = (*fee).try_into().map_err(|()| Error::::BadVersion)?; + let dest: MultiLocation = (*dest).try_into().map_err(|()| Error::::BadVersion)?; + // Zero fee is an error + if fungible_amount(&fee).is_zero() { + return Err(Error::::FeeCannotBeZero.into()); + } + + Self::do_transfer_multiasset_with_fee(who, asset, fee, dest, dest_weight, true) + } } impl Pallet { @@ -215,6 +307,25 @@ pub mod module { Ok(()) } + fn do_transfer_with_fee( + who: T::AccountId, + currency_id: T::CurrencyId, + amount: T::Balance, + fee: T::Balance, + dest: MultiLocation, + dest_weight: Weight, + ) -> DispatchResult { + let location: MultiLocation = T::CurrencyIdConvert::convert(currency_id.clone()) + .ok_or(Error::::NotCrossChainTransferableCurrency)?; + + let asset = (location.clone(), amount.into()).into(); + let fee_asset: MultiAsset = (location, fee.into()).into(); + Self::do_transfer_multiasset_with_fee(who.clone(), asset, fee_asset, dest.clone(), dest_weight, false)?; + + Self::deposit_event(Event::::TransferredWithFee(who, currency_id, amount, fee, dest)); + Ok(()) + } + fn do_transfer_multiasset( who: T::AccountId, asset: MultiAsset, @@ -254,6 +365,65 @@ pub mod module { Ok(()) } + fn do_transfer_multiasset_with_fee( + who: T::AccountId, + asset: MultiAsset, + fee: MultiAsset, + dest: MultiLocation, + dest_weight: Weight, + deposit_event: bool, + ) -> DispatchResult { + if !asset.is_fungible(None) || !fee.is_fungible(None) { + return Err(Error::::NotFungible.into()); + } + + if fungible_amount(&asset).is_zero() { + return Ok(()); + } + + // For now fee and asset id should be identical + // We can relax this assumption in the future + ensure!(fee.id == asset.id, Error::::DistincAssetAndFeeId); + + let (transfer_kind, dest, reserve, recipient) = Self::transfer_kind(&asset, &dest)?; + let mut msg = match transfer_kind { + SelfReserveAsset => Self::transfer_self_reserve_asset_with_fee( + asset.clone(), + fee.clone(), + dest.clone(), + recipient, + dest_weight, + )?, + ToReserve => Self::transfer_to_reserve_with_fee( + asset.clone(), + fee.clone(), + dest.clone(), + recipient, + dest_weight, + )?, + ToNonReserve => Self::transfer_to_non_reserve_with_fee( + asset.clone(), + fee.clone(), + reserve, + dest.clone(), + recipient, + dest_weight, + )?, + }; + + let origin_location = T::AccountIdToMultiLocation::convert(who.clone()); + let weight = T::Weigher::weight(&mut msg).map_err(|()| Error::::UnweighableMessage)?; + T::XcmExecutor::execute_xcm_in_credit(origin_location, msg, weight, weight) + .ensure_complete() + .map_err(|_| Error::::XcmExecutionFailed)?; + + if deposit_event { + Self::deposit_event(Event::::TransferredMultiAssetWithFee(who, asset, fee, dest)); + } + + Ok(()) + } + fn transfer_self_reserve_asset( asset: MultiAsset, dest: MultiLocation, @@ -274,6 +444,27 @@ pub mod module { ])) } + fn transfer_self_reserve_asset_with_fee( + asset: MultiAsset, + fee: MultiAsset, + dest: MultiLocation, + recipient: MultiLocation, + dest_weight: Weight, + ) -> Result, DispatchError> { + Ok(Xcm(vec![ + WithdrawAsset(vec![asset, fee.clone()].into()), + DepositReserveAsset { + assets: All.into(), + max_assets: 1, + dest: dest.clone(), + xcm: Xcm(vec![ + Self::buy_execution(fee, &dest, dest_weight)?, + Self::deposit_asset(recipient), + ]), + }, + ])) + } + fn transfer_to_reserve( asset: MultiAsset, reserve: MultiLocation, @@ -293,6 +484,26 @@ pub mod module { ])) } + fn transfer_to_reserve_with_fee( + asset: MultiAsset, + fee: MultiAsset, + reserve: MultiLocation, + recipient: MultiLocation, + dest_weight: Weight, + ) -> Result, DispatchError> { + Ok(Xcm(vec![ + WithdrawAsset(vec![asset, fee.clone()].into()), + InitiateReserveWithdraw { + assets: All.into(), + reserve: reserve.clone(), + xcm: Xcm(vec![ + Self::buy_execution(fee, &reserve, dest_weight)?, + Self::deposit_asset(recipient), + ]), + }, + ])) + } + fn transfer_to_non_reserve( asset: MultiAsset, reserve: MultiLocation, @@ -334,6 +545,48 @@ pub mod module { ])) } + fn transfer_to_non_reserve_with_fee( + asset: MultiAsset, + fee: MultiAsset, + reserve: MultiLocation, + dest: MultiLocation, + recipient: MultiLocation, + dest_weight: Weight, + ) -> Result, DispatchError> { + let mut reanchored_dest = dest.clone(); + if reserve == MultiLocation::parent() { + match dest { + MultiLocation { + parents, + interior: X1(Parachain(id)), + } if parents == 1 => { + reanchored_dest = Parachain(id).into(); + } + _ => {} + } + } + + Ok(Xcm(vec![ + WithdrawAsset(vec![asset, fee.clone()].into()), + InitiateReserveWithdraw { + assets: All.into(), + reserve: reserve.clone(), + xcm: Xcm(vec![ + Self::buy_execution(half(&fee), &reserve, dest_weight)?, + DepositReserveAsset { + assets: All.into(), + max_assets: 1, + dest: reanchored_dest, + xcm: Xcm(vec![ + Self::buy_execution(half(&fee), &dest, dest_weight)?, + Self::deposit_asset(recipient), + ]), + }, + ]), + }, + ])) + } + fn deposit_asset(recipient: MultiLocation) -> Instruction<()> { DepositAsset { assets: All.into(), diff --git a/xtokens/src/mock/para.rs b/xtokens/src/mock/para.rs index 0c67e974a..087217078 100644 --- a/xtokens/src/mock/para.rs +++ b/xtokens/src/mock/para.rs @@ -198,8 +198,8 @@ impl Config for XcmConfig { type Weigher = FixedWeightBounds; type Trader = AllTokensAreCreatedEqualToWeight; type ResponseHandler = (); - type AssetTrap = (); - type AssetClaims = (); + type AssetTrap = PolkadotXcm; + type AssetClaims = PolkadotXcm; type SubscriptionService = PolkadotXcm; } diff --git a/xtokens/src/tests.rs b/xtokens/src/tests.rs index 74a927d92..9bd661014 100644 --- a/xtokens/src/tests.rs +++ b/xtokens/src/tests.rs @@ -77,6 +77,42 @@ fn send_relay_chain_asset_to_relay_chain() { }); } +#[test] +fn send_relay_chain_asset_to_relay_chain_with_fee() { + TestNet::reset(); + + Relay::execute_with(|| { + let _ = RelayBalances::deposit_creating(¶_a_account(), 1_000); + }); + + ParaA::execute_with(|| { + assert_ok!(ParaXTokens::transfer_with_fee( + Some(ALICE).into(), + CurrencyId::R, + 450, + 50, + Box::new( + MultiLocation::new( + 1, + X1(Junction::AccountId32 { + network: NetworkId::Any, + id: BOB.into(), + }) + ) + .into() + ), + 40, + )); + assert_eq!(ParaTokens::free_balance(CurrencyId::R, &ALICE), 500); + }); + + // It should use 40 for weight, so 460 should reach destination + Relay::execute_with(|| { + assert_eq!(RelayBalances::free_balance(¶_a_account()), 500); + assert_eq!(RelayBalances::free_balance(&BOB), 460); + }); +} + #[test] fn cannot_lost_fund_on_send_failed() { TestNet::reset(); @@ -149,6 +185,50 @@ fn send_relay_chain_asset_to_sibling() { }); } +#[test] +fn send_relay_chain_asset_to_sibling_with_fee() { + TestNet::reset(); + + Relay::execute_with(|| { + let _ = RelayBalances::deposit_creating(¶_a_account(), 1000); + }); + + ParaA::execute_with(|| { + assert_ok!(ParaXTokens::transfer_with_fee( + Some(ALICE).into(), + CurrencyId::R, + 410, + 90, + Box::new( + MultiLocation::new( + 1, + X2( + Parachain(2), + Junction::AccountId32 { + network: NetworkId::Any, + id: BOB.into(), + } + ) + ) + .into() + ), + 40, + )); + assert_eq!(ParaTokens::free_balance(CurrencyId::R, &ALICE), 500); + }); + + // It should use 40 weight + Relay::execute_with(|| { + assert_eq!(RelayBalances::free_balance(¶_a_account()), 500); + assert_eq!(RelayBalances::free_balance(¶_b_account()), 460); + }); + + // It should use another 40 weight in paraB + ParaB::execute_with(|| { + assert_eq!(ParaTokens::free_balance(CurrencyId::R, &BOB), 420); + }); +} + #[test] fn send_sibling_asset_to_reserve_sibling() { TestNet::reset(); @@ -189,6 +269,48 @@ fn send_sibling_asset_to_reserve_sibling() { }); } +#[test] +fn send_sibling_asset_to_reserve_sibling_with_fee() { + TestNet::reset(); + + ParaA::execute_with(|| { + assert_ok!(ParaTokens::deposit(CurrencyId::B, &ALICE, 1_000)); + }); + + ParaB::execute_with(|| { + assert_ok!(ParaTokens::deposit(CurrencyId::B, &sibling_a_account(), 1_000)); + }); + + ParaA::execute_with(|| { + assert_ok!(ParaXTokens::transfer_with_fee( + Some(ALICE).into(), + CurrencyId::B, + 450, + 50, + Box::new( + ( + Parent, + Parachain(2), + Junction::AccountId32 { + network: NetworkId::Any, + id: BOB.into(), + }, + ) + .into() + ), + 40, + )); + + assert_eq!(ParaTokens::free_balance(CurrencyId::B, &ALICE), 500); + }); + + // It should use 40 for weight, so 460 should reach destination + ParaB::execute_with(|| { + assert_eq!(ParaTokens::free_balance(CurrencyId::B, &sibling_a_account()), 500); + assert_eq!(ParaTokens::free_balance(CurrencyId::B, &BOB), 460); + }); +} + #[test] fn send_sibling_asset_to_non_reserve_sibling() { TestNet::reset(); @@ -235,6 +357,55 @@ fn send_sibling_asset_to_non_reserve_sibling() { }); } +#[test] +fn send_sibling_asset_to_non_reserve_sibling_with_fee() { + TestNet::reset(); + + ParaA::execute_with(|| { + assert_ok!(ParaTokens::deposit(CurrencyId::B, &ALICE, 1_000)); + }); + + ParaB::execute_with(|| { + assert_ok!(ParaTokens::deposit(CurrencyId::B, &sibling_a_account(), 1_000)); + }); + + ParaA::execute_with(|| { + assert_ok!(ParaXTokens::transfer_with_fee( + Some(ALICE).into(), + CurrencyId::B, + 410, + 90, + Box::new( + MultiLocation::new( + 1, + X2( + Parachain(3), + Junction::AccountId32 { + network: NetworkId::Any, + id: BOB.into(), + } + ) + ) + .into() + ), + 40 + )); + assert_eq!(ParaTokens::free_balance(CurrencyId::B, &ALICE), 500); + }); + + // Should use only 40 weight + // check reserve accounts + ParaB::execute_with(|| { + assert_eq!(ParaTokens::free_balance(CurrencyId::B, &sibling_a_account()), 500); + assert_eq!(ParaTokens::free_balance(CurrencyId::B, &sibling_c_account()), 460); + }); + + // Should use 40 additional weight + ParaC::execute_with(|| { + assert_eq!(ParaTokens::free_balance(CurrencyId::B, &BOB), 420); + }); +} + #[test] fn send_self_parachain_asset_to_sibling() { TestNet::reset(); @@ -271,6 +442,44 @@ fn send_self_parachain_asset_to_sibling() { }); } +#[test] +fn send_self_parachain_asset_to_sibling_with_fee() { + TestNet::reset(); + + ParaA::execute_with(|| { + assert_ok!(ParaTokens::deposit(CurrencyId::A, &ALICE, 1_000)); + + assert_ok!(ParaXTokens::transfer_with_fee( + Some(ALICE).into(), + CurrencyId::A, + 450, + 50, + Box::new( + MultiLocation::new( + 1, + X2( + Parachain(2), + Junction::AccountId32 { + network: NetworkId::Any, + id: BOB.into(), + } + ) + ) + .into() + ), + 40, + )); + + assert_eq!(ParaTokens::free_balance(CurrencyId::A, &ALICE), 500); + assert_eq!(ParaTokens::free_balance(CurrencyId::A, &sibling_b_account()), 500); + }); + + // It should use 40 for weight, so 460 should reach destination + ParaB::execute_with(|| { + assert_eq!(ParaTokens::free_balance(CurrencyId::A, &BOB), 460); + }); +} + #[test] fn transfer_no_reserve_assets_fails() { TestNet::reset(); @@ -448,3 +657,79 @@ fn call_size_limit() { If the limit is too strong, maybe consider increasing the limit", ); } + +#[test] +fn send_with_zero_fee_should_yield_an_error() { + TestNet::reset(); + + ParaA::execute_with(|| { + assert_ok!(ParaTokens::deposit(CurrencyId::A, &ALICE, 1_000)); + + // Transferring with zero fee should fail + assert_noop!( + ParaXTokens::transfer_with_fee( + Some(ALICE).into(), + CurrencyId::A, + 450, + 0, + Box::new( + MultiLocation::new( + 1, + X2( + Parachain(2), + Junction::AccountId32 { + network: NetworkId::Any, + id: BOB.into(), + } + ) + ) + .into() + ), + 40, + ), + Error::::FeeCannotBeZero + ); + }); +} + +#[test] +fn send_with_insufficient_fee_traps_assets() { + TestNet::reset(); + + ParaA::execute_with(|| { + assert_ok!(ParaTokens::deposit(CurrencyId::A, &ALICE, 1_000)); + + // ParaB charges 40, but we specify 30 as fee. Assets will be trapped + // Call succedes in paraA + assert_ok!(ParaXTokens::transfer_with_fee( + Some(ALICE).into(), + CurrencyId::A, + 450, + 30, + Box::new( + MultiLocation::new( + 1, + X2( + Parachain(2), + Junction::AccountId32 { + network: NetworkId::Any, + id: BOB.into(), + } + ) + ) + .into() + ), + 40, + )); + }); + + // In paraB, assets have been trapped due to he failed execution + ParaB::execute_with(|| { + assert!(para::System::events().iter().any(|r| { + matches!( + r.event, + para::Event::PolkadotXcm(pallet_xcm::Event::::AssetsTrapped(_, _, _)) + ) + })); + }) +}