diff --git a/README.md b/README.md index 847e50f..7befb7f 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,9 @@ For details regarding the contract deployment go to this [page](https://github.c Some tests require a zombienet network to be run in the background. The steps to take before running the tests are the following: 1. Run a local [swanky](https://github.com/AstarNetwork/swanky-node) test node. This is where the contracts will be deployed to locally. The command to run: `./swanky-node --dev --tmp` -2. Run the local zombienet network: `zombienet-macos -p native spawn local_network.toml` +2. Follow the instructions on [trappist](https://github.com/paritytech/trappist) and run the [full_network.toml](https://github.com/paritytech/trappist/blob/main/zombienet/full_network.toml) network. -After the swanky node and the zombienet is running you can run all the tests: +After the swanky node and the zombienet network is running you can run all the tests: ``` yarn test diff --git a/__tests__/crossChainRouter.test.ts b/__tests__/crossChainRouter.test.ts new file mode 100644 index 0000000..6e3e541 --- /dev/null +++ b/__tests__/crossChainRouter.test.ts @@ -0,0 +1,498 @@ +import { ApiPromise, Keyring, WsProvider } from "@polkadot/api"; +import { KeyringPair } from "@polkadot/keyring/types"; +import { u8aToHex } from '@polkadot/util'; + +import TransactionRouter from "@/utils/transactionRouter"; +import { Fungible, Receiver, Sender } from "@/utils/transactionRouter/types"; + +import IdentityContractFactory from "../types/constructors/identity"; +import IdentityContract from "../types/contracts/identity"; +import { AccountType, NetworkInfo } from "../types/types-arguments/identity"; + +const wsProvider = new WsProvider("ws://127.0.0.1:9944"); +const keyring = new Keyring({ type: "sr25519" }); + +const USDT_ASSET_ID = 1984; + +const WS_ROROCO_LOCAL = "ws://127.0.0.1:9900"; +const WS_ASSET_HUB_LOCAL = "ws://127.0.0.1:9910"; +const WS_TRAPPIST_LOCAL = "ws://127.0.0.1:9920"; + +describe("TransactionRouter Cross-chain", () => { + let swankyApi: ApiPromise; + let alice: KeyringPair; + let bob: KeyringPair; + let charlie: KeyringPair; + let identityContract: any; + + beforeEach(async function (): Promise { + swankyApi = await ApiPromise.create({ + provider: wsProvider, + noInitWarn: true, + }); + alice = keyring.addFromUri("//Alice"); + bob = keyring.addFromUri("//Bob"); + charlie = keyring.addFromUri("//Charlie"); + + const factory = new IdentityContractFactory(swankyApi, alice); + identityContract = new IdentityContract( + (await factory.new()).address, + alice, + swankyApi + ); + + await addNetwork(identityContract, alice, { + rpcUrl: WS_ASSET_HUB_LOCAL, + accountType: AccountType.accountId32, + }); + + await addNetwork(identityContract, alice, { + rpcUrl: WS_TRAPPIST_LOCAL, + accountType: AccountType.accountId32, + }); + + await addNetwork(identityContract, alice, { + rpcUrl: "ws://127.0.0.1:9930", + accountType: AccountType.accountId32, + }); + }); + + test("Transferring cross-chain from asset's reserve chain works", async () => { + const sender: Sender = { + keypair: alice, + network: 0 + }; + + const receiver: Receiver = { + addressRaw: bob.addressRaw, + type: AccountType.accountId32, + network: 1, + }; + + const rococoProvider = new WsProvider(WS_ROROCO_LOCAL); + const rococoApi = await ApiPromise.create({ + provider: rococoProvider, + }); + + const assetHubProvider = new WsProvider(WS_ASSET_HUB_LOCAL); + const assetHubApi = await ApiPromise.create({ + provider: assetHubProvider, + }); + + const trappistProvider = new WsProvider(WS_TRAPPIST_LOCAL); + const trappistApi = await ApiPromise.create({ + provider: trappistProvider, + }); + + const lockdownMode = await getLockdownMode(trappistApi); + if (lockdownMode) { + await deactivateLockdown(trappistApi, alice); + } + + // Create assets on both networks + + if (!(await getAsset(assetHubApi, USDT_ASSET_ID))) { + await forceCreateAsset(rococoApi, assetHubApi, 1000, alice, USDT_ASSET_ID); + } + + if (!(await getAsset(trappistApi, USDT_ASSET_ID))) { + await createAsset(trappistApi, alice, USDT_ASSET_ID); + } + + // If the asset is not already registered in the registry make sure we add it. + if (!(await getAssetIdMultiLocation(trappistApi, USDT_ASSET_ID))) { + await registerReserveAsset(trappistApi, alice, USDT_ASSET_ID, { + parents: 1, + interior: { + X3: [ + { Parachain: 1000 }, + { PalletInstance: 50 }, + { GeneralIndex: USDT_ASSET_ID } + ] + } + }); + } + + const mintAmount = 5000000000000; + // Mint some assets to the creator. + await mintAsset(assetHubApi, sender.keypair, USDT_ASSET_ID, mintAmount); + + const senderBalanceBefore = await getAssetBalance(assetHubApi, USDT_ASSET_ID, alice.address); + const receiverBalanceBefore = await getAssetBalance(trappistApi, USDT_ASSET_ID, bob.address); + + const amount = 4000000000000; + const assetReserveChainId = 0; + + const asset: Fungible = { + multiAsset: { + interior: { + X2: [ + { PalletInstance: 50 }, + { GeneralIndex: USDT_ASSET_ID } + ] + }, + parents: 0, + }, + amount + }; + + await TransactionRouter.sendTokens( + identityContract, + sender, + receiver, + assetReserveChainId, + asset + ); + + const senderBalanceAfter = await getAssetBalance(assetHubApi, USDT_ASSET_ID, alice.address); + const receiverBalanceAfter = await getAssetBalance(trappistApi, USDT_ASSET_ID, bob.address); + + expect(senderBalanceAfter).toBe(senderBalanceBefore - amount); + // The `receiverBalanceAfter` won't be exactly equal to `receiverBalanceBefore + amount` since some of the tokens are + // used for `BuyExecution`. + expect(receiverBalanceAfter).toBeGreaterThan(receiverBalanceBefore); + }, 180000); + + test("Transferring cross-chain to asset's reserve chain works", async () => { + // NOTE this test depends on the success of the first test. + + const rococoProvider = new WsProvider(WS_ROROCO_LOCAL); + const rococoApi = await ApiPromise.create({ + provider: rococoProvider, + }); + + const assetHubProvider = new WsProvider(WS_ASSET_HUB_LOCAL); + const assetHubApi = await ApiPromise.create({ + provider: assetHubProvider, + }); + + const trappistProvider = new WsProvider(WS_TRAPPIST_LOCAL); + const trappistApi = await ApiPromise.create({ + provider: trappistProvider, + }); + + const lockdownMode = await getLockdownMode(trappistApi); + if (lockdownMode) { + await deactivateLockdown(trappistApi, alice); + } + + // Create assets on both networks. + + if (!(await getAsset(assetHubApi, USDT_ASSET_ID))) { + await forceCreateAsset(rococoApi, assetHubApi, 1000, alice, USDT_ASSET_ID); + } + + if (!(await getAsset(trappistApi, USDT_ASSET_ID))) { + await createAsset(trappistApi, alice, USDT_ASSET_ID); + } + + // If the asset is not already registered in the registry make sure we add it. + if (!(await getAssetIdMultiLocation(trappistApi, USDT_ASSET_ID))) { + await registerReserveAsset(trappistApi, alice, USDT_ASSET_ID, { + parents: 1, + interior: { + X3: [ + { Parachain: 1000 }, + { PalletInstance: 50 }, + { GeneralIndex: USDT_ASSET_ID } + ] + } + }); + } + + const amount = 950000000000; + + const sender: Sender = { + keypair: bob, + network: 1 + }; + + const receiver: Receiver = { + addressRaw: charlie.addressRaw, + type: AccountType.accountId32, + network: 0, + }; + + const asset: Fungible = { + multiAsset: { + interior: { + X3: [ + { Parachain: 1000 }, + { PalletInstance: 50 }, + { GeneralIndex: USDT_ASSET_ID } + ] + }, + parents: 1, + }, + amount + }; + + const senderBalanceBefore = await getAssetBalance(trappistApi, USDT_ASSET_ID, bob.address); + const receiverBalanceBefore = await getAssetBalance(assetHubApi, USDT_ASSET_ID, charlie.address); + + // Transfer the tokens to charlies's account on asset hub: + await TransactionRouter.sendTokens(identityContract, sender, receiver, receiver.network, asset); + + // We need to wait a bit more to actually receive the assets on the base chain. + await delay(5000); + + const senderBalanceAfter = await getAssetBalance(trappistApi, USDT_ASSET_ID, bob.address); + const receiverBalanceAfter = await getAssetBalance(assetHubApi, USDT_ASSET_ID, charlie.address); + + // Some tolerance since part of the tokens will be used for fee payment. + const tolerance = 100000; + expect(senderBalanceAfter).toBeLessThanOrEqual(senderBalanceBefore - amount); + expect(receiverBalanceAfter).toBeGreaterThanOrEqual(receiverBalanceBefore + amount - tolerance); + }, 120000); + + test("Transferring cross-chain accross reserve chain works", async () => { + // NOTE this test depends on the success of the first test. + + const rococoProvider = new WsProvider(WS_ROROCO_LOCAL); + const rococoApi = await ApiPromise.create({ + provider: rococoProvider, + }); + + const assetHubProvider = new WsProvider(WS_ASSET_HUB_LOCAL); + const assetHubApi = await ApiPromise.create({ + provider: assetHubProvider, + }); + + const trappistProvider = new WsProvider(WS_TRAPPIST_LOCAL); + const trappistApi = await ApiPromise.create({ + provider: trappistProvider, + }); + + const baseProvider = new WsProvider("ws://127.0.0.1:9930"); + const baseApi = await ApiPromise.create({ + provider: baseProvider, + }); + + const lockdownMode = await getLockdownMode(trappistApi); + if (lockdownMode) { + await deactivateLockdown(trappistApi, alice); + } + + // Create assets on all networks. + + if (!(await getAsset(assetHubApi, USDT_ASSET_ID))) { + await forceCreateAsset(rococoApi, assetHubApi, 1000, alice, USDT_ASSET_ID); + } + + if (!(await getAsset(trappistApi, USDT_ASSET_ID))) { + await createAsset(trappistApi, alice, USDT_ASSET_ID); + } + + if (!(await getAsset(baseApi, USDT_ASSET_ID))) { + await createAsset(baseApi, alice, USDT_ASSET_ID); + } + + // If the asset is not already registered in the registry make sure we add it. + if (!(await getAssetIdMultiLocation(trappistApi, USDT_ASSET_ID))) { + await registerReserveAsset(trappistApi, alice, USDT_ASSET_ID, { + parents: 1, + interior: { + X3: [ + { Parachain: 1000 }, + { PalletInstance: 50 }, + { GeneralIndex: USDT_ASSET_ID } + ] + } + }); + } + + const amount = 950000000000; + const assetReserveChainId = 0; + + const sender: Sender = { + keypair: bob, + network: 1 + }; + + const receiver: Receiver = { + addressRaw: bob.addressRaw, + type: AccountType.accountId32, + network: 2, + }; + + const asset: Fungible = { + multiAsset: { + interior: { + X3: [ + { Parachain: 1000 }, + { PalletInstance: 50 }, + { GeneralIndex: USDT_ASSET_ID } + ] + }, + parents: 1, + }, + amount + }; + + const senderBalanceBefore = await getAssetBalance(trappistApi, USDT_ASSET_ID, bob.address); + const receiverBalanceBefore = await getAssetBalance(baseApi, USDT_ASSET_ID, bob.address); + + // Transfer the tokens to bob's account on base: + await TransactionRouter.sendTokens(identityContract, sender, receiver, assetReserveChainId, asset); + + // We need to wait a bit more to actually receive the assets on the base chain. + await delay(12000); + + const senderBalanceAfter = await getAssetBalance(trappistApi, USDT_ASSET_ID, bob.address); + const receiverBalanceAfter = await getAssetBalance(baseApi, USDT_ASSET_ID, bob.address); + + // Some tolerance since part of the tokens will be used for fee payment. + const tolerance = 100000; + expect(senderBalanceAfter).toBeLessThanOrEqual(senderBalanceBefore - amount); + expect(receiverBalanceAfter).toBeGreaterThanOrEqual(receiverBalanceBefore + amount - tolerance); + }, 180000); +}); + +const addNetwork = async ( + contract: IdentityContract, + signer: KeyringPair, + network: NetworkInfo +): Promise => { + await contract + .withSigner(signer) + .tx.addNetwork(network); +}; + +const createAsset = async ( + api: ApiPromise, + signer: KeyringPair, + id: number +): Promise => { + const callTx = async (resolve: () => void) => { + const create = api.tx.assets.create(id, signer.address, 10); + const unsub = await create.signAndSend(signer, (result: any) => { + if (result.status.isInBlock) { + unsub(); + resolve(); + } + }); + }; + return new Promise(callTx); +}; + +const forceCreateAsset = async ( + relaychainApi: ApiPromise, + paraApi: ApiPromise, + paraId: number, + signer: KeyringPair, + id: number +): Promise => { + const forceCreate = u8aToHex(paraApi.tx.assets.forceCreate(id, signer.address, true, 10).method.toU8a()); + + const xcm = { + V3: [ + { + UnpaidExecution: { + weightLimit: "Unlimited" + } + }, + { + Transact: { + originKind: "Superuser", + requireWeightAtMost: { + refTime: 9000000000, + proofSize: 10000 + }, + call: { + encoded: forceCreate, + } + } + } + ] + }; + + const callTx = async (resolve: () => void) => { + const paraSudoCall = relaychainApi.tx.parasSudoWrapper.sudoQueueDownwardXcm(paraId, xcm); + + const unsub = await relaychainApi.tx.sudo.sudo(paraSudoCall).signAndSend(signer, (result: any) => { + if (result.status.isInBlock) { + unsub(); + resolve(); + } + }); + } + return new Promise(callTx); +} + +const mintAsset = async ( + api: ApiPromise, + signer: KeyringPair, + id: number, + amount: number +): Promise => { + const callTx = async (resolve: () => void) => { + const unsub = await api.tx.assets + .mint( + id, + signer.address, // beneficiary + amount + ) + .signAndSend(signer, (result: any) => { + if (result.status.isInBlock) { + unsub(); + resolve(); + } + }); + }; + return new Promise(callTx); +}; + +const registerReserveAsset = async ( + api: ApiPromise, + signer: KeyringPair, + id: number, + assetLocation: any +): Promise => { + const callTx = async (resolve: () => void) => { + const register = api.tx.assetRegistry.registerReserveAsset(id, assetLocation); + const unsub = await api.tx.sudo.sudo(register) + .signAndSend(signer, (result: any) => { + if (result.status.isInBlock) { + unsub(); + resolve(); + } + }); + }; + return new Promise(callTx); +} + +const getAssetIdMultiLocation = async (api: ApiPromise, id: number): Promise => { + return (await api.query.assetRegistry.assetIdMultiLocation(id)).toJSON(); +} + +const deactivateLockdown = async (api: ApiPromise, signer: KeyringPair): Promise => { + const callTx = async (resolve: () => void) => { + const forceDisable = api.tx.lockdownMode.deactivateLockdownMode(); + const unsub = await api.tx.sudo.sudo(forceDisable) + .signAndSend(signer, (result: any) => { + if (result.status.isInBlock) { + unsub(); + resolve(); + } + }); + }; + return new Promise(callTx); +} + +const getLockdownMode = async (api: ApiPromise): Promise => { + return (await api.query.lockdownMode.lockdownModeStatus()).toJSON(); +}; + +const getAsset = async (api: ApiPromise, id: number): Promise => { + return (await api.query.assets.asset(id)).toJSON(); +}; + +const getAssetBalance = async (api: ApiPromise, id: number, who: string): Promise => { + const maybeBalance: any = (await api.query.assets.account(id, who)).toJSON(); + if (maybeBalance && maybeBalance.balance) { + return maybeBalance.balance; + } + return 0; +} + +const delay = (ms: number) => new Promise(res => setTimeout(res, ms)); diff --git a/__tests__/transactionRouter.test.ts b/__tests__/transactionRouter.test.ts index 6af82a5..d7e1844 100644 --- a/__tests__/transactionRouter.test.ts +++ b/__tests__/transactionRouter.test.ts @@ -11,7 +11,7 @@ import { AccountType, NetworkInfo } from "../types/types-arguments/identity"; const wsProvider = new WsProvider("ws://127.0.0.1:9944"); const keyring = new Keyring({ type: "sr25519" }); -describe("TransactionRouter", () => { +describe("TransactionRouter e2e tests", () => { let swankyApi: ApiPromise; let alice: KeyringPair; let bob: KeyringPair; @@ -37,7 +37,7 @@ describe("TransactionRouter", () => { // First lets add a network and create an identity. await addNetwork(identityContract, alice, { - rpcUrl: "ws://127.0.0.1:4242", + rpcUrl: "ws://127.0.0.1:9910", accountType: AccountType.accountId32, }); @@ -57,11 +57,14 @@ describe("TransactionRouter", () => { amount: 1000 }; + const assetReserveChainId = 0; + await expect( TransactionRouter.sendTokens( identityContract, sender, receiver, + assetReserveChainId, asset ) ).rejects.toThrow("Cannot send tokens to yourself"); @@ -79,17 +82,17 @@ describe("TransactionRouter", () => { network: 0, }; - const westendProvider = new WsProvider("ws://127.0.0.1:4242"); - const westendApi = await ApiPromise.create({ provider: westendProvider }); + const rococoProvider = new WsProvider("ws://127.0.0.1:9900"); + const rococoApi = await ApiPromise.create({ provider: rococoProvider }); - const { data: balance } = (await westendApi.query.system.account( + const { data: balance } = (await rococoApi.query.system.account( receiver.addressRaw )) as any; const receiverBalance = parseInt(balance.free.toHuman().replace(/,/g, "")); // First lets add a network. await addNetwork(identityContract, alice, { - rpcUrl: "ws://127.0.0.1:4242", + rpcUrl: "ws://127.0.0.1:9900", accountType: AccountType.accountId32, }); @@ -102,15 +105,17 @@ describe("TransactionRouter", () => { }, amount }; + const assetReserveChainId = 0; await TransactionRouter.sendTokens( identityContract, sender, receiver, + assetReserveChainId, asset ); - const { data: newBalance } = (await westendApi.query.system.account( + const { data: newBalance } = (await rococoApi.query.system.account( receiver.addressRaw )) as any; const newReceiverBalance = parseInt( @@ -132,29 +137,34 @@ describe("TransactionRouter", () => { network: 0, }; - const assetHubProvider = new WsProvider("ws://127.0.0.1:4243"); - const assetHubApi = await ApiPromise.create({ - provider: assetHubProvider, + const trappitProvider = new WsProvider("ws://127.0.0.1:9920"); + const trappistApi = await ApiPromise.create({ + provider: trappitProvider, }); + const lockdownMode = await getLockdownMode(trappistApi); + if (lockdownMode) { + await deactivateLockdown(trappistApi, alice); + } + // First create an asset. - if (!(await getAsset(assetHubApi, 0))) { - await createAsset(assetHubApi, sender.keypair, 0); + if (!(await getAsset(trappistApi, 0))) { + await createAsset(trappistApi, sender.keypair, 0); } // Mint some assets to the creator. - await mintAsset(assetHubApi, sender.keypair, 0, 500); + await mintAsset(trappistApi, sender.keypair, 0, 500); const amount = 200; - const senderAccountBefore: any = (await assetHubApi.query.assets.account( + const senderAccountBefore: any = (await trappistApi.query.assets.account( 0, sender.keypair.address )).toHuman(); const senderBalanceBefore = parseInt(senderAccountBefore.balance.replace(/,/g, "")); - const receiverAccountBefore: any = (await assetHubApi.query.assets.account( + const receiverAccountBefore: any = (await trappistApi.query.assets.account( 0, bob.address )).toHuman(); @@ -163,7 +173,7 @@ describe("TransactionRouter", () => { // First lets add a network. await addNetwork(identityContract, alice, { - rpcUrl: "ws://127.0.0.1:4243", + rpcUrl: "ws://127.0.0.1:9920", accountType: AccountType.accountId32, }); @@ -171,7 +181,7 @@ describe("TransactionRouter", () => { multiAsset: { interior: { X2: [ - { PalletInstance: 50 }, // assets pallet + { PalletInstance: 41 }, // assets pallet { GeneralIndex: 0 }, ], }, @@ -179,32 +189,35 @@ describe("TransactionRouter", () => { }, amount }; + const assetReserveChainId = 0; await TransactionRouter.sendTokens( identityContract, sender, receiver, + assetReserveChainId, asset ); - const senderAccountAfter: any = (await assetHubApi.query.assets.account( + const senderAccountAfter: any = (await trappistApi.query.assets.account( 0, sender.keypair.address )).toHuman(); const senderBalanceAfter = parseInt(senderAccountAfter.balance.replace(/,/g, "")); - const receiverAccountAfter: any = (await assetHubApi.query.assets.account( + const receiverAccountAfter: any = (await trappistApi.query.assets.account( 0, bob.address )).toHuman(); + console.log(receiverAccountAfter); const receiverBalanceAfter = parseInt(receiverAccountAfter.balance.replace(/,/g, "")); expect(senderBalanceAfter).toBe(senderBalanceBefore - amount); expect(receiverBalanceAfter).toBe(receiverBalanceBefore + amount); - }, 120000); + }, 180000); }); const addNetwork = async ( @@ -266,3 +279,21 @@ const mintAsset = async ( const getAsset = async (api: ApiPromise, id: number): Promise => { return (await api.query.assets.asset(id)).toHuman(); }; + +const deactivateLockdown = async (api: ApiPromise, signer: KeyringPair): Promise => { + const callTx = async (resolve: () => void) => { + const forceDisable = api.tx.lockdownMode.deactivateLockdownMode(); + const unsub = await api.tx.sudo.sudo(forceDisable) + .signAndSend(signer, (result: any) => { + if (result.status.isInBlock) { + unsub(); + resolve(); + } + }); + }; + return new Promise(callTx); +} + +const getLockdownMode = async (api: ApiPromise): Promise => { + return (await api.query.lockdownMode.lockdownModeStatus()).toJSON(); +}; diff --git a/local_network.toml b/local_network.toml deleted file mode 100644 index ad11f18..0000000 --- a/local_network.toml +++ /dev/null @@ -1,29 +0,0 @@ -[relaychain] -default_command = "./bin/polkadot-v0.9.37" -default_args = [ "-lparachain=debug" ] - -chain = "wococo-local" - # relaychain nodes are by default validators - [[relaychain.nodes]] - ws_port = 4242 - name = "alice" - - [[relaychain.nodes]] - name = "bob" - - [[relaychain.nodes]] - name = "charlie" - - [[relaychain.nodes]] - name = "dave" - -[[parachains]] -id = 1000 -chain = "westmint-local" -cumulus_based = true - - [parachains.collator] - ws_port = 4243 - name = "westmint-collator-01" - command = "./bin/polkadot-parachain" - args = ["--log=xcm=trace,pallet-assets=trace"] diff --git a/src/utils/index.ts b/src/utils/index.ts index d7fb9a3..1a007f6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ +import { ApiPromise } from "@polkadot/api"; import { isHex } from "@polkadot/util"; import { validateAddress } from '@polkadot/util-crypto'; @@ -20,3 +21,12 @@ export const isValidAddress = (networkAddress: string, ss58Prefix: number) => { return false; } }; + +export const getParaId = async (api: ApiPromise): Promise => { + if (api.query.parachainInfo) { + const response = (await api.query.parachainInfo.parachainId()).toJSON(); + return Number(response); + } else { + return -1; + } +} \ No newline at end of file diff --git a/src/utils/transactionRouter/index.ts b/src/utils/transactionRouter/index.ts index 162f32b..bf79e27 100644 --- a/src/utils/transactionRouter/index.ts +++ b/src/utils/transactionRouter/index.ts @@ -1,27 +1,46 @@ import { ApiPromise, WsProvider } from "@polkadot/api"; +import ReserveTransfer from "./reserveTransfer"; import TransferAsset from "./transferAsset"; import { Fungible, Receiver, Sender } from "./types"; import IdentityContract from "../../../types/contracts/identity"; +// Responsible for handling all the transfer logic. +// +// Supports both non-cross-chain and cross-chain transfers. +// +// At the moment it doesn't support teleports. class TransactionRouter { + // Sends tokens to the receiver on the receiver chain. + // + // If the sender and receiver are on the same chain the asset gets sent by executing the xcm `TransferAsset` + // instruction. The reason we use xcm is because we don't know on which chain the transfer will be + // executed, so the only assumption we make is that the chain supports xcm. + // + // The other more complex case involves transferring the asset to a different blockchain. We are utilising + // reserve transfers for this. There are three different scenarios that can occur: + // 1. The chain from which the transfer is ocurring the reserve chain of the asset. In this case we simply + // utilise the existing `limitedReserveTransferAssets` extrinsic. + // 2. The destination chain is the reserve of the asset. This is a more complex case, so we need to construct + // our own xcm program to do this. + // 3. Neither the sender or the destination chain are the reserve chains of the asset. In this case we do a + // 'two-hop` reserve transfer. This is essentially pretty similar to the previous scenario, only difference + // being that we deposit the assets to the receiver's chain sovereign account on the reserve chain and then + // do a separate `DepositAsset` instruction on the destination chain. public static async sendTokens( identityContract: IdentityContract, sender: Sender, receiver: Receiver, + reserveChainId: number, asset: Fungible ): Promise { if (sender.network === receiver.network && sender.keypair.addressRaw === receiver.addressRaw) { throw new Error("Cannot send tokens to yourself"); } + // The simplest case, both the sender and the receiver are on the same network: if (sender.network === receiver.network) { - // We will extract all the chain information from the RPC node. - const rpcUrl = (await identityContract.query.networkInfoOf(sender.network)).value - .ok?.rpcUrl; - - const wsProvider = new WsProvider(rpcUrl); - const api = await ApiPromise.create({ provider: wsProvider }); + const api = await this.getApi(identityContract, sender.network); await TransferAsset.send( api, @@ -29,10 +48,59 @@ class TransactionRouter { receiver, asset ); + + return; + } + + const originApi = await this.getApi(identityContract, sender.network); + const destApi = await this.getApi(identityContract, receiver.network); + + // The sender chain is the reserve chain of the asset. This will simply use the existing + // `limitedReserveTransferAssets` extrinsic + if (sender.network == reserveChainId) { + await ReserveTransfer.sendFromReserveChain( + originApi, + destApi, + sender.keypair, + receiver, + asset + ); + } else if (receiver.network == reserveChainId) { + // The destination chain is the reserve chain of the asset: + await ReserveTransfer.sendToReserveChain( + originApi, + destApi, + sender.keypair, + receiver, + asset + ); } else { - // Send cross-chain. + // The most complex case, the reserve chain is neither the sender or the destination chain. + // For this we will have to send tokens accross the reserve chain. + + const reserveChain = await this.getApi(identityContract, reserveChainId); + + await ReserveTransfer.sendAcrossReserveChain( + originApi, + destApi, + reserveChain, + sender.keypair, + receiver, + asset + ); } } + + // Simple helper function to get the api of a chain with the corresponding id. + private static async getApi(identityContract: IdentityContract, networkId: number): Promise { + const rpcUrl = (await identityContract.query.networkInfoOf(networkId)).value + .ok?.rpcUrl; + + const wsProvider = new WsProvider(rpcUrl); + const api = await ApiPromise.create({ provider: wsProvider }); + + return api; + } } export default TransactionRouter; diff --git a/src/utils/transactionRouter/reserveTransfer.test.ts b/src/utils/transactionRouter/reserveTransfer.test.ts new file mode 100644 index 0000000..762e6ed --- /dev/null +++ b/src/utils/transactionRouter/reserveTransfer.test.ts @@ -0,0 +1,1228 @@ +// File containging unit tests for the `ReserveTransfer` class. +// +// The e2e tests are placed in the `__tests__` directory in the root of the project. + +import { Keyring } from "@polkadot/api"; +import { cryptoWaitReady } from "@polkadot/util-crypto"; + +import ReserveTransfer from "./reserveTransfer"; +import { Fungible, Receiver } from "./types"; +import { AccountType } from "../../../types/types-arguments/identity"; + +const sr25519Keyring = new Keyring({ type: "sr25519" }); +const ecdsaKeyring = new Keyring({ type: "ecdsa" }); + +describe("TransactionRouter unit tests", () => { + describe("getDestination works", () => { + it("Works with the destination being the relay chain", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.getDestination(true, 69, false)).toStrictEqual({ + V2: { + parents: 1, + interior: "Here", + }, + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.getDestination(false, 69, false)).toStrictEqual({ + V2: { + parents: 0, + interior: "Here", + }, + }); + }); + + it("Works with the destination being a parachain", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.getDestination(false, 2000, true)).toStrictEqual({ + V2: { + parents: 0, + interior: { + X1: { Parachain: 2000 }, + }, + }, + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.getDestination(true, 2000, true)).toStrictEqual({ + V2: { + parents: 1, + interior: { + X1: { Parachain: 2000 }, + }, + }, + }); + }); + }); + + describe("getReserveTransferBeneficiary works", () => { + it("Works with AccountId32", async () => { + await cryptoWaitReady(); + + const alice = sr25519Keyring.addFromUri("//Alice"); + const bob = ecdsaKeyring.addFromUri("//Bob"); + + const receiverAccId32: Receiver = { + addressRaw: alice.addressRaw, + network: 0, + type: AccountType.accountId32, + }; + + expect( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ReserveTransfer.getReserveTransferBeneficiary(receiver), + ).toStrictEqual({ + V2: { + parents: 0, + interior: { + X1: { + AccountId32: { + network: "Any", + id: receiverAccId32.addressRaw, + }, + }, + }, + }, + }); + + const receiverAccKey20: Receiver = { + addressRaw: bob.addressRaw, + network: 0, + type: AccountType.accountKey20, + }; + + expect( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ReserveTransfer.getReserveTransferBeneficiary(receiver), + ).toStrictEqual({ + V2: { + parents: 0, + interior: { + X1: { + AccountKey20: { + network: "Any", + id: receiverAccKey20.addressRaw, + }, + }, + }, + }, + }); + }); + }); + + describe("getMultiAsset works", () => { + it("Should work", () => { + const asset: Fungible = { + multiAsset: { + interior: "Here", + parents: 0, + }, + amount: 200, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.getMultiAsset(asset)).toStrictEqual({ + V2: [ + { + fun: { + Fungible: asset.amount, + }, + id: { + Concrete: asset.multiAsset, + }, + }, + ], + }); + }); + + describe("withdrawAsset works", () => { + it("Works with parachain origin", () => { + const asset: Fungible = { + multiAsset: { + interior: { + X3: [ + { Parachain: 2000 }, + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + amount: 200, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.withdrawAsset(asset, true)).toStrictEqual({ + WithdrawAsset: [{ + id: { + Concrete: { + interior: { + X3: [ + { Parachain: 2000 }, + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 1 + } + }, + fun: { + Fungible: 200 + } + }] + }); + }); + + it("Works with relaychain origin", () => { + const complexAsset: Fungible = { + multiAsset: { + interior: { + X2: [ + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + amount: 200, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.withdrawAsset(complexAsset, false)).toStrictEqual({ + WithdrawAsset: [{ + id: { + Concrete: { + interior: { + X2: [ + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0 + } + }, + fun: { + Fungible: 200 + } + }] + }); + + // Works with asset which has "Here" as interior. + const simpleAsset: Fungible = { + multiAsset: { + interior: "Here", + parents: 0 + }, + amount: 200, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.withdrawAsset(simpleAsset, false)).toStrictEqual({ + WithdrawAsset: [{ + id: { + Concrete: { + interior: "Here", + parents: 0 + } + }, + fun: { + Fungible: 200 + } + }] + }); + }); + }); + + describe("buyExecution works", () => { + it("Works", () => { + // Works with asset which has "Here" as interior. + const asset: Fungible = { + multiAsset: { + interior: "Here", + parents: 0 + }, + amount: 200, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.buyExecution(asset.multiAsset, 500)).toStrictEqual({ + BuyExecution: { + fees: { + id: { + Concrete: { + interior: "Here", + parents: 0 + } + }, + fun: { + Fungible: 500 + } + }, + weightLimit: "Unlimited" + } + }) + }); + }); + + describe("depositReserveAsset works", () => { + it("Works", () => { + // Works with asset which has "Here" as interior. + const asset: Fungible = { + multiAsset: { + interior: "Here", + parents: 0 + }, + amount: 200, + }; + + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.depositReserveAsset(asset, 1, { + parents: 1, + interior: { + X1: { + Parachain: 2000 + } + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + }, [])).toStrictEqual({ + DepositReserveAsset: { + assets: asset, + maxAssets: 1, + dest: { + parents: 1, + interior: { + X1: { + Parachain: 2000 + } + } + }, + xcm: [] + } + }); + }); + }); + + describe("depositAsset & getReceiverAccount work", () => { + it("Works with AccountId32", () => { + const bob = ecdsaKeyring.addFromUri("//Bob"); + + const receiver: Receiver = { + addressRaw: bob.addressRaw, + type: AccountType.accountId32, + network: 0, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.depositAsset({ Wild: "All" }, 1, receiver)).toStrictEqual({ + DepositAsset: { + assets: { Wild: "All" }, + maxAssets: 1, + beneficiary: { + interior: { + X1: { + AccountId32: { + id: receiver.addressRaw, + network: "Any" + } + } + }, + parents: 0 + } + } + }); + }); + + it("Works with AccountKey20", () => { + const bob = ecdsaKeyring.addFromUri("//Bob"); + + const receiver: Receiver = { + addressRaw: bob.addressRaw, + type: AccountType.accountKey20, + network: 0, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.depositAsset({ Wild: "All" }, 1, receiver)).toStrictEqual({ + DepositAsset: { + assets: { Wild: "All" }, + maxAssets: 1, + beneficiary: { + interior: { + X1: { + AccountKey20: { + id: receiver.addressRaw, + network: "Any" + } + } + }, + parents: 0 + } + } + }); + }); + }); + + describe("getReserve works", () => { + it("works with origin para", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.getReserve(1000, true)).toStrictEqual({ + parents: 1, + interior: { + X1: { + Parachain: 1000 + } + } + }); + }); + + it("works with origin being the relaychain", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.getReserve(1000, false)).toStrictEqual({ + parents: 0, + interior: { + X1: { + Parachain: 1000 + } + } + }); + }); + + it("works with origin para and reserve relay", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.getReserve(-1, true)).toStrictEqual({ + parents: 1, + interior: "Here" + }); + }); + }); + + describe("assetFromReservePerspective works", () => { + it("Works with any number of junctions and 'Here'", () => { + const junctions = { + interior: { + X5: [ + { Parachain: 1000 }, + { GeneralIndex: 42 }, + { GeneralIndex: 42 }, + { GeneralIndex: 42 }, + { GeneralIndex: 42 }, + // Doesn't matter what is in here for testing... + ] + }, + parents: 0 + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ReserveTransfer.assetFromReservePerspective(junctions); + expect(junctions).toStrictEqual({ + interior: { + X4: [ + { GeneralIndex: 42 }, + { GeneralIndex: 42 }, + { GeneralIndex: 42 }, + { GeneralIndex: 42 }, + ] + }, + parents: 0 + }); + + const junctions2 = { + interior: { + X1: [ + { Parachain: 1000 }, + // Doesn't matter what is in here for testing this + ] + }, + parents: 1 + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ReserveTransfer.assetFromReservePerspective(junctions2); + expect(junctions2).toStrictEqual({ + interior: "Here", + parents: 0 + }); + }); + }); + + describe("extractJunctions works", () => { + it("Works with any number of junctions and 'Here'", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.extractJunctions({ + interior: { + X3: [ + // Doesn't matter what is in here for testing this + { GeneralIndex: 42 }, + { Parachain: 42 }, + { GeneralIndex: 42 }, + ] + }, + parents: 1 + })).toStrictEqual([ + { GeneralIndex: 42 }, + { Parachain: 42 }, + { GeneralIndex: 42 }, + ]); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(ReserveTransfer.extractJunctions({ + interior: "Here", + parents: 1 + })).toStrictEqual("Here"); + }); + }); + + describe("getSendToReserveChainInstructions works", () => { + it("Works from parachain to parachain", () => { + const bob = ecdsaKeyring.addFromUri("//Bob"); + + const destParaId = 2002; + const beneficiary: Receiver = { + addressRaw: bob.addressRaw, + network: 1, + type: AccountType.accountId32, + }; + + const asset: Fungible = { + multiAsset: { + interior: { + X3: [ + { Parachain: destParaId }, + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + amount: 200, + }; + + expect( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ReserveTransfer.getSendToReserveChainInstructions( + asset, + destParaId, + beneficiary, + true, + ), + ).toStrictEqual({ + V2: [ + { + WithdrawAsset: [ + { + fun: { + Fungible: 200, + }, + id: { + Concrete: { + interior: { + X3: [ + { Parachain: destParaId }, + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 1, + }, + }, + }, + ], + }, + { + InitiateReserveWithdraw: { + assets: { + Wild: "All", + }, + reserve: { + interior: { + X1: { + Parachain: destParaId, + }, + }, + parents: 1, + }, + xcm: [ + { + BuyExecution: { + fees: { + fun: { + Fungible: 450000000000, + }, + id: { + Concrete: { + interior: { + X2: [ + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + }, + }, + weightLimit: "Unlimited", + }, + }, + { + DepositAsset: { + assets: { + Wild: "All", + }, + beneficiary: { + interior: { + X1: { + AccountId32: { + id: bob.addressRaw, + network: "Any", + }, + }, + }, + parents: 0, + }, + maxAssets: 1, + }, + }, + ], + }, + }, + ], + }); + }); + + it("Works from parachain to relaychain", () => { + const bob = ecdsaKeyring.addFromUri("//Bob"); + + const destParaId = -1; + const beneficiary: Receiver = { + addressRaw: bob.addressRaw, + network: 1, + type: AccountType.accountId32, + }; + + const asset: Fungible = { + multiAsset: { + interior: { + X2: [{ PalletInstance: 42 }, { GeneralIndex: 69 }], + }, + parents: 0, + }, + amount: 200, + }; + + expect( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ReserveTransfer.getSendToReserveChainInstructions( + asset, + destParaId, + beneficiary, + true, + ), + ).toStrictEqual({ + V2: [ + { + WithdrawAsset: [ + { + fun: { + Fungible: 200, + }, + id: { + Concrete: { + interior: { + X2: [ + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 1, + }, + }, + }, + ], + }, + { + InitiateReserveWithdraw: { + assets: { + Wild: "All", + }, + reserve: { + interior: "Here", + parents: 1, + }, + xcm: [ + { + BuyExecution: { + fees: { + fun: { + Fungible: 450000000000, + }, + id: { + Concrete: { + interior: { + X2: [ + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + }, + }, + weightLimit: "Unlimited", + }, + }, + { + DepositAsset: { + assets: { + Wild: "All", + }, + beneficiary: { + interior: { + X1: { + AccountId32: { + id: bob.addressRaw, + network: "Any", + }, + }, + }, + parents: 0, + }, + maxAssets: 1, + }, + }, + ], + }, + }, + ], + }); + }); + + it("Works from relaychain to parachain", () => { + const bob = ecdsaKeyring.addFromUri("//Bob"); + + const destParaId = 1000; + const beneficiary: Receiver = { + addressRaw: bob.addressRaw, + network: 1, + type: AccountType.accountId32, + }; + + const asset: Fungible = { + multiAsset: { + interior: { + X2: [ + { Parachain: destParaId }, + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + amount: 200, + }; + + expect( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ReserveTransfer.getSendToReserveChainInstructions( + asset, + destParaId, + beneficiary, + false, + ), + ).toStrictEqual({ + V2: [ + { + WithdrawAsset: [ + { + fun: { + Fungible: 200, + }, + id: { + Concrete: { + interior: { + X3: [ + { Parachain: destParaId }, + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + }, + }, + ], + }, + { + InitiateReserveWithdraw: { + assets: { + Wild: "All", + }, + reserve: { + interior: { X1: { Parachain: destParaId } }, + parents: 0, + }, + xcm: [ + { + BuyExecution: { + fees: { + fun: { + Fungible: 450000000000, + }, + id: { + Concrete: { + interior: { + X2: [ + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + }, + }, + weightLimit: "Unlimited", + }, + }, + { + DepositAsset: { + assets: { + Wild: "All", + }, + beneficiary: { + interior: { + X1: { + AccountId32: { + id: bob.addressRaw, + network: "Any", + }, + }, + }, + parents: 0, + }, + maxAssets: 1, + }, + }, + ], + }, + }, + ], + }); + }); + }); + + describe("getTwoHopTransferInstructions works", () => { + it("Works with parachain reserve", () => { + const bob = ecdsaKeyring.addFromUri("//Bob"); + + const reserveParaId = 2000; + const destParaId = 2002; + const beneficiary: Receiver = { + addressRaw: bob.addressRaw, + network: 1, + type: AccountType.accountId32, + }; + + const asset: Fungible = { + multiAsset: { + interior: { + X3: [ + { Parachain: reserveParaId }, + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + amount: 200, + }; + + expect( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ReserveTransfer.getTwoHopTransferInstructions( + asset, + reserveParaId, + destParaId, + beneficiary, + true, + ), + ).toStrictEqual({ + V2: [ + { + WithdrawAsset: [ + { + id: { + Concrete: { + interior: { + X3: [ + { Parachain: reserveParaId }, + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 1, + }, + }, + fun: { + Fungible: asset.amount, + }, + }, + ], + }, + { + InitiateReserveWithdraw: { + assets: { + Wild: "All", + }, + reserve: { + interior: { + X1: { + Parachain: reserveParaId, + }, + }, + parents: 1, + }, + xcm: [ + { + BuyExecution: { + fees: { + fun: { + Fungible: 450000000000, + }, + id: { + Concrete: { + interior: { + X2: [ + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + }, + }, + weightLimit: "Unlimited", + }, + }, + { + DepositReserveAsset: { + assets: { + Wild: "All", + }, + dest: { + interior: { + X1: { + Parachain: destParaId, + }, + }, + parents: 1, + }, + maxAssets: 1, + xcm: [ + { + DepositAsset: { + assets: { + Wild: "All", + }, + beneficiary: { + interior: { + X1: { + AccountId32: { + id: bob.addressRaw, + network: "Any", + }, + }, + }, + parents: 0, + }, + maxAssets: 1, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }); + }); + + it("Works with relaychain being the reserve chain", () => { + const bob = ecdsaKeyring.addFromUri("//Bob"); + + const reserveParaId = -1; + const destParaId = 2002; + const beneficiary: Receiver = { + addressRaw: bob.addressRaw, + network: 1, + type: AccountType.accountId32, + }; + + const asset: Fungible = { + multiAsset: { + interior: "Here", + parents: 0, + }, + amount: 200, + }; + + expect( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ReserveTransfer.getTwoHopTransferInstructions( + asset, + reserveParaId, + destParaId, + beneficiary, + true, + ), + ).toStrictEqual({ + V2: [ + { + WithdrawAsset: [ + { + fun: { + Fungible: 200, + }, + id: { + Concrete: { + interior: "Here", + parents: 1, + }, + }, + }, + ], + }, + { + InitiateReserveWithdraw: { + assets: { + Wild: "All", + }, + reserve: { + interior: "Here", + parents: 1, + }, + xcm: [ + { + BuyExecution: { + fees: { + fun: { + Fungible: 450000000000, + }, + id: { + Concrete: { + interior: "Here", + parents: 0, + }, + }, + }, + weightLimit: "Unlimited", + }, + }, + { + DepositReserveAsset: { + assets: { + Wild: "All", + }, + dest: { + interior: { + X1: { + Parachain: destParaId, + }, + }, + parents: 1, + }, + maxAssets: 1, + xcm: [ + { + DepositAsset: { + assets: { + Wild: "All", + }, + beneficiary: { + interior: { + X1: { + AccountId32: { + id: bob.addressRaw, + network: "Any", + }, + }, + }, + parents: 0, + }, + maxAssets: 1, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }); + }); + + test("Sending from relaychain to a parachain through a reserve parachain", () => { + const bob = ecdsaKeyring.addFromUri("//Bob"); + + const reserveParaId = 1000; + const destParaId = 2000; + const beneficiary: Receiver = { + addressRaw: bob.addressRaw, + network: 1, + type: AccountType.accountId32, + }; + + const asset: Fungible = { + multiAsset: { + interior: { + X3: [ + { Parachain: reserveParaId }, + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + amount: 200, + }; + + expect( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ReserveTransfer.getTwoHopTransferInstructions( + asset, + reserveParaId, + destParaId, + beneficiary, + false, + ), + ).toStrictEqual({ + V2: [ + { + WithdrawAsset: [ + { + fun: { + Fungible: 200, + }, + id: { + Concrete: { + interior: { + X3: [ + { Parachain: reserveParaId }, + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + }, + }, + ], + }, + { + InitiateReserveWithdraw: { + assets: { + Wild: "All", + }, + reserve: { + interior: { + X1: { + Parachain: reserveParaId, + }, + }, + parents: 0, + }, + xcm: [ + { + BuyExecution: { + fees: { + fun: { + Fungible: 450000000000, + }, + id: { + Concrete: { + interior: { + X2: [ + { PalletInstance: 42 }, + { GeneralIndex: 69 }, + ], + }, + parents: 0, + }, + }, + }, + weightLimit: "Unlimited", + }, + }, + { + DepositReserveAsset: { + assets: { + Wild: "All", + }, + dest: { + interior: { + X1: { + Parachain: 2000, + }, + }, + parents: 1, + }, + maxAssets: 1, + xcm: [ + { + DepositAsset: { + assets: { + Wild: "All", + }, + beneficiary: { + interior: { + X1: { + AccountId32: { + id: bob.addressRaw, + network: "Any", + }, + }, + }, + parents: 0, + }, + maxAssets: 1, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }); + }); + }); + }); +}); diff --git a/src/utils/transactionRouter/reserveTransfer.ts b/src/utils/transactionRouter/reserveTransfer.ts new file mode 100644 index 0000000..e17fbb5 --- /dev/null +++ b/src/utils/transactionRouter/reserveTransfer.ts @@ -0,0 +1,448 @@ +import { ApiPromise } from "@polkadot/api"; +import { KeyringPair } from "@polkadot/keyring/types"; + +import { Fungible, Receiver } from "./types"; +import { getParaId } from ".."; +import { AccountType } from "../../../types/types-arguments/identity"; + +class ReserveTransfer { + // Transfers assets from the sender to the receiver. + // + // This function assumes that the sender chain is the reserve chain of the asset. + public static async sendFromReserveChain( + originApi: ApiPromise, + destinationApi: ApiPromise, + sender: KeyringPair, + receiver: Receiver, + asset: Fungible + ): Promise { + this.ensureContainsXcmPallet(originApi); + this.ensureContainsXcmPallet(destinationApi); + + const destParaId = await getParaId(destinationApi); + + // eslint-disable-next-line no-prototype-builtins + const isOriginPara = originApi.query.hasOwnProperty("parachainInfo"); + + const destination = this.getDestination(isOriginPara, destParaId, destParaId >= 0); + const beneficiary = this.getReserveTransferBeneficiary(receiver); + const multiAsset = this.getMultiAsset(asset); + + const feeAssetItem = 0; + const weightLimit = "Unlimited"; + + const xcmPallet = (originApi.tx.xcmPallet || originApi.tx.polkadotXcm); + + const reserveTransfer = xcmPallet.limitedReserveTransferAssets( + destination, + beneficiary, + multiAsset, + feeAssetItem, + weightLimit + ); + + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + const unsub = await reserveTransfer.signAndSend(sender, (result: any) => { + if (result.status.isFinalized) { + unsub(); + resolve(); + } + }) + }); + } + + // Transfers assets from the sender to the receiver. + // + // This function assumes that the chain on which the receiver is receiving the tokens is the actual + // reserve chain of the asset. + public static async sendToReserveChain( + originApi: ApiPromise, + destinationApi: ApiPromise, + sender: KeyringPair, + receiver: Receiver, + asset: Fungible + ): Promise { + this.ensureContainsXcmPallet(originApi); + this.ensureContainsXcmPallet(destinationApi); + + const destinationParaId = await getParaId(destinationApi); + + // eslint-disable-next-line no-prototype-builtins + const isOriginPara = originApi.query.hasOwnProperty("parachainInfo"); + const xcmProgram = this.getSendToReserveChainInstructions(asset, destinationParaId, receiver, isOriginPara); + + const xcmPallet = originApi.tx.xcmPallet || originApi.tx.polkadotXcm; + + const reserveTransfer = xcmPallet.execute(xcmProgram, { + refTime: 3 * Math.pow(10, 11), + proofSize: Math.pow(10, 6), + }); + + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + const unsub = await reserveTransfer.signAndSend(sender, (result: any) => { + if (result.status.isFinalized) { + unsub(); + resolve(); + } + }) + }); + } + + // Neither the sender nor the receiver chain is the reserve chain of the asset being sent. + // + // For this reason we are gonna need to transfer the asset across the reserve chain. + public static async sendAcrossReserveChain( + originApi: ApiPromise, + destinationApi: ApiPromise, + reserveChainApi: ApiPromise, + sender: KeyringPair, + receiver: Receiver, + asset: Fungible + ): Promise { + this.ensureContainsXcmPallet(originApi); + this.ensureContainsXcmPallet(destinationApi); + this.ensureContainsXcmPallet(reserveChainApi); + + const reserveParaId = await getParaId(reserveChainApi); + const destinationParaId = await getParaId(destinationApi); + + // eslint-disable-next-line no-prototype-builtins + const isOriginPara = originApi.query.hasOwnProperty("parachainInfo"); + const xcmProgram = this.getTwoHopTransferInstructions(asset, reserveParaId, destinationParaId, receiver, isOriginPara); + + const xcmPallet = originApi.tx.xcmPallet || originApi.tx.polkadotXcm; + + const reserveTransfer = xcmPallet.execute(xcmProgram, { + refTime: 3 * Math.pow(10, 11), + proofSize: Math.pow(10, 6), + }); + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + const unsub = await reserveTransfer.signAndSend(sender, (result: any) => { + if (result.status.isFinalized) { + unsub(); + resolve(); + } + }) + }); + } + + // Returns the xcm instruction for transferring an asset accross its reserve chain. + // + // This xcm program executes the following instructions: + // 1. `WithdrawAsset`, withdraw the amount the user wants to transfer from the sending chain + // and put it into the holding register + // 2. `InitiateReserveWithdraw`, this will take all the assets in the holding register and burn + // them on the origin chain. This will put the appropriate amount into the holding register on + // the reserve chain. + // 3. `BuyExecution`, on the reserve chain + // 4. `DepositReserveAsset`, deposit into the parachain sovereign account. This will also notify the + // destination chain that it received the tokens. + // 5. `DepositAsset`, this deposits the received assets on the destination chain into the receiver's + // account. + private static getTwoHopTransferInstructions( + asset: Fungible, + reserveParaId: number, + destParaId: number, + beneficiary: Receiver, + isOriginPara: boolean + ): any { + const reserve = this.getReserve(reserveParaId, isOriginPara); + + // NOTE: we use parse and stringify to make a hard copy of the asset. + const assetFromReservePerspective = JSON.parse(JSON.stringify(asset.multiAsset)); + if (reserveParaId > 0) { + // The location of the asset will always start with the parachain if the reserve is a parachain. + this.assetFromReservePerspective(assetFromReservePerspective); + } else { + // The reserve is the relay chain. + assetFromReservePerspective.parents = 0; + } + + return { + V2: [ + this.withdrawAsset(asset, isOriginPara), + { + InitiateReserveWithdraw: { + assets: { + Wild: "All" + }, + reserve, + xcm: [ + // TODO: the hardcoded number isn't really accurate to what we actually need. + this.buyExecution(assetFromReservePerspective, 450000000000), + this.depositReserveAsset({ Wild: "All" }, 1, { + parents: 1, + interior: { + X1: { + Parachain: destParaId + } + } + }, [ + this.depositAsset({ Wild: "All" }, 1, beneficiary) + ]) + ] + } + }, + ] + } + } + + // Returns the XCM instruction for transfering an asset where the destination is the reserve chain of the asset. + // + // This xcm program executes the following instructions: + // 1. `WithdrawAsset`, withdraw the amount the user wants to transfer from the sending chain + // and put it into the holding register + // 2. `InitiateReserveWithdraw`, this will take all the assets in the holding register and burn + // them on the origin chain. This will put the appropriate amount into the holding register on + // the reserve chain. + // 3. `BuyExecution`, on the reserve chain + // 4. `DepositAsset`, this deposits the received assets to the receiver on the reserve chain. + private static getSendToReserveChainInstructions( + asset: Fungible, + destParaId: number, + beneficiary: Receiver, + isOriginPara: boolean + ): any { + const reserve = this.getReserve(destParaId, isOriginPara); + + // NOTE: we use parse and stringify to make a hard copy of the asset. + const assetFromReservePerspective = JSON.parse(JSON.stringify(asset.multiAsset)); + if (destParaId >= 0) { + // The location of the asset will always start with the parachain if the reserve is a parachain. + this.assetFromReservePerspective(assetFromReservePerspective); + } else { + // The reserve is the relay chain. + assetFromReservePerspective.parents = 0; + } + + return { + V2: [ + this.withdrawAsset(asset, isOriginPara), + { + InitiateReserveWithdraw: { + assets: { + Wild: "All" + }, + reserve, + xcm: [ + // TODO: the hardcoded number isn't really accurate to what we actually need. + this.buyExecution(assetFromReservePerspective, 450000000000), + this.depositAsset({ Wild: "All" }, 1, beneficiary) + ] + } + }, + ] + } + } + + private static withdrawAsset(asset: Fungible, isOriginPara: boolean): any { + const junctions = this.extractJunctions(asset.multiAsset); + const parents = isOriginPara ? 1 : 0; + + const interior = junctions == "Here" ? "Here" : { [`X${junctions.length}`]: junctions }; + + return { + WithdrawAsset: [ + { + id: + { + Concrete: { + interior, + parents + }, + }, + fun: { + Fungible: asset.amount + } + } + ] + }; + } + + private static buyExecution(multiAsset: any, amount: number): any { + return { + BuyExecution: { + fees: { + id: { + Concrete: multiAsset + }, + fun: { + Fungible: amount + } + }, + weightLimit: "Unlimited" + }, + }; + } + + private static depositReserveAsset(assets: any, maxAssets: number, dest: any, xcm: any[]): any { + return { + DepositReserveAsset: { + assets, + maxAssets, + dest, + xcm + } + } + } + + private static depositAsset(assets: any, maxAssets: number, receiver: Receiver): any { + const beneficiary = { + parents: 0, + interior: { + X1: this.getReceiverAccount(receiver) + } + }; + + return { + DepositAsset: { + assets, + maxAssets, + beneficiary + } + }; + } + + private static getReserve(reserveParaId: number, isOriginPara: boolean) { + const parents = isOriginPara ? 1 : 0; + + if (reserveParaId < 0) { + return { + parents, + interior: "Here" + } + } else { + return { + parents, + interior: { + X1: { + Parachain: reserveParaId + } + } + } + } + } + + // Returns the destination of an xcm reserve transfer. + // + // The destination is an entity that will process the xcm message(i.e a relaychain or a parachain). + private static getDestination(isOriginPara: boolean, destParaId: number, isDestPara: boolean): any { + const parents = isOriginPara ? 1 : 0; + + if (isDestPara) { + return { + V2: + { + parents, + interior: { + X1: { Parachain: destParaId } + } + } + } + } else { + // If the destination is not a parachain it is basically a relay chain. + return { + V2: + { + parents, + interior: "Here" + } + } + } + } + + // Returns the beneficiary of an xcm reserve transfer. + // + // The beneficiary is an interior entity of the destination that will actually receive the tokens. + private static getReserveTransferBeneficiary(receiver: Receiver) { + const receiverAccount = this.getReceiverAccount(receiver); + + return { + V2: { + parents: 0, + interior: { + X1: { + ...receiverAccount + } + } + } + }; + } + + private static getReceiverAccount(receiver: Receiver): any { + if (receiver.type == AccountType.accountId32) { + return { + AccountId32: { + network: "Any", + id: receiver.addressRaw, + }, + }; + } else if (receiver.type == AccountType.accountKey20) { + return { + AccountKey20: { + network: "Any", + id: receiver.addressRaw, + }, + }; + } + } + + // Returns a proper MultiAsset. + private static getMultiAsset(asset: Fungible): any { + return { + V2: [ + { + fun: { + Fungible: asset.amount, + }, + id: { + Concrete: asset.multiAsset, + }, + }, + ] + } + } + + private static ensureContainsXcmPallet(api: ApiPromise) { + if (!(api.tx.xcmPallet || api.tx.polkadotXcm)) { + throw new Error("The blockchain does not support XCM"); + } + } + + // Helper function to remove a specific key from an object. + private static assetFromReservePerspective(location: any) { + const junctions = this.extractJunctions(location); + + if (junctions.length === 1) { + location.interior = "Here"; + location.parents = 0; + return; + } + + junctions.splice(0, 1); + delete location.interior[`X${junctions.length + 1}`]; + location.interior[`X${junctions.length}`] = junctions; + location.parents = 0; + } + + private static extractJunctions(location: any): any { + const keyPattern = /^X\d$/; + + if (location.interior == "Here") { + return "Here"; + } + + const key = Object.keys(location.interior).find(key => keyPattern.test(key)); + + if (key) { + return location.interior[key]; + } else { + throw Error("Couldn't get junctions of an asset's location"); + } + } +} + +export default ReserveTransfer; diff --git a/src/utils/transactionRouter/transferAsset.ts b/src/utils/transactionRouter/transferAsset.ts index b9ea65b..feeaa39 100644 --- a/src/utils/transactionRouter/transferAsset.ts +++ b/src/utils/transactionRouter/transferAsset.ts @@ -11,17 +11,11 @@ class TransferAsset { receiver: Receiver, asset: Fungible ): Promise { - // We use XCM even for transfers that are occurring on the same chain. The - // reason for this is that we cannot know what is the pallet and function - // for transferring tokens since it can be different on each chain. For that - // reason we will use the XCM `TransferAsset` instruction which is - // standardized and as far as the chain has an XCM executor the transaction - // will be executed correctly. - - const chainInfo = api.registry.getChainProperties(); - if (!chainInfo) { - throw new Error("Failed to get chain info"); - } + // We use XCM even for transfers that are occurring on the same chain. The reason for + // this is that we cannot know what is the pallet and function for transferring tokens + // since it can be different on each chain. For that reason we will use the XCM `TransferAsset` + // instruction which is standardized and as far as the chain has an XCM executor the + // transaction will be executed correctly. const xcm = this.xcmTransferAssetMessage( receiver.addressRaw, @@ -30,41 +24,17 @@ class TransferAsset { asset.amount ); - let xcmExecute: any; - - if (api.tx.xcmPallet) { - const paymentInfo = (await api.tx.xcmPallet - .execute(xcm, 0) - .paymentInfo(sender)).toHuman(); - - if (!paymentInfo || !paymentInfo.weight) { - throw new Error("Couldn't estimate transaction fee"); - } - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const xcmMaxRefTime = parseInt(paymentInfo.weight.refTime.replace(/,/g, "")); - - // TODO: don't hardcode the max weight. - xcmExecute = api.tx.xcmPallet.execute(xcm, xcmMaxRefTime * 10); - } else if (api.tx.polkadotXcm) { - const paymentInfo = (await api.tx.polkadotXcm - .execute(xcm, 0) - .paymentInfo(sender)).toHuman(); + const xcmPallet = api.tx.xcmPallet || api.tx.polkadotXcm; - if (!paymentInfo || !paymentInfo.weight) { - throw new Error("Couldn't estimate transaction fee"); - } - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const xcmMaxRefTime = parseInt(paymentInfo.weight.refTime.replace(/,/g, "")); - - // TODO: don't hardcode the max weight. - xcmExecute = api.tx.polkadotXcm.execute(xcm, xcmMaxRefTime * 10); - } else { + if (!xcmPallet) { throw new Error("The blockchain does not support XCM"); - } + }; + + // TODO: come up with more precise weight estimations. + const xcmExecute = xcmPallet.execute(xcm, { + refTime: Math.pow(10, 9), + proofSize: 10000, + }); // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve) => { @@ -102,6 +72,7 @@ class TransferAsset { }; } + // TODO: should this have `BuyExecution`? const xcmMessage = { V2: [ {