--- a/README.md +++ b/README.md @@ -333,3 +333,62 @@ */ ``` + + +## Karura token transfer + +To get started, you need to open inbound and outbound hrmp channels. +Next, we need to register our asset at Karura. + +``` +assetRegistry -> registerForeignAsset(location, metadata) +location: + V0(X2(Parent, Parachain(PARA_ID))) +metadata: + name OPL + symbol OPL + decimals 18 +minimalBalance 1 +``` + +Next, we can send tokens from Opal to Karura: +``` +polkadotXcm -> reserveTransferAssets +dest: + V0(X2(Parent, Parachain())) +beneficiary: + X1(AccountId(Any, )) +assets: + V1(Concrete(0,Here), Fungible()) +feeAssetItem: + 0 +weightLimit: + +``` + +The result will be displayed in ChainState +tokens -> accounts + + +To send tokens from Karura to Opal: + +``` +xtokens -> transfer + +currencyId: + ForeingAsset + + +amount: + +dest: + V1 + ( + Parents:1, + X2(Parachain(), AccountId(Any, ) + ) +destWeight: + + + +``` \ No newline at end of file --- a/launch-config.json +++ b/launch-config.json @@ -9,7 +9,8 @@ "rpcPort": 9843, "port": 30444, "flags": [ - "-lparachain::candidate_validation=debug" + "-lparachain::candidate_validation=debug", + "-lxcm=trace" ] }, { @@ -18,7 +19,8 @@ "rpcPort": 9854, "port": 30555, "flags": [ - "-lparachain::candidate_validation=debug" + "-lparachain::candidate_validation=debug", + "-lxcm=trace" ] }, { @@ -38,6 +40,15 @@ "flags": [ "-lparachain::candidate_validation=debug" ] + }, + { + "name": "eve", + "wsPort": 9888, + "rpcPort": 9887, + "port": 30888, + "flags": [ + "-lparachain::candidate_validation=debug" + ] } ], "genesis": { @@ -67,7 +78,8 @@ "flags": [ "--rpc-cors=all", "--unsafe-rpc-external", - "--unsafe-ws-external" + "--unsafe-ws-external", + "-lxcm=trace" ] }, { @@ -78,13 +90,46 @@ "flags": [ "--rpc-cors=all", "--unsafe-rpc-external", - "--unsafe-ws-external" + "--unsafe-ws-external", + "-lxcm=trace" ] } ] - } + }, + { + "bin": "../Acala/target/release/acala", + "id": "2000", + "chain": "karura-dev", + "balance": "1000000000000000000000", + "nodes": [ + { + "wsPort": 9946, + "port": 31202, + "name": "alice", + "flags": [ + "--rpc-cors=all", + "--unsafe-rpc-external", + "--unsafe-ws-external", + "-lxcm=trace" + ] + } + ] + } ], "simpleParachains": [], - "hrmpChannels": [], + "hrmpChannels": [ + { + "sender": 2000, + "recipient": 1000, + "maxCapacity": 8, + "maxMessageSize": 512 + }, + { + "sender": 1000, + "recipient": 2000, + "maxCapacity": 8, + "maxMessageSize": 512 + } + ], "finalization": false } --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -23,7 +23,8 @@ use sp_runtime::{ Permill, Perbill, Percent, create_runtime_str, generic, impl_opaque_keys, traits::{ - AccountIdLookup, BlakeTwo256, Block as BlockT, IdentifyAccount, Verify, AccountIdConversion, + AccountIdLookup, BlakeTwo256, Block as BlockT, IdentifyAccount, Verify, + AccountIdConversion, Zero, }, transaction_validity::{TransactionSource, TransactionValidity}, ApplyExtrinsicResult, MultiSignature, RuntimeAppPublic, @@ -45,8 +46,9 @@ dispatch::DispatchResult, PalletId, parameter_types, StorageValue, ConsensusEngineId, traits::{ - Everything, Currency, ExistenceRequirement, Get, IsInVec, KeyOwnerProofSystem, - LockIdentifier, OnUnbalanced, Randomness, FindAuthor, + tokens::currency::Currency as CurrencyT, OnUnbalanced as OnUnbalancedT, Everything, + Currency, ExistenceRequirement, Get, IsInVec, KeyOwnerProofSystem, LockIdentifier, + OnUnbalanced, Randomness, FindAuthor, }, weights::{ constants::{BlockExecutionWeight, ExtrinsicBaseWeight, RocksDbWeight, WEIGHT_PER_SECOND}, @@ -69,8 +71,9 @@ use pallet_evm::{Account as EVMAccount, FeeCalculator, GasWeightMapping, OnMethodCall}; use fp_rpc::TransactionStatus; use sp_runtime::{ - traits::{BlockNumberProvider, Dispatchable, PostDispatchInfoOf}, + traits::{BlockNumberProvider, Dispatchable, PostDispatchInfoOf, Saturating}, transaction_validity::TransactionValidityError, + SaturatedConversion, }; // pub use pallet_timestamp::Call as TimestampCall; @@ -82,12 +85,23 @@ use xcm::v1::{BodyId, Junction::*, MultiLocation, NetworkId, Junctions::*}; use xcm_builder::{ AccountId32Aliases, AllowTopLevelPaidExecutionFrom, AllowUnpaidExecutionFrom, CurrencyAdapter, - EnsureXcmOrigin, FixedWeightBounds, IsConcrete, LocationInverter, NativeAsset, - ParentAsSuperuser, ParentIsDefault, RelayChainAsNative, SiblingParachainAsNative, - SiblingParachainConvertsVia, SignedAccountId32AsNative, SignedToAccountId32, - SovereignSignedViaLocation, TakeWeightCredit, UsingComponents, + EnsureXcmOrigin, FixedWeightBounds, LocationInverter, NativeAsset, ParentAsSuperuser, + ParentIsDefault, RelayChainAsNative, SiblingParachainAsNative, SiblingParachainConvertsVia, + SignedAccountId32AsNative, SignedToAccountId32, SovereignSignedViaLocation, TakeWeightCredit, +}; +use xcm_executor::{Config, XcmExecutor, Assets}; +use sp_std::{marker::PhantomData}; + +use xcm::latest::{ + // Xcm, + AssetId::{Concrete}, + Fungibility::Fungible as XcmFungible, + MultiAsset, + Error as XcmError, }; -use xcm_executor::{Config, XcmExecutor}; +use xcm_executor::traits::{MatchesFungible, WeightTrader}; +//use xcm_executor::traits::MatchesFungible; +use sp_runtime::traits::CheckedConversion; // mod chain_extension; // use crate::chain_extension::{NFTExtension, Imbalance}; @@ -616,12 +630,22 @@ AccountId32Aliases, ); +pub struct OnlySelfCurrency; +impl> MatchesFungible for OnlySelfCurrency { + fn matches_fungible(a: &MultiAsset) -> Option { + match (&a.id, &a.fun) { + (Concrete(_), XcmFungible(ref amount)) => CheckedConversion::checked_from(*amount), + _ => None, + } + } +} + /// Means for transacting assets on this chain. pub type LocalAssetTransactor = CurrencyAdapter< // Use this currency: Balances, // Use this currency when it is a fungible asset matching the given location or name: - IsConcrete, + OnlySelfCurrency, // Do a simple punn to convert an AccountId32 MultiLocation into a native chain account ID: LocationToAccountId, // Our chain's account ID type (we can't get away without mentioning it explicitly): @@ -677,6 +701,88 @@ // ^^^ Parent & its unit plurality gets free execution ); +pub struct UsingOnlySelfCurrencyComponents< + WeightToFee: WeightToFeePolynomial, + AssetId: Get, + AccountId, + Currency: CurrencyT, + OnUnbalanced: OnUnbalancedT, +>( + Weight, + Currency::Balance, + PhantomData<(WeightToFee, AssetId, AccountId, Currency, OnUnbalanced)>, +); +impl< + WeightToFee: WeightToFeePolynomial, + AssetId: Get, + AccountId, + Currency: CurrencyT, + OnUnbalanced: OnUnbalancedT, + > WeightTrader + for UsingOnlySelfCurrencyComponents +{ + fn new() -> Self { + Self(0, Zero::zero(), PhantomData) + } + + fn buy_weight(&mut self, weight: Weight, payment: Assets) -> Result { + let amount = WeightToFee::calc(&weight); + let u128_amount: u128 = amount.try_into().map_err(|_| XcmError::Overflow)?; + + // location to this parachain through relay chain + let option1: xcm::v1::AssetId = Concrete(MultiLocation { + parents: 1, + interior: X1(Parachain(ParachainInfo::parachain_id().into())), + }); + // direct location + let option2: xcm::v1::AssetId = Concrete(MultiLocation { + parents: 0, + interior: Here, + }); + + let required = if payment.fungible.contains_key(&option1) { + (option1, u128_amount).into() + } else if payment.fungible.contains_key(&option2) { + (option2, u128_amount).into() + } else { + (Concrete(MultiLocation::default()), u128_amount).into() + }; + + let unused = payment + .checked_sub(required) + .map_err(|_| XcmError::TooExpensive)?; + self.0 = self.0.saturating_add(weight); + self.1 = self.1.saturating_add(amount); + Ok(unused) + } + + fn refund_weight(&mut self, weight: Weight) -> Option { + let weight = weight.min(self.0); + let amount = WeightToFee::calc(&weight); + self.0 -= weight; + self.1 = self.1.saturating_sub(amount); + let amount: u128 = amount.saturated_into(); + if amount > 0 { + Some((AssetId::get(), amount).into()) + } else { + None + } + } +} +impl< + WeightToFee: WeightToFeePolynomial, + AssetId: Get, + AccountId, + Currency: CurrencyT, + OnUnbalanced: OnUnbalancedT, + > Drop + for UsingOnlySelfCurrencyComponents +{ + fn drop(&mut self) { + OnUnbalanced::on_unbalanced(Currency::issue(self.1)); + } +} + pub struct XcmConfig; impl Config for XcmConfig { type Call = Call; @@ -689,7 +795,13 @@ type LocationInverter = LocationInverter; type Barrier = Barrier; type Weigher = FixedWeightBounds; - type Trader = UsingComponents, RelayLocation, AccountId, Balances, ()>; + type Trader = UsingOnlySelfCurrencyComponents< + IdentityFee, + RelayLocation, + AccountId, + Balances, + (), + >; type ResponseHandler = (); // Don't handle responses for now. type SubscriptionService = PolkadotXcm; --- a/tests/package.json +++ b/tests/package.json @@ -64,6 +64,7 @@ "testOverflow": "mocha --timeout 9999999 -r ts-node/register ./**/overflow.test.ts", "testSetVariableMetadataSponsoringRateLimit": "mocha --timeout 9999999 -r ts-node/register ./**/setVariableMetadataSponsoringRateLimit.test.ts", "testInflation": "mocha --timeout 9999999 -r ts-node/register ./**/inflation.test.ts", + "testXcmTransfer": "mocha --timeout 9999999 -r ts-node/register ./**/xcmTransfer.test.ts", "testPalletPresence": "mocha --timeout 9999999 -r ts-node/register ./**/pallet-presence.test.ts", "testBlockProduction": "mocha --timeout 9999999 -r ts-node/register ./**/block-production.test.ts", "testEnableDisableTransfers": "mocha --timeout 9999999 -r ts-node/register ./**/enableDisableTransfer.test.ts", --- /dev/null +++ b/tests/src/xcmTransfer.test.ts @@ -0,0 +1,178 @@ +// +// This file is subject to the terms and conditions defined in +// file 'LICENSE', which is part of this source code package. +// + +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import {WsProvider} from '@polkadot/api'; +import {ApiOptions} from '@polkadot/api/types'; +import {IKeyringPair} from '@polkadot/types/types'; +import privateKey from './substrate/privateKey'; +import usingApi, {submitTransactionAsync} from './substrate/substrate-api'; +import {getGenericResult} from './util/helpers'; +import waitNewBlocks from './substrate/wait-new-blocks'; +import getBalance from './substrate/get-balance'; +import {alicesPublicKey} from './accounts'; + +chai.use(chaiAsPromised); +const expect = chai.expect; + +const UNIQUE_CHAIN = 1000; +const KARURA_CHAIN = 2000; +const KARURA_PORT = '9946'; + +describe('Integration test: Exchanging OPL with Karura', () => { + let alice: IKeyringPair; + + before(async () => { + await usingApi(async () => { + alice = privateKey('//Alice'); + }); + + const karuraApiOptions: ApiOptions = { + provider: new WsProvider('ws://127.0.0.1:' + KARURA_PORT), + }; + + await usingApi(async (api) => { + const destination = { + V0: { + X2: [ + 'Parent', + { + Parachain: UNIQUE_CHAIN, + }, + ], + }, + }; + + const metadata = + { + name: 'OPL', + symbol: 'OPL', + decimals: 18, + minimalBalance: 1, + }; + + const tx = api.tx.assetRegistry.registerForeignAsset(destination, metadata); + const sudoTx = api.tx.sudo.sudo(tx as any); + const events = await submitTransactionAsync(alice, sudoTx); + const result = getGenericResult(events); + expect(result.success).to.be.true; + }, karuraApiOptions); + }); + + it('Should connect and send OPL to Karura', async () => { + let balanceOnKaruraBefore: bigint; + + await usingApi(async (api) => { + const {free} = (await api.query.tokens.accounts(alice.addressRaw, {ForeignAsset: 0})).toJSON() as any; + balanceOnKaruraBefore = free; + }, {provider: new WsProvider('ws://127.0.0.1:' + KARURA_PORT)}); + + await usingApi(async (api) => { + const destination = { + V0: { + X2: [ + 'Parent', + { + Parachain: KARURA_CHAIN, + }, + ], + }, + }; + + const beneficiary = { + V0: { + X1: { + AccountId32: { + network: 'Any', + id: alice.addressRaw, + }, + }, + }, + }; + + const assets = { + V1: [ + { + id: { + Concrete: { + parents: 0, + interior: 'Here', + }, + }, + fun: { + Fungible: 5000000000, + }, + }, + ], + }; + + const feeAssetItem = 0; + + const weightLimit = { + Limited: 5000000000, + }; + + const tx = api.tx.polkadotXcm.limitedReserveTransferAssets(destination, beneficiary, assets, feeAssetItem, weightLimit); + const events = await submitTransactionAsync(alice, tx); + const result = getGenericResult(events); + expect(result.success).to.be.true; + }); + + await usingApi(async (api) => { + // todo do something about instant sealing, where there might not be any new blocks + await waitNewBlocks(api, 3); + const {free} = (await api.query.tokens.accounts(alice.addressRaw, {ForeignAsset: 0})).toJSON() as any; + expect(free > balanceOnKaruraBefore).to.be.true; + }, {provider: new WsProvider('ws://127.0.0.1:' + KARURA_PORT)}); + }); + + it('Should connect to Karura and send OPL back', async () => { + let balanceBefore: bigint; + + await usingApi(async (api) => { + [balanceBefore] = await getBalance(api, [alicesPublicKey]); + }); + + await usingApi(async (api) => { + const destination = { + V0: { + X3: [ + 'Parent', + { + Parachain: UNIQUE_CHAIN, + }, + { + AccountId32: { + network: 'Any', + id: alice.addressRaw, + }, + }, + ], + }, + }; + + const id = { + ForeignAsset: 0, + }; + + const amount = 5000000000; + const destWeight = 50000000; + + const tx = api.tx.xTokens.transfer(id, amount, destination, destWeight); + const events = await submitTransactionAsync(alice, tx); + const result = getGenericResult(events); + expect(result.success).to.be.true; + }, {provider: new WsProvider('ws://127.0.0.1:' + KARURA_PORT)}); + + await usingApi(async (api) => { + // todo do something about instant sealing, where there might not be any new blocks + await waitNewBlocks(api, 3); + const [balanceAfter] = await getBalance(api, [alicesPublicKey]); + expect(balanceAfter > balanceBefore).to.be.true; + }); + }); +}); \ No newline at end of file