diff --git a/apps/portal/package.json b/apps/portal/package.json index 04be9c6eb..c8080718a 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -77,6 +77,7 @@ "react-use": "^17.4.0", "react-winbox": "^1.5.0", "recoil": "^0.7.7", + "rxjs": "^7.8.1", "scale-ts": "^1.6.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", diff --git a/apps/portal/src/App.tsx b/apps/portal/src/App.tsx index 389fd6f78..6e39802b4 100644 --- a/apps/portal/src/App.tsx +++ b/apps/portal/src/App.tsx @@ -23,6 +23,7 @@ import { ExtensionWatcher } from '@/domains/extension/main' import { TalismanExtensionSynchronizer } from '@/domains/extension/TalismanExtensionSynchronizer' import { EvmProvider } from '@/domains/extension/wagmi' import router from '@/routes' +import { JotaiProvider } from '@/util/jotaiStore' const App = () => ( @@ -35,40 +36,42 @@ const App = () => ( )} > }> - - - - + + + - - - - - - }> - - - - - - - - - + + + + + + + }> + + + + + + + + + + diff --git a/apps/portal/src/components/molecules/CurrencySelect.tsx b/apps/portal/src/components/molecules/CurrencySelect.tsx index 970ec1692..83d5141da 100644 --- a/apps/portal/src/components/molecules/CurrencySelect.tsx +++ b/apps/portal/src/components/molecules/CurrencySelect.tsx @@ -16,12 +16,14 @@ export const CurrencySelect = ({ className }: { className?: string }) => { return ( - - -
-
{symbol}
-
-
+ +
+ +
+
{symbol}
+
+
+
{ - const substrateApiGetter = get(substrateApiGetterAtom) - const sourceChain = get(sourceChainAtom) const genesisHash = sourceChain instanceof Parachain ? sourceChain.genesisHash : null const chain = genesisHash && (await get(chainsAtom)).find(chain => chain.genesisHash === genesisHash) if (!chain) return - const chainRpc = chain?.rpcs?.[0] - if (!chainRpc) return - return await substrateApiGetter?.getApi(chainRpc.url) + const chainId = chain.id + if (!chainId) return + + return await get(apiPromiseAtom(chainId)) }) diff --git a/apps/portal/src/components/widgets/xcm/api/utils/wrapChainApi.ts b/apps/portal/src/components/widgets/xcm/api/utils/wrapChainApi.ts new file mode 100644 index 000000000..4f488fc28 --- /dev/null +++ b/apps/portal/src/components/widgets/xcm/api/utils/wrapChainApi.ts @@ -0,0 +1,85 @@ +import { AnyChain, ChainRoutes, Parachain } from '@galacticcouncil/xcm-core' +import { chainsByGenesisHashAtom } from '@talismn/balances-react' + +import { apiPromiseAtom } from '@/domains/common/atoms/apiPromiseAtom' +import { jotaiStore } from '@/util/jotaiStore' + +/** + * This function wraps the `chain.api` method from `@galacticcouncil/xcm-cfg` so that the chain connections + * made via the `@galacticcouncil/xcm-sdk` library can share the one WsProvider with the balances library. + * + * Without this, two connections would need to be made to each chain rpc: one for the balances library, one for the XCM SDK. + */ +export function overrideChainApis(chains: Map): Map { + return new Map(chains.entries().map(([key, chain]) => [key, wrapChainApi(chain)])) +} + +/** + * This function wraps the `chain.api` method from `@galacticcouncil/xcm-cfg` so that the chain connections + * made via the `@galacticcouncil/xcm-sdk` library can share the one WsProvider with the balances library. + * + * Without this, two connections would need to be made to each chain rpc: one for the balances library, one for the XCM SDK. + */ +export function overrideRoutesChainApis(routes: Map): Map { + return new Map( + routes.entries().map(([key, route]) => { + return [ + key, + // we need to use a Proxy, because `route.chain` is a getter (i.e. we can't assign to it directly) + new Proxy(route, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(target: any, prop) { + if (prop !== 'chain') return target[prop] + return wrapChainApi(target.chain) + }, + }), + ] + }) + ) +} + +/** + * Returns the `chain` given to it, but with an override for the `chain.api` getter. + * + * The new `chain.api` getter will proxy all websocket requests through to the Talisman Wallet. + * Also, the connection will be shared with any other Talisman Portal atoms/hooks which use `apiPromiseAtom`. + */ +function wrapChainApi(chain: AnyChain): AnyChain { + if (!(chain instanceof Parachain)) return chain + + const chainProxy = new Proxy(chain, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(target: any, prop) { + if (prop !== 'api') return target[prop] + + const getApi = async () => { + const chaindataChainsByGenesisHash = await jotaiStore.get(chainsByGenesisHashAtom) + const chaindataChain = chaindataChainsByGenesisHash?.[chain.genesisHash] + if (!chaindataChain) { + console.warn( + `Unable to proxy ${chain.key} connection through Talisman Wallet shared interface [NO CHAINDATA CHAIN]` + ) + return chain.api + } + + const api = await jotaiStore.get(apiPromiseAtom(chaindataChain.id)) + if (!api) { + console.warn(`Unable to proxy ${chain.key} connection through Talisman Wallet shared interface [NO API]`) + return chain.api + } + + // we need to await api.isReady here, because the xcm-sdk library expects us to have done so before + // we return the ApiPromise to it + await api.isReady + + return api + } + + // NOTE: Make sure to call getApi() here, + // i.e. don't return the function - return the Promise which is returned by the function + return getApi() + }, + }) + + return chainProxy +} diff --git a/apps/portal/src/domains/common/atoms/apiPromiseAtom.ts b/apps/portal/src/domains/common/atoms/apiPromiseAtom.ts new file mode 100644 index 000000000..911682b0a --- /dev/null +++ b/apps/portal/src/domains/common/atoms/apiPromiseAtom.ts @@ -0,0 +1,49 @@ +import { ApiPromise } from '@polkadot/api' +import { chainConnectorsAtom } from '@talismn/balances-react' +import * as AvailJsSdk from 'avail-js-sdk' +import { atom } from 'jotai' +import { atomEffect } from 'jotai-effect' +import { atomFamily } from 'jotai/utils' + +/** + * This atom can be used to get access to an `ApiPromise` for talking to a Polkadot blockchain. + * + * The advantage of using this atom over creating your own `ApiPromise`, is that the underlying websocket + * connections will be shared between all code which uses this atom. + * + * Also, when the user has Talisman Wallet installed, the underlying websocket connections will be routed + * through their wallet, thus further reducing the total number of active websocket connections. + */ +export const apiPromiseAtom = atomFamily((chainId?: string) => + atom(async get => { + if (!chainId) return + + const subChainConnector = get(chainConnectorsAtom).substrate + if (!subChainConnector) return + + const isAvail = ['avail', 'avail-turing-testnet'].includes(chainId) + const extraProps = isAvail + ? { types: AvailJsSdk.spec.types, rpc: AvailJsSdk.spec.rpc, signedExtensions: AvailJsSdk.spec.signedExtensions } + : {} + + const provider = subChainConnector.asProvider(chainId) + const apiPromise = new ApiPromise({ provider, noInitWarn: true, ...extraProps }) + + // register effect to clean up ApiPromise when it's no longer in use + get(cleanupApiPromiseEffect(chainId, apiPromise)) + + return apiPromise + }) +) + +const cleanupApiPromiseEffect = (chainId: string | undefined, apiPromise: ApiPromise) => + atomEffect(() => { + return () => { + apiPromiseAtom.remove(chainId) + try { + apiPromise.disconnect() + } catch (cause) { + console.warn(`Failed to close ${chainId} apiPromise: ${cause}`) + } + } + }) diff --git a/apps/portal/src/domains/common/recoils/api.ts b/apps/portal/src/domains/common/recoils/api.ts index a83e015be..8a211a9ae 100644 --- a/apps/portal/src/domains/common/recoils/api.ts +++ b/apps/portal/src/domains/common/recoils/api.ts @@ -4,6 +4,13 @@ import { atom, useAtom } from 'jotai' import { useEffect, useRef } from 'react' import { selectorFamily, useRecoilCallback } from 'recoil' +// TODO: Convert this into a facade for the `@/domains/common/atoms/apiPromiseAtom.ts` atom. +// +// That atom is superior to this one because: +// a) It uses jotai (our preferred state management tool) instead of recoil. +// b) It proxies websocket requests through to Talisman Wallet, +// so that users can maintain the one websocket connection per chain across both Talisman Wallet and Talisman Portal. +// For Talisman Wallet users, this means there will be zero websocket overhead when browsing Talisman Portal. export const substrateApiState = selectorFamily({ key: 'SubstrateApiState', // DO NOT USE any atom dependency here, nothing should invalidate an api object once created diff --git a/apps/portal/src/util/jotaiStore.tsx b/apps/portal/src/util/jotaiStore.tsx new file mode 100644 index 000000000..b507a66b1 --- /dev/null +++ b/apps/portal/src/util/jotaiStore.tsx @@ -0,0 +1,13 @@ +import { createStore, Provider } from 'jotai' +import { ReactNode } from 'react' + +/** + * Use this for access to the jotai store from outside of the react component lifecycle. + * + * For more information, see https://jotai.org/docs/guides/using-store-outside-react. + */ +export const jotaiStore = createStore() + +export const JotaiProvider = ({ children }: { children?: ReactNode }) => ( + {children} +) diff --git a/yarn.lock b/yarn.lock index 40c362a2d..8714b8762 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9239,6 +9239,7 @@ __metadata: react-use: "npm:^17.4.0" react-winbox: "npm:^1.5.0" recoil: "npm:^0.7.7" + rxjs: "npm:^7.8.1" scale-ts: "npm:^1.6.0" storybook: "npm:^7.6.5" tailwind-merge: "npm:^2.5.4"