From 81d445193b3f0f5cdec75b28b38c9861eb336552 Mon Sep 17 00:00:00 2001 From: Michael Absolon Date: Mon, 25 Nov 2024 20:34:21 +0100 Subject: [PATCH] =?UTF-8?q?refactor(sdk):=20Refactor=20ParaToPara=20and=20?= =?UTF-8?q?ParaToRelay=20transfer=20function=20=F0=9F=A7=91=E2=80=8D?= =?UTF-8?q?=F0=9F=92=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/sdk/src/api/IPolkadotApi.ts | 1 + .../src/pallets/xcmPallet/transfer.test.ts | 625 ------------------ .../sdk/src/pallets/xcmPallet/transfer.ts | 285 -------- .../determineAssetCheckEnabled.test.ts | 141 ++++ .../transfer/determineAssetCheckEnabled.ts | 16 + .../src/pallets/xcmPallet/transfer/index.ts | 2 + .../transfer/isBridgeTransfer.test.ts | 60 ++ .../xcmPallet/transfer/isBridgeTransfer.ts | 11 + .../transfer/performKeepAliveCheck.test.ts | 158 +++++ .../transfer/performKeepAliveCheck.ts | 39 ++ .../xcmPallet/transfer/resolveAsset.test.ts | 91 +++ .../xcmPallet/transfer/resolveAsset.ts | 24 + .../xcmPallet/transfer/transfer.test.ts | 338 ++++++++++ .../pallets/xcmPallet/transfer/transfer.ts | 83 +++ .../transfer/transferRelayToPara.test.ts | 287 ++++++++ .../xcmPallet/transfer/transferRelayToPara.ts | 57 ++ .../validateDestinationAddress.test.ts | 12 +- .../validateDestinationAddress.ts | 8 +- .../transfer/validationUtils.test.ts | 598 +++++++++++++++++ .../xcmPallet/transfer/validationUtils.ts | 144 ++++ packages/sdk/src/papi/PapiApi.ts | 4 + packages/sdk/src/pjs/PolkadotJsApi.ts | 4 + 22 files changed, 2068 insertions(+), 920 deletions(-) delete mode 100644 packages/sdk/src/pallets/xcmPallet/transfer.test.ts delete mode 100644 packages/sdk/src/pallets/xcmPallet/transfer.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/determineAssetCheckEnabled.test.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/determineAssetCheckEnabled.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/index.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/isBridgeTransfer.test.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/isBridgeTransfer.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/performKeepAliveCheck.test.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/performKeepAliveCheck.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/resolveAsset.test.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/resolveAsset.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/transfer.test.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/transfer.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/transferRelayToPara.test.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/transferRelayToPara.ts rename packages/sdk/src/pallets/xcmPallet/{ => transfer}/validateDestinationAddress.test.ts (95%) rename packages/sdk/src/pallets/xcmPallet/{ => transfer}/validateDestinationAddress.ts (78%) create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/validationUtils.test.ts create mode 100644 packages/sdk/src/pallets/xcmPallet/transfer/validationUtils.ts diff --git a/packages/sdk/src/api/IPolkadotApi.ts b/packages/sdk/src/api/IPolkadotApi.ts index 76f7011f..8ea092e4 100644 --- a/packages/sdk/src/api/IPolkadotApi.ts +++ b/packages/sdk/src/api/IPolkadotApi.ts @@ -10,6 +10,7 @@ import type { TApiOrUrl } from '../types/TApi' export interface IPolkadotApi { setApi(api?: TApiOrUrl): void getApi(): TApi + getApiOrUrl(): TApiOrUrl | undefined init(node: TNodeWithRelayChains): Promise createApiInstance: (wsUrl: string) => Promise createAccountId(address: string): THexString diff --git a/packages/sdk/src/pallets/xcmPallet/transfer.test.ts b/packages/sdk/src/pallets/xcmPallet/transfer.test.ts deleted file mode 100644 index ef9e8afa..00000000 --- a/packages/sdk/src/pallets/xcmPallet/transfer.test.ts +++ /dev/null @@ -1,625 +0,0 @@ -// Tests designed to try different XCM Pallet XCM messages and errors - -import { type ApiPromise } from '@polkadot/api' -import { describe, expect, it, beforeEach, vi } from 'vitest' -import { NODE_NAMES_DOT_KSM } from '../../maps/consts' -import { - Foreign, - getAllAssetsSymbols, - getNativeAssets, - getOtherAssets, - getRelayChainSymbol -} from '../assets' -import { InvalidCurrencyError } from '../../errors/InvalidCurrencyError' -import { DuplicateAssetError, IncompatibleNodesError } from '../../errors' -import type { TNodePolkadotKusama } from '../../types' -import { type TSendOptions, type TNode, type TMultiAsset, type TMultiLocation } from '../../types' -import { send } from './transfer' -import ParachainNode from '../../nodes/ParachainNode' -import { getNode } from '../../utils' -import Astar from '../../nodes/supported/Astar' -import Shiden from '../../nodes/supported/Shiden' -import AssetHubKusama from '../../nodes/supported/AssetHubKusama' -import type { IPolkadotApi } from '../../api/IPolkadotApi' -import type { Extrinsic } from '../../pjs/types' - -vi.mock('../../utils/createApiInstanceForNode', () => ({ - createApiInstanceForNode: vi.fn().mockReturnValue({} as ApiPromise) -})) - -vi.spyOn(ParachainNode.prototype, 'transfer').mockReturnValue({} as Promise) -vi.spyOn(Astar.prototype, 'transfer').mockReturnValue({} as Promise) -vi.spyOn(Shiden.prototype, 'transfer').mockReturnValue({} as Promise) -vi.spyOn(AssetHubKusama.prototype, 'transferRelayToPara').mockReturnValue({ - module: 'XcmPallet', - section: 'limited_teleport_assets', - parameters: {} -}) - -const randomCurrencySymbol = 'DOT' - -const MOCK_OPTIONS_BASE = { - amount: 1000, - address: '23sxrMSmaUMqe2ufSJg8U3Y8kxHfKT67YbubwXWFazpYi7w6' -} - -const mockApi = { - getApi: vi.fn(), - setApi: vi.fn(), - init: vi.fn(), - callTxMethod: vi.fn(), - disconnect: vi.fn() -} as unknown as IPolkadotApi - -describe('send', () => { - let polkadotNodes: TNodePolkadotKusama[] - let kusamaNodes: TNode[] - let sendOptions: TSendOptions - - beforeEach(() => { - polkadotNodes = NODE_NAMES_DOT_KSM.filter(node => getRelayChainSymbol(node) === 'KSM') - kusamaNodes = NODE_NAMES_DOT_KSM.filter(node => getRelayChainSymbol(node) === 'DOT') - sendOptions = { - ...MOCK_OPTIONS_BASE, - origin: 'Acala', - currency: { symbol: 'ACA' }, - api: mockApi, - destApiForKeepAlive: mockApi - } - }) - - it('should throw an InvalidCurrencyError when passing Acala and UNIT', async () => { - await expect( - send({ - ...sendOptions, - origin: 'Acala', - destination: 'Astar', - currency: { symbol: 'UNIT' } - }) - ).rejects.toThrowError(InvalidCurrencyError) - }) - - it('should not throw an InvalidCurrencyError when passing Acala and ACA', async () => { - await expect( - send({ ...sendOptions, origin: 'Acala', currency: { symbol: 'ACA' } }) - ).resolves.not.toThrowError(InvalidCurrencyError) - }) - - it('should create a new API instance when API is not provided', async () => { - const options = { - ...sendOptions - } - - const spy = vi.spyOn(options.api, 'init') - - options.api.setApi(undefined) - - await send(options) - - expect(spy).toHaveBeenCalled() - }) - - it('should not throw an InvalidCurrencyError when passing Acala and ACA and Unique as destination', async () => { - await expect( - send({ ...sendOptions, origin: 'Acala', currency: { symbol: 'UNQ' }, destination: 'Unique' }) - ).resolves.not.toThrowError(InvalidCurrencyError) - }) - - it('should not throw an InvalidCurrencyError when passing Karura and BSX and Basilisk as destination', async () => { - await expect( - send({ - ...sendOptions, - origin: 'Karura', - currency: { symbol: 'BSX' }, - destination: 'Basilisk' - }) - ).resolves.not.toThrowError(InvalidCurrencyError) - }) - - it('should throw an InvalidCurrencyError when passing Acala and ACA and BifrostPolkadot as destination', async () => { - await expect( - send({ - ...sendOptions, - origin: 'Acala', - currency: { symbol: 'UNQ' }, - destination: 'BifrostPolkadot' - }) - ).rejects.toThrowError(InvalidCurrencyError) - }) - - it('should throw an IncompatibleNodesError when passing AssetHubKusama, DOT and Hydration as destination', async () => { - await expect( - send({ - ...sendOptions, - origin: 'AssetHubKusama', - currency: { symbol: 'DOT' }, - destination: 'Hydration' - }) - ).rejects.toThrowError(IncompatibleNodesError) - }) - - it('should throw an IncompatibleNodesError when passing Hydration, DOT and AssetHubKusama as destination', async () => { - await expect( - send({ - ...sendOptions, - origin: 'Hydration', - currency: { symbol: 'DOT' }, - destination: 'AssetHubKusama' - }) - ).rejects.toThrowError(IncompatibleNodesError) - }) - - it('should not throw an InvalidCurrencyError when passing all defined symbols from all nodes', async () => { - for (const node of NODE_NAMES_DOT_KSM) { - if (getNode(node).assetCheckEnabled) { - const symbols = getAllAssetsSymbols(node) - for (const symbol of symbols) { - const otherAssetsMatches = getOtherAssets(node).filter( - ({ symbol: assetSymbol }) => assetSymbol?.toLowerCase() === symbol.toLowerCase() - ) - const nativeAssetsMatches = getNativeAssets(node).filter( - ({ symbol: assetSymbol }) => assetSymbol?.toLowerCase() === symbol.toLowerCase() - ) - if (otherAssetsMatches.length + nativeAssetsMatches.length > 1) { - continue - } - await expect( - send({ ...sendOptions, origin: node, currency: { symbol } }) - ).resolves.not.toThrowError(InvalidCurrencyError) - } - } - } - }) - - it('should throw an IncompatibleNodesError when passing all nodes which have different relaychains', async () => { - for (const polkadotNode of polkadotNodes) { - for (const kusamaNode of kusamaNodes) { - // Ignore these cases because they are using bridge - if ( - (polkadotNode === 'AssetHubPolkadot' && kusamaNode === 'AssetHubKusama') || - (polkadotNode === 'AssetHubKusama' && kusamaNode === 'AssetHubPolkadot') - ) { - continue - } - - await expect( - send({ - ...sendOptions, - origin: polkadotNode, - currency: { symbol: randomCurrencySymbol }, - destination: kusamaNode - }) - ).rejects.toThrowError(IncompatibleNodesError) - } - } - }) - - it('should not throw an IncompatibleNodesError when passing nodes which have the same relaychains', async () => { - await expect( - send({ - ...sendOptions, - origin: 'Acala', - currency: { symbol: randomCurrencySymbol }, - destination: 'Hydration' - }) - ).resolves.not.toThrowError(IncompatibleNodesError) - - await expect( - send({ - ...sendOptions, - origin: 'Karura', - currency: { symbol: 'CSM' }, - destination: 'CrustShadow' - }) - ).resolves.not.toThrowError(IncompatibleNodesError) - }) - - it('should throw InvalidCurrencyError when multi assets array is empty', async () => { - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'AssetHubPolkadot' as TNodePolkadotKusama, - destination: 'Hydration' as TNode, - currency: { multiasset: [] }, - feeAsset: 0, - amount: 1000, - address: '0x123' - } - - await expect(send(options)).rejects.toThrow(InvalidCurrencyError) - await expect(send(options)).rejects.toThrow('Overrided multi assets cannot be empty') - }) - - it('should throw DuplicateAssetError when Hydration and USDT is passed', async () => { - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'Hydration' as TNodePolkadotKusama, - destination: 'Acala' as TNode, - currency: { symbol: 'USDT' }, - feeAsset: 0, - amount: 1000, - address: '0x123' - } - - await expect(send(options)).rejects.toThrow(DuplicateAssetError) - }) - - it('should not throw DuplicateAssetError when AssetHubPolkadot and WETH is passed', async () => { - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'AssetHubPolkadot' as TNodePolkadotKusama, - destination: 'Hydration' as TNode, - currency: { symbol: 'WETH.e' }, - feeAsset: 0, - amount: 1000, - address: '0x123' - } - - await expect(send(options)).resolves.not.toThrow(DuplicateAssetError) - }) - - it('should throw InvalidCurrencyError when single multi asset is used with fee asset', async () => { - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'AssetHubPolkadot' as TNodePolkadotKusama, - destination: 'Hydration' as TNode, - currency: { - multiasset: [ - { - id: { - Concrete: { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - }, - fun: { Fungible: 1000 } - } - ] as TMultiAsset[] - }, - feeAsset: 1, - amount: 1000, - address: '0x456' - } - - await expect(send(options)).rejects.toThrow(InvalidCurrencyError) - }) - - it('should throw InvalidCurrencyError when single multi location is used with fee asset', async () => { - const multilocation: TMultiLocation = { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'AssetHubPolkadot' as TNodePolkadotKusama, - destination: 'Hydration' as TNode, - currency: { multilocation }, - feeAsset: 1, - amount: 1000, - address: '0x456' - } - - await expect(send(options)).rejects.toThrow(InvalidCurrencyError) - }) - - it('should throw Error if missing amount and is using multilocation', async () => { - const multilocation: TMultiLocation = { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'AssetHubPolkadot' as TNodePolkadotKusama, - destination: 'Hydration' as TNode, - currency: { multilocation }, - feeAsset: 1, - amount: null, - address: '0x456' - } - - await expect(send(options)).rejects.toThrow('Amount is required') - }) - - it('should throw Error if trying to transfer to ethereum from not AssetHub', async () => { - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'Acala' as TNodePolkadotKusama, - destination: 'Ethereum' as TNode, - currency: { symbol: 'WETH' }, - feeAsset: 1, - amount: 1000, - address: '0x456' - } - - await expect(send(options)).rejects.toThrow(IncompatibleNodesError) - }) - - it('should throw InvalidCurrencyError when multi assets are used without specifying fee asset', async () => { - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'AssetHubPolkadot' as TNodePolkadotKusama, - destination: 'Hydration' as TNode, - currency: { - multiasset: [ - { - id: { - Concrete: { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - }, - fun: { Fungible: 1000 } - }, - { - id: { - Concrete: { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - }, - fun: { Fungible: 500 } - } - ] as TMultiAsset[] - }, - feeAsset: undefined, - amount: 1000, - address: '0x789' - } - - await expect(send(options)).rejects.toThrow(InvalidCurrencyError) - }) - - it('should throw InvalidCurrencyError when multi assets are used without specifying fee asset', async () => { - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'AssetHubPolkadot' as TNodePolkadotKusama, - destination: 'Hydration' as TNode, - currency: { - multiasset: [ - { - id: { - Concrete: { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - }, - fun: { Fungible: 1000 } - }, - { - id: { - Concrete: { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - }, - fun: { Fungible: 500 } - } - ] as TMultiAsset[] - }, - feeAsset: 1, - amount: 1000, - address: '0x789' - } - - await expect(send(options)).resolves.not.toThrow() - }) - - it('should throw InvalidCurrencyError when multi assets are used without specifying fee asset', async () => { - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'AssetHubPolkadot' as TNodePolkadotKusama, - destination: 'Hydration' as TNode, - currency: { - multiasset: [ - { - id: { - Concrete: { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - }, - fun: { Fungible: 1000 } - }, - { - id: { - Concrete: { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - }, - fun: { Fungible: 500 } - } - ] as TMultiAsset[] - }, - feeAsset: 0, - amount: 1000, - address: '0x789' - } - - await expect(send(options)).resolves.not.toThrow() - }) - - it('should throw InvalidCurrencyError when multi assets are used with fee asset too big', async () => { - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'AssetHubPolkadot' as TNodePolkadotKusama, - destination: 'Hydration' as TNode, - currency: { - multiasset: [ - { - id: { - Concrete: { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - }, - fun: { Fungible: 1000 } - }, - { - id: { - Concrete: { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - }, - fun: { Fungible: 500 } - } - ] as TMultiAsset[] - }, - feeAsset: -1, - amount: 1000, - address: '0x789' - } - - await expect(send(options)).rejects.toThrow(InvalidCurrencyError) - }) - - it('should throw InvalidCurrencyError when multi assets are used with fee asset too big', async () => { - const options = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'AssetHubPolkadot' as TNodePolkadotKusama, - destination: 'Hydration' as TNode, - currency: { - multiasset: [ - { - id: { - Concrete: { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - }, - fun: { Fungible: 1000 } - }, - { - id: { - Concrete: { - parents: 0, - interior: { - X2: [{ PalletInstance: '50' }, { Parachain: '30' }] - } - } - }, - fun: { Fungible: 500 } - } - ] as TMultiAsset[] - }, - feeAsset: 2, - amount: 1000, - address: '0x789' - } - - await expect(send(options)).rejects.toThrow(InvalidCurrencyError) - }) - - it('should throw InvalidCurrencyError when destination is AssetHubPolkadot and currency is DOT', async () => { - const options: TSendOptions = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'Acala' as TNodePolkadotKusama, - destination: 'AssetHubPolkadot' as TNode, - currency: { symbol: 'DOT' }, - feeAsset: 0, - amount: 1000, - address: '0x789' - } - - await expect(send(options)).rejects.toThrow(InvalidCurrencyError) - }) - - it('should throw InvalidCurrencyError when destination is AssetHubPolkadot and currency is not supported', async () => { - const options: TSendOptions = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'Hydration' as TNodePolkadotKusama, - destination: 'AssetHubPolkadot' as TNode, - currency: { symbol: 'ETH' }, - feeAsset: 0, - amount: 1000, - address: '0x789' - } - - await expect(send(options)).rejects.toThrow(InvalidCurrencyError) - }) - - it('should throw InvalidCurrencyError when destination is AssetHubPolkadot and currency is not supported on AssetHub', async () => { - const options: TSendOptions = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'Hydration' as TNodePolkadotKusama, - destination: 'AssetHubPolkadot' as TNode, - currency: { symbol: '4-Pool' }, - feeAsset: 0, - amount: 1000, - address: '0x789' - } - - await expect(send(options)).rejects.toThrow(InvalidCurrencyError) - }) - - it('should throw if assetCheck is disabled and we are using symbol specifier', async () => { - const options: TSendOptions = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'CoretimePolkadot' as TNodePolkadotKusama, - destination: 'AssetHubPolkadot' as TNode, - currency: { symbol: Foreign('DOT') }, - feeAsset: 0, - amount: 1000, - address: '0x789' - } - - await expect(send(options)).rejects.toThrow(InvalidCurrencyError) - }) - - it('should throw if assetCheck is disabled and we are using id specifier', async () => { - const options: TSendOptions = { - api: mockApi, - destApiForKeepAlive: mockApi, - origin: 'CoretimePolkadot' as TNodePolkadotKusama, - destination: 'AssetHubPolkadot' as TNode, - currency: { id: 123 }, - feeAsset: 0, - amount: 1000, - address: '0x789' - } - - await expect(send(options)).rejects.toThrow(InvalidCurrencyError) - }) -}) diff --git a/packages/sdk/src/pallets/xcmPallet/transfer.ts b/packages/sdk/src/pallets/xcmPallet/transfer.ts deleted file mode 100644 index 3cf54368..00000000 --- a/packages/sdk/src/pallets/xcmPallet/transfer.ts +++ /dev/null @@ -1,285 +0,0 @@ -// Contains basic call formatting for different XCM Palletss - -import type { TNativeAsset } from '../../types' -import { type TRelayToParaOptions, type TSendOptions, type TNode } from '../../types' -import { InvalidCurrencyError } from '../../errors/InvalidCurrencyError' -import { IncompatibleNodesError } from '../../errors' -import { checkKeepAlive } from './keepAlive/checkKeepAlive' -import { isTMultiLocation, resolveTNodeFromMultiLocation, throwUnsupportedCurrency } from './utils' -import { getAssetBySymbolOrId } from '../assets/getAssetBySymbolOrId' -import { getDefaultPallet } from '../pallets' -import { getNativeAssets, getRelayChainSymbol, hasSupportForAsset } from '../assets' -import { getNode, determineRelayChain } from '../../utils' -import { isSymbolSpecifier } from '../../utils/assets/isSymbolSpecifier' -import { isOverrideMultiLocationSpecifier } from '../../utils/multiLocation/isOverrideMultiLocationSpecifier' -import { isPjsClient } from '../../utils/isPjsClient' -import { validateDestinationAddress } from './validateDestinationAddress' - -export const send = async (options: TSendOptions): Promise => { - const { - api, - origin, - currency, - amount, - address, - destination, - paraIdTo, - destApiForKeepAlive, - feeAsset, - version, - ahAddress - } = options - - if ((!('multiasset' in currency) || 'multilocation' in currency) && amount === null) { - throw new Error('Amount is required') - } - - // Multi location checks - if ('multilocation' in currency && (feeAsset === 0 || feeAsset !== undefined)) { - throw new InvalidCurrencyError('Overrided single multi asset cannot be used with fee asset') - } - - // Multi assets checks - if ('multiasset' in currency) { - if (amount !== null) { - console.warn( - 'Amount is ignored when using overriding currency using multiple multi locations. Please set it to null.' - ) - } - - if (currency.multiasset.length === 0) { - throw new InvalidCurrencyError('Overrided multi assets cannot be empty') - } - - if (currency.multiasset.length === 1 && (feeAsset === 0 || feeAsset !== undefined)) { - throw new InvalidCurrencyError('Overrided single multi asset cannot be used with fee asset') - } - - if (currency.multiasset.length > 1 && feeAsset === undefined) { - throw new InvalidCurrencyError( - 'Overrided multi assets cannot be used without specifying fee asset' - ) - } - - if ( - currency.multiasset.length > 1 && - feeAsset !== undefined && - ((feeAsset as number) < 0 || (feeAsset as number) >= currency.multiasset.length) - ) { - throw new InvalidCurrencyError( - 'Fee asset index is out of bounds. Please provide a valid index.' - ) - } - } - - if (destination === 'Ethereum' && origin !== 'AssetHubPolkadot' && origin !== 'Hydration') { - throw new IncompatibleNodesError( - 'Transfers to Ethereum are only supported from AssetHubPolkadot and Hydration.' - ) - } - - const isMultiLocationDestination = typeof destination === 'object' - - const isBridge = - (origin === 'AssetHubPolkadot' && destination === 'AssetHubKusama') || - (origin === 'AssetHubKusama' && destination === 'AssetHubPolkadot') - - const isRelayDestination = destination === undefined - - if (!isRelayDestination && !isMultiLocationDestination) { - const originRelayChainSymbol = getRelayChainSymbol(origin) - const destinationRelayChainSymbol = getRelayChainSymbol(destination) - if (!isBridge && originRelayChainSymbol !== destinationRelayChainSymbol) { - throw new IncompatibleNodesError() - } - } - - const originNode = getNode(origin) - - const assetCheckEnabled = - 'multiasset' in currency || - ('multilocation' in currency && isOverrideMultiLocationSpecifier(currency.multilocation)) || - isBridge - ? false - : originNode.assetCheckEnabled - - validateDestinationAddress(address, destination) - - const isDestAssetHub = destination === 'AssetHubPolkadot' || destination === 'AssetHubKusama' - - const pallet = getDefaultPallet(origin) - - const isBifrost = origin === 'BifrostPolkadot' || origin === 'BifrostKusama' - - if (!assetCheckEnabled && 'symbol' in currency && isSymbolSpecifier(currency.symbol)) { - throw new InvalidCurrencyError( - 'Symbol specifier is not supported when asset check is disabled. Please use normal symbol instead.' - ) - } - - if (!assetCheckEnabled && 'id' in currency) { - throw new InvalidCurrencyError( - 'Asset ID is not supported when asset check is disabled. Please use normal symbol instead' - ) - } - - const asset = assetCheckEnabled - ? getAssetBySymbolOrId( - origin, - currency, - isRelayDestination - ? determineRelayChain(origin) - : !isTMultiLocation(destination) - ? destination - : null - ) - : null - - if (!isBridge && isDestAssetHub && pallet === 'XTokens' && !isBifrost) { - let nativeAssets = getNativeAssets(destination) - - if (origin === 'Hydration') { - nativeAssets = nativeAssets.filter(nativeAsset => nativeAsset.symbol !== 'DOT') - } - - if ( - 'symbol' in currency && - nativeAssets.some( - nativeAsset => nativeAsset.symbol.toLowerCase() === asset?.symbol?.toLowerCase() - ) - ) { - throw new InvalidCurrencyError( - `${JSON.stringify(asset?.symbol)} is not supported for transfers to ${destination}.` - ) - } - } - - if (!isBridge && asset === null && assetCheckEnabled) { - throwUnsupportedCurrency(currency, origin) - } - - if ( - !isBridge && - !isRelayDestination && - !isMultiLocationDestination && - asset?.symbol !== undefined && - assetCheckEnabled && - !('id' in currency) && - !hasSupportForAsset(destination, asset.symbol) - ) { - throw new InvalidCurrencyError( - `Destination node ${destination} does not support currency ${JSON.stringify(currency)}.` - ) - } - - await api.init(origin) - - try { - const amountStr = amount?.toString() - - if ('multilocation' in currency || 'multiasset' in currency) { - console.warn('Keep alive check is not supported when using MultiLocation as currency.') - } else if (typeof address === 'object') { - console.warn('Keep alive check is not supported when using MultiLocation as address.') - } else if (typeof destination === 'object') { - console.warn('Keep alive check is not supported when using MultiLocation as destination.') - } else if (destination === 'Ethereum') { - console.warn( - 'Keep alive check is not supported when using Ethereum as origin or destination.' - ) - } else if (!asset) { - console.warn('Keep alive check is not supported when asset check is disabled.') - } else { - await checkKeepAlive({ - originApi: api, - address, - amount: amountStr ?? '', - originNode: origin, - destApi: destApiForKeepAlive, - asset, - destNode: destination - }) - } - - // In case asset check is disabled, we create asset object from currency symbol - const resolvedAsset = - asset ?? - ({ - symbol: 'symbol' in currency ? currency.symbol : undefined - } as TNativeAsset) - - return await originNode.transfer({ - api, - asset: resolvedAsset, - amount: amountStr ?? '', - address, - destination, - paraIdTo, - overridedCurrencyMultiLocation: - 'multilocation' in currency && isOverrideMultiLocationSpecifier(currency.multilocation) - ? currency.multilocation.value - : 'multiasset' in currency - ? currency.multiasset - : undefined, - feeAsset, - version, - destApiForKeepAlive, - ahAddress - }) - } finally { - if (isPjsClient(api)) { - await api.disconnect() - } - } -} - -export const transferRelayToPara = async ( - options: TRelayToParaOptions -): Promise => { - const { api, destination, amount, address, paraIdTo, destApiForKeepAlive, version } = options - const isMultiLocationDestination = typeof destination === 'object' - const isAddressMultiLocation = typeof address === 'object' - - if (api === undefined && isMultiLocationDestination) { - throw new Error('API is required when using MultiLocation as destination.') - } - - await api.init(determineRelayChain(destination as TNode)) - - try { - const amountStr = amount.toString() - - if (isMultiLocationDestination) { - console.warn('Keep alive check is not supported when using MultiLocation as destination.') - } else if (isAddressMultiLocation) { - console.warn('Keep alive check is not supported when using MultiLocation as address.') - } else { - await checkKeepAlive({ - originApi: api, - address, - amount: amountStr, - destApi: destApiForKeepAlive, - asset: { symbol: getRelayChainSymbol(destination) }, - destNode: destination - }) - } - - const serializedApiCall = getNode( - isMultiLocationDestination ? resolveTNodeFromMultiLocation(destination) : destination - ).transferRelayToPara({ - api, - destination, - address, - amount: amountStr, - paraIdTo, - destApiForKeepAlive, - version - }) - - return api.callTxMethod(serializedApiCall) - } finally { - if (isPjsClient(api)) { - await api.disconnect() - } - } -} diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/determineAssetCheckEnabled.test.ts b/packages/sdk/src/pallets/xcmPallet/transfer/determineAssetCheckEnabled.test.ts new file mode 100644 index 00000000..45f135c9 --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/determineAssetCheckEnabled.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { getNode } from '../../../utils' +import { isOverrideMultiLocationSpecifier } from '../../../utils/multiLocation/isOverrideMultiLocationSpecifier' +import type { TCurrencyInput, TNodePolkadotKusama } from '../../../types' +import { determineAssetCheckEnabled } from './determineAssetCheckEnabled' +import type ParachainNode from '../../../nodes/ParachainNode' +import type { Extrinsic, TPjsApi } from '../../../pjs' + +vi.mock('../../../utils', () => ({ + getNode: vi.fn() +})) + +vi.mock('../../../utils/multiLocation/isOverrideMultiLocationSpecifier', () => ({ + isOverrideMultiLocationSpecifier: vi.fn() +})) + +describe('determineAssetCheckEnabled', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return false if "multiasset" is in currency', () => { + const origin = 'Acala' as TNodePolkadotKusama + const currency = { multiasset: [] } as TCurrencyInput + const isBridge = false + + const originNode = { assetCheckEnabled: true } as ParachainNode + vi.mocked(getNode).mockReturnValue(originNode) + + const result = determineAssetCheckEnabled(origin, currency, isBridge) + + expect(result).toBe(false) + }) + + it('should return false if "multilocation" is in currency and isOverrideMultiLocationSpecifier returns true', () => { + const origin = {} as TNodePolkadotKusama + const currency = { multilocation: {} } as TCurrencyInput + const isBridge = false + + vi.mocked(isOverrideMultiLocationSpecifier).mockReturnValue(true) + const originNode = { assetCheckEnabled: true } as ParachainNode + vi.mocked(getNode).mockReturnValue(originNode) + + const result = determineAssetCheckEnabled(origin, currency, isBridge) + + expect(result).toBe(false) + }) + + it('should return false if isBridge is true', () => { + const origin = {} as TNodePolkadotKusama + const currency = {} as TCurrencyInput + const isBridge = true + + const originNode = { assetCheckEnabled: true } as ParachainNode + vi.mocked(getNode).mockReturnValue(originNode) + + const result = determineAssetCheckEnabled(origin, currency, isBridge) + + expect(result).toBe(false) + }) + + it('should return originNode.assetCheckEnabled when none of the conditions are met', () => { + const origin = {} as TNodePolkadotKusama + const currency = {} as TCurrencyInput + const isBridge = false + + const originNode = { assetCheckEnabled: true } as ParachainNode + vi.mocked(getNode).mockReturnValue(originNode) + + const result = determineAssetCheckEnabled(origin, currency, isBridge) + + expect(result).toBe(true) + }) + + it('should return originNode.assetCheckEnabled (false) when none of the conditions are met', () => { + const origin = {} as TNodePolkadotKusama + const currency = {} as TCurrencyInput + const isBridge = false + + const originNode = { assetCheckEnabled: false } as ParachainNode + vi.mocked(getNode).mockReturnValue(originNode) + + const result = determineAssetCheckEnabled(origin, currency, isBridge) + + expect(result).toBe(false) + }) + + it('should prioritize "multiasset" in currency over other conditions', () => { + const origin = {} as TNodePolkadotKusama + const currency = { multiasset: {} } as TCurrencyInput + const isBridge = true + + const originNode = { assetCheckEnabled: true } as ParachainNode + vi.mocked(getNode).mockReturnValue(originNode) + + const result = determineAssetCheckEnabled(origin, currency, isBridge) + + expect(result).toBe(false) + }) + + it('should return false when both "multilocation" in currency and isOverrideMultiLocationSpecifier returns true, even if isBridge is false', () => { + const origin = {} as TNodePolkadotKusama + const currency = { multilocation: {} } as TCurrencyInput + const isBridge = false + + vi.mocked(isOverrideMultiLocationSpecifier).mockReturnValue(true) + const originNode = { assetCheckEnabled: true } as ParachainNode + vi.mocked(getNode).mockReturnValue(originNode) + + const result = determineAssetCheckEnabled(origin, currency, isBridge) + + expect(result).toBe(false) + }) + + it('should return originNode.assetCheckEnabled when "multilocation" in currency but isOverrideMultiLocationSpecifier returns false', () => { + const origin = {} as TNodePolkadotKusama + const currency = { multilocation: {} } as TCurrencyInput + const isBridge = false + + vi.mocked(isOverrideMultiLocationSpecifier).mockReturnValue(false) + const originNode = { assetCheckEnabled: true } as ParachainNode + vi.mocked(getNode).mockReturnValue(originNode) + + const result = determineAssetCheckEnabled(origin, currency, isBridge) + + expect(result).toBe(true) + }) + + it('should return false when isBridge is true, regardless of other conditions', () => { + const origin = {} as TNodePolkadotKusama + const currency = {} as TCurrencyInput + const isBridge = true + + const originNode = { assetCheckEnabled: false } as ParachainNode + vi.mocked(getNode).mockReturnValue(originNode) + + const result = determineAssetCheckEnabled(origin, currency, isBridge) + + expect(result).toBe(false) + }) +}) diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/determineAssetCheckEnabled.ts b/packages/sdk/src/pallets/xcmPallet/transfer/determineAssetCheckEnabled.ts new file mode 100644 index 00000000..0b51cf5a --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/determineAssetCheckEnabled.ts @@ -0,0 +1,16 @@ +import type { TCurrencyInput, TNodePolkadotKusama } from '../../../types' +import { getNode } from '../../../utils' +import { isOverrideMultiLocationSpecifier } from '../../../utils/multiLocation/isOverrideMultiLocationSpecifier' + +export const determineAssetCheckEnabled = ( + origin: TNodePolkadotKusama, + currency: TCurrencyInput, + isBridge: boolean +): boolean => { + const originNode = getNode(origin) + return 'multiasset' in currency || + ('multilocation' in currency && isOverrideMultiLocationSpecifier(currency.multilocation)) || + isBridge + ? false + : originNode.assetCheckEnabled +} diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/index.ts b/packages/sdk/src/pallets/xcmPallet/transfer/index.ts new file mode 100644 index 00000000..63d950ee --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/index.ts @@ -0,0 +1,2 @@ +export * from './transfer' +export * from './transferRelayToPara' diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/isBridgeTransfer.test.ts b/packages/sdk/src/pallets/xcmPallet/transfer/isBridgeTransfer.test.ts new file mode 100644 index 00000000..a303eb32 --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/isBridgeTransfer.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { isBridgeTransfer } from './isBridgeTransfer' + +describe('isBridgeTransfer', () => { + it('should return true when origin is AssetHubPolkadot and destination is AssetHubKusama', () => { + const origin = 'AssetHubPolkadot' + const destination = 'AssetHubKusama' + const result = isBridgeTransfer(origin, destination) + expect(result).toBe(true) + }) + + it('should return true when origin is AssetHubKusama and destination is AssetHubPolkadot', () => { + const origin = 'AssetHubKusama' + const destination = 'AssetHubPolkadot' + const result = isBridgeTransfer(origin, destination) + expect(result).toBe(true) + }) + + it('should return false when origin and destination are both AssetHubPolkadot', () => { + const origin = 'AssetHubPolkadot' + const destination = 'AssetHubPolkadot' + const result = isBridgeTransfer(origin, destination) + expect(result).toBe(false) + }) + + it('should return false when origin and destination are both AssetHubKusama', () => { + const origin = 'AssetHubKusama' + const destination = 'AssetHubKusama' + const result = isBridgeTransfer(origin, destination) + expect(result).toBe(false) + }) + + it('should return false when origin is AssetHubPolkadot and destination is undefined', () => { + const origin = 'AssetHubPolkadot' + const destination = undefined + const result = isBridgeTransfer(origin, destination) + expect(result).toBe(false) + }) + + it('should return false when origin is AssetHubKusama and destination is undefined', () => { + const origin = 'AssetHubKusama' + const destination = undefined + const result = isBridgeTransfer(origin, destination) + expect(result).toBe(false) + }) + + it('should return false when origin is AssetHubPolkadot and destination is SomeOtherDestination', () => { + const origin = 'AssetHubPolkadot' + const destination = 'Acala' + const result = isBridgeTransfer(origin, destination) + expect(result).toBe(false) + }) + + it('should return false when origin is SomeOtherOrigin and destination is AssetHubKusama', () => { + const origin = 'Unique' + const destination = 'AssetHubKusama' + const result = isBridgeTransfer(origin, destination) + expect(result).toBe(false) + }) +}) diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/isBridgeTransfer.ts b/packages/sdk/src/pallets/xcmPallet/transfer/isBridgeTransfer.ts new file mode 100644 index 00000000..c11022a4 --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/isBridgeTransfer.ts @@ -0,0 +1,11 @@ +import type { TDestination, TNodePolkadotKusama } from '../../../types' + +export const isBridgeTransfer = ( + origin: TNodePolkadotKusama, + destination: TDestination | undefined +) => { + return ( + (origin === 'AssetHubPolkadot' && destination === 'AssetHubKusama') || + (origin === 'AssetHubKusama' && destination === 'AssetHubPolkadot') + ) +} diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/performKeepAliveCheck.test.ts b/packages/sdk/src/pallets/xcmPallet/transfer/performKeepAliveCheck.test.ts new file mode 100644 index 00000000..77138baf --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/performKeepAliveCheck.test.ts @@ -0,0 +1,158 @@ +import type { MockInstance } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { checkKeepAlive } from '../keepAlive' +import type { TAddress, TAsset, TCurrencyInput, TDestination, TSendOptions } from '../../../types' +import type { Extrinsic, TPjsApi } from '../../../pjs' +import { performKeepAliveCheck } from './performKeepAliveCheck' +import type { IPolkadotApi } from '../../../api' + +vi.mock('../keepAlive', () => ({ + checkKeepAlive: vi.fn() +})) + +describe('performKeepAliveCheck', () => { + let consoleWarnSpy: MockInstance + + const options = { + api: {} as IPolkadotApi, + origin: 'Acala', + destApiForKeepAlive: {} as IPolkadotApi, + currency: { + symbol: 'ACA' + }, + amount: '100', + address: 'some-address', + destination: undefined + } as TSendOptions + + beforeEach(() => { + vi.clearAllMocks() + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + consoleWarnSpy.mockRestore() + }) + + it('should warn when currency has multilocation', async () => { + const modifiedOptions = { + ...options, + currency: { + multilocation: {} + } as unknown as TCurrencyInput + } + + await performKeepAliveCheck(modifiedOptions, null) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Keep alive check is not supported when using MultiLocation as currency.' + ) + expect(checkKeepAlive).not.toHaveBeenCalled() + }) + + it('should warn when currency has multiasset', async () => { + const modifiedOptions = { + ...options, + currency: { + multiasset: {} + } as unknown as TCurrencyInput + } + + await performKeepAliveCheck(modifiedOptions, null) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Keep alive check is not supported when using MultiLocation as currency.' + ) + expect(checkKeepAlive).not.toHaveBeenCalled() + }) + + it('should warn when address is an object', async () => { + const modifiedOptions = { + ...options, + address: {} as TAddress + } + + await performKeepAliveCheck(modifiedOptions, null) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Keep alive check is not supported when using MultiLocation as address.' + ) + expect(checkKeepAlive).not.toHaveBeenCalled() + }) + + it('should warn when destination is an object', async () => { + const modifiedOptions = { + ...options, + destination: {} as TDestination + } + + await performKeepAliveCheck(modifiedOptions, null) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Keep alive check is not supported when using MultiLocation as destination.' + ) + expect(checkKeepAlive).not.toHaveBeenCalled() + }) + + it('should warn when destination is Ethereum', async () => { + const modifiedOptions = { + ...options, + destination: 'Ethereum' as TDestination + } + + await performKeepAliveCheck(modifiedOptions, null) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Keep alive check is not supported when using Ethereum as origin or destination.' + ) + expect(checkKeepAlive).not.toHaveBeenCalled() + }) + + it('should warn when asset is null', async () => { + const asset: TAsset | null = null + + await performKeepAliveCheck(options, asset) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Keep alive check is not supported when asset check is disabled.' + ) + expect(checkKeepAlive).not.toHaveBeenCalled() + }) + + it('should call checkKeepAlive when all conditions are met', async () => { + const asset: TAsset | null = {} as TAsset + + await performKeepAliveCheck(options, asset) + + expect(consoleWarnSpy).not.toHaveBeenCalled() + expect(checkKeepAlive).toHaveBeenCalledWith({ + originApi: options.api, + address: options.address, + amount: options.amount, + originNode: options.origin, + destApi: options.destApiForKeepAlive, + asset: asset, + destNode: options.destination + }) + }) + + it('should handle undefined amount by passing empty string', async () => { + const modifiedOptions = { + ...options, + amount: null + } + const asset: TAsset | null = {} as TAsset + + await performKeepAliveCheck(modifiedOptions, asset) + + expect(checkKeepAlive).toHaveBeenCalledWith({ + originApi: options.api, + address: options.address, + amount: '', + originNode: options.origin, + destApi: options.destApiForKeepAlive, + asset: asset, + destNode: options.destination + }) + }) +}) diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/performKeepAliveCheck.ts b/packages/sdk/src/pallets/xcmPallet/transfer/performKeepAliveCheck.ts new file mode 100644 index 00000000..e04f6aee --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/performKeepAliveCheck.ts @@ -0,0 +1,39 @@ +import type { TAsset, TSendOptions } from '../../../types' +import { checkKeepAlive } from '../keepAlive' + +export const performKeepAliveCheck = async ( + { + api, + origin, + destApiForKeepAlive, + amount, + currency, + address, + destination + }: TSendOptions, + asset: TAsset | null +) => { + const amountStr = amount?.toString() + + if ('multilocation' in currency || 'multiasset' in currency) { + console.warn('Keep alive check is not supported when using MultiLocation as currency.') + } else if (typeof address === 'object') { + console.warn('Keep alive check is not supported when using MultiLocation as address.') + } else if (typeof destination === 'object') { + console.warn('Keep alive check is not supported when using MultiLocation as destination.') + } else if (destination === 'Ethereum') { + console.warn('Keep alive check is not supported when using Ethereum as origin or destination.') + } else if (!asset) { + console.warn('Keep alive check is not supported when asset check is disabled.') + } else { + await checkKeepAlive({ + originApi: api, + address, + amount: amountStr ?? '', + originNode: origin, + destApi: destApiForKeepAlive, + asset, + destNode: destination + }) + } +} diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/resolveAsset.test.ts b/packages/sdk/src/pallets/xcmPallet/transfer/resolveAsset.test.ts new file mode 100644 index 00000000..df613881 --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/resolveAsset.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { getAssetBySymbolOrId } from '../../assets/getAssetBySymbolOrId' +import { determineRelayChain } from '../../../utils' +import { isTMultiLocation } from '../utils' +import type { TAsset, TCurrencyInput, TDestination, TNodePolkadotKusama } from '../../../types' +import { resolveAsset } from './resolveAsset' + +vi.mock('../../assets/getAssetBySymbolOrId', () => ({ + getAssetBySymbolOrId: vi.fn() +})) + +vi.mock('../../../utils', () => ({ + determineRelayChain: vi.fn() +})) + +vi.mock('../utils', () => ({ + isTMultiLocation: vi.fn() +})) + +describe('resolveAsset', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return null when assetCheckEnabled is false', () => { + const currency = {} as TCurrencyInput + const origin = 'Acala' as TNodePolkadotKusama + const destination: TDestination | undefined = 'Astar' + const assetCheckEnabled = false + + const result = resolveAsset(currency, origin, destination, assetCheckEnabled) + + expect(result).toBeNull() + expect(getAssetBySymbolOrId).not.toHaveBeenCalled() + }) + + it('should call getAssetBySymbolOrId with determineRelayChain(origin) when destination is undefined', () => { + const currency = {} as TCurrencyInput + const origin = 'Acala' as TNodePolkadotKusama + const destination: TDestination | undefined = undefined + const assetCheckEnabled = true + const asset = {} as TAsset + + vi.mocked(determineRelayChain).mockReturnValue('Polkadot') + vi.mocked(getAssetBySymbolOrId).mockReturnValue(asset) + + const result = resolveAsset(currency, origin, destination, assetCheckEnabled) + + expect(determineRelayChain).toHaveBeenCalledWith(origin) + expect(isTMultiLocation).not.toHaveBeenCalled() + expect(getAssetBySymbolOrId).toHaveBeenCalledWith(origin, currency, 'Polkadot') + expect(result).toBe(asset) + }) + + it('should call getAssetBySymbolOrId with destination when destination is defined and !isTMultiLocation(destination) is true', () => { + const currency = {} as TCurrencyInput + const origin = 'Acala' as TNodePolkadotKusama + const destination: TDestination | undefined = 'Astar' + const assetCheckEnabled = true + const asset = {} as TAsset + + vi.mocked(isTMultiLocation).mockReturnValue(false) + vi.mocked(getAssetBySymbolOrId).mockReturnValue(asset) + + const result = resolveAsset(currency, origin, destination, assetCheckEnabled) + + expect(isTMultiLocation).toHaveBeenCalledWith(destination) + expect(determineRelayChain).not.toHaveBeenCalled() + expect(getAssetBySymbolOrId).toHaveBeenCalledWith(origin, currency, destination) + expect(result).toBe(asset) + }) + + it('should call getAssetBySymbolOrId with null when destination is defined and !isTMultiLocation(destination) is false', () => { + const currency = {} as TCurrencyInput + const origin = 'Acala' as TNodePolkadotKusama + const destination: TDestination | undefined = 'Astar' + const assetCheckEnabled = true + + const asset = {} as TAsset + + vi.mocked(isTMultiLocation).mockReturnValue(true) + vi.mocked(getAssetBySymbolOrId).mockReturnValue(asset) + + const result = resolveAsset(currency, origin, destination, assetCheckEnabled) + + expect(isTMultiLocation).toHaveBeenCalledWith(destination) + expect(determineRelayChain).not.toHaveBeenCalled() + expect(getAssetBySymbolOrId).toHaveBeenCalledWith(origin, currency, null) + expect(result).toBe(asset) + }) +}) diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/resolveAsset.ts b/packages/sdk/src/pallets/xcmPallet/transfer/resolveAsset.ts new file mode 100644 index 00000000..9f6b08c1 --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/resolveAsset.ts @@ -0,0 +1,24 @@ +import type { TCurrencyInput, TDestination, TNodePolkadotKusama } from '../../../types' +import { getAssetBySymbolOrId } from '../../assets/getAssetBySymbolOrId' +import { determineRelayChain } from '../../../utils' +import { isTMultiLocation } from '../utils' + +export const resolveAsset = ( + currency: TCurrencyInput, + origin: TNodePolkadotKusama, + destination: TDestination | undefined, + assetCheckEnabled: boolean +) => { + const isRelayDestination = destination === undefined + return assetCheckEnabled + ? getAssetBySymbolOrId( + origin, + currency, + isRelayDestination + ? determineRelayChain(origin) + : !isTMultiLocation(destination) + ? destination + : null + ) + : null +} diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/transfer.test.ts b/packages/sdk/src/pallets/xcmPallet/transfer/transfer.test.ts new file mode 100644 index 00000000..5702f119 --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/transfer.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { IPolkadotApi } from '../../../api' +import type { TCurrencyInput, TMultiLocationValueWithOverride, TSendOptions } from '../../../pjs' +import { type Extrinsic, type TPjsApi } from '../../../pjs' +import type ParachainNode from '../../../nodes/ParachainNode' +import { isBridgeTransfer } from './isBridgeTransfer' +import { determineAssetCheckEnabled } from './determineAssetCheckEnabled' +import { resolveAsset } from './resolveAsset' +import { isOverrideMultiLocationSpecifier } from '../../../utils/multiLocation/isOverrideMultiLocationSpecifier' +import { send } from './transfer' +import { + validateAssetSpecifiers, + validateAssetSupport, + validateCurrency, + validateDestination +} from './validationUtils' +import { validateDestinationAddress } from './validateDestinationAddress' +import { performKeepAliveCheck } from './performKeepAliveCheck' +import { getNode } from '../../../utils' +import { isPjsClient } from '../../../utils/isPjsClient' + +vi.mock('../../../utils', () => ({ + getNode: vi.fn(), + isPjsClient: vi.fn() +})) + +vi.mock('../../../utils/isPjsClient', () => ({ + isPjsClient: vi.fn() +})) + +vi.mock('../../../utils/multiLocation/isOverrideMultiLocationSpecifier', () => ({ + isOverrideMultiLocationSpecifier: vi.fn() +})) + +vi.mock('./validateDestinationAddress', () => ({ + validateDestinationAddress: vi.fn() +})) + +vi.mock('./determineAssetCheckEnabled', () => ({ + determineAssetCheckEnabled: vi.fn() +})) + +vi.mock('./isBridgeTransfer', () => ({ + isBridgeTransfer: vi.fn() +})) + +vi.mock('./performKeepAliveCheck', () => ({ + performKeepAliveCheck: vi.fn() +})) + +vi.mock('./resolveAsset', () => ({ + resolveAsset: vi.fn() +})) + +vi.mock('./validationUtils', () => ({ + validateCurrency: vi.fn(), + validateDestination: vi.fn(), + validateAssetSpecifiers: vi.fn(), + validateAssetSupport: vi.fn() +})) + +describe('send', () => { + let apiMock: IPolkadotApi + let originNodeMock: ParachainNode + + beforeEach(() => { + vi.clearAllMocks() + + apiMock = { + init: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined) + } as unknown as IPolkadotApi + + originNodeMock = { + transfer: vi.fn().mockResolvedValue('transferResult') + } as unknown as ParachainNode + + vi.mocked(getNode).mockReturnValue(originNodeMock) + + vi.mocked(isPjsClient).mockReturnValue(true) + + vi.mocked(isBridgeTransfer).mockReturnValue(false) + vi.mocked(determineAssetCheckEnabled).mockReturnValue(true) + vi.mocked(resolveAsset).mockReturnValue({ symbol: 'TEST' }) + vi.mocked(isOverrideMultiLocationSpecifier).mockReturnValue(false) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should perform the send operation successfully', async () => { + const options = { + api: apiMock, + origin: 'Acala', + currency: { symbol: 'TEST' }, + amount: 100, + address: 'some-address', + destination: 'Astar' + } as TSendOptions + const transferSpy = vi.spyOn(originNodeMock, 'transfer') + const apiSpy = vi.spyOn(apiMock, 'init') + const apiDisconnectSpy = vi.spyOn(apiMock, 'disconnect') + + const result = await send(options) + + expect(validateCurrency).toHaveBeenCalledWith(options.currency, options.amount, undefined) + expect(validateDestination).toHaveBeenCalledWith(options.origin, options.destination) + expect(validateDestinationAddress).toHaveBeenCalledWith(options.address, options.destination) + expect(validateAssetSpecifiers).toHaveBeenCalledWith(true, options.currency) + expect(validateAssetSupport).toHaveBeenCalledWith(options, true, false, { symbol: 'TEST' }) + + expect(apiSpy).toHaveBeenCalledWith(options.origin) + + expect(performKeepAliveCheck).toHaveBeenCalledWith(options, { symbol: 'TEST' }) + + expect(transferSpy).toHaveBeenCalledWith({ + api: apiMock, + asset: { symbol: 'TEST' }, + amount: '100', + address: options.address, + destination: options.destination, + paraIdTo: options.paraIdTo, + overridedCurrencyMultiLocation: undefined, + feeAsset: undefined, + version: options.version, + destApiForKeepAlive: options.destApiForKeepAlive, + ahAddress: options.ahAddress + }) + + expect(isPjsClient).toHaveBeenCalledWith(apiMock) + + expect(apiDisconnectSpy).toHaveBeenCalled() + + expect(result).toBe('transferResult') + }) + + it('should handle when assetCheckEnabled is false', async () => { + vi.mocked(determineAssetCheckEnabled).mockReturnValue(false) + vi.mocked(resolveAsset).mockReturnValue(null) + + const options = { + api: apiMock, + origin: 'Acala', + currency: { symbol: 'TEST' }, + amount: 100, + address: 'some-address', + destination: 'Astar' + } as TSendOptions + + const transferSpy = vi.spyOn(originNodeMock, 'transfer') + + const result = await send(options) + + expect(validateAssetSpecifiers).toHaveBeenCalledWith(false, options.currency) + expect(validateAssetSupport).toHaveBeenCalledWith(options, false, false, null) + + expect(performKeepAliveCheck).toHaveBeenCalledWith(options, null) + + expect(transferSpy).toHaveBeenCalledWith( + expect.objectContaining({ + asset: { symbol: 'TEST' } + }) + ) + + expect(result).toBe('transferResult') + }) + + it('should not disconnect if api is not a PjsClient', async () => { + vi.mocked(isPjsClient).mockReturnValue(false) + + const options = { + api: apiMock, + origin: 'Acala', + currency: { symbol: 'TEST' }, + amount: 100, + address: 'some-address', + destination: 'Astar' + } as TSendOptions + + const result = await send(options) + + expect(isPjsClient).toHaveBeenCalledWith(apiMock) + + const apiSpy = vi.spyOn(apiMock, 'init') + expect(apiSpy).not.toHaveBeenCalled() + + expect(result).toBe('transferResult') + }) + + it('should handle exceptions and still disconnect if api is a PjsClient', async () => { + apiMock.init = vi.fn().mockRejectedValue(new Error('Initialization Error')) + + const options = { + api: apiMock, + origin: 'Acala', + currency: { symbol: 'TEST' }, + amount: 100, + address: 'some-address', + destination: 'Astar' + } as TSendOptions + + const apiSpy = vi.spyOn(apiMock, 'init') + + await expect(send(options)).rejects.toThrow('Initialization Error') + + expect(apiSpy).toHaveBeenCalled() + }) + + it('should throw validation errors', async () => { + vi.mocked(validateCurrency).mockImplementation(() => { + throw new Error('Invalid currency') + }) + + const options = { + api: apiMock, + origin: 'Acala', + currency: {}, + amount: 100, + address: 'some-address', + destination: 'Astar' + } as TSendOptions + + await expect(send(options)).rejects.toThrow('Invalid currency') + + const apiSpy = vi.spyOn(apiMock, 'init') + expect(apiSpy).not.toHaveBeenCalled() + }) + + it('should handle overridedCurrencyMultiLocation when multilocation is present and isOverrideMultiLocationSpecifier returns true', async () => { + vi.mocked(isOverrideMultiLocationSpecifier).mockReturnValue(true) + + const currency = { multilocation: { type: 'Override', value: {} } } + + const options = { + api: apiMock, + origin: 'Acala', + currency: currency as { + multilocation: TMultiLocationValueWithOverride + }, + amount: 100, + address: 'some-address', + destination: 'Astar' + } as TSendOptions + + const transferSpy = vi.spyOn(originNodeMock, 'transfer') + + const result = await send(options) + + expect(isOverrideMultiLocationSpecifier).toHaveBeenCalledWith(currency.multilocation) + + expect(transferSpy).toHaveBeenCalledWith( + expect.objectContaining({ + overridedCurrencyMultiLocation: {} + }) + ) + + expect(result).toBe('transferResult') + }) + + it('should handle overridedCurrencyMultiLocation when multiasset is present', async () => { + const options = { + api: apiMock, + origin: 'Acala', + currency: { multiasset: [] } as TCurrencyInput, + amount: 100, + address: 'some-address', + destination: 'Astar' + } as TSendOptions + + const transferSpy = vi.spyOn(originNodeMock, 'transfer') + + const result = await send(options) + + expect(transferSpy).toHaveBeenCalledWith( + expect.objectContaining({ + overridedCurrencyMultiLocation: [] + }) + ) + + expect(result).toBe('transferResult') + }) + + it('should use amount as empty string if amount is undefined', async () => { + const options = { + api: apiMock, + origin: 'Acala', + currency: { symbol: 'TEST' }, + amount: null, + address: 'some-address', + destination: 'Astar' + } as TSendOptions + + const transferSpy = vi.spyOn(originNodeMock, 'transfer') + + const result = await send(options) + + expect(transferSpy).toHaveBeenCalledWith( + expect.objectContaining({ + amount: '' + }) + ) + + expect(result).toBe('transferResult') + }) + + it('should not include optional parameters if they are undefined', async () => { + const options = { + api: apiMock, + origin: 'Acala', + currency: { symbol: 'TEST' }, + amount: 100, + address: 'some-address', + destination: 'Astar', + paraIdTo: undefined, + feeAsset: undefined, + version: undefined, + destApiForKeepAlive: undefined, + ahAddress: undefined + } as unknown as TSendOptions + + const transferSpy = vi.spyOn(originNodeMock, 'transfer') + + const result = await send(options) + + expect(transferSpy).toHaveBeenCalledWith( + expect.objectContaining({ + paraIdTo: undefined, + feeAsset: undefined, + version: undefined, + destApiForKeepAlive: undefined, + ahAddress: undefined + }) + ) + + expect(result).toBe('transferResult') + }) +}) diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/transfer.ts b/packages/sdk/src/pallets/xcmPallet/transfer/transfer.ts new file mode 100644 index 00000000..13619a94 --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/transfer.ts @@ -0,0 +1,83 @@ +// Contains basic call formatting for different XCM Palletss + +import type { TNativeAsset, TSendOptions } from '../../../types' +import { getNode } from '../../../utils' +import { isPjsClient } from '../../../utils/isPjsClient' +import { isOverrideMultiLocationSpecifier } from '../../../utils/multiLocation/isOverrideMultiLocationSpecifier' +import { validateDestinationAddress } from './validateDestinationAddress' +import { determineAssetCheckEnabled } from './determineAssetCheckEnabled' +import { isBridgeTransfer } from './isBridgeTransfer' +import { performKeepAliveCheck } from './performKeepAliveCheck' +import { resolveAsset } from './resolveAsset' +import { + validateCurrency, + validateDestination, + validateAssetSpecifiers, + validateAssetSupport +} from './validationUtils' + +export const send = async (options: TSendOptions): Promise => { + const { + api, + origin, + currency, + amount, + address, + destination, + paraIdTo, + destApiForKeepAlive, + feeAsset, + version, + ahAddress + } = options + + validateCurrency(currency, amount, feeAsset) + validateDestination(origin, destination) + validateDestinationAddress(address, destination) + + const originNode = getNode(origin) + + const isBridge = isBridgeTransfer(origin, destination) + + const assetCheckEnabled = determineAssetCheckEnabled(origin, currency, isBridge) + + validateAssetSpecifiers(assetCheckEnabled, currency) + const asset = resolveAsset(currency, origin, destination, assetCheckEnabled) + validateAssetSupport(options, assetCheckEnabled, isBridge, asset) + + await api.init(origin) + + try { + await performKeepAliveCheck(options, asset) + + // In case asset check is disabled, we create asset object from currency symbol + const resolvedAsset = + asset ?? + ({ + symbol: 'symbol' in currency ? currency.symbol : undefined + } as TNativeAsset) + + return await originNode.transfer({ + api, + asset: resolvedAsset, + amount: amount?.toString() ?? '', + address, + destination, + paraIdTo, + overridedCurrencyMultiLocation: + 'multilocation' in currency && isOverrideMultiLocationSpecifier(currency.multilocation) + ? currency.multilocation.value + : 'multiasset' in currency + ? currency.multiasset + : undefined, + feeAsset, + version, + destApiForKeepAlive, + ahAddress + }) + } finally { + if (isPjsClient(api)) { + await api.disconnect() + } + } +} diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/transferRelayToPara.test.ts b/packages/sdk/src/pallets/xcmPallet/transfer/transferRelayToPara.test.ts new file mode 100644 index 00000000..20e58a86 --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/transferRelayToPara.test.ts @@ -0,0 +1,287 @@ +import type { MockInstance } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { determineRelayChain, getNode } from '../../../utils' +import { isPjsClient } from '../../../utils/isPjsClient' +import { getRelayChainSymbol } from '../../assets' +import { checkKeepAlive } from '../keepAlive' +import { resolveTNodeFromMultiLocation } from '../utils' +import type { IPolkadotApi } from '../../../api' +import { Version, type Extrinsic, type TPjsApi, type TRelayToParaOptions } from '../../../pjs' +import type ParachainNode from '../../../nodes/ParachainNode' +import { transferRelayToPara } from './transferRelayToPara' + +vi.mock('../../../utils', () => ({ + determineRelayChain: vi.fn(), + getNode: vi.fn() +})) + +vi.mock('../../../utils/isPjsClient', () => ({ + isPjsClient: vi.fn() +})) + +vi.mock('../../assets', () => ({ + getRelayChainSymbol: vi.fn() +})) + +vi.mock('../keepAlive', () => ({ + checkKeepAlive: vi.fn() +})) + +vi.mock('../utils', () => ({ + resolveTNodeFromMultiLocation: vi.fn() +})) + +describe('transferRelayToPara', () => { + let apiMock: IPolkadotApi + let nodeMock: ParachainNode + let consoleWarnSpy: MockInstance + + beforeEach(() => { + vi.clearAllMocks() + apiMock = { + init: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + callTxMethod: vi.fn().mockResolvedValue('callTxResult'), + getApiOrUrl: vi.fn().mockReturnValue({}) + } as unknown as IPolkadotApi + + nodeMock = { + transferRelayToPara: vi.fn().mockReturnValue('serializedApiCall') + } as unknown as ParachainNode + + vi.mocked(getNode).mockReturnValue(nodeMock) + vi.mocked(isPjsClient).mockReturnValue(true) + vi.mocked(determineRelayChain).mockReturnValue('Polkadot') + vi.mocked(getRelayChainSymbol).mockReturnValue('DOT') + vi.mocked(resolveTNodeFromMultiLocation).mockReturnValue('Acala') + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + consoleWarnSpy.mockRestore() + }) + + it('should throw an error when api is undefined and destination is MultiLocation', async () => { + const options = { + api: apiMock, + destination: {}, + amount: 100, + address: 'some-address' + } as TRelayToParaOptions + + vi.spyOn(apiMock, 'getApiOrUrl').mockReturnValue(undefined) + const spy = vi.spyOn(apiMock, 'init') + + await expect(transferRelayToPara(options)).rejects.toThrow( + 'API is required when using MultiLocation as destination.' + ) + + expect(spy).not.toHaveBeenCalled() + }) + + it('should initialize api with the correct relay chain', async () => { + const options = { + api: apiMock, + destination: 'Astar', + amount: 100, + address: 'some-address' + } as TRelayToParaOptions + + const spy = vi.spyOn(apiMock, 'init') + + await transferRelayToPara(options) + + expect(determineRelayChain).toHaveBeenCalledWith(options.destination) + expect(spy).toHaveBeenCalledWith('Polkadot') + }) + + it('should log a warning and not call checkKeepAlive when destination is MultiLocation', async () => { + const options = { + api: apiMock, + destination: {}, + amount: 100, + address: 'some-address' + } as TRelayToParaOptions + + await transferRelayToPara(options) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Keep alive check is not supported when using MultiLocation as destination.' + ) + expect(checkKeepAlive).not.toHaveBeenCalled() + }) + + it('should log a warning and not call checkKeepAlive when address is MultiLocation', async () => { + const options = { + api: apiMock, + destination: 'Astar', + amount: 100, + address: {} + } as TRelayToParaOptions + + await transferRelayToPara(options) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Keep alive check is not supported when using MultiLocation as address.' + ) + expect(checkKeepAlive).not.toHaveBeenCalled() + }) + + it('should call checkKeepAlive when destination and address are not MultiLocation', async () => { + const options = { + api: apiMock, + destination: 'Astar', + amount: 100, + address: 'some-address' + } as TRelayToParaOptions + + await transferRelayToPara(options) + + expect(getRelayChainSymbol).toHaveBeenCalledWith(options.destination) + expect(checkKeepAlive).toHaveBeenCalledWith({ + originApi: apiMock, + address: options.address, + amount: '100', + destApi: options.destApiForKeepAlive, + asset: { symbol: 'DOT' }, + destNode: options.destination + }) + }) + + it('should get the serialized api call correctly when destination is MultiLocation', async () => { + const options = { + api: apiMock, + destination: {}, + amount: 100, + address: 'some-address' + } as TRelayToParaOptions + + const transferSpy = vi.spyOn(nodeMock, 'transferRelayToPara') + const apiSpy = vi.spyOn(apiMock, 'callTxMethod') + + await transferRelayToPara(options) + + expect(resolveTNodeFromMultiLocation).toHaveBeenCalledWith({}) + expect(getNode).toHaveBeenCalledWith('Acala') + expect(transferSpy).toHaveBeenCalledWith({ + api: apiMock, + destination: {}, + address: 'some-address', + amount: '100', + paraIdTo: undefined, + destApiForKeepAlive: undefined, + version: undefined + }) + expect(apiSpy).toHaveBeenCalledWith('serializedApiCall') + }) + + it('should get the serialized api call correctly when destination is not MultiLocation', async () => { + const options = { + api: apiMock, + destination: 'Astar', + amount: 100, + address: 'some-address' + } as TRelayToParaOptions + + const transferSpy = vi.spyOn(nodeMock, 'transferRelayToPara') + const apiSpy = vi.spyOn(apiMock, 'callTxMethod') + + await transferRelayToPara(options) + + expect(getNode).toHaveBeenCalledWith(options.destination) + expect(transferSpy).toHaveBeenCalledWith({ + api: apiMock, + destination: options.destination, + address: options.address, + amount: '100', + paraIdTo: undefined, + destApiForKeepAlive: undefined, + version: undefined + }) + expect(apiSpy).toHaveBeenCalledWith('serializedApiCall') + }) + + it('should disconnect api if isPjsClient returns true', async () => { + vi.mocked(isPjsClient).mockReturnValue(true) + const options = { + api: apiMock, + destination: 'Astar', + amount: 100, + address: 'some-address' + } as TRelayToParaOptions + + const apiSpy = vi.spyOn(apiMock, 'disconnect') + + await transferRelayToPara(options) + + expect(isPjsClient).toHaveBeenCalledWith(apiMock) + expect(apiSpy).toHaveBeenCalled() + }) + + it('should not disconnect api if isPjsClient returns false', async () => { + vi.mocked(isPjsClient).mockReturnValue(false) + const options = { + api: apiMock, + destination: 'Astar', + amount: 100, + address: 'some-address' + } as TRelayToParaOptions + + const spy = vi.spyOn(apiMock, 'disconnect') + + await transferRelayToPara(options) + + expect(isPjsClient).toHaveBeenCalledWith(apiMock) + expect(spy).not.toHaveBeenCalled() + }) + + it('should handle exceptions and still disconnect api if isPjsClient returns true', async () => { + vi.mocked(isPjsClient).mockReturnValue(true) + apiMock.callTxMethod = vi.fn().mockRejectedValue(new Error('Some error')) + + const options = { + api: apiMock, + destination: 'Astar', + amount: 100, + address: 'some-address' + } as TRelayToParaOptions + + const apiSpy = vi.spyOn(apiMock, 'disconnect') + + await expect(transferRelayToPara(options)).rejects.toThrow('Some error') + + expect(apiSpy).toHaveBeenCalled() + }) + + it('should pass optional parameters when provided', async () => { + const destApiMock = { + init: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + callTxMethod: vi.fn().mockResolvedValue('callTxResult') + } as unknown as IPolkadotApi + + const options = { + api: apiMock, + destination: 'Astar', + amount: 100, + address: 'some-address', + paraIdTo: 2000, + destApiForKeepAlive: destApiMock, + version: Version.V3 + } as TRelayToParaOptions + + const transferSpy = vi.spyOn(nodeMock, 'transferRelayToPara') + + await transferRelayToPara(options) + + expect(transferSpy).toHaveBeenCalledWith({ + api: apiMock, + destination: options.destination, + address: options.address, + amount: '100', + paraIdTo: 2000, + destApiForKeepAlive: destApiMock, + version: options.version + }) + }) +}) diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/transferRelayToPara.ts b/packages/sdk/src/pallets/xcmPallet/transfer/transferRelayToPara.ts new file mode 100644 index 00000000..73c63a0e --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/transferRelayToPara.ts @@ -0,0 +1,57 @@ +import type { TNode, TRelayToParaOptions } from '../../../types' +import { determineRelayChain, getNode } from '../../../utils' +import { isPjsClient } from '../../../utils/isPjsClient' +import { getRelayChainSymbol } from '../../assets' +import { checkKeepAlive } from '../keepAlive' +import { resolveTNodeFromMultiLocation } from '../utils' + +export const transferRelayToPara = async ( + options: TRelayToParaOptions +): Promise => { + const { api, destination, amount, address, paraIdTo, destApiForKeepAlive, version } = options + const isMultiLocationDestination = typeof destination === 'object' + const isAddressMultiLocation = typeof address === 'object' + + if (api.getApiOrUrl() === undefined && isMultiLocationDestination) { + throw new Error('API is required when using MultiLocation as destination.') + } + + await api.init(determineRelayChain(destination as TNode)) + + try { + const amountStr = amount.toString() + + if (isMultiLocationDestination) { + console.warn('Keep alive check is not supported when using MultiLocation as destination.') + } else if (isAddressMultiLocation) { + console.warn('Keep alive check is not supported when using MultiLocation as address.') + } else { + await checkKeepAlive({ + originApi: api, + address, + amount: amountStr, + destApi: destApiForKeepAlive, + asset: { symbol: getRelayChainSymbol(destination) }, + destNode: destination + }) + } + + const serializedApiCall = getNode( + isMultiLocationDestination ? resolveTNodeFromMultiLocation(destination) : destination + ).transferRelayToPara({ + api, + destination, + address, + amount: amountStr, + paraIdTo, + destApiForKeepAlive, + version + }) + + return api.callTxMethod(serializedApiCall) + } finally { + if (isPjsClient(api)) { + await api.disconnect() + } + } +} diff --git a/packages/sdk/src/pallets/xcmPallet/validateDestinationAddress.test.ts b/packages/sdk/src/pallets/xcmPallet/transfer/validateDestinationAddress.test.ts similarity index 95% rename from packages/sdk/src/pallets/xcmPallet/validateDestinationAddress.test.ts rename to packages/sdk/src/pallets/xcmPallet/transfer/validateDestinationAddress.test.ts index 62522fc5..c7b437f1 100644 --- a/packages/sdk/src/pallets/xcmPallet/validateDestinationAddress.test.ts +++ b/packages/sdk/src/pallets/xcmPallet/transfer/validateDestinationAddress.test.ts @@ -1,14 +1,14 @@ import { describe, it, expect, vi, afterEach } from 'vitest' import { validateDestinationAddress } from './validateDestinationAddress' import { ethers } from 'ethers' -import { isNodeEvm } from '../assets' -import { InvalidAddressError } from '../../errors' -import { isTMultiLocation } from './utils' -import type { TAddress, TDestination } from '../../types' +import { isNodeEvm } from '../../assets' +import { InvalidAddressError } from '../../../errors' +import { isTMultiLocation } from './../utils' +import type { TAddress, TDestination } from '../../../types' vi.mock('ethers') -vi.mock('../assets') -vi.mock('./utils') +vi.mock('../../assets') +vi.mock('../utils') describe('validateDestinationAddress', () => { const mockedIsAddress = vi.mocked(ethers.isAddress) diff --git a/packages/sdk/src/pallets/xcmPallet/validateDestinationAddress.ts b/packages/sdk/src/pallets/xcmPallet/transfer/validateDestinationAddress.ts similarity index 78% rename from packages/sdk/src/pallets/xcmPallet/validateDestinationAddress.ts rename to packages/sdk/src/pallets/xcmPallet/transfer/validateDestinationAddress.ts index 8783263d..d5b50fe9 100644 --- a/packages/sdk/src/pallets/xcmPallet/validateDestinationAddress.ts +++ b/packages/sdk/src/pallets/xcmPallet/transfer/validateDestinationAddress.ts @@ -1,8 +1,8 @@ import { ethers } from 'ethers' -import type { TAddress, TDestination } from '../../types' -import { isNodeEvm } from '../assets' -import { InvalidAddressError } from '../../errors' -import { isTMultiLocation } from './utils' +import type { TAddress, TDestination } from '../../../types' +import { isNodeEvm } from '../../assets' +import { isTMultiLocation } from '../utils' +import { InvalidAddressError } from '../../../errors' export const validateDestinationAddress = ( address: TAddress, diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/validationUtils.test.ts b/packages/sdk/src/pallets/xcmPallet/transfer/validationUtils.test.ts new file mode 100644 index 00000000..89bb89d9 --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/validationUtils.test.ts @@ -0,0 +1,598 @@ +import type { MockInstance } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + validateAssetSpecifiers, + validateAssetSupport, + validateCurrency, + validateDestination +} from './validationUtils' +import type { + TAsset, + TCurrencyInput, + TDestination, + TNodePolkadotKusama, + TSendOptions +} from '../../../types' +import { IncompatibleNodesError, InvalidCurrencyError } from '../../../errors' +import { isBridgeTransfer } from './isBridgeTransfer' +import { getNativeAssets, getRelayChainSymbol, hasSupportForAsset } from '../../assets' +import { isSymbolSpecifier } from '../../../utils/assets/isSymbolSpecifier' +import { getDefaultPallet } from '../../pallets' +import type { Extrinsic, TPjsApi } from '../../../pjs' +import { throwUnsupportedCurrency } from '../utils' + +vi.mock('./isBridgeTransfer', () => ({ + isBridgeTransfer: vi.fn() +})) + +vi.mock('../../../utils/assets/isSymbolSpecifier', () => ({ + isSymbolSpecifier: vi.fn() +})) + +vi.mock('../../pallets', () => ({ + getDefaultPallet: vi.fn() +})) + +vi.mock('../../assets', () => ({ + getRelayChainSymbol: vi.fn(), + getNativeAssets: vi.fn(), + hasSupportForAsset: vi.fn() +})) + +vi.mock('../utils', () => ({ + throwUnsupportedCurrency: vi.fn() +})) + +describe('validateCurrency', () => { + let consoleWarnSpy: MockInstance + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + consoleWarnSpy.mockRestore() + }) + + it('should throw "Amount is required" when amount is null and currency does not have multiasset or has multilocation', () => { + const currency = {} as TCurrencyInput + const amount = null + const feeAsset = undefined + + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow('Amount is required') + }) + + it('should not throw when amount is provided and currency does not have multiasset', () => { + const currency = {} as TCurrencyInput + const amount = 100 + const feeAsset = undefined + + expect(() => validateCurrency(currency, amount, feeAsset)).not.toThrow() + }) + + it('should throw "Amount is required" when amount is null and currency has multilocation', () => { + const currency = { multilocation: {} } as TCurrencyInput + const amount = null + const feeAsset = undefined + + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow('Amount is required') + }) + + it('should warn when amount is not null and currency has multiasset', () => { + const currency = { multiasset: [{}] } as TCurrencyInput + const amount = 100 + const feeAsset = undefined + + validateCurrency(currency, amount, feeAsset) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Amount is ignored when using overriding currency using multiple multi locations. Please set it to null.' + ) + }) + + it('should throw InvalidCurrencyError when currency.multiasset is empty', () => { + const currency = { multiasset: [] } as TCurrencyInput + const amount = null + const feeAsset = undefined + + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow(InvalidCurrencyError) + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow( + 'Overrided multi assets cannot be empty' + ) + }) + + it('should throw InvalidCurrencyError when currency.multiasset has length 1 and feeAsset is 0', () => { + const currency = { multiasset: [{}] } as TCurrencyInput + const amount = null + const feeAsset = 0 + + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow(InvalidCurrencyError) + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow( + 'Overrided single multi asset cannot be used with fee asset' + ) + }) + + it('should throw InvalidCurrencyError when currency.multiasset has length 1 and feeAsset is defined', () => { + const currency = { multiasset: [{}] } as TCurrencyInput + const amount = null + const feeAsset = 'someFeeAsset' + + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow(InvalidCurrencyError) + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow( + 'Overrided single multi asset cannot be used with fee asset' + ) + }) + + it('should throw InvalidCurrencyError when currency.multiasset has length >1 and feeAsset is undefined', () => { + const currency = { multiasset: [{}, {}] } as TCurrencyInput + const amount = null + const feeAsset = undefined + + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow(InvalidCurrencyError) + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow( + 'Overrided multi assets cannot be used without specifying fee asset' + ) + }) + + it('should throw InvalidCurrencyError when feeAsset index is out of bounds (negative)', () => { + const currency = { multiasset: [{}, {}] } as TCurrencyInput + const amount = null + const feeAsset = -1 + + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow(InvalidCurrencyError) + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow( + 'Fee asset index is out of bounds. Please provide a valid index.' + ) + }) + + it('should throw InvalidCurrencyError when feeAsset index is out of bounds (too large)', () => { + const currency = { multiasset: [{}, {}] } as TCurrencyInput + const amount = null + const feeAsset = 2 + + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow(InvalidCurrencyError) + expect(() => validateCurrency(currency, amount, feeAsset)).toThrow( + 'Fee asset index is out of bounds. Please provide a valid index.' + ) + }) + + it('should not throw when currency has multiasset with length >1 and valid feeAsset index', () => { + const currency = { multiasset: [{}, {}] } as TCurrencyInput + const amount = null + const feeAsset = 0 + + expect(() => validateCurrency(currency, amount, feeAsset)).not.toThrow() + }) + + it('should not throw when currency has multiasset with length 1 and feeAsset is undefined', () => { + const currency = { multiasset: [{}] } as TCurrencyInput + const amount = null + const feeAsset = undefined + + expect(() => validateCurrency(currency, amount, feeAsset)).not.toThrow() + }) + + it('should not throw when amount is null and currency has multiasset', () => { + const currency = { multiasset: [{}] } as TCurrencyInput + const amount = null + const feeAsset = undefined + + expect(() => validateCurrency(currency, amount, feeAsset)).not.toThrow() + }) +}) + +describe('validateDestination', () => { + let origin: TNodePolkadotKusama + let destination: TDestination | undefined + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should throw IncompatibleNodesError when destination is Ethereum and origin is not AssetHubPolkadot or Hydration', () => { + origin = 'Acala' + destination = 'Ethereum' + + expect(() => validateDestination(origin, destination)).toThrow(IncompatibleNodesError) + expect(() => validateDestination(origin, destination)).toThrow( + 'Transfers to Ethereum are only supported from AssetHubPolkadot and Hydration.' + ) + }) + + it('should not throw when destination is Ethereum and origin is AssetHubPolkadot', () => { + origin = 'AssetHubPolkadot' + destination = 'Ethereum' + + expect(() => validateDestination(origin, destination)).not.toThrow() + }) + + it('should not throw when destination is Ethereum and origin is Hydration', () => { + origin = 'Hydration' + destination = 'Ethereum' + + expect(() => validateDestination(origin, destination)).not.toThrow() + }) + + it('should not throw when destination is undefined (relay destination)', () => { + origin = 'AssetHubPolkadot' + destination = undefined + + expect(() => validateDestination(origin, destination)).not.toThrow() + }) + + it('should not throw when destination is a MultiLocation object', () => { + origin = 'AssetHubPolkadot' + destination = {} as TDestination + + expect(() => validateDestination(origin, destination)).not.toThrow() + }) + + it('should throw IncompatibleNodesError when relay chain symbols do not match and not a bridge transfer', () => { + origin = 'Acala' + destination = 'Astar' + + vi.mocked(isBridgeTransfer).mockReturnValue(false) + vi.mocked(getRelayChainSymbol).mockReturnValueOnce('DOT').mockReturnValueOnce('KSM') + + expect(() => validateDestination(origin, destination)).toThrow(IncompatibleNodesError) + }) + + it('should not throw when relay chain symbols match and not a bridge transfer', () => { + origin = 'Acala' + destination = 'Astar' + + vi.mocked(isBridgeTransfer).mockReturnValue(false) + vi.mocked(getRelayChainSymbol).mockReturnValueOnce('DOT').mockReturnValueOnce('DOT') + + expect(() => validateDestination(origin, destination)).not.toThrow() + }) + + it('should not throw when it is a bridge transfer regardless of relay chain symbols', () => { + origin = 'Acala' + destination = 'Astar' + + vi.mocked(isBridgeTransfer).mockReturnValue(true) + vi.mocked(getRelayChainSymbol).mockReturnValueOnce('DOT').mockReturnValueOnce('KSM') + + expect(() => validateDestination(origin, destination)).not.toThrow() + }) + + it('should not throw when destination is a MultiLocation object and other conditions are met', () => { + origin = 'Acala' + destination = {} as TDestination + + vi.mocked(isBridgeTransfer).mockReturnValue(false) + // Relay chain symbols should not be fetched in this case + + expect(() => validateDestination(origin, destination)).not.toThrow() + expect(vi.mocked(getRelayChainSymbol)).not.toHaveBeenCalled() + }) + + it('should throw IncompatibleNodesError when origin is undefined and destination is Ethereum', () => { + origin = undefined as unknown as TNodePolkadotKusama + destination = 'Ethereum' + + expect(() => validateDestination(origin, destination)).toThrow(IncompatibleNodesError) + expect(() => validateDestination(origin, destination)).toThrow( + 'Transfers to Ethereum are only supported from AssetHubPolkadot and Hydration.' + ) + }) + + it('should not throw when origin and destination relay chain symbols match even if destination is undefined', () => { + origin = 'Acala' + destination = undefined + + vi.mocked(isBridgeTransfer).mockReturnValue(false) + // Relay chain symbols are not checked when destination is undefined + + expect(() => validateDestination(origin, destination)).not.toThrow() + expect(vi.mocked(getRelayChainSymbol)).not.toHaveBeenCalled() + }) +}) + +describe('validateAssetSpecifiers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should throw InvalidCurrencyError when assetCheckEnabled is false, currency has symbol, and isSymbolSpecifier returns true', () => { + const assetCheckEnabled = false + const currency: TCurrencyInput = { symbol: 'symbol-value' } + vi.mocked(isSymbolSpecifier).mockReturnValue(true) + + expect(() => validateAssetSpecifiers(assetCheckEnabled, currency)).toThrow(InvalidCurrencyError) + expect(() => validateAssetSpecifiers(assetCheckEnabled, currency)).toThrow( + 'Symbol specifier is not supported when asset check is disabled. Please use normal symbol instead.' + ) + expect(isSymbolSpecifier).toHaveBeenCalledWith('symbol-value') + }) + + it('should throw InvalidCurrencyError when assetCheckEnabled is false, and currency has id', () => { + const assetCheckEnabled = false + const currency: TCurrencyInput = { id: 'id-value' } + + expect(() => validateAssetSpecifiers(assetCheckEnabled, currency)).toThrow(InvalidCurrencyError) + expect(() => validateAssetSpecifiers(assetCheckEnabled, currency)).toThrow( + 'Asset ID is not supported when asset check is disabled. Please use normal symbol instead' + ) + }) + + it('should not throw when assetCheckEnabled is false, currency has symbol, but isSymbolSpecifier returns false', () => { + const assetCheckEnabled = false + const currency: TCurrencyInput = { symbol: 'symbol-value' } + vi.mocked(isSymbolSpecifier).mockReturnValue(false) + + expect(() => validateAssetSpecifiers(assetCheckEnabled, currency)).not.toThrow() + expect(isSymbolSpecifier).toHaveBeenCalledWith('symbol-value') + }) + + it('should not throw when assetCheckEnabled is true, regardless of currency', () => { + const assetCheckEnabled = true + const currency1: TCurrencyInput = { symbol: 'symbol-value' } + const currency2: TCurrencyInput = { id: 'id-value' } + vi.mocked(isSymbolSpecifier).mockReturnValue(true) + + expect(() => validateAssetSpecifiers(assetCheckEnabled, currency1)).not.toThrow() + expect(() => validateAssetSpecifiers(assetCheckEnabled, currency2)).not.toThrow() + }) + + it('should not throw when assetCheckEnabled is false and currency has neither symbol nor id', () => { + const assetCheckEnabled = false + const currency = {} as TCurrencyInput + + expect(() => validateAssetSpecifiers(assetCheckEnabled, currency)).not.toThrow() + }) +}) + +describe('validateAssetSupport', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should throw InvalidCurrencyError when asset symbol matches native asset in destination', () => { + const options = { + origin: 'Acala', + destination: 'AssetHubPolkadot', + currency: { symbol: 'TEST' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = { symbol: 'TEST' } + + vi.mocked(getDefaultPallet).mockReturnValue('XTokens') + vi.mocked(getNativeAssets).mockReturnValue([{ symbol: 'TEST' }]) + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).toThrow( + InvalidCurrencyError + ) + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).toThrow( + '"TEST" is not supported for transfers to AssetHubPolkadot.' + ) + }) + + it('should not throw when isBridge is true', () => { + const options = { + origin: 'Acala', + destination: 'AssetHubPolkadot', + currency: { symbol: 'TEST' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = true + const asset = { symbol: 'TEST' } + + vi.mocked(getDefaultPallet).mockReturnValue('XTokens') + vi.mocked(getNativeAssets).mockReturnValue([{ symbol: 'TEST' }]) + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).not.toThrow() + }) + + it('should not throw when destination is not AssetHub', () => { + const options = { + origin: 'Acala', + destination: 'Astar', + currency: { symbol: 'TEST' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = { symbol: 'TEST' } + + vi.mocked(hasSupportForAsset).mockReturnValue(true) + vi.mocked(getDefaultPallet).mockReturnValue('XTokens') + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).not.toThrow() + }) + + it('should not throw when pallet is not XTokens', () => { + const options = { + origin: 'Acala', + destination: 'AssetHubPolkadot', + currency: { symbol: 'TEST' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = { symbol: 'TEST' } + + vi.mocked(hasSupportForAsset).mockReturnValue(true) + vi.mocked(getDefaultPallet).mockReturnValue('PolkadotXcm') + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).not.toThrow() + }) + + it('should not throw when origin is Bifrost', () => { + const options = { + origin: 'BifrostPolkadot', + destination: 'AssetHubPolkadot', + currency: { symbol: 'TEST' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = { symbol: 'TEST' } + + vi.mocked(hasSupportForAsset).mockReturnValue(true) + vi.mocked(getDefaultPallet).mockReturnValue('XTokens') + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).not.toThrow() + }) + + it('should filter out DOT from native assets when origin is Hydration', () => { + const options = { + origin: 'Hydration', + destination: 'AssetHubPolkadot', + currency: { symbol: 'DOT' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = { symbol: 'DOT' } + + vi.mocked(getDefaultPallet).mockReturnValue('XTokens') + vi.mocked(hasSupportForAsset).mockReturnValue(true) + vi.mocked(getNativeAssets).mockReturnValue([{ symbol: 'DOT' }, { symbol: 'KSM' }]) + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).not.toThrow() + }) + + it('should throw InvalidCurrencyError when destination does not support asset', () => { + const options = { + origin: 'Acala', + destination: 'Astar', + currency: { symbol: 'UNSUPPORTED' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = { symbol: 'UNSUPPORTED' } + + vi.mocked(hasSupportForAsset).mockReturnValue(false) + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).toThrow( + InvalidCurrencyError + ) + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).toThrow( + 'Destination node Astar does not support currency {"symbol":"UNSUPPORTED"}.' + ) + }) + + it('should not throw when destination supports asset', () => { + const options = { + origin: 'Acala', + destination: 'Astar', + currency: { symbol: 'SUPPORTED' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = { symbol: 'SUPPORTED' } + + vi.mocked(hasSupportForAsset).mockReturnValue(true) + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).not.toThrow() + }) + + it('should not throw when assetCheckEnabled is false', () => { + const options = { + origin: 'Acala', + destination: 'Astar', + currency: { symbol: 'ANY' } + } as TSendOptions + + const assetCheckEnabled = false + const isBridge = false + const asset = null + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).not.toThrow() + }) + + it('should call throwUnsupportedCurrency when asset is null and assetCheckEnabled is true', () => { + const options = { + origin: 'Acala', + destination: 'Astar', + currency: { symbol: 'UNKNOWN' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = null + + validateAssetSupport(options, assetCheckEnabled, isBridge, asset) + + expect(throwUnsupportedCurrency).toHaveBeenCalledWith(options.currency, options.origin) + }) + + it('should not call throwUnsupportedCurrency when isBridge is true', () => { + const options = { + origin: 'Astar', + destination: 'Acala', + currency: { symbol: 'UNKNOWN' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = true + const asset = null + + validateAssetSupport(options, assetCheckEnabled, isBridge, asset) + + expect(throwUnsupportedCurrency).not.toHaveBeenCalled() + }) + + it('should not throw when destination is relay (undefined)', () => { + const options = { + origin: 'Acala', + destination: undefined, + currency: { symbol: 'TEST' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = { symbol: 'TEST' } + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).not.toThrow() + }) + + it('should not throw when destination is a MultiLocation object', () => { + const options = { + origin: 'Acala', + destination: {} as TDestination, + currency: { symbol: 'TEST' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = { symbol: 'TEST' } + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).not.toThrow() + }) + + it('should not throw when asset symbol is undefined', () => { + const options = { + origin: 'Astar', + destination: 'Acala', + currency: {} + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = { symbol: undefined } as TAsset + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).not.toThrow() + }) + + it('should not throw when currency has id', () => { + const options = { + origin: 'Astar', + destination: 'Acala', + currency: { id: 'some-id' } + } as TSendOptions + + const assetCheckEnabled = true + const isBridge = false + const asset = { symbol: 'TEST' } + + expect(() => validateAssetSupport(options, assetCheckEnabled, isBridge, asset)).not.toThrow() + }) +}) diff --git a/packages/sdk/src/pallets/xcmPallet/transfer/validationUtils.ts b/packages/sdk/src/pallets/xcmPallet/transfer/validationUtils.ts new file mode 100644 index 00000000..332af1bf --- /dev/null +++ b/packages/sdk/src/pallets/xcmPallet/transfer/validationUtils.ts @@ -0,0 +1,144 @@ +import { IncompatibleNodesError, InvalidCurrencyError } from '../../../errors' +import type { + TAmount, + TAsset, + TCurrency, + TCurrencyInput, + TDestination, + TNodePolkadotKusama, + TSendOptions +} from '../../../types' +import { isSymbolSpecifier } from '../../../utils/assets/isSymbolSpecifier' +import { getNativeAssets, getRelayChainSymbol, hasSupportForAsset } from '../../assets' +import { getDefaultPallet } from '../../pallets' +import { throwUnsupportedCurrency } from '../utils' +import { isBridgeTransfer } from './isBridgeTransfer' + +export const validateCurrency = ( + currency: TCurrencyInput, + amount: TAmount | null, + feeAsset: TCurrency | undefined +) => { + if ((!('multiasset' in currency) || 'multilocation' in currency) && amount === null) { + throw new Error('Amount is required') + } + + if ('multiasset' in currency) { + if (amount !== null) { + console.warn( + 'Amount is ignored when using overriding currency using multiple multi locations. Please set it to null.' + ) + } + + if (currency.multiasset.length === 0) { + throw new InvalidCurrencyError('Overrided multi assets cannot be empty') + } + + if (currency.multiasset.length === 1 && (feeAsset === 0 || feeAsset !== undefined)) { + throw new InvalidCurrencyError('Overrided single multi asset cannot be used with fee asset') + } + + if (currency.multiasset.length > 1 && feeAsset === undefined) { + throw new InvalidCurrencyError( + 'Overrided multi assets cannot be used without specifying fee asset' + ) + } + + if ( + currency.multiasset.length > 1 && + feeAsset !== undefined && + ((feeAsset as number) < 0 || (feeAsset as number) >= currency.multiasset.length) + ) { + throw new InvalidCurrencyError( + 'Fee asset index is out of bounds. Please provide a valid index.' + ) + } + } +} + +export const validateDestination = ( + origin: TNodePolkadotKusama, + destination: TDestination | undefined +) => { + if (destination === 'Ethereum' && origin !== 'AssetHubPolkadot' && origin !== 'Hydration') { + throw new IncompatibleNodesError( + 'Transfers to Ethereum are only supported from AssetHubPolkadot and Hydration.' + ) + } + + const isMultiLocationDestination = typeof destination === 'object' + const isBridge = isBridgeTransfer(origin, destination) + const isRelayDestination = destination === undefined + + if (!isRelayDestination && !isMultiLocationDestination) { + const originRelayChainSymbol = getRelayChainSymbol(origin) + const destinationRelayChainSymbol = getRelayChainSymbol(destination) + if (!isBridge && originRelayChainSymbol !== destinationRelayChainSymbol) { + throw new IncompatibleNodesError() + } + } +} + +export const validateAssetSpecifiers = (assetCheckEnabled: boolean, currency: TCurrencyInput) => { + if (!assetCheckEnabled && 'symbol' in currency && isSymbolSpecifier(currency.symbol)) { + throw new InvalidCurrencyError( + 'Symbol specifier is not supported when asset check is disabled. Please use normal symbol instead.' + ) + } + + if (!assetCheckEnabled && 'id' in currency) { + throw new InvalidCurrencyError( + 'Asset ID is not supported when asset check is disabled. Please use normal symbol instead' + ) + } +} + +export const validateAssetSupport = ( + { origin, destination, currency }: TSendOptions, + assetCheckEnabled: boolean, + isBridge: boolean, + asset: TAsset | null +) => { + const isRelayDestination = destination === undefined + const isMultiLocationDestination = typeof destination === 'object' + const isDestAssetHub = destination === 'AssetHubPolkadot' || destination === 'AssetHubKusama' + const pallet = getDefaultPallet(origin) + const isBifrost = origin === 'BifrostPolkadot' || origin === 'BifrostKusama' + + if (!isBridge && isDestAssetHub && pallet === 'XTokens' && !isBifrost) { + let nativeAssets = getNativeAssets(destination) + + if (origin === 'Hydration') { + nativeAssets = nativeAssets.filter(nativeAsset => nativeAsset.symbol !== 'DOT') + } + + if ( + 'symbol' in currency && + nativeAssets.some( + nativeAsset => nativeAsset.symbol.toLowerCase() === asset?.symbol?.toLowerCase() + ) + ) { + throw new InvalidCurrencyError( + `${JSON.stringify(asset?.symbol)} is not supported for transfers to ${destination}.` + ) + } + } + + if ( + !isBridge && + !isRelayDestination && + !isMultiLocationDestination && + asset?.symbol !== undefined && + assetCheckEnabled && + !('id' in currency) && + !hasSupportForAsset(destination, asset.symbol) + ) { + throw new InvalidCurrencyError( + `Destination node ${destination} does not support currency ${JSON.stringify(currency)}.` + ) + } + + if (!isBridge && asset === null && assetCheckEnabled) { + throwUnsupportedCurrency(currency, origin) + } +} diff --git a/packages/sdk/src/papi/PapiApi.ts b/packages/sdk/src/papi/PapiApi.ts index d66d53cb..e0968d15 100644 --- a/packages/sdk/src/papi/PapiApi.ts +++ b/packages/sdk/src/papi/PapiApi.ts @@ -45,6 +45,10 @@ class PapiApi implements IPolkadotApi { this._api = api } + getApiOrUrl(): TPapiApiOrUrl | undefined { + return this._api + } + getApi(): TPapiApi { return this.api } diff --git a/packages/sdk/src/pjs/PolkadotJsApi.ts b/packages/sdk/src/pjs/PolkadotJsApi.ts index f988bc64..8bc804cf 100644 --- a/packages/sdk/src/pjs/PolkadotJsApi.ts +++ b/packages/sdk/src/pjs/PolkadotJsApi.ts @@ -33,6 +33,10 @@ class PolkadotJsApi implements IPolkadotApi { this._api = api } + getApiOrUrl(): TPjsApiOrUrl | undefined { + return this._api + } + getApi(): TPjsApi { return this.api }