diff --git a/.env.example b/.env.example index b6eeb41..fd90eb7 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ CONTRACT_IDENTITY="AddressOfIdentityContract" -CONTRACT_ADDRESS_BOOK="AddressOfAddressBookContract" \ No newline at end of file +CONTRACT_ADDRESS_BOOK="AddressOfAddressBookContract" +RELAY_CHAIN="kusama_or_polkadot" \ No newline at end of file diff --git a/__tests__/crossChainRouter.test.ts b/__tests__/crossChainRouter.test.ts index b0aa570..e9671e7 100644 --- a/__tests__/crossChainRouter.test.ts +++ b/__tests__/crossChainRouter.test.ts @@ -4,7 +4,6 @@ import { u8aToHex } from '@polkadot/util'; import TransactionRouter from "../src/utils/transactionRouter"; import { Fungible, Receiver, Sender } from "../src/utils/transactionRouter/types"; - import IdentityContractFactory from "../types/constructors/identity"; import IdentityContract from "../types/contracts/identity"; import { AccountType, ChainInfo } from "../types/types-arguments/identity"; @@ -144,7 +143,7 @@ describe("TransactionRouter Cross-chain reserve transfer", () => { { originApi: assetHubApi, destApi: trappistApi - } + }, ); const senderBalanceAfter = await getAssetBalance(assetHubApi, USDT_ASSET_ID, alice.address); diff --git a/next.config.js b/next.config.js index 41d7c89..e0948aa 100644 --- a/next.config.js +++ b/next.config.js @@ -7,6 +7,7 @@ const nextConfig = { env: { CONTRACT_IDENTITY: process.env.CONTRACT_IDENTITY, CONTRACT_ADDRESS_BOOK: process.env.CONTRACT_ADDRESS_BOOK, + RELAY_CHAIN: process.env.RELAY_CHAIN, }, async redirects() { return [ diff --git a/src/components/Cards/AddressCard/index.module.scss b/src/components/Cards/AddressCard/index.module.scss index c2713fc..1011f31 100644 --- a/src/components/Cards/AddressCard/index.module.scss +++ b/src/components/Cards/AddressCard/index.module.scss @@ -13,7 +13,7 @@ line-height: 1.6; } -.networkName { +.chainName { display: flex; align-items: center; justify-content: space-between; diff --git a/src/components/Modals/AddAddress/index.tsx b/src/components/Modals/AddAddress/index.tsx index 839018b..9b02029 100644 --- a/src/components/Modals/AddAddress/index.tsx +++ b/src/components/Modals/AddAddress/index.tsx @@ -91,9 +91,10 @@ export const AddAddressModal = ({ open, onClose }: AddAddressModalProps) => { onClose(); } catch (e: any) { toastError( - `Failed to add address. Error: ${e.errorMessage === 'Error' - ? 'Please check your balance.' - : e.errorMessage + `Failed to add address. Error: ${ + e.errorMessage === 'Error' + ? 'Please check your balance.' + : e.errorMessage }` ); setWorking(false); @@ -119,7 +120,7 @@ export const AddAddressModal = ({ open, onClose }: AddAddressModalProps) => { select sx={{ mt: '8px' }} required - value={chainId || ""} + value={chainId === undefined ? '' : chainId} onChange={(e) => setChainId(Number(e.target.value))} > {Object.entries(chains).map(([id, chain], index) => ( @@ -150,7 +151,7 @@ export const AddAddressModal = ({ open, onClose }: AddAddressModalProps) => { maxLength: 64, }} required - value={chainAddress || ""} + value={chainAddress || ''} error={chainAddress === ''} onChange={(e) => setChainAddress(e.target.value)} /> diff --git a/src/consts/index.ts b/src/consts/index.ts new file mode 100644 index 0000000..50f1cbe --- /dev/null +++ b/src/consts/index.ts @@ -0,0 +1,37 @@ +export type RELAY_CHAIN_OPTION = 'polkadot' | 'kusama'; +const RELAY_CHAIN_ENDPOINTS = { + polkadot: "wss://polkadot.api.onfinality.io/public-ws", + kusama: "wss://kusama.api.onfinality.io/public-ws" +}; +export const RELAY_CHAIN = (process.env.RELAY_CHAIN || 'polkadot') as RELAY_CHAIN_OPTION; +export const RELAY_CHAIN_ENDPOINT = RELAY_CHAIN_ENDPOINTS[RELAY_CHAIN]; +export const ZERO = BigInt(0); + +// NOTE: we do not need to store the name of these chains, but they are convenient +// for us while reading to code to see which chains support local XCM execution. +export const chainsSupportingXcmExecute = [ + { + relayChain: "kusama", + paraId: 0 + }, + { + relayChain: "kusama", + name: "crab", + paraId: 2105 + }, + { + relayChain: "kusama", + name: "quartz by unique", + paraId: 2095 + }, + { + relayChain: "polkadot", + name: "darwinia", + paraId: 2046 + }, + { + relayChain: "polkadot", + name: "unique network", + paraId: 2037 + } +]; diff --git a/src/contexts/RelayApi/index.tsx b/src/contexts/RelayApi/index.tsx new file mode 100644 index 0000000..366765b --- /dev/null +++ b/src/contexts/RelayApi/index.tsx @@ -0,0 +1,105 @@ +import { ApiPromise, WsProvider } from '@polkadot/api'; +import jsonrpc from '@polkadot/types/interfaces/jsonrpc'; +import { DefinitionRpcExt } from '@polkadot/types/types'; +import React, { useContext, useEffect, useReducer } from 'react'; + +import { RELAY_CHAIN_ENDPOINT } from '@/consts'; + +import { useToast } from '../Toast'; + +/// +// Initial state for `useReducer` + +type State = { + socket: string; + jsonrpc: Record>; + api: any; + apiError: any; + apiState: any; +}; + +const initialState: State = { + // These are the states + socket: RELAY_CHAIN_ENDPOINT, + jsonrpc: { ...jsonrpc }, + api: null, + apiError: null, + apiState: null, +}; + +/// +// Reducer function for `useReducer` + +const reducer = (state: any, action: any) => { + switch (action.type) { + case 'CONNECT_INIT': + return { ...state, apiState: 'CONNECT_INIT' }; + case 'CONNECT': + return { ...state, api: action.payload, apiState: 'CONNECTING' }; + case 'CONNECT_SUCCESS': + return { ...state, apiState: 'READY' }; + case 'CONNECT_ERROR': + return { ...state, apiState: 'ERROR', apiError: action.payload }; + default: + throw new Error(`Unknown type: ${action.type}`); + } +}; + +/// +// Connecting to the Substrate node + +const connect = (state: any, dispatch: any) => { + const { apiState, socket, jsonrpc } = state; + // We only want this function to be performed once + if (apiState) return; + + dispatch({ type: 'CONNECT_INIT' }); + + const provider = new WsProvider(socket); + const _api = new ApiPromise({ provider, rpc: jsonrpc }); + + // Set listeners for disconnection and reconnection event. + _api.on('connected', () => { + dispatch({ type: 'CONNECT', payload: _api }); + // `ready` event is not emitted upon reconnection and is checked explicitly here. + _api.isReady.then(() => dispatch({ type: 'CONNECT_SUCCESS' })); + }); + _api.on('ready', () => dispatch({ type: 'CONNECT_SUCCESS' })); + _api.on('error', (err) => dispatch({ type: 'CONNECT_ERROR', payload: err })); +}; + +const defaultValue = { + state: initialState, +}; + +const RelayApiContext = React.createContext(defaultValue); + +const RelayApiContextProvider = (props: any) => { + const [state, dispatch] = useReducer(reducer, initialState); + const { toastError, toastSuccess } = useToast(); + + useEffect(() => { + state.apiError && + toastError( + `Failed to connect to relay chain: error = ${state.apiError.toString()}` + ); + }, [state.apiError]); + + useEffect(() => { + state.apiState === 'READY' && + toastSuccess('Successfully connected to relay chain'); + }, [state.apiState]); + + useEffect(() => { + connect(state, dispatch); + }, [process.env.RELAY_CHAIN_ENDPOINT]); + + return ( + + {props.children} + + ); +}; +const useRelayApi = () => useContext(RelayApiContext); + +export { RelayApiContextProvider, useRelayApi }; diff --git a/src/contracts/identity/context.tsx b/src/contracts/identity/context.tsx index 558aaae..238a914 100644 --- a/src/contracts/identity/context.tsx +++ b/src/contracts/identity/context.tsx @@ -34,6 +34,7 @@ interface IdentityContract { contract: ContractPromise | undefined; fetchIdentityNo: () => Promise; fetchAddresses: () => Promise; + getAddresses: (_id: number) => Promise; loading: boolean; } @@ -49,6 +50,9 @@ const defaultIdentity: IdentityContract = { fetchAddresses: async () => { /* */ }, + getAddresses: async (): Promise => { + return []; + }, loading: true, }; @@ -165,14 +169,11 @@ const IdentityContractProvider = ({ children }: Props) => { setLoadingChains(false); }, [api, contract, toastError]); - const fetchAddresses = useCallback(async () => { - if (!api || !contract || identityNo === null) { - setAddresses([]); - return; - } + const getAddresses = async (no: number): Promise => { + if (!api || !contract) return []; try { const result = await contractQuery(api, '', contract, 'identity', {}, [ - identityNo, + no, ]); const { output, isError, decodedOutput } = decodeOutput( result, @@ -191,8 +192,21 @@ const IdentityContractProvider = ({ children }: Props) => { address, }); } - setAddresses(_addresses); + return _addresses; } catch (e) { + return []; + } + }; + + const fetchAddresses = useCallback(async () => { + if (!api || !contract || identityNo === null) { + setAddresses([]); + return; + } + try { + const _addresses = await getAddresses(identityNo); + setAddresses(_addresses); + } catch { setAddresses([]); } }, [api, contract, identityNo]); @@ -218,6 +232,7 @@ const IdentityContractProvider = ({ children }: Props) => { chains, fetchAddresses, fetchIdentityNo, + getAddresses, loading: loadingIdentityNo || loadingChains, }} > diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 33fef4d..2664910 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -14,6 +14,7 @@ import theme from '@/utils/muiTheme'; import { Layout } from '@/components/Layout'; +import { RelayApiContextProvider } from '@/contexts/RelayApi'; import { ToastProvider } from '@/contexts/Toast'; import { IdentityContractProvider } from '@/contracts'; import { AddressBookContractProvider } from '@/contracts/addressbook/context'; @@ -42,11 +43,11 @@ export default function MyApp(props: MyAppProps) { {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} - + - - - - {getLayout()} - - - - + }} + > + + + + {getLayout()} + + + + + diff --git a/src/pages/identity.tsx b/src/pages/identity.tsx index cfbe081..e7d47a2 100644 --- a/src/pages/identity.tsx +++ b/src/pages/identity.tsx @@ -9,6 +9,7 @@ import { Grid, Typography, } from '@mui/material'; +import styles from '@styles/pages/identity.module.scss'; import { useState } from 'react'; import { CreateIdentity, RemoveIdentity } from '@/components/Buttons'; @@ -34,14 +35,7 @@ const IdentityPage = () => { return ( <> - + My Identity diff --git a/src/pages/transfer.tsx b/src/pages/transfer.tsx index d9b8cd2..022cff8 100644 --- a/src/pages/transfer.tsx +++ b/src/pages/transfer.tsx @@ -1,5 +1,390 @@ +import { LoadingButton } from '@mui/lab'; +import { + Alert, + Backdrop, + Box, + CircularProgress, + FormControl, + FormLabel, + MenuItem, + Select, + TextField, +} from '@mui/material'; +import { ApiPromise, Keyring, WsProvider } from '@polkadot/api'; +import { useInkathon } from '@scio-labs/use-inkathon'; +import styles from '@styles/pages/transfer.module.scss'; +import { useCallback, useEffect, useState } from 'react'; +import { AccountType } from 'types/types-arguments/identity'; + +import AssetRegistry, { Asset } from '@/utils/assetRegistry'; +import IdentityKey from '@/utils/identityKey'; +import KeyStore from '@/utils/keyStore'; +import TransactionRouter, { isTeleport } from '@/utils/transactionRouter'; +import { getTeleportableAssets } from '@/utils/transactionRouter/teleportableRoutes'; +import { Fungible } from '@/utils/transactionRouter/types'; + +import { chainsSupportingXcmExecute, RELAY_CHAIN } from '@/consts'; +import { useRelayApi } from '@/contexts/RelayApi'; +import { useToast } from '@/contexts/Toast'; +import { useIdentity } from '@/contracts'; +import { useAddressBook } from '@/contracts/addressbook/context'; + const TransferPage = () => { - return
Transfer
; + const { chains, getAddresses, contract: identityContract } = useIdentity(); + const { activeAccount, activeSigner } = useInkathon(); + const { toastError } = useToast(); + const { identities } = useAddressBook(); + + const [sourceChainId, setSourceChainId] = useState(); + const [destChainId, setDestChainId] = useState(); + const { + state: { api: relayApi }, + } = useRelayApi(); + + const [loadingAssets, setLoadingAssets] = useState(false); + const [assets, setAssets] = useState([]); + const [selectedAsset, setSelectedAsset] = useState(); + const [recipientId, setRecipientId] = useState(); + const [recipientOk, setRecipientOk] = useState(false); + const [recipientAddress, setRecipientAddress] = useState(); + const [amount, setAmount] = useState(); + const [transferring, setTransferring] = useState(false); + + const chainsSelected = + !loadingAssets && sourceChainId !== undefined && destChainId !== undefined; + const assetSelected = chainsSelected && Boolean(selectedAsset); + + useEffect(() => setSelectedAsset(undefined), [chainsSelected]); + + useEffect(() => setRecipientId(undefined), [assetSelected]); + useEffect(() => setAmount(undefined), [assetSelected]); + + const canTransfer = assetSelected && recipientId !== undefined; + + const loadAssets = useCallback(async () => { + if (!relayApi) return; + + if (sourceChainId === undefined || destChainId === undefined) return; + setLoadingAssets(true); + + if (sourceChainId !== destChainId) { + const hrmp = await relayApi.query.hrmp.hrmpChannels({ + sender: chains[sourceChainId].paraId, + recipient: chains[destChainId].paraId, + }); + + if (hrmp.isEmpty && sourceChainId !== 0 && destChainId !== 0) { + toastError( + "There's no HRMP channel open between the source and destination chain" + ); + setAssets([]); + } else { + const _assets = await AssetRegistry.assetsSupportedOnBothChains( + RELAY_CHAIN, + chains[sourceChainId].paraId, + chains[destChainId].paraId + ); + _assets.push(...getTeleportableAssets(sourceChainId, destChainId)); + setAssets(_assets); + } + } else { + const _assets = await AssetRegistry.getAssetsOnBlockchain( + RELAY_CHAIN, + chains[sourceChainId].paraId + ); + _assets.push(...getTeleportableAssets(sourceChainId, destChainId)); + setSelectedAsset([]); + setAssets(_assets); + } + + setLoadingAssets(false); + }, [sourceChainId, destChainId, relayApi]); + + useEffect(() => { + loadAssets(); + }, [sourceChainId, destChainId]); + + useEffect(() => { + const checkIdentity = async () => { + if (recipientId === undefined || destChainId === undefined) return; + const addresses = await getAddresses(identities[recipientId].identityNo); + const index = addresses.findIndex( + (address) => address.chainId === destChainId + ); + setRecipientOk(index !== -1); + if (index === -1) { + toastError( + `${identities[recipientId].nickName} does not have ${chains[destChainId].name} address.` + ); + } else { + const recepientIdentityNo = identities[recipientId].identityNo; + const identityKey = KeyStore.readIdentityKey(recepientIdentityNo) || ''; + const destAddressRaw = addresses[index].address; + if (IdentityKey.containsChainId(identityKey, destChainId)) { + const decryptedAddress = IdentityKey.decryptAddress( + identityKey, + destChainId, + destAddressRaw + ); + setRecipientAddress(decryptedAddress); + } else { + toastError('Cannot find identity key for the recipient'); + } + } + }; + setRecipientOk(false); + checkIdentity(); + }, [recipientId]); + + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars + const isTransferSupported = (): boolean => { + if ( + sourceChainId === undefined || + destChainId === undefined || + selectedAsset === undefined + ) { + return false; + } + const reserveParaId = getParaIdFromXcmInterior( + selectedAsset.xcmInteriorKey + ); + // If the origin is the reserve chain that means that we can use the existing + // `limitedReserveTransferAssets` or `limitedTeleportAssets` extrinsics which are + // supported on all chains that have the xcm pallet. + if (sourceChainId == reserveParaId && sourceChainId !== destChainId) { + return true; + } + + const isSourceParachain = sourceChainId > 0; + + if ( + sourceChainId !== destChainId && + isTeleport( + sourceChainId, + destChainId, + getFungible(selectedAsset.xcmInteriorKey, isSourceParachain, 0) + ) + ) { + return true; + } + + const isOriginSupportingLocalXCM = chainsSupportingXcmExecute.findIndex( + (chain) => + chain.paraId == sourceChainId && chain.relayChain == RELAY_CHAIN + ); + + // We only need the origin chain to support XCM for any other type of transfer to + // work. + if (isOriginSupportingLocalXCM >= 0) { + return true; + } + + return false; + }; + + const transferAsset = async () => { + if ( + recipientAddress === undefined || + destChainId === undefined || + identityContract === undefined || + sourceChainId === undefined || + activeAccount === undefined || + selectedAsset === undefined || + amount === undefined + ) { + return; + } + + const reserveChainId = getParaIdFromXcmInterior( + selectedAsset.xcmInteriorKey + ); + + const count = Math.min( + chains[sourceChainId].rpcUrls.length, + chains[destChainId].rpcUrls.length, + chains[reserveChainId].rpcUrls.length + ); + + const rpcIndex = Math.min(Math.floor(Math.random() * count), count - 1); + + const isSourceParachain = sourceChainId > 0; + + const keypair = new Keyring(); + keypair.addFromAddress(activeAccount.address); + + const receiverKeypair = new Keyring(); + receiverKeypair.addFromAddress(recipientAddress); + + setTransferring(true); + + await TransactionRouter.sendTokens( + { + keypair: keypair.pairs[0], // How to convert active account into a keypair? + chain: sourceChainId, + }, + { + addressRaw: receiverKeypair.pairs[0].publicKey, + chain: destChainId, + type: + chains[destChainId].accountType === 'AccountId32' + ? AccountType.accountId32 + : AccountType.accountKey20, + }, + reserveChainId, + getFungible( + selectedAsset.xcmInteriorKey, + isSourceParachain, + amount * Math.pow(10, selectedAsset.decimals) + ), + { + originApi: await getApi(chains[sourceChainId].rpcUrls[rpcIndex]), + destApi: await getApi(chains[destChainId].rpcUrls[rpcIndex]), + reserveApi: await getApi(chains[reserveChainId].rpcUrls[rpcIndex]), + }, + activeSigner + ); + + setTransferring(false); + }; + + const getParaIdFromXcmInterior = (xcmInterior: any): number => { + if (xcmInterior.length > 1 && Object.hasOwn(xcmInterior[1], 'parachain')) { + return xcmInterior[1].parachain; + } else { + return 0; + } + }; + + const getApi = async (rpc: string): Promise => { + const provider = new WsProvider(rpc); + const api = await ApiPromise.create({ provider }); + return api; + }; + + const getFungible = ( + xcmInterior: any, + isSourceParachain: boolean, + amount: number + ): Fungible => { + xcmInterior = Array.isArray(xcmInterior) + ? xcmInterior + : JSON.parse(xcmInterior.toString()); + return { + multiAsset: AssetRegistry.xcmInteriorToMultiAsset( + xcmInterior, + isSourceParachain, + sourceChainId + ), + amount, + }; + }; + + return ( + + + + Source chain + setSourceChainId(Number(e.target.value))} + > + {Object.entries(chains).map(([chainId, network], index) => ( + + {network.name} + + ))} + + + + Destination chain + setDestChainId(Number(e.target.value))} + > + {Object.entries(chains).map(([chainId, network], index) => ( + + {network.name} + + ))} + + + {chainsSelected && + (assets.length > 0 ? ( + + Select asset to transfer + + + ) : ( +
There are no assets supported on both chains.
+ ))} + {assetSelected && ( + + Select recipient + + + )} + {canTransfer && ( + <> + setAmount(parseFloat(e.target.value))} + /> + {!isTransferSupported() && ( + + This transfer route is currently not supported. + + )} + + Transfer + + + )} +
+ theme.zIndex.drawer + 1 }} + open={loadingAssets} + > + + +
+ ); }; export default TransferPage; diff --git a/src/utils/assetRegistry.ts b/src/utils/assetRegistry.ts index ee47936..810b6c2 100644 --- a/src/utils/assetRegistry.ts +++ b/src/utils/assetRegistry.ts @@ -4,7 +4,7 @@ type ChainId = number | string; type RelayChain = 'polkadot' | 'kusama'; -type Asset = { +export type Asset = { asset: any; name: string; symbol: string; @@ -50,13 +50,13 @@ class AssetRegistry { const assets: Asset[] = (await axios.get(assetsUrl)).data; - assets.map((asset) => { - if (asset.xcmInteriorKey) { - asset.xcmInteriorKey = JSON.parse(asset.xcmInteriorKey); - } + const xcAssets = assets.filter(asset => asset.xcmInteriorKey !== undefined); + + xcAssets.map((asset) => { + asset.xcmInteriorKey = JSON.parse(asset.xcmInteriorKey); }); - return assets; + return xcAssets; } public static xcmInteriorToMultiAsset( @@ -115,8 +115,8 @@ class AssetRegistry { private static getAssetReserveParachainId( xcmInteriorKey: any[] ): ReserveChain { - // -1 will indicate that the reserve chain is actually the relay chain. - let parachainId = -1; + // 0 will indicate that the reserve chain is actually the relay chain. + let parachainId = 0; let index = -1; xcmInteriorKey.forEach((junction, i) => { if (junction.parachain) { @@ -145,7 +145,7 @@ class AssetRegistry { relay: RelayChain, chainA: ChainId, chainB: ChainId, - ): Promise { + ): Promise { const assetsOnChainA = await this.getAssetsOnBlockchain(relay, chainA); const assetsOnChainB = await this.getAssetsOnBlockchain(relay, chainB); @@ -153,6 +153,10 @@ class AssetRegistry { for (let i = 0; i < assetsOnChainA.length; i++) { const asset: Asset = assetsOnChainA[i]; + if (!asset.xcmInteriorKey) { + continue; + } + const isSupported = this.isSupported(asset.xcmInteriorKey, assetsOnChainB); if (isSupported) { @@ -178,18 +182,18 @@ class AssetRegistry { public static async isSupportedOnChain( relay: RelayChain, chain: ChainId, - xcmAsset: any + xcAsset: any ): Promise { const assets = await this.getAssetsOnBlockchain(relay, chain); - return this.isSupported(xcmAsset, assets); + return this.isSupported(xcAsset, assets); } - private static isSupported(xcmAsset: any, assets: Asset[]): boolean { + private static isSupported(xcAsset: any, assets: Asset[]): boolean { const found = assets.find( (el: Asset) => el.xcmInteriorKey && - JSON.stringify(el.xcmInteriorKey) === JSON.stringify(xcmAsset) + JSON.stringify(el.xcmInteriorKey) === JSON.stringify(xcAsset) ); if (found) return true; diff --git a/src/utils/transactionRouter/index.ts b/src/utils/transactionRouter/index.ts index 7d677ed..babca2d 100644 --- a/src/utils/transactionRouter/index.ts +++ b/src/utils/transactionRouter/index.ts @@ -1,7 +1,8 @@ import { ApiPromise } from "@polkadot/api"; +import { Signer } from "@polkadot/types/types"; import ReserveTransfer from "./reserveTransfer"; -import { TeleportableRoute, teleportableRoutes } from "./teleportableRoutes"; +import { teleportableRoutes } from "./teleportableRoutes"; import TeleportTransfer from "./teleportTransfer"; import TransferAsset from "./transferAsset"; import { Fungible, Receiver, Sender, TransferRpcApis } from "./types"; @@ -34,7 +35,8 @@ class TransactionRouter { receiver: Receiver, reserveChainId: number, asset: Fungible, - transferRpcApis: TransferRpcApis + transferRpcApis: TransferRpcApis, + signer?: Signer, ): Promise { if (sender.chain === receiver.chain && sender.keypair.addressRaw === receiver.addressRaw) { throw new Error("Cannot send tokens to yourself"); @@ -46,7 +48,8 @@ class TransactionRouter { transferRpcApis.originApi, sender.keypair, receiver, - asset + asset, + signer ); return; @@ -57,16 +60,16 @@ class TransactionRouter { const originParaId = sender.chain; const destParaId = receiver.chain; - const maybeTeleportableRoute: TeleportableRoute = { - relayChain: process.env.RELAY_CHAIN ? process.env.RELAY_CHAIN : "rococo", - originParaId: originParaId, - destParaId: destParaId, - multiAsset: asset.multiAsset - }; - - if (teleportableRoutes.some(route => JSON.stringify(route) === JSON.stringify(maybeTeleportableRoute))) { + if (isTeleport(originParaId, destParaId, asset)) { // The asset is allowed to be teleported between the origin and the destination. - await TeleportTransfer.send(transferRpcApis.originApi, transferRpcApis.destApi, sender.keypair, receiver, asset); + await TeleportTransfer.send( + transferRpcApis.originApi, + transferRpcApis.destApi, + sender.keypair, + receiver, + asset, + signer + ); return; } @@ -78,7 +81,8 @@ class TransactionRouter { destParaId, sender, receiver, - asset + asset, + signer ); } else if (receiver.chain == reserveChainId) { // The destination chain is the reserve chain of the asset: @@ -87,7 +91,8 @@ class TransactionRouter { destParaId, sender, receiver, - asset + asset, + signer ); } else { // The most complex case, the reserve chain is neither the sender or the destination chain. @@ -106,7 +111,8 @@ class TransactionRouter { reserveParaId, sender, receiver, - asset + asset, + signer ); } } @@ -199,3 +205,15 @@ const ensureContainsXcmPallet = (api: ApiPromise) => { throw new Error("The blockchain does not support XCM"); } } + +// Returns whether the transfer is a teleport. +export const isTeleport = (originParaId: number, destParaId: number, asset: Fungible): boolean => { + const relayChain = process.env.RELAY_CHAIN ? process.env.RELAY_CHAIN : "rococo"; + + return teleportableRoutes.some(route => { + return relayChain === route.relayChain && + originParaId === route.originParaId && + destParaId === route.destParaId && + JSON.stringify(asset.multiAsset) === JSON.stringify(route.multiAsset) + }); +} diff --git a/src/utils/transactionRouter/reserveTransfer.ts b/src/utils/transactionRouter/reserveTransfer.ts index bfae59d..3b1b787 100644 --- a/src/utils/transactionRouter/reserveTransfer.ts +++ b/src/utils/transactionRouter/reserveTransfer.ts @@ -1,4 +1,5 @@ import { ApiPromise } from "@polkadot/api"; +import { Signer } from "@polkadot/types/types"; import { getDestination, getMultiAsset, getReceiverAccount, getTransferBeneficiary } from "."; import { Fungible, Receiver, Sender } from "./types"; @@ -12,7 +13,8 @@ class ReserveTransfer { destParaId: number, sender: Sender, receiver: Receiver, - asset: Fungible + asset: Fungible, + signer?: Signer ): Promise { // Chain represents the para id and in case of a relay chain it is zero. const isOriginPara = sender.chain > 0; @@ -34,9 +36,13 @@ class ReserveTransfer { weightLimit ); + if (signer) originApi.setSigner(signer); + + const account = signer ? sender.keypair.address : sender.keypair; + // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve) => { - const unsub = await reserveTransfer.signAndSend(sender.keypair, (result: any) => { + const unsub = await reserveTransfer.signAndSend(account, (result: any) => { if (result.status.isFinalized) { unsub(); resolve(); @@ -54,7 +60,8 @@ class ReserveTransfer { destParaId: number, sender: Sender, receiver: Receiver, - asset: Fungible + asset: Fungible, + signer?: Signer ): Promise { // Chain represents the para id and in case of a relay chain it is zero. @@ -68,9 +75,13 @@ class ReserveTransfer { proofSize: Math.pow(10, 6), }); + if (signer) originApi.setSigner(signer); + + const account = signer ? sender.keypair.address : sender.keypair; + // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve) => { - const unsub = await reserveTransfer.signAndSend(sender.keypair, (result: any) => { + const unsub = await reserveTransfer.signAndSend(account, (result: any) => { if (result.status.isFinalized) { unsub(); resolve(); @@ -88,7 +99,8 @@ class ReserveTransfer { reserveParaId: number, sender: Sender, receiver: Receiver, - asset: Fungible + asset: Fungible, + signer?: Signer ): Promise { // Chain represents the para id and in case of a relay chain it is zero. @@ -101,9 +113,14 @@ class ReserveTransfer { refTime: 3 * Math.pow(10, 11), proofSize: Math.pow(10, 6), }); + + if (signer) originApi.setSigner(signer); + + const account = signer ? sender.keypair.address : sender.keypair; + // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve) => { - const unsub = await reserveTransfer.signAndSend(sender.keypair, (result: any) => { + const unsub = await reserveTransfer.signAndSend(account, (result: any) => { if (result.status.isFinalized) { unsub(); resolve(); @@ -154,7 +171,6 @@ class ReserveTransfer { }, reserve, xcm: [ - // TODO: the hardcoded number isn't really accurate to what we actually need. this.buyExecution(assetFromReservePerspective, 450000000000), this.depositReserveAsset({ Wild: "All" }, 1, { parents: 1, @@ -211,7 +227,6 @@ class ReserveTransfer { }, reserve, xcm: [ - // TODO: the hardcoded number isn't really accurate to what we actually need. this.buyExecution(assetFromReservePerspective, 450000000000), this.depositAsset({ Wild: "All" }, 1, beneficiary) ] diff --git a/src/utils/transactionRouter/teleportTransfer.ts b/src/utils/transactionRouter/teleportTransfer.ts index d15a41d..a70cb45 100644 --- a/src/utils/transactionRouter/teleportTransfer.ts +++ b/src/utils/transactionRouter/teleportTransfer.ts @@ -1,5 +1,6 @@ import { ApiPromise } from "@polkadot/api"; import { KeyringPair } from "@polkadot/keyring/types"; +import { Signer } from "@polkadot/types/types"; import { getDestination, getMultiAsset, getTransferBeneficiary } from "."; import { Fungible, Receiver } from "./types"; @@ -11,7 +12,8 @@ class TeleportTransfer { destApi: ApiPromise, sender: KeyringPair, receiver: Receiver, - asset: Fungible + asset: Fungible, + signer?: Signer ): Promise { const xcmPallet = originApi.tx.xcmPallet || originApi.tx.polkadotXcm; @@ -39,9 +41,13 @@ class TeleportTransfer { weightLimit ); + if (signer) originApi.setSigner(signer); + + const account = signer ? sender.address : sender; + // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve) => { - const unsub = await teleport.signAndSend(sender, (result: any) => { + const unsub = await teleport.signAndSend(account, (result: any) => { if (result.status.isFinalized) { unsub(); resolve(); diff --git a/src/utils/transactionRouter/teleportableRoutes.ts b/src/utils/transactionRouter/teleportableRoutes.ts index 55e2625..4aa87d3 100644 --- a/src/utils/transactionRouter/teleportableRoutes.ts +++ b/src/utils/transactionRouter/teleportableRoutes.ts @@ -1,12 +1,14 @@ // File containing all the possible assets on all possible routes that support asset // teleportation. -import AssetRegistry from "../assetRegistry"; +import AssetRegistry, { Asset } from "../assetRegistry"; +import { RELAY_CHAIN } from "../../consts"; export type TeleportableRoute = { relayChain: string, originParaId: number, destParaId: number, + asset: Asset, multiAsset: any }; @@ -15,27 +17,130 @@ export const teleportableRoutes: TeleportableRoute[] = [ relayChain: "polkadot", originParaId: 0, destParaId: 1000, + asset: { + asset: { + Token: "DOT" + }, + name: "DOT", + symbol: "DOT", + decimals: 10, + xcmInteriorKey: '[{"network":"polkadot"},"here"]', + inferred: true, + confidence: 0 + }, multiAsset: AssetRegistry.xcmInteriorToMultiAsset( JSON.parse('[{"network":"polkadot"},"here"]'), - false, - ), + false + ) + }, + { + relayChain: "polkadot", + originParaId: 1000, + destParaId: 0, + asset: { + asset: { + Token: "DOT" + }, + name: "DOT", + symbol: "DOT", + decimals: 10, + xcmInteriorKey: '[{"network":"polkadot"},"here"]', + inferred: true, + confidence: 0 + }, + multiAsset: AssetRegistry.xcmInteriorToMultiAsset( + JSON.parse('[{"network":"polkadot"},"here"]'), + false + ) }, { relayChain: "kusama", originParaId: 0, destParaId: 1000, + asset: { + asset: { + Token: "KSM" + }, + name: "KSM", + symbol: "KSM", + decimals: 12, + xcmInteriorKey: '[{"network":"kusama"},"here"]', + inferred: true, + confidence: 0 + }, multiAsset: AssetRegistry.xcmInteriorToMultiAsset( JSON.parse('[{"network":"kusama"},"here"]'), - false, - ), + false + ) + }, + { + relayChain: "kusama", + originParaId: 1000, + destParaId: 0, + asset: { + asset: { + Token: "KSM" + }, + name: "KSM", + symbol: "KSM", + decimals: 12, + xcmInteriorKey: '[{"network":"kusama"},"here"]', + inferred: true, + confidence: 0 + }, + multiAsset: AssetRegistry.xcmInteriorToMultiAsset( + JSON.parse('[{"network":"kusama"},"here"]'), + false + ) }, { relayChain: "rococo", originParaId: 0, destParaId: 1000, + asset: { + asset: { + Token: "ROC" + }, + name: "ROC", + symbol: "ROC", + decimals: 12, + xcmInteriorKey: '[{"network":"rococo"},"here"]', + inferred: true, + confidence: 0 + }, multiAsset: AssetRegistry.xcmInteriorToMultiAsset( - JSON.parse('[{"network":"rocooc"},"here"]'), - false, - ), + JSON.parse('[{"network":"rococo"},"here"]'), + false + ) }, + { + relayChain: "rococo", + originParaId: 1000, + destParaId: 0, + asset: { + asset: { + Token: "ROC" + }, + name: "ROC", + symbol: "ROC", + decimals: 12, + xcmInteriorKey: '[{"network":"rococo"},"here"]', + inferred: true, + confidence: 0 + }, + multiAsset: AssetRegistry.xcmInteriorToMultiAsset( + JSON.parse('[{"network":"rococo"},"here"]'), + false + ) + } ]; + +export const getTeleportableAssets = (originChainId: number, destChainId: number): Asset[] => { + const routes = teleportableRoutes.filter( + (route) => route.originParaId === originChainId + && route.destParaId === destChainId + && route.relayChain === RELAY_CHAIN + ); + + return routes.map((route) => route.asset); +} diff --git a/src/utils/transactionRouter/transferAsset.ts b/src/utils/transactionRouter/transferAsset.ts index feeaa39..c4dbd7a 100644 --- a/src/utils/transactionRouter/transferAsset.ts +++ b/src/utils/transactionRouter/transferAsset.ts @@ -1,5 +1,6 @@ import { ApiPromise } from "@polkadot/api"; import { KeyringPair } from "@polkadot/keyring/types"; +import { Signer } from "@polkadot/types/types"; import { Fungible, Receiver } from "./types"; import { AccountType } from "../../../types/types-arguments/identity"; @@ -9,7 +10,8 @@ class TransferAsset { api: ApiPromise, sender: KeyringPair, receiver: Receiver, - asset: Fungible + asset: Fungible, + signer?: Signer, ): Promise { // We use XCM even for transfers that are occurring on the same chain. The reason for // this is that we cannot know what is the pallet and function for transferring tokens @@ -30,15 +32,17 @@ class TransferAsset { throw new Error("The blockchain does not support XCM"); }; - // TODO: come up with more precise weight estimations. const xcmExecute = xcmPallet.execute(xcm, { refTime: Math.pow(10, 9), proofSize: 10000, }); + if (signer) api.setSigner(signer); + + const account = signer ? sender.address : sender; // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve) => { - const unsub = await xcmExecute.signAndSend(sender, (result: any) => { + const unsub = await xcmExecute.signAndSend(account, (result: any) => { if (result.status.isFinalized) { unsub(); resolve(); @@ -72,7 +76,6 @@ class TransferAsset { }; } - // TODO: should this have `BuyExecution`? const xcmMessage = { V2: [ { diff --git a/styles/global.scss b/styles/global.scss index da77b61..9c8947b 100644 --- a/styles/global.scss +++ b/styles/global.scss @@ -54,7 +54,7 @@ .MuiFormLabel-root { font-size: 16px; - font-weight: 600; + // font-weight: 600; line-height: 1.6; color: #181336; } @@ -70,7 +70,7 @@ .form-item { display: flex; flex-direction: column; - gap: 4px; + gap: 8px; } .modal-buttons { diff --git a/styles/pages/identity.module.scss b/styles/pages/identity.module.scss new file mode 100644 index 0000000..423eae5 --- /dev/null +++ b/styles/pages/identity.module.scss @@ -0,0 +1,6 @@ +.identityContainer { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; +} diff --git a/styles/pages/transfer.module.scss b/styles/pages/transfer.module.scss new file mode 100644 index 0000000..8e97266 --- /dev/null +++ b/styles/pages/transfer.module.scss @@ -0,0 +1,15 @@ +.transferContainer { + display: flex; + flex-direction: column; + background: white; + margin-left: auto; + margin-right: auto; + min-width: 500px; + padding: 24px 16px; +} + +.balanceContainer { + display: flex; + justify-content: space-between; + align-items: center; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e64da08..31fb0ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"], + "@styles/*": ["./styles/*"], "~/*": ["./public/*"] }, "incremental": true