Skip to content

Commit

Permalink
fix: route xcm-sdk chain connections through Talisman Wallet
Browse files Browse the repository at this point in the history
  • Loading branch information
alecdwm committed Dec 29, 2024
1 parent a3f52f1 commit 8a705d5
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 46 deletions.
1 change: 1 addition & 0 deletions apps/portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 36 additions & 33 deletions apps/portal/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
<ThemeProvider>
Expand All @@ -35,40 +36,42 @@ const App = () => (
)}
>
<Suspense fallback={<FullscreenLoader />}>
<PostHogProvider apiKey={import.meta.env.VITE_POSTHOG_AUTH_TOKEN}>
<EvmProvider>
<PolkadotApiProvider
queryState={chainQueryState}
deriveState={chainDeriveState}
queryMultiState={chainQueryMultiState}
>
<BalancesProvider
onfinalityApiKey={import.meta.env.VITE_ONFINALITY_API_KEY ?? undefined}
coingeckoApiUrl={import.meta.env.VITE_COIN_GECKO_API}
coingeckoApiKeyValue={import.meta.env.VITE_COIN_GECKO_API_KEY}
coingeckoApiKeyName={
import.meta.env.VITE_COIN_GECKO_API_TIER === 'pro'
? 'x-cg-pro-api-key'
: import.meta.env.VITE_COIN_GECKO_API_TIER === 'demo'
? 'x-cg-demo-api-key'
: undefined
}
<JotaiProvider>
<PostHogProvider apiKey={import.meta.env.VITE_POSTHOG_AUTH_TOKEN}>
<EvmProvider>
<PolkadotApiProvider
queryState={chainQueryState}
deriveState={chainDeriveState}
queryMultiState={chainQueryMultiState}
>
<ExtensionWatcher />
<AccountWatcher />
<SignetWatcher />
<TalismanExtensionSynchronizer />
<BalancesWatcher />
<Suspense fallback={<FullscreenLoader />}>
<RouterProvider router={router} />
<Toaster position="bottom-right" />
</Suspense>
<FairyBreadBanner />
<Development />
</BalancesProvider>
</PolkadotApiProvider>
</EvmProvider>
</PostHogProvider>
<BalancesProvider
onfinalityApiKey={import.meta.env.VITE_ONFINALITY_API_KEY ?? undefined}
coingeckoApiUrl={import.meta.env.VITE_COIN_GECKO_API}
coingeckoApiKeyValue={import.meta.env.VITE_COIN_GECKO_API_KEY}
coingeckoApiKeyName={
import.meta.env.VITE_COIN_GECKO_API_TIER === 'pro'
? 'x-cg-pro-api-key'
: import.meta.env.VITE_COIN_GECKO_API_TIER === 'demo'
? 'x-cg-demo-api-key'
: undefined
}
>
<ExtensionWatcher />
<AccountWatcher />
<SignetWatcher />
<TalismanExtensionSynchronizer />
<BalancesWatcher />
<Suspense fallback={<FullscreenLoader />}>
<RouterProvider router={router} />
<Toaster position="bottom-right" />
</Suspense>
<FairyBreadBanner />
<Development />
</BalancesProvider>
</PolkadotApiProvider>
</EvmProvider>
</PostHogProvider>
</JotaiProvider>
</Suspense>
</ErrorBoundary>
</RecoilRoot>
Expand Down
14 changes: 8 additions & 6 deletions apps/portal/src/components/molecules/CurrencySelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ export const CurrencySelect = ({ className }: { className?: string }) => {

return (
<DropdownMenu>
<DropdownMenuTrigger>
<Clickable.WithFeedback className={className}>
<div className="flex h-12 w-12 select-none items-center justify-center rounded-full border border-gray-600 bg-gray-950 text-white">
<div className="font-mono text-xl">{symbol}</div>
</div>
</Clickable.WithFeedback>
<DropdownMenuTrigger asChild>
<div>
<Clickable.WithFeedback className={className}>
<div className="flex h-12 w-12 select-none items-center justify-center rounded-full border border-gray-600 bg-gray-950 text-white">
<div className="font-mono text-xl">{symbol}</div>
</div>
</Clickable.WithFeedback>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
className={cn('z-50 flex flex-col gap-1 overflow-hidden rounded-3xl bg-gray-950 py-4')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { atom } from 'jotai'

import { disableRoute } from '../utils/disableRoute'
import { insertTalismanRoutes } from '../utils/insertTalismanRoutes'
import { overrideChainApis, overrideRoutesChainApis } from '../utils/wrapChainApi'

insertTalismanRoutes({ assets, chains, routes })

Expand All @@ -14,4 +15,10 @@ insertTalismanRoutes({ assets, chains, routes })
// We can add this route back after we or @galacticcouncil add support for checking asset sufficiency.
disableRoute(routes, 'mythos', 'myth-assethub')

export const configServiceAtom = atom(new ConfigService({ assets, chains, routes }))
export const configServiceAtom = atom(
new ConfigService({
assets,
chains: overrideChainApis(chains),
routes: overrideRoutesChainApis(routes),
})
)
11 changes: 5 additions & 6 deletions apps/portal/src/components/widgets/xcm/api/atoms/pjsApiAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@ import { Parachain } from '@galacticcouncil/xcm-core'
import { chainsAtom } from '@talismn/balances-react'
import { atom } from 'jotai'

import { substrateApiGetterAtom } from '@/domains/common/recoils/api'
import { apiPromiseAtom } from '@/domains/common/atoms/apiPromiseAtom'

import { sourceChainAtom } from './xcmFieldsAtoms'

export const pjsApiAtom = atom(async get => {
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))
})
85 changes: 85 additions & 0 deletions apps/portal/src/components/widgets/xcm/api/utils/wrapChainApi.ts
Original file line number Diff line number Diff line change
@@ -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<string, AnyChain>): Map<string, AnyChain> {
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<string, ChainRoutes>): Map<string, ChainRoutes> {
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
}
49 changes: 49 additions & 0 deletions apps/portal/src/domains/common/atoms/apiPromiseAtom.ts
Original file line number Diff line number Diff line change
@@ -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}`)
}
}
})
7 changes: 7 additions & 0 deletions apps/portal/src/domains/common/recoils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiPromise, string | undefined>({
key: 'SubstrateApiState',
// DO NOT USE any atom dependency here, nothing should invalidate an api object once created
Expand Down
13 changes: 13 additions & 0 deletions apps/portal/src/util/jotaiStore.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<Provider store={jotaiStore}>{children}</Provider>
)
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 8a705d5

Please sign in to comment.