diff --git a/queue-manager/rango-preset/src/shared.ts b/queue-manager/rango-preset/src/shared.ts index f15269846d..fb8f3b6aae 100644 --- a/queue-manager/rango-preset/src/shared.ts +++ b/queue-manager/rango-preset/src/shared.ts @@ -37,7 +37,6 @@ export type WalletBalance = { }; export type Account = { - balances: WalletBalance[] | null; address: string; loading: boolean; walletType: WalletType; diff --git a/queue-manager/rango-preset/src/types.ts b/queue-manager/rango-preset/src/types.ts index 1810bb9441..a7246dc569 100644 --- a/queue-manager/rango-preset/src/types.ts +++ b/queue-manager/rango-preset/src/types.ts @@ -71,12 +71,8 @@ export interface SwapQueueContext extends QueueContext { switchNetwork: ( wallet: WalletType, network: Network - ) => Promise | undefined; + ) => Promise | undefined; canSwitchNetworkTo: (type: WalletType, network: Network) => boolean; - connect: ( - wallet: WalletType, - network: Network - ) => Promise | undefined; state: (type: WalletType) => WalletState; isMobileWallet: (type: WalletType) => boolean; diff --git a/wallets/core/src/hub/mod.ts b/wallets/core/src/hub/mod.ts index 0845450dc9..4b014aef7d 100644 --- a/wallets/core/src/hub/mod.ts +++ b/wallets/core/src/hub/mod.ts @@ -1,5 +1,8 @@ export { Namespace } from './namespaces/mod.js'; + export { Provider } from './provider/mod.js'; +export type { CommonNamespaces, CommonNamespaceKeys } from './provider/mod.js'; + export { Hub } from './hub.js'; export type { Store, State, ProviderInfo } from './store/mod.js'; export { diff --git a/wallets/core/src/hub/namespaces/namespace.ts b/wallets/core/src/hub/namespaces/namespace.ts index 00923bed65..76487ca474 100644 --- a/wallets/core/src/hub/namespaces/namespace.ts +++ b/wallets/core/src/hub/namespaces/namespace.ts @@ -406,7 +406,10 @@ class Namespace> { return orAction(context, prev); }, actionError); } catch (orError) { - throw new Error(OR_ELSE_ACTION_FAILED_ERROR(actionName.toString()), { + const errorMessage = OR_ELSE_ACTION_FAILED_ERROR( + `${actionName.toString()} for ${this.namespaceId} namespace.` + ); + throw new Error(errorMessage, { cause: actionError, }); } diff --git a/wallets/core/src/hub/provider/mod.ts b/wallets/core/src/hub/provider/mod.ts index 92da36f4d7..720b58f7ee 100644 --- a/wallets/core/src/hub/provider/mod.ts +++ b/wallets/core/src/hub/provider/mod.ts @@ -1,6 +1,7 @@ export type { ExtendableInternalActions, CommonNamespaces, + CommonNamespaceKeys, State, Context, ProviderBuilderOptions, diff --git a/wallets/core/src/hub/provider/types.ts b/wallets/core/src/hub/provider/types.ts index c227d64484..cfc3e0e3c6 100644 --- a/wallets/core/src/hub/provider/types.ts +++ b/wallets/core/src/hub/provider/types.ts @@ -1,9 +1,11 @@ +import type { FindProxiedNamespace } from '../../builders/mod.js'; +import type { Store } from '../../hub/mod.js'; import type { LegacyState } from '../../legacy/mod.js'; -import type { NamespaceInterface, Store } from '../../mod.js'; import type { CosmosActions } from '../../namespaces/cosmos/mod.js'; import type { EvmActions } from '../../namespaces/evm/mod.js'; import type { SolanaActions } from '../../namespaces/solana/mod.js'; import type { AnyFunction, FunctionWithContext } from '../../types/actions.js'; +import type { Prettify } from '../../types/utils.js'; export type Context = { state: () => [GetState, SetState]; @@ -25,13 +27,15 @@ export interface CommonNamespaces { cosmos: CosmosActions; } +export type CommonNamespaceKeys = Prettify; + export interface ExtendableInternalActions { init?: FunctionWithContext; } export type RegisteredNamespaces = Map< K, - NamespaceInterface + FindProxiedNamespace >; export type ProviderBuilderOptions = { store?: Store }; diff --git a/wallets/core/src/hub/store/providers.ts b/wallets/core/src/hub/store/providers.ts index 470e3d20c5..d0ceccb87c 100644 --- a/wallets/core/src/hub/store/providers.ts +++ b/wallets/core/src/hub/store/providers.ts @@ -1,20 +1,14 @@ -import type { - CommonNamespaces, - State as InternalProviderState, -} from '../provider/mod.js'; +import type { State as InternalProviderState } from '../provider/mod.js'; +import type { CommonNamespaceKeys } from '../provider/types.js'; import type { StateCreator } from 'zustand'; import { produce } from 'immer'; import { guessProviderStateSelector, type State } from './mod.js'; -type NamespaceName = - | keyof CommonNamespaces - | Omit; - type Browsers = 'firefox' | 'chrome' | 'edge' | 'brave' | 'homepage'; type Property = { name: N; value: V }; -type DetachedInstances = Property<'detached', NamespaceName[]>; +type DetachedInstances = Property<'detached', CommonNamespaceKeys[]>; export type ProviderInfo = { name: string; diff --git a/wallets/core/src/legacy/mod.ts b/wallets/core/src/legacy/mod.ts index 73a9307d6d..7b2d9f211a 100644 --- a/wallets/core/src/legacy/mod.ts +++ b/wallets/core/src/legacy/mod.ts @@ -23,6 +23,8 @@ export type { InstallObjects as LegacyInstallObjects, WalletInfo as LegacyWalletInfo, ConnectResult as LegacyConnectResult, + NamespaceInputForConnect as LegacyNamespaceInputForConnect, + NamespaceInputWithDiscoverMode as LegacyNamespaceInputWithDiscoverMode, } from './types.js'; export { @@ -35,5 +37,12 @@ export { Persistor } from './persistor.js'; export { readAccountAddress as legacyReadAccountAddress, getBlockChainNameFromId as legacyGetBlockChainNameFromId, + formatAddressWithNetwork as legacyFormatAddressWithNetwork, } from './helpers.js'; export { default as LegacyWallet } from './wallet.js'; + +export { + eagerConnectHandler as legacyEagerConnectHandler, + isNamespaceDiscoverMode as legacyIsNamespaceDiscoverMode, + isEvmNamespace as legacyIsEvmNamespace, +} from './utils.js'; diff --git a/wallets/core/src/legacy/types.ts b/wallets/core/src/legacy/types.ts index 49dab544ea..6d97d0e1af 100644 --- a/wallets/core/src/legacy/types.ts +++ b/wallets/core/src/legacy/types.ts @@ -93,6 +93,9 @@ export type WalletInfo = { name: string; img: string; installLink: InstallObjects | string; + /** + * @deprecated we don't use this value anymore. + */ color: string; supportedChains: BlockchainMeta[]; showOnMobile?: boolean; @@ -202,6 +205,12 @@ export type CanEagerConnect = (options: { meta: BlockchainMeta[]; }) => Promise; +export type EagerConnectResult = { + accounts: string[] | null; + network: string | null; + provider: I | null; +}; + export interface WalletActions { connect: Connect; getInstance: any; @@ -235,3 +244,36 @@ export type WalletProviders = Map< >; export type ProviderInterface = { config: WalletConfig } & WalletActions; + +// it comes from wallets.ts and `connect` +type NetworkTypeFromLegacyConnect = Network | undefined; + +export type NetworkTypeForNamespace = + T extends 'DISCOVER_MODE' + ? string + : T extends Namespace + ? NetworkTypeFromLegacyConnect + : never; + +export type NamespacesWithDiscoverMode = Namespace | 'DISCOVER_MODE'; + +export type NamespaceInputWithDiscoverMode = { + namespace: 'DISCOVER_MODE'; + network: string; + derivationPath?: string; +}; + +export type NamespaceInputForConnect = + | { + /** + * By default, you should specify namespace (e.g. evm). + * For backward compatibility with legacy implementation, DISCOVER_MODE will try to map a list of known (and hardcoded) networks to a namespace. + */ + namespace: T; + /** + * In some cases, we need to connect a specific network on a namespace. e.g. Polygon on EVM. + */ + network: NetworkTypeForNamespace; + derivationPath?: string; + } + | NamespaceInputWithDiscoverMode; diff --git a/wallets/core/src/legacy/utils.ts b/wallets/core/src/legacy/utils.ts new file mode 100644 index 0000000000..99eabe1434 --- /dev/null +++ b/wallets/core/src/legacy/utils.ts @@ -0,0 +1,31 @@ +import type { + NamespaceInputForConnect, + NamespaceInputWithDiscoverMode, +} from './types.js'; + +import { Namespace } from './types.js'; + +export async function eagerConnectHandler(params: { + canEagerConnect: () => Promise; + connectHandler: () => Promise; + providerName: string; +}) { + // Check if we can eagerly connect to the wallet + if (await params.canEagerConnect()) { + // Connect to wallet as usual + return await params.connectHandler(); + } + throw new Error(`can't restore connection for ${params.providerName}.`); +} + +export function isNamespaceDiscoverMode( + namespace: NamespaceInputForConnect +): namespace is NamespaceInputWithDiscoverMode { + return namespace.namespace === 'DISCOVER_MODE'; +} + +export function isEvmNamespace( + namespace: NamespaceInputForConnect +): namespace is NamespaceInputForConnect { + return namespace.namespace === Namespace.Evm; +} diff --git a/wallets/core/src/legacy/wallet.ts b/wallets/core/src/legacy/wallet.ts index 4bb17fd19d..199945071c 100644 --- a/wallets/core/src/legacy/wallet.ts +++ b/wallets/core/src/legacy/wallet.ts @@ -1,4 +1,5 @@ import type { + EagerConnectResult, GetInstanceOptions, NamespaceData, Network, @@ -14,6 +15,7 @@ import { needsCheckInstallation, } from './helpers.js'; import { Events, Networks } from './types.js'; +import { eagerConnectHandler } from './utils.js'; export type EventHandler = ( type: WalletType, @@ -26,11 +28,16 @@ export type EventHandler = ( export type EventInfo = { supportedBlockchains: BlockchainMeta[]; isContractWallet: boolean; + // This is for Hub and be able to make it compatible with legacy behavior. + isHub: boolean; }; export interface State { connected: boolean; connecting: boolean; + /** + * @depreacted it always returns `false`. don't use it. + */ reachable: boolean; installed: boolean; accounts: string[] | null; @@ -57,6 +64,7 @@ class Wallet { this.info = { supportedBlockchains: [], isContractWallet: false, + isHub: false, }; this.state = { connected: false, @@ -264,26 +272,30 @@ class Wallet { } // This method is only used for auto connection - async eagerConnect() { + async eagerConnect(): Promise> { const instance = await this.tryGetInstance({ network: undefined }); const { canEagerConnect } = this.actions; - const error_message = `can't restore connection for ${this.options.config.type} .`; + const providerName = this.options.config.type; - if (canEagerConnect) { - // Check if we can eagerly connect to the wallet - const eagerConnection = await canEagerConnect({ - instance: instance, - meta: this.info.supportedBlockchains, - }); + return await eagerConnectHandler({ + canEagerConnect: async () => { + if (!canEagerConnect) { + throw new Error( + `${providerName} provider hasn't implemented canEagerConnect.` + ); + } - if (eagerConnection) { - // Connect to wallet as usual - return this.connect(); - } - throw new Error(error_message); - } else { - throw new Error(error_message); - } + return await canEagerConnect({ + instance: instance, + meta: this.info.supportedBlockchains, + }); + }, + connectHandler: async () => { + const result = await this.connect(); + return result; + }, + providerName, + }); } async getSigners(provider: any) { @@ -408,6 +420,7 @@ class Wallet { const eventInfo: EventInfo = { supportedBlockchains: this.info.supportedBlockchains, isContractWallet: this.info.isContractWallet, + isHub: false, }; this.options.handler( this.options.config.type, diff --git a/wallets/core/src/mod.ts b/wallets/core/src/mod.ts index a84c47092b..b6eb03010f 100644 --- a/wallets/core/src/mod.ts +++ b/wallets/core/src/mod.ts @@ -1,4 +1,10 @@ -export type { Store, State, ProviderInfo } from './hub/mod.js'; +export type { + Store, + State, + ProviderInfo, + CommonNamespaces, + CommonNamespaceKeys, +} from './hub/mod.js'; export { Hub, Provider, @@ -7,10 +13,8 @@ export { guessProviderStateSelector, namespaceStateSelector, } from './hub/mod.js'; -export type { - ProxiedNamespace, - FindProxiedNamespace as NamespaceInterface, -} from './builders/mod.js'; + +export type { ProxiedNamespace, FindProxiedNamespace } from './builders/mod.js'; export { NamespaceBuilder, ProviderBuilder, @@ -31,5 +35,5 @@ export { * To make it work for Parcel, we should go with second mentioned option. * */ -export type { Versions } from './utils/mod.js'; +export type { VersionedProviders } from './utils/mod.js'; export { defineVersions, pickVersion } from './utils/mod.js'; diff --git a/wallets/core/src/namespaces/common/mod.ts b/wallets/core/src/namespaces/common/mod.ts index bf9cf00906..82aec39630 100644 --- a/wallets/core/src/namespaces/common/mod.ts +++ b/wallets/core/src/namespaces/common/mod.ts @@ -10,3 +10,9 @@ export { recommended as andRecommended, } from './and.js'; export { intoConnecting, recommended as beforeRecommended } from './before.js'; + +export type { + CaipAccount, + Accounts, + AccountsWithActiveChain, +} from '../../types/accounts.js'; diff --git a/wallets/core/src/namespaces/cosmos/types.ts b/wallets/core/src/namespaces/cosmos/types.ts index 27cde09734..83d17ba007 100644 --- a/wallets/core/src/namespaces/cosmos/types.ts +++ b/wallets/core/src/namespaces/cosmos/types.ts @@ -7,4 +7,5 @@ export interface CosmosActions extends AutoImplementedActionsByRecommended, CommonActions { // TODO + connect: () => Promise; } diff --git a/wallets/core/src/utils/versions.ts b/wallets/core/src/utils/versions.ts index 0abee95745..e28e1a8997 100644 --- a/wallets/core/src/utils/versions.ts +++ b/wallets/core/src/utils/versions.ts @@ -1,22 +1,21 @@ import type { Provider } from '../hub/mod.js'; import type { LegacyProviderInterface } from '../legacy/mod.js'; -type VersionedVLegacy = ['0.0.0', LegacyProviderInterface]; -type VersionedV1 = ['1.0.0', Provider]; -type AvailableVersions = VersionedVLegacy | VersionedV1; -export type Versions = AvailableVersions[]; -// eslint-disable-next-line @typescript-eslint/no-magic-numbers -export type VersionInterface = T[1]; +type LegacyVersioned = ['0.0.0', LegacyProviderInterface]; +type HubVersioned = ['1.0.0', Provider]; +type AvailableVersionedProviders = LegacyVersioned | HubVersioned; +export type VersionedProviders = AvailableVersionedProviders[]; +export type VersionInterface = T[1]; type SemVer = T extends [infer U, any] ? U : never; -type MatchVersion = Extract< +type MatchVersion = Extract< T[number], [Version, any] >; export function pickVersion< - L extends Versions, - V extends SemVer + L extends VersionedProviders, + V extends SemVer >(list: L, targetVersion: V): MatchVersion { if (!targetVersion) { throw new Error(`You should provide a valid semver, e.g 1.0.0.`); @@ -35,15 +34,15 @@ export function pickVersion< } interface DefineVersionsApi { - version: >( + version: >( semver: T, - value: VersionInterface> + value: VersionInterface> ) => DefineVersionsApi; - build: () => Versions; + build: () => VersionedProviders; } export function defineVersions(): DefineVersionsApi { - const versions: Versions = []; + const versions: VersionedProviders = []; const api: DefineVersionsApi = { version: (semver, value) => { versions.push([semver, value]); @@ -58,6 +57,6 @@ export function defineVersions(): DefineVersionsApi { export function legacyProviderImportsToVersionsInterface( provider: LegacyProviderInterface -): Versions { +): VersionedProviders { return defineVersions().version('0.0.0', provider).build(); } diff --git a/wallets/provider-all/src/index.ts b/wallets/provider-all/src/index.ts index 4e4e1ea99e..b2689179b9 100644 --- a/wallets/provider-all/src/index.ts +++ b/wallets/provider-all/src/index.ts @@ -21,7 +21,7 @@ import * as ledger from '@rango-dev/provider-ledger'; import * as mathwallet from '@rango-dev/provider-math-wallet'; import * as metamask from '@rango-dev/provider-metamask'; import * as okx from '@rango-dev/provider-okx'; -import * as phantom from '@rango-dev/provider-phantom'; +import { versions as phantom } from '@rango-dev/provider-phantom'; import * as rabby from '@rango-dev/provider-rabby'; import * as safe from '@rango-dev/provider-safe'; import * as safepal from '@rango-dev/provider-safepal'; @@ -35,6 +35,10 @@ import * as tronLink from '@rango-dev/provider-tron-link'; import * as trustwallet from '@rango-dev/provider-trustwallet'; import * as walletconnect2 from '@rango-dev/provider-walletconnect-2'; import * as xdefi from '@rango-dev/provider-xdefi'; +import { + legacyProviderImportsToVersionsInterface, + type VersionedProviders, +} from '@rango-dev/wallets-core/utils'; import { type WalletType, WalletTypes } from '@rango-dev/wallets-shared'; import { isWalletExcluded } from './helpers.js'; @@ -45,7 +49,7 @@ interface Options { trezor?: TrezorEnvironments; } -export const allProviders = (options?: Options) => { +export const allProviders = (options?: Options): VersionedProviders[] => { const providers = options?.selectedProviders || []; if ( @@ -75,38 +79,38 @@ export const allProviders = (options?: Options) => { } return [ - safe, - defaultInjected, - metamask, - solflareSnap, - walletconnect2, - keplr, + legacyProviderImportsToVersionsInterface(safe), + legacyProviderImportsToVersionsInterface(defaultInjected), + legacyProviderImportsToVersionsInterface(metamask), + legacyProviderImportsToVersionsInterface(solflareSnap), + legacyProviderImportsToVersionsInterface(walletconnect2), + legacyProviderImportsToVersionsInterface(keplr), phantom, - argentx, - tronLink, - trustwallet, - bitget, - enkrypt, - xdefi, - clover, - safepal, - brave, - coin98, - coinbase, - cosmostation, - exodus, - mathwallet, - okx, - tokenpocket, - tomo, - halo, - leapCosmos, - frontier, - taho, - braavos, - ledger, - rabby, - trezor, - solflare, + legacyProviderImportsToVersionsInterface(argentx), + legacyProviderImportsToVersionsInterface(tronLink), + legacyProviderImportsToVersionsInterface(trustwallet), + legacyProviderImportsToVersionsInterface(bitget), + legacyProviderImportsToVersionsInterface(enkrypt), + legacyProviderImportsToVersionsInterface(xdefi), + legacyProviderImportsToVersionsInterface(clover), + legacyProviderImportsToVersionsInterface(safepal), + legacyProviderImportsToVersionsInterface(brave), + legacyProviderImportsToVersionsInterface(coin98), + legacyProviderImportsToVersionsInterface(coinbase), + legacyProviderImportsToVersionsInterface(cosmostation), + legacyProviderImportsToVersionsInterface(exodus), + legacyProviderImportsToVersionsInterface(mathwallet), + legacyProviderImportsToVersionsInterface(okx), + legacyProviderImportsToVersionsInterface(tokenpocket), + legacyProviderImportsToVersionsInterface(tomo), + legacyProviderImportsToVersionsInterface(halo), + legacyProviderImportsToVersionsInterface(leapCosmos), + legacyProviderImportsToVersionsInterface(frontier), + legacyProviderImportsToVersionsInterface(taho), + legacyProviderImportsToVersionsInterface(braavos), + legacyProviderImportsToVersionsInterface(ledger), + legacyProviderImportsToVersionsInterface(rabby), + legacyProviderImportsToVersionsInterface(trezor), + legacyProviderImportsToVersionsInterface(solflare), ]; }; diff --git a/wallets/provider-phantom/package.json b/wallets/provider-phantom/package.json index 3dc2b9c35e..fc23ec03ae 100644 --- a/wallets/provider-phantom/package.json +++ b/wallets/provider-phantom/package.json @@ -3,18 +3,18 @@ "version": "0.38.0", "license": "MIT", "type": "module", - "source": "./src/index.ts", - "main": "./dist/index.js", + "source": "./src/mod.ts", + "main": "./dist/mod.js", "exports": { - ".": "./dist/index.js" + ".": "./dist/mod.js" }, - "typings": "dist/index.d.ts", + "typings": "dist/mod.d.ts", "files": [ "dist", "src" ], "scripts": { - "build": "node ../../scripts/build/command.mjs --path wallets/provider-phantom", + "build": "node ../../scripts/build/command.mjs --path wallets/provider-phantom --inputs src/mod.ts", "ts-check": "tsc --declaration --emitDeclarationOnly -p ./tsconfig.json", "clean": "rimraf dist", "format": "prettier --write '{.,src}/**/*.{ts,tsx}'", @@ -28,4 +28,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/wallets/provider-phantom/src/constants.ts b/wallets/provider-phantom/src/constants.ts new file mode 100644 index 0000000000..14b8bbf497 --- /dev/null +++ b/wallets/provider-phantom/src/constants.ts @@ -0,0 +1,26 @@ +import { type ProviderInfo } from '@rango-dev/wallets-core'; +import { LegacyNetworks } from '@rango-dev/wallets-core/legacy'; + +export const EVM_SUPPORTED_CHAINS = [ + LegacyNetworks.ETHEREUM, + LegacyNetworks.POLYGON, +]; + +export const WALLET_ID = 'phantom'; + +export const info: ProviderInfo = { + name: 'Phantom', + icon: 'https://raw.githubusercontent.com/rango-exchange/assets/main/wallets/phantom/icon.svg', + extensions: { + chrome: + 'https://chrome.google.com/webstore/detail/phantom/bfnaelmomeimhlpmgjnjophhpkkoljpa', + homepage: 'https://phantom.app/', + }, + properties: [ + { + name: 'detached', + // if you are adding a new namespace, don't forget to also update `getWalletInfo` + value: ['solana', 'evm'], + }, + ], +}; diff --git a/wallets/provider-phantom/src/helpers.ts b/wallets/provider-phantom/src/helpers.ts deleted file mode 100644 index 007b5ed099..0000000000 --- a/wallets/provider-phantom/src/helpers.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function phantom() { - if ('phantom' in window) { - const instance = window.phantom?.solana; - - if (instance?.isPhantom) { - return instance; - } - } - - return null; -} diff --git a/wallets/provider-phantom/src/index.ts b/wallets/provider-phantom/src/index.ts deleted file mode 100644 index 8e0f184a66..0000000000 --- a/wallets/provider-phantom/src/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { - CanEagerConnect, - CanSwitchNetwork, - Connect, - Subscribe, - WalletInfo, -} from '@rango-dev/wallets-shared'; -import type { BlockchainMeta, SignerFactory } from 'rango-types'; - -import { - getSolanaAccounts, - Networks, - WalletTypes, -} from '@rango-dev/wallets-shared'; -import { solanaBlockchain } from 'rango-types'; - -import { phantom as phantom_instance } from './helpers.js'; -import signer from './signer.js'; - -const WALLET = WalletTypes.PHANTOM; - -export const config = { - type: WALLET, -}; - -export const getInstance = phantom_instance; -export const connect: Connect = getSolanaAccounts; - -export const subscribe: Subscribe = ({ instance, updateAccounts, connect }) => { - const handleAccountsChanged = async (publicKey: string) => { - const network = Networks.SOLANA; - if (publicKey) { - const account = publicKey.toString(); - updateAccounts([account]); - } else { - connect(network); - } - }; - instance?.on?.('accountChanged', handleAccountsChanged); - - return () => { - instance?.off?.('accountChanged', handleAccountsChanged); - }; -}; - -export const canSwitchNetworkTo: CanSwitchNetwork = () => false; - -export const getSigners: (provider: any) => Promise = signer; - -export const canEagerConnect: CanEagerConnect = async ({ instance }) => { - try { - const result = await instance.connect({ onlyIfTrusted: true }); - return !!result; - } catch (error) { - return false; - } -}; - -export const getWalletInfo: (allBlockChains: BlockchainMeta[]) => WalletInfo = ( - allBlockChains -) => { - const solana = solanaBlockchain(allBlockChains); - return { - name: 'Phantom', - img: 'https://raw.githubusercontent.com/rango-exchange/assets/main/wallets/phantom/icon.svg', - installLink: { - CHROME: - 'https://chrome.google.com/webstore/detail/phantom/bfnaelmomeimhlpmgjnjophhpkkoljpa', - - DEFAULT: 'https://phantom.app/', - }, - color: '#4d40c6', - supportedChains: solana, - }; -}; diff --git a/wallets/provider-phantom/src/legacy/index.ts b/wallets/provider-phantom/src/legacy/index.ts new file mode 100644 index 0000000000..527f77ed39 --- /dev/null +++ b/wallets/provider-phantom/src/legacy/index.ts @@ -0,0 +1,124 @@ +import type { LegacyProviderInterface } from '@rango-dev/wallets-core/legacy'; +import type { + CanEagerConnect, + CanSwitchNetwork, + Connect, + Subscribe, + WalletInfo, +} from '@rango-dev/wallets-shared'; +import type { + BlockchainMeta, + EvmBlockchainMeta, + SignerFactory, +} from 'rango-types'; + +import { LegacyNetworks as Networks } from '@rango-dev/wallets-core/legacy'; +import { + chooseInstance, + getSolanaAccounts, + WalletTypes, +} from '@rango-dev/wallets-shared'; +import { isEvmBlockchain, solanaBlockchain } from 'rango-types'; + +import { EVM_SUPPORTED_CHAINS } from '../constants.js'; +import { phantom as phantom_instance } from '../utils.js'; + +import signer from './signer.js'; + +const WALLET = WalletTypes.PHANTOM; + +export const config = { + type: WALLET, +}; + +export const getInstance = phantom_instance; + +/* + * NOTE: Phantom's Hub version has support for EVM as well since we are deprecating the legacy, + * we just want to keep the implementation for some time and then legacy provider will be removed soon. + * So we don't add new namespaces (like EVM) to legacy. + */ +const connect: Connect = async ({ instance, meta }) => { + const solanaInstance = instance.get(Networks.SOLANA); + const result = await getSolanaAccounts({ + instance: solanaInstance, + meta, + }); + + return result; +}; + +export const subscribe: Subscribe = ({ instance, updateAccounts, connect }) => { + const handleAccountsChanged = async (publicKey: string) => { + const network = Networks.SOLANA; + if (publicKey) { + const account = publicKey.toString(); + updateAccounts([account]); + } else { + connect(network); + } + }; + instance?.on?.('accountChanged', handleAccountsChanged); + + return () => { + instance?.off?.('accountChanged', handleAccountsChanged); + }; +}; + +const canSwitchNetworkTo: CanSwitchNetwork = ({ network }) => { + return EVM_SUPPORTED_CHAINS.includes(network as Networks); +}; + +export const getSigners: (provider: any) => Promise = signer; + +const canEagerConnect: CanEagerConnect = async ({ instance, meta }) => { + const solanaInstance = chooseInstance(instance, meta, Networks.SOLANA); + try { + const result = await solanaInstance.connect({ onlyIfTrusted: true }); + return !!result; + } catch (error) { + return false; + } +}; +export const getWalletInfo: (allBlockChains: BlockchainMeta[]) => WalletInfo = ( + allBlockChains +) => { + const solana = solanaBlockchain(allBlockChains); + const evms = allBlockChains.filter( + (chain): chain is EvmBlockchainMeta => + isEvmBlockchain(chain) && + EVM_SUPPORTED_CHAINS.includes(chain.name as Networks) + ); + + return { + name: 'Phantom', + img: 'https://raw.githubusercontent.com/rango-exchange/assets/main/wallets/phantom/icon.svg', + installLink: { + CHROME: + 'https://chrome.google.com/webstore/detail/phantom/bfnaelmomeimhlpmgjnjophhpkkoljpa', + + DEFAULT: 'https://phantom.app/', + }, + color: '#4d40c6', + // if you are adding a new namespace, don't forget to also update `properties` + supportedChains: [ + ...solana, + ...evms.filter((chain) => + EVM_SUPPORTED_CHAINS.includes(chain.name as Networks) + ), + ], + }; +}; + +const legacyProvider: LegacyProviderInterface = { + config, + getInstance, + connect, + subscribe, + canSwitchNetworkTo, + getSigners, + getWalletInfo, + canEagerConnect, +}; + +export { legacyProvider }; diff --git a/wallets/provider-phantom/src/signer.ts b/wallets/provider-phantom/src/legacy/signer.ts similarity index 58% rename from wallets/provider-phantom/src/signer.ts rename to wallets/provider-phantom/src/legacy/signer.ts index 42397b7562..c0af13aaae 100644 --- a/wallets/provider-phantom/src/signer.ts +++ b/wallets/provider-phantom/src/legacy/signer.ts @@ -1,14 +1,19 @@ import type { SignerFactory } from 'rango-types'; -import { getNetworkInstance, Networks } from '@rango-dev/wallets-shared'; +import { LegacyNetworks as Networks } from '@rango-dev/wallets-core/legacy'; +import { getNetworkInstance } from '@rango-dev/wallets-shared'; import { DefaultSignerFactory, TransactionType as TxType } from 'rango-types'; export default async function getSigners( provider: any ): Promise { const solProvider = getNetworkInstance(provider, Networks.SOLANA); - const signers = new DefaultSignerFactory(); + const evmProvider = getNetworkInstance(provider, Networks.ETHEREUM); + + const { DefaultEvmSigner } = await import('@rango-dev/signer-evm'); const { DefaultSolanaSigner } = await import('@rango-dev/signer-solana'); + const signers = new DefaultSignerFactory(); signers.registerSigner(TxType.SOLANA, new DefaultSolanaSigner(solProvider)); + signers.registerSigner(TxType.EVM, new DefaultEvmSigner(evmProvider)); return signers; } diff --git a/wallets/provider-phantom/src/mod.ts b/wallets/provider-phantom/src/mod.ts new file mode 100644 index 0000000000..c762b39d44 --- /dev/null +++ b/wallets/provider-phantom/src/mod.ts @@ -0,0 +1,11 @@ +import { defineVersions } from '@rango-dev/wallets-core/utils'; + +import { legacyProvider } from './legacy/index.js'; +import { provider } from './provider.js'; + +const versions = defineVersions() + .version('0.0.0', legacyProvider) + .version('1.0.0', provider) + .build(); + +export { versions }; diff --git a/wallets/provider-phantom/src/namespaces/evm.ts b/wallets/provider-phantom/src/namespaces/evm.ts new file mode 100644 index 0000000000..4343aec514 --- /dev/null +++ b/wallets/provider-phantom/src/namespaces/evm.ts @@ -0,0 +1,36 @@ +import type { EvmActions } from '@rango-dev/wallets-core/namespaces/evm'; + +import { NamespaceBuilder } from '@rango-dev/wallets-core'; +import { builders as commonBuilders } from '@rango-dev/wallets-core/namespaces/common'; +import { actions, builders } from '@rango-dev/wallets-core/namespaces/evm'; + +import { WALLET_ID } from '../constants.js'; +import { evmPhantom } from '../utils.js'; + +const [changeAccountSubscriber, changeAccountCleanup] = + actions.changeAccountSubscriber(evmPhantom); + +/* + * TODO: If user imported a private key for EVM, it hasn't solana. + * when trying to connect to solana for this user we go through `-32603` which is an internal error. + * If phantom added an specific error code for this situation, we can consider handling the error here. + * @see https://docs.phantom.app/solana/errors + */ +const connect = builders + .connect() + .action(actions.connect(evmPhantom)) + .before(changeAccountSubscriber) + .or(changeAccountCleanup) + .build(); + +const disconnect = commonBuilders + .disconnect() + .after(changeAccountCleanup) + .build(); + +const evm = new NamespaceBuilder('EVM', WALLET_ID) + .action(connect) + .action(disconnect) + .build(); + +export { evm }; diff --git a/wallets/provider-phantom/src/namespaces/solana.ts b/wallets/provider-phantom/src/namespaces/solana.ts new file mode 100644 index 0000000000..0a5506e4fd --- /dev/null +++ b/wallets/provider-phantom/src/namespaces/solana.ts @@ -0,0 +1,68 @@ +import type { CaipAccount } from '@rango-dev/wallets-core/namespaces/common'; +import type { SolanaActions } from '@rango-dev/wallets-core/namespaces/solana'; + +import { NamespaceBuilder } from '@rango-dev/wallets-core'; +import { builders as commonBuilders } from '@rango-dev/wallets-core/namespaces/common'; +import { + actions, + builders, + CAIP_NAMESPACE, + CAIP_SOLANA_CHAIN_ID, +} from '@rango-dev/wallets-core/namespaces/solana'; +import { CAIP } from '@rango-dev/wallets-core/utils'; +import { getSolanaAccounts } from '@rango-dev/wallets-shared'; + +import { WALLET_ID } from '../constants.js'; +import { solanaPhantom } from '../utils.js'; + +const [changeAccountSubscriber, changeAccountCleanup] = + actions.changeAccountSubscriber(solanaPhantom); + +/* + * TODO: If user imported a private key for EVM, it hasn't solana. + * when trying to connect to solana for this user we go through `-32603` which is an internal error. + * If phantom added an specific error code for this situation, we can consider handling the error here. + * @see https://docs.phantom.app/solana/errors + */ +const connect = builders + .connect() + .action(async function () { + const solanaInstance = solanaPhantom(); + const result = await getSolanaAccounts({ + instance: solanaInstance, + meta: [], + }); + if (Array.isArray(result)) { + throw new Error( + 'Expecting solana response to be a single value, not an array.' + ); + } + + const formatAccounts = result.accounts.map( + (account) => + CAIP.AccountId.format({ + address: account, + chainId: { + namespace: CAIP_NAMESPACE, + reference: CAIP_SOLANA_CHAIN_ID, + }, + }) as CaipAccount + ); + + return formatAccounts; + }) + .before(changeAccountSubscriber) + .or(changeAccountCleanup) + .build(); + +const disconnect = commonBuilders + .disconnect() + .after(changeAccountCleanup) + .build(); + +const solana = new NamespaceBuilder('Solana', WALLET_ID) + .action(connect) + .action(disconnect) + .build(); + +export { solana }; diff --git a/wallets/provider-phantom/src/provider.ts b/wallets/provider-phantom/src/provider.ts new file mode 100644 index 0000000000..8e022815ca --- /dev/null +++ b/wallets/provider-phantom/src/provider.ts @@ -0,0 +1,22 @@ +import { ProviderBuilder } from '@rango-dev/wallets-core'; + +import { info, WALLET_ID } from './constants.js'; +import { evm } from './namespaces/evm.js'; +import { solana } from './namespaces/solana.js'; +import { phantom as phantomInstance } from './utils.js'; + +const provider = new ProviderBuilder(WALLET_ID) + .init(function (context) { + const [, setState] = context.state(); + + if (phantomInstance()) { + setState('installed', true); + console.debug('[phantom] instance detected.', context); + } + }) + .config('info', info) + .add('solana', solana) + .add('evm', evm) + .build(); + +export { provider }; diff --git a/wallets/provider-phantom/src/utils.ts b/wallets/provider-phantom/src/utils.ts new file mode 100644 index 0000000000..be8309b6df --- /dev/null +++ b/wallets/provider-phantom/src/utils.ts @@ -0,0 +1,55 @@ +import type { ProviderAPI as EvmProviderApi } from '@rango-dev/wallets-core/namespaces/evm'; +import type { ProviderAPI as SolanaProviderApi } from '@rango-dev/wallets-core/namespaces/solana'; + +import { LegacyNetworks } from '@rango-dev/wallets-core/legacy'; + +type Provider = Map; + +export function phantom(): Provider | null { + const { phantom } = window; + + if (!phantom) { + return null; + } + + const { solana, ethereum } = phantom; + + const instances: Provider = new Map(); + + if (ethereum && ethereum.isPhantom) { + instances.set(LegacyNetworks.ETHEREUM, ethereum); + } + + if (solana && solana.isPhantom) { + instances.set(LegacyNetworks.SOLANA, solana); + } + + return instances; +} + +export function evmPhantom(): EvmProviderApi { + const instances = phantom(); + + const evmInstance = instances?.get(LegacyNetworks.ETHEREUM); + + if (!evmInstance) { + throw new Error( + 'Phantom not injected or EVM not enabled. Please check your wallet.' + ); + } + + return evmInstance as EvmProviderApi; +} + +export function solanaPhantom(): SolanaProviderApi { + const instance = phantom(); + const solanaInstance = instance?.get(LegacyNetworks.SOLANA); + + if (!solanaInstance) { + throw new Error( + 'Phantom not injected or Solana not enabled. Please check your wallet.' + ); + } + + return solanaInstance; +} diff --git a/wallets/provider-phantom/tsconfig.build.json b/wallets/provider-phantom/tsconfig.build.json index d9181ce0cd..fc43a1c995 100644 --- a/wallets/provider-phantom/tsconfig.build.json +++ b/wallets/provider-phantom/tsconfig.build.json @@ -1,7 +1,8 @@ { // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs "extends": "../../tsconfig.libnext.json", - "include": ["src", "types", "../../global-wallets-env.d.ts"], + "include": ["src", "types"], + "files": ["../../global-wallets-env.d.ts"], "compilerOptions": { "outDir": "dist", "rootDir": "./src", diff --git a/wallets/react/src/hub/autoConnect.ts b/wallets/react/src/hub/autoConnect.ts new file mode 100644 index 0000000000..f4551ec089 --- /dev/null +++ b/wallets/react/src/hub/autoConnect.ts @@ -0,0 +1,186 @@ +import type { AllProxiedNamespaces } from './types.js'; +import type { UseAdapterParams } from './useHubAdapter.js'; +import type { Hub } from '@rango-dev/wallets-core'; +import type { + LegacyNamespaceInputForConnect, + LegacyProviderInterface, + LegacyNamespace as Namespace, +} from '@rango-dev/wallets-core/legacy'; + +import { + legacyEagerConnectHandler, + legacyIsEvmNamespace, + legacyIsNamespaceDiscoverMode, +} from '@rango-dev/wallets-core/legacy'; + +import { HUB_LAST_CONNECTED_WALLETS } from '../legacy/mod.js'; + +import { sequentiallyRun } from './helpers.js'; +import { LastConnectedWalletsFromStorage } from './lastConnectedWallets.js'; +import { + convertNamespaceNetworkToEvmChainId, + discoverNamespace, +} from './utils.js'; + +/** + * Run `.connect` action on some selected namespaces (passed as param) for a provider. + */ +async function eagerConnect( + type: string, + namespacesInput: LegacyNamespaceInputForConnect[] | undefined, + params: { + getHub: () => Hub; + allBlockChains: UseAdapterParams['allBlockChains']; + } +) { + const { getHub, allBlockChains } = params; + const wallet = getHub().get(type); + if (!wallet) { + throw new Error( + `You should add ${type} to provider first then call 'connect'.` + ); + } + + if (!namespacesInput) { + throw new Error( + 'Passing namespace to `connect` is required. you can pass DISCOVERY_MODE for legacy.' + ); + } + + const targetNamespaces: [ + LegacyNamespaceInputForConnect, + AllProxiedNamespaces + ][] = []; + namespacesInput.forEach((namespaceInput) => { + let targetNamespace: Namespace; + if (legacyIsNamespaceDiscoverMode(namespaceInput)) { + targetNamespace = discoverNamespace(namespaceInput.network); + } else { + targetNamespace = namespaceInput.namespace; + } + + const result = wallet.findByNamespace(targetNamespace); + + if (!result) { + throw new Error( + `We couldn't find any provider matched with your request namespace. (requested namespace: ${namespaceInput.namespace})` + ); + } + + targetNamespaces.push([namespaceInput, result]); + }); + + const finalResult = targetNamespaces.map(([info, namespace]) => { + const evmChain = legacyIsEvmNamespace(info) + ? convertNamespaceNetworkToEvmChainId(info, allBlockChains || []) + : undefined; + const chain = evmChain || info.network; + + return async () => await namespace.connect(chain); + }); + + /** + * Sometimes calling methods on a instance in parallel, would cause an error in wallet. + * We are running a method at a time to make sure we are covering this. + * e.g. when we are trying to eagerConnect evm and solana on phantom at the same time, the last namespace throw an error. + */ + return await sequentiallyRun(finalResult); +} + +/* + * + * Get last connected wallets from storage then run `.connect` on them if `.canEagerConnect` returns true. + * + * Note 1: + * - It currently use `.getInstance`, `.canEagerConenct` and `getWalletInfo()`.supported chains from legacy provider implementation. + * - For each namespace, we don't have a separate `.canEagerConnect`. it's only one and will be used for all namespaces. + */ +export async function autoConnect(deps: { + getHub: () => Hub; + allBlockChains: UseAdapterParams['allBlockChains']; + getLegacyProvider: (type: string) => LegacyProviderInterface; +}): Promise { + const { getHub, allBlockChains, getLegacyProvider } = deps; + + // Getting connected wallets from storage + const lastConnectedWalletsFromStorage = new LastConnectedWalletsFromStorage( + HUB_LAST_CONNECTED_WALLETS + ); + const lastConnectedWallets = lastConnectedWalletsFromStorage.list(); + const walletIds = Object.keys(lastConnectedWallets); + + const walletsToRemoveFromPersistance: string[] = []; + + if (walletIds.length) { + const eagerConnectQueue: any[] = []; + + // Run `.connect` if `.canEagerConnect` returns `true`. + walletIds.forEach((providerName) => { + const legacyProvider = getLegacyProvider(providerName); + + let legacyInstance: any; + try { + legacyInstance = legacyProvider.getInstance(); + } catch (e) { + console.warn( + "It seems instance isn't available yet. This can happens when extension not loaded yet (sometimes when opening browser for first time) or extension is disabled." + ); + return; + } + + const namespaces: LegacyNamespaceInputForConnect[] = lastConnectedWallets[ + providerName + ].map((namespace) => ({ + namespace: namespace as Namespace, + network: undefined, + })); + + const canEagerConnect = async () => { + if (!legacyProvider.canEagerConnect) { + throw new Error( + `${providerName} provider hasn't implemented canEagerConnect.` + ); + } + return await legacyProvider.canEagerConnect({ + instance: legacyInstance, + meta: legacyProvider.getWalletInfo(allBlockChains || []) + .supportedChains, + }); + }; + const connectHandler = async () => { + return eagerConnect(providerName, namespaces, { + allBlockChains, + getHub, + }); + }; + + eagerConnectQueue.push( + legacyEagerConnectHandler({ + canEagerConnect, + connectHandler, + providerName, + }).catch((e) => { + walletsToRemoveFromPersistance.push(providerName); + throw e; + }) + ); + }); + + await Promise.allSettled(eagerConnectQueue); + + /* + *After successfully connecting to at least one wallet, + *we will removing the other wallets from persistence. + *If we are unable to connect to any wallet, + *the persistence will not be removed and the eager connection will be retried with another page load. + */ + const canRestoreAnyConnection = + walletIds.length > walletsToRemoveFromPersistance.length; + + if (canRestoreAnyConnection) { + lastConnectedWalletsFromStorage.removeWallets( + walletsToRemoveFromPersistance + ); + } + } +} diff --git a/wallets/react/src/hub/constants.ts b/wallets/react/src/hub/constants.ts new file mode 100644 index 0000000000..325f9c5f4e --- /dev/null +++ b/wallets/react/src/hub/constants.ts @@ -0,0 +1,2 @@ +export const LEGACY_LAST_CONNECTED_WALLETS = 'last-connected-wallets'; +export const HUB_LAST_CONNECTED_WALLETS = 'hub-v1-last-connected-wallets'; diff --git a/wallets/react/src/hub/helpers.ts b/wallets/react/src/hub/helpers.ts new file mode 100644 index 0000000000..2a9cfa6c7a --- /dev/null +++ b/wallets/react/src/hub/helpers.ts @@ -0,0 +1,67 @@ +import type { AllProxiedNamespaces } from './types.js'; +import type { + Accounts, + AccountsWithActiveChain, +} from '@rango-dev/wallets-core/namespaces/common'; + +import { legacyFormatAddressWithNetwork as formatAddressWithNetwork } from '@rango-dev/wallets-core/legacy'; +import { CAIP } from '@rango-dev/wallets-core/utils'; + +export function mapCaipNamespaceToLegacyNetworkName( + chainId: CAIP.ChainIdParams | string +): string { + if (typeof chainId === 'string') { + return chainId; + } + const useNamespaceAsNetworkFor = ['solana']; + + if (useNamespaceAsNetworkFor.includes(chainId.namespace.toLowerCase())) { + return chainId.namespace.toUpperCase(); + } + + if (chainId.namespace.toLowerCase() === 'eip155') { + return 'ETH'; + } + + return chainId.reference; +} + +/** + * CAIP's accountId has a format like this: eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb + * Legacy format is something like this: ETH:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb + * This function will try to convert this two format. + * + * @see https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-10.md + */ +export function fromAccountIdToLegacyAddressFormat(account: string): string { + const { chainId, address } = CAIP.AccountId.parse(account); + const network = mapCaipNamespaceToLegacyNetworkName(chainId); + return formatAddressWithNetwork(address, network); +} + +/** + * Getting a list of (lazy) promises and run them one after another. + */ +export async function sequentiallyRun Promise>( + promises: Array +): Promise Promise ? R : never>> { + const result = await promises.reduce(async (prev, task) => { + const previousResults = await prev; + const taskResult = await task(); + + return [...previousResults, taskResult]; + }, Promise.resolve([]) as Promise); + return result; +} + +export function isConnectResultEvm( + result: Awaited> +): result is AccountsWithActiveChain { + return typeof result === 'object' && !Array.isArray(result); +} + +export function isConnectResultSolana( + result: Awaited> +): result is Accounts { + return Array.isArray(result); +} diff --git a/wallets/react/src/hub/lastConnectedWallets.ts b/wallets/react/src/hub/lastConnectedWallets.ts new file mode 100644 index 0000000000..ff0b6c15c3 --- /dev/null +++ b/wallets/react/src/hub/lastConnectedWallets.ts @@ -0,0 +1,117 @@ +import { Persistor } from '@rango-dev/wallets-core/legacy'; + +import { + HUB_LAST_CONNECTED_WALLETS, + LEGACY_LAST_CONNECTED_WALLETS, +} from './constants.js'; + +export interface LastConnectedWalletsStorage { + [providerId: string]: string[]; +} + +export type LegacyLastConnectedWalletsStorage = string[]; + +/** + * We are doing some certain actions on storage for `last-connected-wallets` key. + * This class helps us to define them in one place and also it has support for both legacy and hub. + */ +export class LastConnectedWalletsFromStorage { + #storageKey: string; + + constructor(storageKey: string) { + this.#storageKey = storageKey; + } + + addWallet(providerId: string, namespaces: string[]): void { + if (this.#storageKey === HUB_LAST_CONNECTED_WALLETS) { + return this.#addWalletToHub(providerId, namespaces); + } else if (this.#storageKey === LEGACY_LAST_CONNECTED_WALLETS) { + return this.#addWalletToLegacy(providerId); + } + throw new Error('Not implemented'); + } + removeWallets(providerIds?: string[]): void { + if (this.#storageKey === HUB_LAST_CONNECTED_WALLETS) { + return this.#removeWalletsFromHub(providerIds); + } else if (this.#storageKey === LEGACY_LAST_CONNECTED_WALLETS) { + return this.#removeWalletsFromLegacy(providerIds); + } + throw new Error('Not implemented'); + } + list(): LastConnectedWalletsStorage { + if (this.#storageKey === HUB_LAST_CONNECTED_WALLETS) { + return this.#listFromHub(); + } else if (this.#storageKey === LEGACY_LAST_CONNECTED_WALLETS) { + return this.#listFromLegacy(); + } + throw new Error('Not implemented'); + } + + #listFromLegacy(): LastConnectedWalletsStorage { + const persistor = new Persistor(); + const lastConnectedWallets = + persistor.getItem(LEGACY_LAST_CONNECTED_WALLETS) || []; + const output: LastConnectedWalletsStorage = {}; + lastConnectedWallets.forEach((provider) => { + // Setting empty namespaces + output[provider] = []; + }); + return output; + } + #listFromHub(): LastConnectedWalletsStorage { + const persistor = new Persistor(); + const lastConnectedWallets = + persistor.getItem(HUB_LAST_CONNECTED_WALLETS) || {}; + return lastConnectedWallets; + } + #addWalletToHub(providerId: string, namespaces: string[]): void { + const storage = new Persistor(); + const data = storage.getItem(this.#storageKey) || {}; + + storage.setItem(this.#storageKey, { + ...data, + [providerId]: namespaces, + }); + } + #addWalletToLegacy(providerId: string): void { + const storage = new Persistor(); + const data = storage.getItem(this.#storageKey) || []; + + storage.setItem(LEGACY_LAST_CONNECTED_WALLETS, data.concat(providerId)); + } + #removeWalletsFromHub(providerIds?: string[]): void { + const persistor = new Persistor(); + const storageState = persistor.getItem(this.#storageKey) || {}; + + // Remove all wallets + if (!providerIds) { + persistor.setItem(this.#storageKey, {}); + return; + } + + // Remove some of the wallets + providerIds.forEach((providerId) => { + if (storageState[providerId]) { + delete storageState[providerId]; + } + }); + + persistor.setItem(this.#storageKey, storageState); + } + #removeWalletsFromLegacy(providerIds?: string[]): void { + const persistor = new Persistor(); + const storageState = persistor.getItem(this.#storageKey) || []; + + // Remove all wallets + if (!providerIds) { + persistor.setItem(this.#storageKey, []); + return; + } + + // Remove some of the wallets + persistor.setItem( + LEGACY_LAST_CONNECTED_WALLETS, + storageState.filter((wallet) => !providerIds.includes(wallet)) + ); + } +} diff --git a/wallets/react/src/hub/mod.ts b/wallets/react/src/hub/mod.ts new file mode 100644 index 0000000000..019b402a10 --- /dev/null +++ b/wallets/react/src/hub/mod.ts @@ -0,0 +1,2 @@ +export { separateLegacyAndHubProviders, findProviderByType } from './utils.js'; +export { useHubAdapter } from './useHubAdapter.js'; diff --git a/wallets/react/src/hub/types.ts b/wallets/react/src/hub/types.ts new file mode 100644 index 0000000000..b412ca477b --- /dev/null +++ b/wallets/react/src/hub/types.ts @@ -0,0 +1,12 @@ +import type { + CommonNamespaces, + FindProxiedNamespace, + ProviderInfo, +} from '@rango-dev/wallets-core'; + +export type AllProxiedNamespaces = FindProxiedNamespace< + keyof CommonNamespaces, + CommonNamespaces +>; + +export type ExtensionLink = keyof ProviderInfo['extensions']; diff --git a/wallets/react/src/hub/useHubAdapter.ts b/wallets/react/src/hub/useHubAdapter.ts new file mode 100644 index 0000000000..9548ca35de --- /dev/null +++ b/wallets/react/src/hub/useHubAdapter.ts @@ -0,0 +1,327 @@ +import type { AllProxiedNamespaces, ExtensionLink } from './types.js'; +import type { Providers } from '../index.js'; +import type { Provider } from '@rango-dev/wallets-core'; +import type { + LegacyNamespaceInputForConnect, + LegacyNamespace as Namespace, +} from '@rango-dev/wallets-core/legacy'; +import type { VersionedProviders } from '@rango-dev/wallets-core/utils'; + +import { legacyIsNamespaceDiscoverMode } from '@rango-dev/wallets-core/legacy'; +import { type WalletInfo } from '@rango-dev/wallets-shared'; +import { useEffect, useRef, useState } from 'react'; + +import { + type ConnectResult, + HUB_LAST_CONNECTED_WALLETS, + type ProviderContext, + type ProviderProps, +} from '../legacy/mod.js'; +import { useAutoConnect } from '../legacy/useAutoConnect.js'; + +import { autoConnect } from './autoConnect.js'; +import { fromAccountIdToLegacyAddressFormat } from './helpers.js'; +import { LastConnectedWalletsFromStorage } from './lastConnectedWallets.js'; +import { useHubRefs } from './useHubRefs.js'; +import { + checkHubStateAndTriggerEvents, + discoverNamespace, + getLegacyProvider, + transformHubResultToLegacyResult, + tryConvertNamespaceNetworkToChainInfo, +} from './utils.js'; + +export type UseAdapterParams = Omit & { + providers: Provider[]; + /** This is only will be used to access some parts of the legacy provider that doesn't exists in Hub. */ + allVersionedProviders: VersionedProviders[]; +}; + +export function useHubAdapter(params: UseAdapterParams): ProviderContext { + const { getStore, getHub } = useHubRefs(params.providers); + const [, rerender] = useState(0); + // useEffect will run `subscribe` once, so we need a reference and mutate the value if it's changes. + const dataRef = useRef({ + onUpdateState: params.onUpdateState, + allVersionedProviders: params.allVersionedProviders, + allBlockChains: params.allBlockChains, + }); + + useEffect(() => { + dataRef.current = { + onUpdateState: params.onUpdateState, + allVersionedProviders: params.allVersionedProviders, + allBlockChains: params.allBlockChains, + }; + }, [params]); + + // Initialize instances + useEffect(() => { + // Then will call init whenever page is ready. + const initHubWhenPageIsReady = (event: Event) => { + if ( + event.target && + (event.target as Document).readyState === 'complete' + ) { + getHub().init(); + + rerender((currentRender) => currentRender + 1); + } + }; + + /* + * Try again when the page has been completely loaded. + * Some of wallets, take some time to be fully injected and loaded. + */ + document.addEventListener('readystatechange', initHubWhenPageIsReady); + + getStore().subscribe((curr, prev) => { + if (dataRef.current.onUpdateState) { + checkHubStateAndTriggerEvents( + getHub(), + curr, + prev, + dataRef.current.onUpdateState, + dataRef.current.allVersionedProviders, + dataRef.current.allBlockChains + ); + } + rerender((currentRender) => currentRender + 1); + }); + }, []); + + useAutoConnect({ + autoConnect: params.autoConnect, + allBlockChains: params.allBlockChains, + autoConnectHandler: () => { + void autoConnect({ + getLegacyProvider: getLegacyProvider.bind( + null, + params.allVersionedProviders + ), + allBlockChains: params.allBlockChains, + getHub, + }); + }, + }); + + const lastConnectedWalletsFromStorage = new LastConnectedWalletsFromStorage( + HUB_LAST_CONNECTED_WALLETS + ); + + const api: ProviderContext = { + canSwitchNetworkTo(type, network) { + const provider = getLegacyProvider(params.allVersionedProviders, type); + const switchTo = provider.canSwitchNetworkTo; + + if (!switchTo) { + return false; + } + + return switchTo({ + network, + meta: params.allBlockChains || [], + provider: provider.getInstance(), + }); + }, + async connect(type, namespaces) { + const wallet = getHub().get(type); + if (!wallet) { + throw new Error( + `You should add ${type} to provider first then call 'connect'.` + ); + } + + if (!namespaces) { + throw new Error( + 'Passing namespace to `connect` is required. you can pass DISCOVERY_MODE for legacy.' + ); + } + + // Check `namespace` and look into hub to see how it can match given namespace to hub namespace. + const targetNamespaces: [ + LegacyNamespaceInputForConnect, + AllProxiedNamespaces + ][] = []; + namespaces.forEach((namespace) => { + let targetNamespace: Namespace; + if (legacyIsNamespaceDiscoverMode(namespace)) { + targetNamespace = discoverNamespace(namespace.network); + } else { + targetNamespace = namespace.namespace; + } + + const result = wallet.findByNamespace(targetNamespace); + + if (!result) { + throw new Error( + `We couldn't find any provider matched with your request namespace. (requested namespace: ${namespace.namespace})` + ); + } + + targetNamespaces.push([namespace, result]); + }); + + // Try to run `connect` on matched namespaces + const connectResultFromTargetNamespaces = targetNamespaces.map( + async ([namespaceInput, namespace]) => { + const network = tryConvertNamespaceNetworkToChainInfo( + namespaceInput, + params.allBlockChains || [] + ); + + /* + * `connect` can have different interfaces (e.g. Solana -> .connect(), EVM -> .connect("0x1") ), + * our assumption here is all the `connect` hasn't chain or if they have, they will accept it in first argument. + * By this assumption, always passing a chain should be problematic since it will be ignored if the namespace's `connect` hasn't chain. + */ + const result = namespace.connect(network); + return result.then(transformHubResultToLegacyResult); + } + ); + const connectResultWithLegacyFormat = await Promise.all( + connectResultFromTargetNamespaces + ); + + // If Provider has support for auto connect, we will add the wallet to storage. + const legacyProvider = getLegacyProvider( + params.allVersionedProviders, + type + ); + if (legacyProvider.canEagerConnect) { + const namespaces = targetNamespaces.map( + (targetNamespace) => targetNamespace[0].namespace + ); + lastConnectedWalletsFromStorage.addWallet(type, namespaces); + } + + return connectResultWithLegacyFormat; + }, + async disconnect(type) { + const wallet = getHub().get(type); + if (!wallet) { + throw new Error( + `You should add ${type} to provider first then call 'connect'.` + ); + } + + wallet.getAll().forEach((namespace) => { + return namespace.disconnect(); + }); + + if (params.autoConnect) { + lastConnectedWalletsFromStorage.removeWallets([type]); + } + }, + disconnectAll() { + throw new Error('`disconnectAll` not implemented'); + }, + async getSigners(type) { + const provider = getLegacyProvider(params.allVersionedProviders, type); + return provider.getSigners(provider.getInstance()); + }, + getWalletInfo(type) { + const wallet = getHub().get(type); + if (!wallet) { + throw new Error(`You should add ${type} to provider first.`); + } + + const info = wallet.info(); + if (!info) { + throw new Error('Your provider should have required `info`.'); + } + + const provider = getLegacyProvider(params.allVersionedProviders, type); + + const installLink: Exclude = { + DEFAULT: '', + }; + + // `extensions` in legacy format was uppercase and also `DEFAULT` was used instead of `homepage` + Object.keys(info.extensions).forEach((k) => { + const key = k as ExtensionLink; + + if (info.extensions[key] === 'homepage') { + installLink.DEFAULT = info.extensions[key] || ''; + } + + const allowedKeys: ExtensionLink[] = [ + 'firefox', + 'chrome', + 'brave', + 'edge', + ]; + if (allowedKeys.includes(key)) { + const upperCasedKey = key.toUpperCase() as keyof Exclude< + WalletInfo['installLink'], + string + >; + installLink[upperCasedKey] = info.extensions[key] || ''; + } + }); + + return { + name: info.name, + img: info.icon, + installLink: installLink, + // We don't have this values anymore, fill them with some values that communicate this. + color: 'red', + supportedChains: provider.getWalletInfo(params.allBlockChains || []) + .supportedChains, + isContractWallet: false, + mobileWallet: false, + showOnMobile: false, + + isHub: true, + properties: wallet.info()?.properties, + }; + }, + providers() { + const output: Providers = {}; + + Array.from(getHub().getAll().keys()).forEach((id) => { + try { + const provider = getLegacyProvider(params.allVersionedProviders, id); + output[id] = provider.getInstance(); + } catch (e) { + console.warn(e); + } + }); + + return output; + }, + state(type) { + const result = getHub().state(); + const provider = result[type]; + + if (!provider) { + throw new Error( + `It seems your requested provider doesn't exist in hub. Provider Id: ${type}` + ); + } + + const accounts = provider.namespaces + .filter((namespace) => namespace.connected) + .flatMap((namespace) => + namespace.accounts?.map(fromAccountIdToLegacyAddressFormat) + ) + .filter((account): account is string => !!account); + + const coreState = { + connected: provider.connected, + connecting: provider.connecting, + installed: provider.installed, + reachable: true, + accounts: accounts, + network: null, + }; + + return coreState; + }, + suggestAndConnect(_type, _network): never { + throw new Error('`suggestAndConnect` is not implemented'); + }, + }; + + return api; +} diff --git a/wallets/react/src/hub/useHubRefs.ts b/wallets/react/src/hub/useHubRefs.ts new file mode 100644 index 0000000000..299c642dad --- /dev/null +++ b/wallets/react/src/hub/useHubRefs.ts @@ -0,0 +1,41 @@ +import type { Provider, Store } from '@rango-dev/wallets-core'; + +import { createStore, Hub } from '@rango-dev/wallets-core'; +import { useRef } from 'react'; + +export function useHubRefs(providers: Provider[]) { + const store = useRef(null); + + const hub = useRef(null); + + // https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents + function getStore() { + if (store.current !== null) { + return store.current; + } + const createdStore = createStore(); + store.current = createdStore; + return createdStore; + } + + function getHub(): Hub { + if (hub.current !== null) { + return hub.current; + } + const createdHub = new Hub({ + store: getStore(), + }); + /* + * First add providers to hub + * This helps to `getWalletInfo` be usable, before initialize. + */ + providers.forEach((provider) => { + createdHub.add(provider.id, provider); + }); + + hub.current = createdHub; + return createdHub; + } + + return { getStore, getHub }; +} diff --git a/wallets/react/src/hub/utils.ts b/wallets/react/src/hub/utils.ts new file mode 100644 index 0000000000..bbd331eb8b --- /dev/null +++ b/wallets/react/src/hub/utils.ts @@ -0,0 +1,388 @@ +import type { AllProxiedNamespaces } from './types.js'; +import type { ConnectResult, ProviderProps } from '../legacy/mod.js'; +import type { Hub, Provider, State } from '@rango-dev/wallets-core'; +import type { + LegacyNamespaceInputForConnect, + LegacyProviderInterface, + LegacyEventHandler as WalletEventHandler, +} from '@rango-dev/wallets-core/legacy'; + +import { + guessProviderStateSelector, + namespaceStateSelector, +} from '@rango-dev/wallets-core'; +import { + LegacyEvents as Events, + LegacyNamespace as Namespace, +} from '@rango-dev/wallets-core/legacy'; +import { + generateStoreId, + type VersionedProviders, +} from '@rango-dev/wallets-core/utils'; +import { pickVersion } from '@rango-dev/wallets-core/utils'; +import { + type AddEthereumChainParameter, + convertEvmBlockchainMetaToEvmChainInfo, + Networks, +} from '@rango-dev/wallets-shared'; +import { type BlockchainMeta, isEvmBlockchain } from 'rango-types'; + +import { + fromAccountIdToLegacyAddressFormat, + isConnectResultEvm, + isConnectResultSolana, +} from './helpers.js'; + +/* Gets a list of hub and legacy providers and returns a tuple which separates them. */ +export function separateLegacyAndHubProviders( + providers: VersionedProviders[], + options?: { isExperimentalEnabled?: boolean } +): [LegacyProviderInterface[], Provider[]] { + const LEGACY_VERSION = '0.0.0'; + const HUB_VERSION = '1.0.0'; + const { isExperimentalEnabled = false } = options || {}; + + if (isExperimentalEnabled) { + const legacyProviders: LegacyProviderInterface[] = []; + const hubProviders: Provider[] = []; + + providers.forEach((provider) => { + try { + const target = pickVersion(provider, HUB_VERSION); + hubProviders.push(target[1]); + } catch { + const target = pickVersion(provider, LEGACY_VERSION); + legacyProviders.push(target[1]); + } + }); + + return [legacyProviders, hubProviders]; + } + + const legacyProviders = providers.map( + (provider) => pickVersion(provider, LEGACY_VERSION)[1] + ); + return [legacyProviders, []]; +} + +export function findProviderByType( + providers: Provider[], + type: string +): Provider | undefined { + return providers.find((provider) => provider.id === type); +} + +/** + * We will call this function on hub's `subscribe`. + * it will check states and will emit legacy events for backward compatibility. + */ +export function checkHubStateAndTriggerEvents( + hub: Hub, + currentState: State, + previousState: State, + onUpdateState: WalletEventHandler, + allProviders: VersionedProviders[], + allBlockChains: ProviderProps['allBlockChains'] +): void { + hub.getAll().forEach((provider, providerId) => { + const currentProviderState = guessProviderStateSelector( + currentState, + providerId + ); + const previousProviderState = guessProviderStateSelector( + previousState, + providerId + ); + + let accounts: string[] | null = []; + /* + * We don't rely `accounts` to make sure we will trigger proper event on this case: + * previous value: [0x...] + * current value: [] + */ + let hasAccountChanged = false; + let hasNetworkChanged = false; + let hasProviderDisconnected = false; + // It will pick the last network from namespaces. + let maybeNetwork = null; + provider.getAll().forEach((namespace) => { + const storeId = generateStoreId(providerId, namespace.namespaceId); + const currentNamespaceState = namespaceStateSelector( + currentState, + storeId + ); + const previousNamespaceState = namespaceStateSelector( + previousState, + storeId + ); + + if (currentNamespaceState.network !== null) { + maybeNetwork = currentNamespaceState.network; + } + + // Check for network + if (currentNamespaceState.network !== previousNamespaceState.network) { + hasNetworkChanged = true; + } + + // Check for accounts + if ( + previousNamespaceState.accounts?.sort().toString() !== + currentNamespaceState.accounts?.sort().toString() + ) { + if (currentNamespaceState.accounts) { + const formattedAddresses = currentNamespaceState.accounts.map( + fromAccountIdToLegacyAddressFormat + ); + + if (accounts) { + accounts = [...accounts, ...formattedAddresses]; + } else { + accounts = [...formattedAddresses]; + } + + hasAccountChanged = true; + } else { + accounts = null; + hasProviderDisconnected = true; + } + } + }); + + let legacyProvider; + try { + legacyProvider = getLegacyProvider(allProviders, providerId); + } catch (e) { + console.warn( + 'Having legacy provider is required for including some information like supported chain. ', + e + ); + } + + const coreState = { + connected: currentProviderState.connected, + connecting: currentProviderState.connecting, + installed: currentProviderState.installed, + accounts: accounts, + network: maybeNetwork, + reachable: true, + }; + + const eventInfo = { + supportedBlockchains: + legacyProvider?.getWalletInfo(allBlockChains || []).supportedChains || + [], + isContractWallet: false, + isHub: true, + }; + + if (previousProviderState.installed !== currentProviderState.installed) { + onUpdateState( + providerId, + Events.INSTALLED, + currentProviderState.installed, + coreState, + eventInfo + ); + } + if (previousProviderState.connecting !== currentProviderState.connecting) { + onUpdateState( + providerId, + Events.CONNECTING, + currentProviderState.connecting, + coreState, + eventInfo + ); + } + if (previousProviderState.connected !== currentProviderState.connected) { + onUpdateState( + providerId, + Events.CONNECTED, + currentProviderState.connected, + coreState, + eventInfo + ); + } + if (hasAccountChanged) { + onUpdateState( + providerId, + Events.ACCOUNTS, + accounts, + coreState, + eventInfo + ); + } + if (hasProviderDisconnected) { + onUpdateState(providerId, Events.ACCOUNTS, null, coreState, eventInfo); + } + if (hasNetworkChanged) { + onUpdateState( + providerId, + Events.NETWORK, + maybeNetwork, + coreState, + eventInfo + ); + } + }); +} + +/** + * For backward compatibility, there is an special namespace called DISCOVER_MODE. + * Alongside `DISCOVER_MODE`, `network` will be set as well. here we are manually matching networks to namespaces. + * This will help us keep the legacy interface and have what hub needs as well. + */ +export function discoverNamespace(network: string): Namespace { + // This trick is using for enforcing exhaustiveness check. + network = network as unknown as Networks; + switch (network) { + case Networks.AKASH: + case Networks.BANDCHAIN: + case Networks.BITCANNA: + case Networks.BITSONG: + case Networks.BINANCE: + case Networks.CRYPTO_ORG: + case Networks.CHIHUAHUA: + case Networks.COMDEX: + case Networks.COSMOS: + case Networks.CRONOS: + case Networks.DESMOS: + case Networks.EMONEY: + case Networks.INJECTIVE: + case Networks.IRIS: + case Networks.JUNO: + case Networks.KI: + case Networks.KONSTELLATION: + case Networks.KUJIRA: + case Networks.LUMNETWORK: + case Networks.MEDIBLOC: + case Networks.OSMOSIS: + case Networks.PERSISTENCE: + case Networks.REGEN: + case Networks.SECRET: + case Networks.SENTINEL: + case Networks.SIF: + case Networks.STARGAZE: + case Networks.STARNAME: + case Networks.TERRA: + case Networks.THORCHAIN: + case Networks.UMEE: + return Namespace.Cosmos; + case Networks.AVAX_CCHAIN: + case Networks.ARBITRUM: + case Networks.BOBA: + case Networks.BSC: + case Networks.FANTOM: + case Networks.ETHEREUM: + case Networks.FUSE: + case Networks.GNOSIS: + case Networks.HARMONY: + case Networks.MOONBEAM: + case Networks.MOONRIVER: + case Networks.OPTIMISM: + case Networks.POLYGON: + case Networks.STARKNET: + return Namespace.Evm; + case Networks.SOLANA: + return Namespace.Solana; + case Networks.BTC: + case Networks.BCH: + case Networks.DOGE: + case Networks.LTC: + case Networks.TRON: + return Namespace.Utxo; + case Networks.POLKADOT: + case Networks.TON: + case Networks.AXELAR: + case Networks.MARS: + case Networks.MAYA: + case Networks.STRIDE: + case Networks.Unknown: + throw new Error("Namespace isn't supported. network: " + network); + } + + throw new Error( + "Couldn't matched network with any namespace. it's not discoverable. network: " + + network + ); +} + +export function getLegacyProvider( + allProviders: VersionedProviders[], + type: string +): LegacyProviderInterface { + const [legacy] = separateLegacyAndHubProviders(allProviders); + const provider = legacy.find((legacyProvider) => { + return legacyProvider.config.type === type; + }); + + if (!provider) { + console.warn( + `You have a provider that doesn't have legacy provider. It causes some problems since we need some legacy functionality. Provider Id: ${type}` + ); + throw new Error( + `You need to have legacy implementation to use some methods. Provider Id: ${type}` + ); + } + + return provider; +} + +/** + * In legacy mode, for those who have switch network functionality (like evm), we are using an enum for network names + * this enum only has meaning for us, and when we are going to connect an instance (e.g. window.ethereum) we should pass chain id. + */ +export function convertNamespaceNetworkToEvmChainId( + namespace: LegacyNamespaceInputForConnect, + meta: BlockchainMeta[] +) { + if (!namespace.network) { + return undefined; + } + + const evmBlockchainsList = meta.filter(isEvmBlockchain); + const evmChains = convertEvmBlockchainMetaToEvmChainInfo(evmBlockchainsList); + + return evmChains[namespace.network]; +} + +/** + * We are passing an string for chain id (e.g. ETH, POLYGON), but wallet's instances (e.g. window.ethereum) needs chainId (e.g. 0x1). + * This function will help us to map these strings to proper hex ids. + * + * If you need same functionality for other blockchain types (e.g. Cosmos), You can make a separate function and add it here. + */ +export function tryConvertNamespaceNetworkToChainInfo( + namespace: LegacyNamespaceInputForConnect, + meta: BlockchainMeta[] +): string | AddEthereumChainParameter | undefined { + // `undefined` means it's not evm or we couldn't find it in meta. + const evmChain = convertNamespaceNetworkToEvmChainId(namespace, meta); + const network = evmChain || namespace.network; + + return network; +} + +export function transformHubResultToLegacyResult( + res: Awaited> +): ConnectResult { + if (isConnectResultEvm(res)) { + return { + accounts: res.accounts, + network: res.network, + provider: undefined, + }; + } else if (isConnectResultSolana(res)) { + return { + accounts: res, + network: null, + provider: undefined, + }; + } + + return { + accounts: [res], + network: null, + provider: undefined, + }; +} diff --git a/wallets/react/src/legacy/autoConnect.ts b/wallets/react/src/legacy/autoConnect.ts new file mode 100644 index 0000000000..2d04d78a33 --- /dev/null +++ b/wallets/react/src/legacy/autoConnect.ts @@ -0,0 +1,78 @@ +import type { WalletActions, WalletProviders } from './types.js'; +import type { LegacyWallet as Wallet } from '@rango-dev/wallets-core/legacy'; +import type { WalletConfig, WalletType } from '@rango-dev/wallets-shared'; + +import { LastConnectedWalletsFromStorage } from '../hub/lastConnectedWallets.js'; + +import { LEGACY_LAST_CONNECTED_WALLETS } from './mod.js'; + +/* + *If a wallet has multiple providers and one of them can be eagerly connected, + *then the whole wallet will support it at that point and we try to connect to that wallet as usual in eagerConnect method. + */ +export async function autoConnect( + wallets: WalletProviders, + getWalletInstance: (wallet: { + actions: WalletActions; + config: WalletConfig; + }) => Wallet +) { + const lastConnectedWalletsFromStorage = new LastConnectedWalletsFromStorage( + LEGACY_LAST_CONNECTED_WALLETS + ); + + const lastConnectedWallets = lastConnectedWalletsFromStorage.list(); + const walletIds = Object.keys(lastConnectedWallets); + + if (walletIds.length) { + const eagerConnectQueue: { + walletType: WalletType; + eagerConnect: () => Promise; + }[] = []; + + walletIds.forEach((walletType) => { + const wallet = wallets.get(walletType); + + if (!!wallet) { + const walletInstance = getWalletInstance(wallet); + eagerConnectQueue.push({ + walletType, + eagerConnect: walletInstance.eagerConnect.bind(walletInstance), + }); + } + }); + + const result = await Promise.allSettled( + eagerConnectQueue.map(async ({ eagerConnect }) => eagerConnect()) + ); + + const canRestoreAnyConnection = !!result.find( + ({ status }) => status === 'fulfilled' + ); + + /* + *After successfully connecting to at least one wallet, + *we will removing the other wallets from persistence. + *If we are unable to connect to any wallet, + *the persistence will not be removed and the eager connection will be retried with another page load. + */ + if (canRestoreAnyConnection) { + const walletsToRemoveFromPersistance: WalletType[] = []; + result.forEach((settleResult, index) => { + const { status } = settleResult; + + if (status === 'rejected') { + walletsToRemoveFromPersistance.push( + eagerConnectQueue[index].walletType + ); + } + }); + + if (walletsToRemoveFromPersistance.length) { + lastConnectedWalletsFromStorage.removeWallets( + walletsToRemoveFromPersistance + ); + } + } + } +} diff --git a/wallets/react/src/legacy/constants.ts b/wallets/react/src/legacy/constants.ts deleted file mode 100644 index 612a2ae861..0000000000 --- a/wallets/react/src/legacy/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const LAST_CONNECTED_WALLETS = 'last-connected-wallets'; diff --git a/wallets/react/src/legacy/helpers.ts b/wallets/react/src/legacy/helpers.ts index 7c89b46e19..5fdbc48a5d 100644 --- a/wallets/react/src/legacy/helpers.ts +++ b/wallets/react/src/legacy/helpers.ts @@ -6,15 +6,15 @@ import type { } from './types.js'; import type { LegacyOptions as Options, - LegacyWallet as Wallet, LegacyEventHandler as WalletEventHandler, LegacyState as WalletState, } from '@rango-dev/wallets-core/legacy'; -import type { WalletConfig, WalletType } from '@rango-dev/wallets-shared'; +import type { WalletType } from '@rango-dev/wallets-shared'; import { Persistor } from '@rango-dev/wallets-core/legacy'; -import { LAST_CONNECTED_WALLETS } from './constants.js'; +import { LEGACY_LAST_CONNECTED_WALLETS } from '../hub/constants.js'; +import { LastConnectedWalletsFromStorage } from '../hub/lastConnectedWallets.js'; export function choose(wallets: any[], type: WalletType): any | null { return wallets.find((wallet) => wallet.type === type) || null; @@ -109,26 +109,22 @@ export async function tryPersistWallet({ getState: (walletType: WalletType) => WalletState; }) { if (walletActions.canEagerConnect) { - const persistor = new Persistor(); - const wallets = persistor.getItem(LAST_CONNECTED_WALLETS); + const lastConnectedWalletsFromStorage = new LastConnectedWalletsFromStorage( + LEGACY_LAST_CONNECTED_WALLETS + ); + const lastConnectedWallets = lastConnectedWalletsFromStorage.list(); + const walletAlreadyPersisted = !!lastConnectedWallets[type]; /* *If on the last attempt we are unable to eagerly connect to any wallet and the user connects any wallet manualy, *persistance will be outdated and will need to be removed. */ - const shouldClearPersistance = wallets?.find( - (walletType) => !getState(walletType).connected - ); - - if (shouldClearPersistance) { + if (walletAlreadyPersisted && !getState(type).connected) { clearPersistance(); } - const walletAlreadyPersisted = !!wallets?.find((wallet) => wallet === type); - if (wallets && !walletAlreadyPersisted) { - persistor.setItem(LAST_CONNECTED_WALLETS, wallets.concat(type)); - } else { - persistor.setItem(LAST_CONNECTED_WALLETS, [type]); + if (!walletAlreadyPersisted) { + lastConnectedWalletsFromStorage.addWallet(type, []); } } } @@ -141,92 +137,21 @@ export function tryRemoveWalletFromPersistance({ walletActions: WalletActions; }) { if (walletActions.canEagerConnect) { - const persistor = new Persistor(); - const wallets = persistor.getItem(LAST_CONNECTED_WALLETS); - if (wallets) { - persistor.setItem( - LAST_CONNECTED_WALLETS, - wallets.filter((wallet) => wallet !== type) - ); - } + const lastConnectedWalletsFromStorage = new LastConnectedWalletsFromStorage( + LEGACY_LAST_CONNECTED_WALLETS + ); + lastConnectedWalletsFromStorage.removeWallets([type]); } } export function clearPersistance() { const persistor = new Persistor(); - const wallets = persistor.getItem(LAST_CONNECTED_WALLETS); + const wallets = persistor.getItem(LEGACY_LAST_CONNECTED_WALLETS); if (wallets) { - persistor.removeItem(LAST_CONNECTED_WALLETS); + persistor.removeItem(LEGACY_LAST_CONNECTED_WALLETS); } } -/* - *If a wallet has multiple providers and one of them can be eagerly connected, - *then the whole wallet will support it at that point and we try to connect to that wallet as usual in eagerConnect method. - */ -export async function autoConnect( - wallets: WalletProviders, - getWalletInstance: (wallet: { - actions: WalletActions; - config: WalletConfig; - }) => Wallet -) { - const persistor = new Persistor(); - const lastConnectedWallets = persistor.getItem(LAST_CONNECTED_WALLETS); - if (lastConnectedWallets && lastConnectedWallets.length) { - const connect_promises: { - walletType: WalletType; - eagerConnect: () => Promise; - }[] = []; - lastConnectedWallets.forEach((walletType) => { - const wallet = wallets.get(walletType); - - if (!!wallet) { - const walletInstance = getWalletInstance(wallet); - connect_promises.push({ - walletType, - eagerConnect: walletInstance.eagerConnect.bind(walletInstance), - }); - } - }); - - const result = await Promise.allSettled( - connect_promises.map(async ({ eagerConnect }) => eagerConnect()) - ); - - const canRestoreAnyConnection = !!result.find( - ({ status }) => status === 'fulfilled' - ); - - /* - *After successfully connecting to at least one wallet, - *we will removing the other wallets from persistence. - *If we are unable to connect to any wallet, - *the persistence will not be removed and the eager connection will be retried with another page load. - */ - if (canRestoreAnyConnection) { - const walletsToRemoveFromPersistance: WalletType[] = []; - result.forEach((settleResult, index) => { - const { status } = settleResult; - - if (status === 'rejected') { - walletsToRemoveFromPersistance.push( - connect_promises[index].walletType - ); - } - }); - - if (walletsToRemoveFromPersistance.length) { - persistor.setItem( - LAST_CONNECTED_WALLETS, - lastConnectedWallets.filter( - (walletType) => !walletsToRemoveFromPersistance.includes(walletType) - ) - ); - } - } - } -} /* *Our event handler includes an internal state updater, and a notifier *for the outside listener. diff --git a/wallets/react/src/legacy/mod.ts b/wallets/react/src/legacy/mod.ts new file mode 100644 index 0000000000..fb14a6809d --- /dev/null +++ b/wallets/react/src/legacy/mod.ts @@ -0,0 +1,13 @@ +export type { + ProviderProps, + ProviderContext, + ConnectResult, + ExtendedWalletInfo, +} from './types.js'; +export { + LEGACY_LAST_CONNECTED_WALLETS, + HUB_LAST_CONNECTED_WALLETS, +} from '../hub/constants.js'; + +export { WalletContext } from './context.js'; +export { useLegacyProviders } from './useLegacyProviders.js'; diff --git a/wallets/react/src/legacy/types.ts b/wallets/react/src/legacy/types.ts index 17648eb17a..102621fba4 100644 --- a/wallets/react/src/legacy/types.ts +++ b/wallets/react/src/legacy/types.ts @@ -1,5 +1,6 @@ +import type { ProviderInfo, VersionedProviders } from '@rango-dev/wallets-core'; import type { - LegacyNamespaceData as NamespaceData, + LegacyNamespaceInputForConnect, LegacyNetwork as Network, LegacyEventHandler as WalletEventHandler, LegacyWalletInfo as WalletInfo, @@ -21,12 +22,16 @@ export type ConnectResult = { export type Providers = { [type in WalletType]?: any }; +export type ExtendedWalletInfo = WalletInfo & { + properties?: ProviderInfo['properties']; + isHub?: boolean; +}; + export type ProviderContext = { connect( type: WalletType, - network?: Network, - namespaces?: NamespaceData[] - ): Promise; + namespaces?: LegacyNamespaceInputForConnect[] + ): Promise; disconnect(type: WalletType): Promise; disconnectAll(): Promise[]>; state(type: WalletType): WalletState; @@ -40,7 +45,7 @@ export type ProviderContext = { */ providers(): Providers; getSigners(type: WalletType): Promise; - getWalletInfo(type: WalletType): WalletInfo; + getWalletInfo(type: WalletType): ExtendedWalletInfo; suggestAndConnect(type: WalletType, network: Network): Promise; }; @@ -48,7 +53,10 @@ export type ProviderProps = PropsWithChildren<{ onUpdateState?: WalletEventHandler; allBlockChains?: BlockchainMeta[]; autoConnect?: boolean; - providers: ProviderInterface[]; + providers: VersionedProviders[]; + configs?: { + isExperimentalEnabled?: boolean; + }; }>; export enum Events { diff --git a/wallets/react/src/legacy/useAutoConnect.ts b/wallets/react/src/legacy/useAutoConnect.ts index 9de474a192..6963425433 100644 --- a/wallets/react/src/legacy/useAutoConnect.ts +++ b/wallets/react/src/legacy/useAutoConnect.ts @@ -1,31 +1,23 @@ -import type { GetWalletInstance } from './hooks.js'; -import type { ProviderProps, WalletProviders } from './types.js'; +import type { ProviderProps } from './types.js'; import { useEffect, useRef } from 'react'; -import { autoConnect } from './helpers.js'; +import { shouldTryAutoConnect } from './utils.js'; export function useAutoConnect( props: Pick & { - wallets: WalletProviders; - getWalletInstanceFromLegacy: GetWalletInstance; + /** + * A function to run autoConnect on instances + */ + autoConnectHandler: () => void; } ) { const autoConnectInitiated = useRef(false); - // Running auto connect on instances useEffect(() => { - const shouldTryAutoConnect = - props.allBlockChains && - props.allBlockChains.length && - props.autoConnect && - !autoConnectInitiated.current; - - if (shouldTryAutoConnect) { + if (shouldTryAutoConnect(props) && !autoConnectInitiated.current) { autoConnectInitiated.current = true; - void (async () => { - await autoConnect(props.wallets, props.getWalletInstanceFromLegacy); - })(); + props.autoConnectHandler(); } }, [props.autoConnect, props.allBlockChains]); } diff --git a/wallets/react/src/legacy/useLegacyProviders.ts b/wallets/react/src/legacy/useLegacyProviders.ts index 05bd94ce6f..bc7ff29b5b 100644 --- a/wallets/react/src/legacy/useLegacyProviders.ts +++ b/wallets/react/src/legacy/useLegacyProviders.ts @@ -1,8 +1,14 @@ import type { ProviderContext, ProviderProps } from './types.js'; +import type { + LegacyNamespaceInputForConnect, + LegacyNamespaceInputWithDiscoverMode, + LegacyProviderInterface, +} from '@rango-dev/wallets-core/legacy'; import type { WalletType } from '@rango-dev/wallets-shared'; import { useEffect, useReducer } from 'react'; +import { autoConnect } from './autoConnect.js'; import { availableWallets, checkWalletProviders, @@ -17,7 +23,13 @@ import { import { useInitializers } from './hooks.js'; import { useAutoConnect } from './useAutoConnect.js'; -export function useLegacyProviders(props: ProviderProps): ProviderContext { +export type LegacyProviderProps = Omit & { + providers: LegacyProviderInterface[]; +}; + +export function useLegacyProviders( + props: LegacyProviderProps +): ProviderContext { const [providersState, dispatch] = useReducer(stateReducer, {}); // Get (or add) wallet instance (`provider`s will be wrapped in a `Wallet`) @@ -30,22 +42,48 @@ export function useLegacyProviders(props: ProviderProps): ProviderContext { const wallets = checkWalletProviders(listOfProviders); useAutoConnect({ - wallets, allBlockChains: props.allBlockChains, autoConnect: props.autoConnect, - getWalletInstanceFromLegacy: getWalletInstance, + autoConnectHandler: async () => autoConnect(wallets, getWalletInstance), }); // Final API we put in context and it will be available to use for users. - // eslint-disable-next-line react/jsx-no-constructed-context-values const api: ProviderContext = { - async connect(type, network, namespaces) { + async connect(type, namespaces) { const wallet = wallets.get(type); if (!wallet) { throw new Error(`You should add ${type} to provider first.`); } + + /** + * Discover mode has a meaning in hub, so we are considering whenever a namespace with DISCOVER_MODE reaches here, + * we can ignore it and don't pass it to provider. + */ + const namespacesForConnect = namespaces?.filter( + ( + ns + ): ns is Exclude< + LegacyNamespaceInputForConnect, + LegacyNamespaceInputWithDiscoverMode + > => { + return ns.namespace !== 'DISCOVER_MODE'; + } + ); + // Legacy providers doesn't implemented multiple namespaces, so it will always be one value. + let network = undefined; + if (namespaces && namespaces.length > 0) { + /* + * This may not be safe in cases there are two `network` for namespaces, the first one will be picked always. + * But since legacy provider only accepts one value, it shouldn't be happened when we are using legacy mode. + */ + network = namespaces.find((ns) => !!ns.network)?.network; + } + const walletInstance = getWalletInstance(wallet); - const result = await walletInstance.connect(network, namespaces); + const result = await walletInstance.connect( + network, + namespacesForConnect + ); if (props.autoConnect) { void tryPersistWallet({ type, @@ -54,7 +92,7 @@ export function useLegacyProviders(props: ProviderProps): ProviderContext { }); } - return result; + return [result]; }, async disconnect(type) { const wallet = wallets.get(type); diff --git a/wallets/react/src/legacy/utils.ts b/wallets/react/src/legacy/utils.ts new file mode 100644 index 0000000000..dc31871344 --- /dev/null +++ b/wallets/react/src/legacy/utils.ts @@ -0,0 +1,7 @@ +import type { ProviderProps } from './types.js'; + +export function shouldTryAutoConnect( + props: Pick +): boolean { + return !!props.allBlockChains?.length && !!props.autoConnect; +} diff --git a/wallets/react/src/provider.tsx b/wallets/react/src/provider.tsx index b680ab1555..7f25e92fd8 100644 --- a/wallets/react/src/provider.tsx +++ b/wallets/react/src/provider.tsx @@ -3,13 +3,13 @@ import type { ProviderProps } from './legacy/types.js'; import React from 'react'; import { WalletContext } from './legacy/context.js'; -import { useLegacyProviders } from './legacy/useLegacyProviders.js'; +import { useProviders } from './useProviders.js'; function Provider(props: ProviderProps) { - const legacyApi = useLegacyProviders(props); + const api = useProviders(props); return ( - + {props.children} ); diff --git a/wallets/react/src/useProviders.ts b/wallets/react/src/useProviders.ts new file mode 100644 index 0000000000..686ce1ad50 --- /dev/null +++ b/wallets/react/src/useProviders.ts @@ -0,0 +1,120 @@ +import type { + ExtendedWalletInfo, + ProviderContext, + ProviderProps, + Providers, +} from './index.js'; +import type { ConnectResult } from './legacy/mod.js'; +import type { LegacyState } from '@rango-dev/wallets-core/legacy'; +import type { SignerFactory } from 'rango-types'; + +import { + findProviderByType, + separateLegacyAndHubProviders, + useHubAdapter, +} from './hub/mod.js'; +import { useLegacyProviders } from './legacy/mod.js'; + +/* + * We have two separate interface for our providers: legacy and hub. + * This hook sits between this two interface by keeping old interface as main API and try to add Hub providers by using an adapter. + * For gradual migrating and backward compatibility, we are supporting hub by an adapter besides of the old one. + */ +function useProviders(props: ProviderProps) { + const { providers, ...restProps } = props; + const [legacyProviders, hubProviders] = separateLegacyAndHubProviders( + providers, + { + isExperimentalEnabled: restProps.configs?.isExperimentalEnabled, + } + ); + + const legacyApi = useLegacyProviders({ + ...restProps, + providers: legacyProviders, + }); + const hubApi = useHubAdapter({ + ...restProps, + providers: hubProviders, + allVersionedProviders: providers, + }); + + const api: ProviderContext = { + canSwitchNetworkTo(type, network): boolean { + if (findProviderByType(hubProviders, type)) { + return hubApi.canSwitchNetworkTo(type, network); + } + return legacyApi.canSwitchNetworkTo(type, network); + }, + async connect(type, network): Promise { + const hubProvider = findProviderByType(hubProviders, type); + if (hubProvider) { + return await hubApi.connect(type, network); + } + + return await legacyApi.connect(type, network); + }, + async disconnect(type): Promise { + const hubProvider = findProviderByType(hubProviders, type); + if (hubProvider) { + return await hubApi.disconnect(type); + } + + return await legacyApi.disconnect(type); + }, + async disconnectAll() { + return await Promise.allSettled([ + hubApi.disconnectAll(), + legacyApi.disconnectAll(), + ]); + }, + async getSigners(type): Promise { + const hubProvider = findProviderByType(hubProviders, type); + if (hubProvider) { + return hubApi.getSigners(type); + } + return legacyApi.getSigners(type); + }, + getWalletInfo(type): ExtendedWalletInfo { + const hubProvider = findProviderByType(hubProviders, type); + if (hubProvider) { + return hubApi.getWalletInfo(type); + } + + return legacyApi.getWalletInfo(type); + }, + providers(): Providers { + let output: Providers = {}; + if (hubProviders.length > 0) { + output = { ...output, ...hubApi.providers() }; + } + if (legacyProviders.length > 0) { + output = { ...output, ...legacyApi.providers() }; + } + + return output; + }, + state(type): LegacyState { + const hubProvider = findProviderByType(hubProviders, type); + + if (hubProvider) { + return hubApi.state(type); + } + + return legacyApi.state(type); + }, + async suggestAndConnect(type, network): Promise { + const hubProvider = findProviderByType(hubProviders, type); + + if (hubProvider) { + return hubApi.suggestAndConnect(type, network); + } + + return await legacyApi.suggestAndConnect(type, network); + }, + }; + + return api; +} + +export { useProviders }; diff --git a/wallets/wallets-adapter/src/provider.tsx b/wallets/wallets-adapter/src/provider.tsx index e24c8825a5..94b36883b9 100644 --- a/wallets/wallets-adapter/src/provider.tsx +++ b/wallets/wallets-adapter/src/provider.tsx @@ -1,7 +1,5 @@ -import type { - ProviderInterface, - ProviderProps, -} from '@rango-dev/wallets-react'; +import type { LegacyProviderInterface } from '@rango-dev/wallets-core/legacy'; +import type { ProviderProps } from '@rango-dev/wallets-react'; import { Provider } from '@rango-dev/wallets-react'; import React from 'react'; @@ -10,7 +8,9 @@ import Adapter from './adapter'; function AdapterProvider({ children, ...props }: ProviderProps) { const list = props.providers.map( - (provider: ProviderInterface) => provider.config.type + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-next-line + (provider: LegacyProviderInterface) => provider.config.type ); return ( diff --git a/widget/app/src/App.tsx b/widget/app/src/App.tsx index 31e18cbc32..595906f088 100644 --- a/widget/app/src/App.tsx +++ b/widget/app/src/App.tsx @@ -36,6 +36,9 @@ export function App() { apiKey: '', walletConnectProjectId: WC_PROJECT_ID, trezorManifest: TREZOR_MANIFEST, + features: { + experimentalWallet: 'enabled', + }, }; } if (!!config) { diff --git a/widget/embedded/src/QueueManager.tsx b/widget/embedded/src/QueueManager.tsx index fd3e1f7d87..5e8b6d5151 100644 --- a/widget/embedded/src/QueueManager.tsx +++ b/widget/embedded/src/QueueManager.tsx @@ -15,7 +15,6 @@ import React, { useMemo } from 'react'; import { eventEmitter } from './services/eventEmitter'; import { useAppStore } from './store/AppStore'; import { useUiStore } from './store/ui'; -import { useWalletsStore } from './store/wallets'; import { getConfig } from './utils/configs'; import { walletAndSupportedChainsNames } from './utils/wallets'; @@ -39,8 +38,8 @@ function QueueManager(props: PropsWithChildren<{ apiKey?: string }>) { }); }, [props.apiKey]); - const blockchains = useAppStore().blockchains(); - const connectedWallets = useWalletsStore.use.connectedWallets(); + const { blockchains, connectedWallets } = useAppStore(); + const blockchainsList = blockchains(); const wallets = { blockchains: connectedWallets.map((wallet) => ({ @@ -53,14 +52,21 @@ function QueueManager(props: PropsWithChildren<{ apiKey?: string }>) { if (!canSwitchNetworkTo(wallet, network)) { return undefined; } - return connect(wallet, network); + const result = await connect(wallet, [ + { + namespace: 'DISCOVER_MODE', + network, + }, + ]); + + return result; }; const isMobileWallet = (walletType: WalletType): boolean => !!getWalletInfo(walletType).mobileWallet; // TODO: this code copy & pasted from rango, should be refactored. - const allBlockchains = blockchains + const allBlockchains = blockchainsList .filter((blockchain) => blockchain.enabled) .reduce( (blockchainsObj: any, blockchain) => ( @@ -68,7 +74,7 @@ function QueueManager(props: PropsWithChildren<{ apiKey?: string }>) { ), {} ); - const evmBasedChains = blockchains.filter(isEvmBlockchain); + const evmBasedChains = blockchainsList.filter(isEvmBlockchain); const getSupportedChainNames = (type: WalletType) => { const { supportedChains } = getWalletInfo(type); return walletAndSupportedChainsNames(supportedChains); @@ -88,7 +94,6 @@ function QueueManager(props: PropsWithChildren<{ apiKey?: string }>) { providers: allProviders, switchNetwork, canSwitchNetworkTo, - connect, state, isMobileWallet, }; diff --git a/widget/embedded/src/components/ConfirmWalletsModal/ConfirmWalletsModal.tsx b/widget/embedded/src/components/ConfirmWalletsModal/ConfirmWalletsModal.tsx index e0c2647c3a..6813e4ee5f 100644 --- a/widget/embedded/src/components/ConfirmWalletsModal/ConfirmWalletsModal.tsx +++ b/widget/embedded/src/components/ConfirmWalletsModal/ConfirmWalletsModal.tsx @@ -1,5 +1,5 @@ import type { PropTypes } from './ConfirmWalletsModal.types'; -import type { ConnectedWallet } from '../../store/wallets'; +import type { ConnectedWallet } from '../../store/slices/wallets'; import type { ConfirmSwapWarnings, Wallet } from '../../types'; import { i18n } from '@lingui/core'; @@ -20,7 +20,6 @@ import { getQuoteErrorMessage } from '../../constants/errors'; import { getQuoteUpdateWarningMessage } from '../../constants/warnings'; import { useAppStore } from '../../store/AppStore'; import { useQuoteStore } from '../../store/quote'; -import { useWalletsStore } from '../../store/wallets'; import { getBlockchainShortNameFor } from '../../utils/meta'; import { isConfirmSwapDisabled } from '../../utils/swap'; import { getQuoteWallets } from '../../utils/wallets'; @@ -54,8 +53,7 @@ export function ConfirmWalletsModal(props: PropTypes) { customDestination, setCustomDestination, } = useQuoteStore(); - const { connectedWallets, selectWallets } = useWalletsStore(); - const { config } = useAppStore(); + const { config, connectedWallets, setWalletsAsSelected } = useAppStore(); const [showMoreWalletFor, setShowMoreWalletFor] = useState(''); const [balanceWarnings, setBalanceWarnings] = useState([]); @@ -217,7 +215,7 @@ export function ConfirmWalletsModal(props: PropTypes) { const lastSelectedWallets = selectableWallets.filter( (wallet) => wallet.selected ); - selectWallets(lastSelectedWallets); + setWalletsAsSelected(lastSelectedWallets); selectQuoteWallets(lastSelectedWallets); setQuoteWalletConfirmed(true); onClose(); diff --git a/widget/embedded/src/components/ConfirmWalletsModal/WalletList.tsx b/widget/embedded/src/components/ConfirmWalletsModal/WalletList.tsx index 1b872272f0..48b9b5cffe 100644 --- a/widget/embedded/src/components/ConfirmWalletsModal/WalletList.tsx +++ b/widget/embedded/src/components/ConfirmWalletsModal/WalletList.tsx @@ -1,5 +1,6 @@ import type { PropTypes } from './WalletList.type'; -import type { Wallet, WalletInfoWithExtra } from '../../types'; +import type { Wallet } from '../../types'; +import type { ExtendedModalWalletInfo } from '../../utils/wallets'; import { i18n } from '@lingui/core'; import { warn } from '@rango-dev/logging-core'; @@ -20,7 +21,6 @@ import { import { useWalletList } from '../../hooks/useWalletList'; import { useAppStore } from '../../store/AppStore'; import { useUiStore } from '../../store/ui'; -import { useWalletsStore } from '../../store/wallets'; import { getBlockchainDisplayNameFor } from '../../utils/meta'; import { getAddress, @@ -41,10 +41,9 @@ export function WalletList(props: PropTypes) { const { chain, isSelected, selectWallet, limit, onShowMore } = props; const isActiveTab = useUiStore.use.isActiveTab(); - const connectedWallets = useWalletsStore.use.connectedWallets(); - const { blockchains } = useAppStore(); + const { blockchains, connectedWallets } = useAppStore(); const [selectedWalletToConnect, setSelectedWalletToConnect] = - useState(); + useState(); const [experimentalChainWallet, setExperimentalChainWallet] = useState(null); const [showExperimentalChainModal, setShowExperimentalChainModal] = @@ -57,7 +56,7 @@ export function WalletList(props: PropTypes) { chain, }); - const [sortedList, setSortedList] = useState(list); + const [sortedList, setSortedList] = useState(list); const numberOfSupportedWallets = list.length; const shouldShowMoreWallets = limit && numberOfSupportedWallets - limit > 0; @@ -148,9 +147,13 @@ export function WalletList(props: PropTypes) { const onSelectableWalletClick = async () => { const isDisconnected = wallet.state === WalletState.DISCONNECTED; + const isConnectedButDifferentThanTargetNamespace = wallet.isHub + ? !conciseAddress + : !!wallet.namespaces && !conciseAddress; + if (isDisconnected) { setSelectedWalletToConnect(wallet); - } else if (!!wallet.namespaces && !conciseAddress) { + } else if (isConnectedButDifferentThanTargetNamespace) { // wallet is connected on a different namespace await handleDisconnect(wallet.type); diff --git a/widget/embedded/src/components/Layout/Layout.tsx b/widget/embedded/src/components/Layout/Layout.tsx index ddbf38df92..1843ee1fe5 100644 --- a/widget/embedded/src/components/Layout/Layout.tsx +++ b/widget/embedded/src/components/Layout/Layout.tsx @@ -13,7 +13,6 @@ import { useNavigateBack } from '../../hooks/useNavigateBack'; import { useTheme } from '../../hooks/useTheme'; import { useAppStore } from '../../store/AppStore'; import { tabManager, useUiStore } from '../../store/ui'; -import { useWalletsStore } from '../../store/wallets'; import { getContainer } from '../../utils/common'; import { getPendingSwaps } from '../../utils/queue'; import { isFeatureHidden } from '../../utils/settings'; @@ -28,9 +27,8 @@ import { Container, Content, Footer, LayoutContainer } from './Layout.styles'; function Layout(props: PropsWithChildren) { const { connectHeightObserver, disconnectHeightObserver } = useIframe(); const { children, header, footer, height = 'fixed' } = props; - const { fetchStatus } = useAppStore(); + const { fetchStatus, connectedWallets } = useAppStore(); const [openRefreshModal, setOpenRefreshModal] = useState(false); - const connectedWallets = useWalletsStore.use.connectedWallets(); const { config: { features, theme }, } = useAppStore(); diff --git a/widget/embedded/src/components/SwapDetails/SwapDetails.tsx b/widget/embedded/src/components/SwapDetails/SwapDetails.tsx index 18bae9b5f4..7f6f5916c1 100644 --- a/widget/embedded/src/components/SwapDetails/SwapDetails.tsx +++ b/widget/embedded/src/components/SwapDetails/SwapDetails.tsx @@ -160,7 +160,12 @@ export function SwapDetails(props: SwapDetailsProps) { canSwitchNetworkTo(currentStepWallet.walletType, currentStepBlockchain)); const switchNetwork = showSwitchNetwork - ? connect.bind(null, currentStepWallet.walletType, currentStepBlockchain) + ? connect.bind(null, currentStepWallet.walletType, [ + { + namespace: 'DISCOVER_MODE', + network: currentStepBlockchain, + }, + ]) : undefined; const stepMessage = getSwapMessages(swap, currentStep); diff --git a/widget/embedded/src/components/SwapDetailsAlerts/SwapDetailsAlerts.types.ts b/widget/embedded/src/components/SwapDetailsAlerts/SwapDetailsAlerts.types.ts index 2aba3044ca..32f3b3cc8e 100644 --- a/widget/embedded/src/components/SwapDetailsAlerts/SwapDetailsAlerts.types.ts +++ b/widget/embedded/src/components/SwapDetailsAlerts/SwapDetailsAlerts.types.ts @@ -15,7 +15,7 @@ export interface SwapAlertsProps extends WaningAlertsProps { } export interface WaningAlertsProps extends FailedAlertsProps { - switchNetwork: (() => Promise) | undefined; + switchNetwork: (() => Promise) | undefined; showNetworkModal: PendingSwapNetworkStatus | null | undefined; setNetworkModal: (network: ModalState) => void; } diff --git a/widget/embedded/src/components/TokenList/TokenList.tsx b/widget/embedded/src/components/TokenList/TokenList.tsx index b8d6a1d62e..77fad770ca 100644 --- a/widget/embedded/src/components/TokenList/TokenList.tsx +++ b/widget/embedded/src/components/TokenList/TokenList.tsx @@ -15,11 +15,9 @@ import { Typography, VirtualizedList, } from '@rango-dev/ui'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; -import { useObserveBalanceChanges } from '../../hooks/useObserveBalanceChanges'; import { useAppStore } from '../../store/AppStore'; -import { useWalletsStore } from '../../store/wallets'; import { createTintsAndShades } from '../../utils/colors'; import { formatBalance } from '../../utils/wallets'; @@ -93,7 +91,7 @@ const renderDesc = (props: RenderDescProps) => { export function TokenList(props: PropTypes) { const { - list, + list: tokens, searchedFor = '', onChange, selectedBlockchain, @@ -101,29 +99,10 @@ export function TokenList(props: PropTypes) { action, } = props; - const [tokens, setTokens] = useState(list); const fetchStatus = useAppStore().fetchStatus; const blockchains = useAppStore().blockchains(); - const [hasNextPage, setHasNextPage] = useState(true); - const { getBalanceFor, loading: loadingWallet } = useWalletsStore(); + const { getBalanceFor, fetchingWallets: loadingWallet } = useAppStore(); const { isTokenPinned } = useAppStore(); - /** - * We can create the key by hashing the list of tokens, - * but if the list is large, the memory usage and cost of comparisons may be high. - */ - const { balanceKey } = useObserveBalanceChanges(selectedBlockchain); - - const loadNextPage = () => { - setTokens(list.slice(0, tokens.length + PAGE_SIZE)); - }; - - useEffect(() => { - setHasNextPage(list.length > tokens.length); - }, [tokens.length]); - - useEffect(() => { - setTokens(list.slice(0, PAGE_SIZE)); - }, [list.length, selectedBlockchain, balanceKey]); const endRenderer = (token: Token) => { const tokenBalance = formatBalance(getBalanceFor(token)); @@ -164,7 +143,6 @@ export function TokenList(props: PropTypes) { const renderList = () => { return ( { const token = tokens[index]; const address = token.address || ''; @@ -258,7 +236,7 @@ export function TokenList(props: PropTypes) { ); }} totalCount={tokens.length} - key={`${selectedBlockchain}-${searchedFor}-${balanceKey}`} + key={`${selectedBlockchain}-${searchedFor}`} /> ); }; diff --git a/widget/embedded/src/containers/Inputs/Inputs.tsx b/widget/embedded/src/containers/Inputs/Inputs.tsx index 34a1a53f7c..dbb645d2ac 100644 --- a/widget/embedded/src/containers/Inputs/Inputs.tsx +++ b/widget/embedded/src/containers/Inputs/Inputs.tsx @@ -2,10 +2,12 @@ import type { PropTypes } from './Inputs.types'; import { i18n } from '@lingui/core'; import { SwapInput } from '@rango-dev/ui'; +import BigNumber from 'bignumber.js'; import React from 'react'; import { SwitchFromAndToButton } from '../../components/SwitchFromAndTo'; import { errorMessages } from '../../constants/errors'; +import { ZERO } from '../../constants/numbers'; import { PERCENTAGE_CHANGE_MAX_DECIMALS, PERCENTAGE_CHANGE_MIN_DECIMALS, @@ -14,8 +16,8 @@ import { USD_VALUE_MAX_DECIMALS, USD_VALUE_MIN_DECIMALS, } from '../../constants/routing'; +import { useAppStore } from '../../store/AppStore'; import { useQuoteStore } from '../../store/quote'; -import { useWalletsStore } from '../../store/wallets'; import { getContainer } from '../../utils/common'; import { numberToString } from '../../utils/numbers'; import { getPriceImpact, getPriceImpactLevel } from '../../utils/quote'; @@ -38,15 +40,16 @@ export function Inputs(props: PropTypes) { outputUsdValue, selectedQuote, } = useQuoteStore(); - const { connectedWallets, getBalanceFor } = useWalletsStore(); + const { connectedWallets, getBalanceFor } = useAppStore(); const fromTokenBalance = fromToken ? getBalanceFor(fromToken) : null; const fromTokenFormattedBalance = formatBalance(fromTokenBalance)?.amount ?? '0'; - const tokenBalanceReal = - !!fromBlockchain && !!fromToken - ? numberToString(fromTokenBalance?.amount, fromTokenBalance?.decimals) - : '0'; + const fromBalanceAmount = fromTokenBalance + ? new BigNumber(fromTokenBalance.amount).shiftedBy( + -fromTokenBalance.decimals + ) + : ZERO; const fetchingBalance = !!fromBlockchain && @@ -107,7 +110,17 @@ export function Inputs(props: PropTypes) { loadingBalance={fetchingBalance} tooltipContainer={getContainer()} onSelectMaxBalance={() => { - setInputAmount(tokenBalanceReal.split(',').join('')); + const tokenBalanceReal = numberToString( + fromBalanceAmount, + fromTokenBalance?.decimals + ); + + // if a token hasn't any value, we will reset the input by setting an empty string. + const nextInputAmount = !!fromTokenBalance?.amount + ? tokenBalanceReal + : ''; + + setInputAmount(nextInputAmount); }} anyWalletConnected={connectedWallets.length > 0} /> diff --git a/widget/embedded/src/containers/Wallets/Wallets.tsx b/widget/embedded/src/containers/Wallets/Wallets.tsx index bbf19aa42b..82a865cf80 100644 --- a/widget/embedded/src/containers/Wallets/Wallets.tsx +++ b/widget/embedded/src/containers/Wallets/Wallets.tsx @@ -15,7 +15,7 @@ import React, { createContext, useEffect, useMemo, useRef } from 'react'; import { useWalletProviders } from '../../hooks/useWalletProviders'; import { AppStoreProvider, useAppStore } from '../../store/AppStore'; import { useUiStore } from '../../store/ui'; -import { useWalletsStore } from '../../store/wallets'; +import { isFeatureEnabled } from '../../utils/settings'; import { prepareAccountsForWalletStore, walletAndSupportedChainsNames, @@ -38,7 +38,7 @@ function Main(props: PropsWithChildren) { fetchStatus, } = useAppStore(); const blockchains = useAppStore().blockchains(); - const { findToken } = useAppStore(); + const { newWalletConnected, disconnectWallet } = useAppStore(); const config = useAppStore().config; const walletOptions: ProvidersOptions = { @@ -47,9 +47,9 @@ function Main(props: PropsWithChildren) { walletConnectListedDesktopWalletLink: props.config.__UNSTABLE_OR_INTERNAL__ ?.walletConnectListedDesktopWalletLink, + experimentalWallet: props.config.features?.experimentalWallet, }; const { providers } = useWalletProviders(config.wallets, walletOptions); - const { connectWallet, disconnectWallet } = useWalletsStore(); const onConnectWalletHandler = useRef(); const onDisconnectWalletHandler = useRef(); @@ -85,7 +85,7 @@ function Main(props: PropsWithChildren) { meta.isContractWallet ); if (data.length) { - connectWallet(data, findToken); + void newWalletConnected(data); } } else { disconnectWallet(type); @@ -98,17 +98,17 @@ function Main(props: PropsWithChildren) { } } } - if (event === Events.ACCOUNTS && state.connected) { + if ( + (event === Events.ACCOUNTS && state.connected) || + // Hub works differently, and this check should be enough. + (event === Events.ACCOUNTS && meta.isHub) + ) { const key = `${type}-${state.network}-${value}`; - if (state.connected) { - if (!!onConnectWalletHandler.current) { - onConnectWalletHandler.current(key); - } else { - console.warn( - `onConnectWallet handler hasn't been set. Are you sure?` - ); - } + if (!!onConnectWalletHandler.current) { + onConnectWalletHandler.current(key); + } else { + console.warn(`onConnectWallet handler hasn't been set. Are you sure?`); } } @@ -146,7 +146,13 @@ function Main(props: PropsWithChildren) { allBlockChains={blockchains} providers={providers} onUpdateState={onUpdateState} - autoConnect={!!isActiveTab}> + autoConnect={!!isActiveTab} + configs={{ + isExperimentalEnabled: isFeatureEnabled( + 'experimentalWallet', + props.config.features + ), + }}> {props.children} diff --git a/widget/embedded/src/containers/WidgetInfo/WidgetInfo.tsx b/widget/embedded/src/containers/WidgetInfo/WidgetInfo.tsx index 30b95f2a0c..38984a3f3f 100644 --- a/widget/embedded/src/containers/WidgetInfo/WidgetInfo.tsx +++ b/widget/embedded/src/containers/WidgetInfo/WidgetInfo.tsx @@ -9,7 +9,6 @@ import { useAppStore } from '../../store/AppStore'; import { useNotificationStore } from '../../store/notification'; import { useQuoteStore } from '../../store/quote'; import { tabManager, useUiStore } from '../../store/ui'; -import { useWalletsStore } from '../../store/wallets'; import { calculateWalletUsdValue } from '../../utils/wallets'; import { WidgetHistory } from './WidgetInfo.helpers'; @@ -22,13 +21,16 @@ export function WidgetInfo(props: React.PropsWithChildren) { const { manager } = useManager(); const isActiveTab = useUiStore.use.isActiveTab(); const retrySwap = useQuoteStore.use.retry(); - const { findToken } = useAppStore(); + const { + findToken, + connectedWallets, + getBalances, + fetchBalances: refetch, + } = useAppStore(); const history = new WidgetHistory(manager, { retrySwap, findToken }); - const details = useWalletsStore.use.connectedWallets(); - const isLoading = useWalletsStore.use.loading(); - const totalBalance = calculateWalletUsdValue(details); - const refetch = useWalletsStore.use.getWalletsDetails(); + const { fetchingWallets: isLoading } = useAppStore(); + const totalBalance = calculateWalletUsdValue(getBalances()); const blockchains = useAppStore().blockchains(); const tokens = useAppStore().tokens(); const swappers = useAppStore().swappers(); @@ -47,9 +49,9 @@ export function WidgetInfo(props: React.PropsWithChildren) { history, wallets: { isLoading, - details, + details: connectedWallets, totalBalance, - refetch: (accounts) => refetch(accounts, findToken), + refetch: async (accounts) => refetch(accounts), }, meta: { blockchains, diff --git a/widget/embedded/src/containers/WidgetInfo/WidgetInfo.types.ts b/widget/embedded/src/containers/WidgetInfo/WidgetInfo.types.ts index 51e6e59376..33593df1aa 100644 --- a/widget/embedded/src/containers/WidgetInfo/WidgetInfo.types.ts +++ b/widget/embedded/src/containers/WidgetInfo/WidgetInfo.types.ts @@ -1,6 +1,6 @@ import type { WidgetHistory } from './WidgetInfo.helpers'; import type { FetchStatus, FindToken } from '../../store/slices/data'; -import type { ConnectedWallet } from '../../store/wallets'; +import type { ConnectedWallet } from '../../store/slices/wallets'; import type { QuoteInputs, UpdateQuoteInputs, Wallet } from '../../types'; import type { Notification } from '../../types/notification'; import type { BlockchainMeta, SwapperMeta, Token } from 'rango-sdk'; @@ -13,7 +13,7 @@ export interface WidgetInfoContextInterface { details: ConnectedWallet[]; totalBalance: string; isLoading: boolean; - refetch: (accounts: Wallet[], tokens: Token[]) => void; + refetch: (accounts: Wallet[]) => void; }; meta: { blockchains: BlockchainMeta[]; diff --git a/widget/embedded/src/hooks/useObserveBalanceChanges/index.ts b/widget/embedded/src/hooks/useObserveBalanceChanges/index.ts deleted file mode 100644 index 3da464ca48..0000000000 --- a/widget/embedded/src/hooks/useObserveBalanceChanges/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useObserveBalanceChanges } from './useObserveBalanceChanges'; diff --git a/widget/embedded/src/hooks/useObserveBalanceChanges/useObserveBalanceChanges.ts b/widget/embedded/src/hooks/useObserveBalanceChanges/useObserveBalanceChanges.ts deleted file mode 100644 index 0da169e28c..0000000000 --- a/widget/embedded/src/hooks/useObserveBalanceChanges/useObserveBalanceChanges.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { WalletEventData } from '../../types'; - -import { useWallets } from '@rango-dev/wallets-react'; -import { useEffect, useRef, useState } from 'react'; - -import { widgetEventEmitter } from '../../services/eventEmitter'; -import { useWalletsStore } from '../../store/wallets'; -import { WalletEventTypes, WidgetEvents } from '../../types'; - -// A hook to listen for and detect changes in balances on a specific blockchain. -export function useObserveBalanceChanges(selectedBlockchain?: string) { - const { connectedWallets } = useWalletsStore(); - const { getWalletInfo } = useWallets(); - const prevFetchingBalanceWallets = useRef([]); - // The "balanceKey" will be updated and incremented after each change in the balance for a blockchain. - const [balanceKey, setBalanceKey] = useState(0); - - useEffect(() => { - const handleWalletEvent = (event: WalletEventData) => { - if (event.type === WalletEventTypes.DISCONNECT) { - const walletInfo = getWalletInfo(event.payload.walletType); - const blockchainSupported = walletInfo.supportedChains.some( - (chain) => chain.name === selectedBlockchain - ); - - if (blockchainSupported) { - setBalanceKey((prev) => prev + 1); - } - } - }; - - widgetEventEmitter.on(WidgetEvents.WalletEvent, handleWalletEvent); - - if (!selectedBlockchain) { - return setBalanceKey((prev) => prev + 1); - } - - const supportedWallets = connectedWallets.filter( - (wallet) => wallet.chain === selectedBlockchain - ); - - supportedWallets.forEach((wallet) => { - const { walletType } = wallet; - const walletIsAlreadyFetching = - prevFetchingBalanceWallets.current.includes(walletType); - - /** - * Watching for changes in "wallet.balances.length" is not accurate. - * Additionally, checking the equality of previous and current balances for a specific blockchain is resource-intensive, so we avoid these methods. - */ - if (wallet.loading && !walletIsAlreadyFetching) { - prevFetchingBalanceWallets.current = - prevFetchingBalanceWallets.current.concat(walletType); - } else if (!wallet.loading && walletIsAlreadyFetching) { - prevFetchingBalanceWallets.current = - prevFetchingBalanceWallets.current.filter( - (prevWallet) => prevWallet !== walletType - ); - setBalanceKey((prev) => prev + 1); - } - }); - - return () => - widgetEventEmitter.off(WidgetEvents.WalletEvent, handleWalletEvent); - }, [connectedWallets, selectedBlockchain, getWalletInfo]); - - return { balanceKey }; -} diff --git a/widget/embedded/src/hooks/useStatefulConnect/useStatefulConnect.ts b/widget/embedded/src/hooks/useStatefulConnect/useStatefulConnect.ts index 6145114d5e..2fb30a224a 100644 --- a/widget/embedded/src/hooks/useStatefulConnect/useStatefulConnect.ts +++ b/widget/embedded/src/hooks/useStatefulConnect/useStatefulConnect.ts @@ -1,5 +1,6 @@ import type { HandleConnectOptions, Result } from './useStatefulConnect.types'; import type { WalletInfoWithExtra } from '../../types'; +import type { ExtendedModalWalletInfo } from '../../utils/wallets'; import type { Namespace, NamespaceData, @@ -10,6 +11,8 @@ import { WalletState } from '@rango-dev/ui'; import { useWallets } from '@rango-dev/wallets-react'; import { useReducer } from 'react'; +import { convertCommonNamespacesKeysToLegacyNamespace } from '../../utils/hub'; + import { isStateOnDerivationPathStep, isStateOnNamespace, @@ -61,7 +64,11 @@ export function useStatefulConnect(): UseStatefulConnect { }); try { - await connect(type, undefined, namespaces); + const legacyNamespacesInput = namespaces?.map((namespaceInput) => ({ + ...namespaceInput, + network: undefined, + })); + await connect(type, legacyNamespacesInput); return { status: ResultStatus.Connected }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -75,7 +82,7 @@ export function useStatefulConnect(): UseStatefulConnect { }; const handleConnect = async ( - wallet: WalletInfoWithExtra, + wallet: ExtendedModalWalletInfo, options?: HandleConnectOptions ): Promise<{ status: ResultStatus; @@ -83,6 +90,37 @@ export function useStatefulConnect(): UseStatefulConnect { const isDisconnected = wallet.state === WalletState.DISCONNECTED; if (isDisconnected) { + // Legacy and hub have different structure to check wether we need to show namespace or not. + + // Hub + const isHub = !!wallet.isHub; + if (isHub) { + const detachedInstances = wallet.properties?.find( + (item) => item.name === 'detached' + ); + const needsNamespace = + detachedInstances && wallet.state !== 'connected'; + + if (needsNamespace) { + const availableNamespaces = + convertCommonNamespacesKeysToLegacyNamespace( + detachedInstances.value + ); + + dispatch({ + type: 'needsNamespace', + payload: { + providerType: wallet.type, + providerImage: wallet.image, + availableNamespaces, + singleNamespace: false, + }, + }); + return { status: ResultStatus.Namespace }; + } + } + + // Legacy if (!wallet.namespaces) { return await runConnect(wallet.type, undefined, options); } diff --git a/widget/embedded/src/hooks/useSubscribeToWidgetEvents/useSubscribeToWidgetEvents.ts b/widget/embedded/src/hooks/useSubscribeToWidgetEvents/useSubscribeToWidgetEvents.ts index 7c590a8409..2c97af2e4f 100644 --- a/widget/embedded/src/hooks/useSubscribeToWidgetEvents/useSubscribeToWidgetEvents.ts +++ b/widget/embedded/src/hooks/useSubscribeToWidgetEvents/useSubscribeToWidgetEvents.ts @@ -12,13 +12,10 @@ import { useEffect } from 'react'; import { eventEmitter } from '../../services/eventEmitter'; import { useAppStore } from '../../store/AppStore'; import { useNotificationStore } from '../../store/notification'; -import { useWalletsStore } from '../../store/wallets'; export function useSubscribeToWidgetEvents() { - const connectedWallets = useWalletsStore.use.connectedWallets(); - const getWalletsDetails = useWalletsStore.use.getWalletsDetails(); const setNotification = useNotificationStore.use.setNotification(); - const { findToken } = useAppStore(); + const { connectedWallets, fetchBalances } = useAppStore(); useEffect(() => { const handleStepEvent = (widgetEvent: StepEventData) => { @@ -39,8 +36,12 @@ export function useSubscribeToWidgetEvents() { (wallet) => wallet.chain === step?.toBlockchain ); - fromAccount && getWalletsDetails([fromAccount], findToken); - toAccount && getWalletsDetails([toAccount], findToken); + if (fromAccount) { + void fetchBalances([fromAccount]); + } + if (toAccount) { + void fetchBalances([toAccount]); + } } setNotification(event, route); diff --git a/widget/embedded/src/hooks/useSwapInput.ts b/widget/embedded/src/hooks/useSwapInput.ts index 5bca482537..04752dccdf 100644 --- a/widget/embedded/src/hooks/useSwapInput.ts +++ b/widget/embedded/src/hooks/useSwapInput.ts @@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useAppStore } from '../store/AppStore'; import { useQuoteStore } from '../store/quote'; -import { useWalletsStore } from '../store/wallets'; import { QuoteErrorType } from '../types'; import { debounce } from '../utils/common'; import { isPositiveNumber } from '../utils/numbers'; @@ -53,7 +52,7 @@ export function useSwapInput({ features, enableCentralizedSwappers, } = useAppStore().config; - const connectedWallets = useWalletsStore.use.connectedWallets(); + const { connectedWallets } = useAppStore(); const { fromToken, diff --git a/widget/embedded/src/hooks/useWalletList.ts b/widget/embedded/src/hooks/useWalletList.ts index af23a7d50a..2253fc807f 100644 --- a/widget/embedded/src/hooks/useWalletList.ts +++ b/widget/embedded/src/hooks/useWalletList.ts @@ -12,7 +12,6 @@ import { import { useCallback, useEffect } from 'react'; import { useAppStore } from '../store/AppStore'; -import { useWalletsStore } from '../store/wallets'; import { configWalletsToWalletName } from '../utils/providers'; import { hashWalletsState, @@ -43,9 +42,8 @@ interface API { */ export function useWalletList(params?: Params): API { const { chain } = params || {}; - const { config } = useAppStore(); + const { config, connectedWallets } = useAppStore(); const { state, getWalletInfo } = useWallets(); - const { connectedWallets } = useWalletsStore(); const blockchains = useAppStore().blockchains(); const { handleDisconnect } = useStatefulConnect(); diff --git a/widget/embedded/src/hooks/useWalletProviders/useWalletProviders.ts b/widget/embedded/src/hooks/useWalletProviders/useWalletProviders.ts index 4028d59d03..03ececae1f 100644 --- a/widget/embedded/src/hooks/useWalletProviders/useWalletProviders.ts +++ b/widget/embedded/src/hooks/useWalletProviders/useWalletProviders.ts @@ -1,10 +1,9 @@ import type { WidgetConfig } from '../../types'; import type { ProvidersOptions } from '../../utils/providers'; -import type { ProviderInterface } from '@rango-dev/wallets-react'; import { useEffect } from 'react'; -import { useWalletsStore } from '../../store/wallets'; +import { useAppStore } from '../../store/AppStore'; import { matchAndGenerateProviders } from '../../utils/providers'; import { hashProviders } from './useWalletProviders.helpers'; @@ -13,11 +12,8 @@ export function useWalletProviders( providers: WidgetConfig['wallets'], options?: ProvidersOptions ) { - const clearConnectedWallet = useWalletsStore.use.clearConnectedWallet(); - let generateProviders: ProviderInterface[] = matchAndGenerateProviders( - providers, - options - ); + const { clearConnectedWallet } = useAppStore(); + let generateProviders = matchAndGenerateProviders(providers, options); useEffect(() => { clearConnectedWallet(); diff --git a/widget/embedded/src/index.ts b/widget/embedded/src/index.ts index bcdb5ac656..5c64a666d1 100644 --- a/widget/embedded/src/index.ts +++ b/widget/embedded/src/index.ts @@ -1,5 +1,5 @@ import type { WidgetProps } from './containers/Widget'; -import type { ConnectedWallet } from './store/wallets'; +import type { ConnectedWallet } from './store/slices/wallets'; import type { BlockchainAndTokenConfig, QuoteEventData, diff --git a/widget/embedded/src/pages/Home.tsx b/widget/embedded/src/pages/Home.tsx index a59ca27e44..9a2ea95a0b 100644 --- a/widget/embedded/src/pages/Home.tsx +++ b/widget/embedded/src/pages/Home.tsx @@ -18,7 +18,6 @@ import { useSwapInput } from '../hooks/useSwapInput'; import { useAppStore } from '../store/AppStore'; import { useQuoteStore } from '../store/quote'; import { useUiStore } from '../store/ui'; -import { useWalletsStore } from '../store/wallets'; import { UiEventTypes } from '../types'; import { isVariantExpandable } from '../utils/configs'; import { emitPreventableEvent } from '../utils/events'; @@ -54,9 +53,12 @@ export function Home() { const { isLargeScreen, isExtraLargeScreen } = useScreenDetect(); const { fetch: fetchQuote, loading } = useSwapInput({ refetchQuote }); - const { config, fetchStatus: fetchMetaStatus } = useAppStore(); + const { + config, + fetchStatus: fetchMetaStatus, + connectedWallets, + } = useAppStore(); - const { connectedWallets } = useWalletsStore(); const { isActiveTab } = useUiStore(); const [showQuoteWarningModal, setShowQuoteWarningModal] = useState(false); diff --git a/widget/embedded/src/pages/SelectSwapItemsPage.tsx b/widget/embedded/src/pages/SelectSwapItemsPage.tsx index 2355662fa1..5e1f99a0b8 100644 --- a/widget/embedded/src/pages/SelectSwapItemsPage.tsx +++ b/widget/embedded/src/pages/SelectSwapItemsPage.tsx @@ -13,7 +13,6 @@ import { navigationRoutes } from '../constants/navigationRoutes'; import { useNavigateBack } from '../hooks/useNavigateBack'; import { useAppStore } from '../store/AppStore'; import { useQuoteStore } from '../store/quote'; -import { useWalletsStore } from '../store/wallets'; interface PropTypes { type: 'source' | 'destination'; @@ -31,7 +30,7 @@ export function SelectSwapItemsPage(props: PropTypes) { setFromBlockchain, setToBlockchain, } = useQuoteStore(); - const getBalanceFor = useWalletsStore.use.getBalanceFor(); + const { getBalanceFor } = useAppStore(); const [searchedFor, setSearchedFor] = useState(''); const selectedBlockchain = type === 'source' ? fromBlockchain : toBlockchain; diff --git a/widget/embedded/src/store/app.ts b/widget/embedded/src/store/app.ts index e2a94f4804..1276cb99e7 100644 --- a/widget/embedded/src/store/app.ts +++ b/widget/embedded/src/store/app.ts @@ -1,6 +1,4 @@ -import type { ConfigSlice } from './slices/config'; -import type { DataSlice } from './slices/data'; -import type { SettingsSlice } from './slices/settings'; +import type { AppStoreState } from './slices/types'; import type { WidgetConfig } from '../types'; import type { StateCreator } from 'zustand'; @@ -10,6 +8,7 @@ import { persist } from 'zustand/middleware'; import { createConfigSlice } from './slices/config'; import { createDataSlice } from './slices/data'; import { createSettingsSlice } from './slices/settings'; +import { createWalletsSlice } from './slices/wallets'; export type StateCreatorWithInitialData< T extends Partial, @@ -20,12 +19,13 @@ export type StateCreatorWithInitialData< ...rest: Parameters> ) => ReturnType>; -export type AppStoreState = DataSlice & ConfigSlice & SettingsSlice; +export type { AppStoreState }; export function createAppStore(initialData?: WidgetConfig) { return create()( persist( (...a) => ({ + ...createWalletsSlice(...a), ...createDataSlice(...a), ...createSettingsSlice(...a), ...createConfigSlice(initialData, ...a), diff --git a/widget/embedded/src/store/slices/types.ts b/widget/embedded/src/store/slices/types.ts new file mode 100644 index 0000000000..99cd924852 --- /dev/null +++ b/widget/embedded/src/store/slices/types.ts @@ -0,0 +1,9 @@ +import type { ConfigSlice } from './config'; +import type { DataSlice } from './data'; +import type { SettingsSlice } from './settings'; +import type { WalletsSlice } from './wallets'; + +export type AppStoreState = DataSlice & + ConfigSlice & + SettingsSlice & + WalletsSlice; diff --git a/widget/embedded/src/store/slices/wallets.ts b/widget/embedded/src/store/slices/wallets.ts new file mode 100644 index 0000000000..75f18e0ba2 --- /dev/null +++ b/widget/embedded/src/store/slices/wallets.ts @@ -0,0 +1,456 @@ +import type { AppStoreState } from './types'; +import type { Token } from 'rango-sdk'; +import type { StateCreator } from 'zustand'; + +import BigNumber from 'bignumber.js'; + +import { eventEmitter } from '../../services/eventEmitter'; +import { httpService } from '../../services/httpService'; +import { + type Balance, + type Wallet, + WalletEventTypes, + WidgetEvents, +} from '../../types'; +import { isAccountAndWalletMatched } from '../../utils/wallets'; +import { + createAssetKey, + createBalanceKey, + createBalanceStateForNewAccount, + extractAssetFromBalanceKey, + removeBalanceFromAggregatedBalance, + updateAggregatedBalanceStateForNewAccount, +} from '../utils/wallets'; + +type WalletAddress = string; +type TokenAddress = string; +type BlockchainId = string; +/** format: `BlockchainId-TokenAddress` */ +export type AssetKey = `${BlockchainId}-${TokenAddress}`; +/** format: `BlockchainId-TokenAddress-WalletAddress` */ +export type BalanceKey = `${BlockchainId}-${TokenAddress}-${WalletAddress}`; +export type BalanceState = { + [key: BalanceKey]: Balance; +}; +export type AggregatedBalanceState = { + [key: AssetKey]: BalanceKey[]; +}; + +export interface ConnectedWallet extends Wallet { + explorerUrl: string | null; + selected: boolean; + loading: boolean; + error: boolean; +} + +export interface WalletsSlice { + _balances: BalanceState; + _aggregatedBalances: AggregatedBalanceState; + connectedWallets: ConnectedWallet[]; + fetchingWallets: boolean; + + setConnectedWalletAsRefetching: (walletType: string) => void; + setConnectedWalletHasError: (walletType: string) => void; + setConnectedWalletRetrievedData: (walletType: string) => void; + removeBalancesForWallet: (walletType: string) => void; + addConnectedWallet: (accounts: Wallet[]) => void; + setWalletsAsSelected: ( + wallets: { walletType: string; chain: string }[] + ) => void; + /** + * Add new accounts to store and fetch balances for them. + */ + newWalletConnected: (accounts: Wallet[]) => Promise; + /** + * Disconnect a wallet and clean up balances after that. + */ + disconnectWallet: (walletType: string) => void; + clearConnectedWallet: () => void; + fetchBalances: ( + accounts: Wallet[], + options?: { retryOnFailedBalances?: boolean } + ) => Promise; + getBalanceFor: (token: Token) => Balance | null; + getBalances: () => BalanceState; +} + +export const createWalletsSlice: StateCreator< + AppStoreState, + [], + [], + WalletsSlice +> = (set, get) => ({ + _balances: {}, + _aggregatedBalances: {}, + connectedWallets: [], + fetchingWallets: false, + + // Actions + setConnectedWalletAsRefetching: (walletType: string) => { + set((state) => { + return { + fetchingWallets: true, + connectedWallets: state.connectedWallets.map((connectedWallet) => { + if (connectedWallet.walletType === walletType) { + return { + ...connectedWallet, + loading: true, + error: false, + }; + } + + return connectedWallet; + }), + }; + }); + }, + setConnectedWalletRetrievedData: (walletType: string) => { + set((state) => { + return { + fetchingWallets: false, + connectedWallets: state.connectedWallets.map((connectedWallet) => { + if (connectedWallet.walletType === walletType) { + return { + ...connectedWallet, + loading: false, + error: false, + }; + } + + return connectedWallet; + }), + }; + }); + }, + setConnectedWalletHasError: (walletType: string) => { + set((state) => { + return { + fetchingWallets: false, + connectedWallets: state.connectedWallets.map((connectedWallet) => { + if (connectedWallet.walletType === walletType) { + return { + ...connectedWallet, + loading: false, + error: true, + }; + } + + return connectedWallet; + }), + }; + }); + }, + addConnectedWallet: (accounts: Wallet[]) => { + /* + * When we are going to add a new account, there are two thing that can be happens: + * 1. Wallet hasn't add yet. + * 2. Wallet has added, and there are some more account that needs to added to connected wallet. consider we've added an ETH and Pol account, then we need to add Arb account later as well. + * + * For handling this, we need to only keep not-added-account, then only add those. + * + * Note: + * The second option would be useful for hub particularly. + */ + const connectedWallets = get().connectedWallets; + const walletsNeedToBeAdded = accounts.filter( + (account) => + !connectedWallets.some((connectedWallet) => + isAccountAndWalletMatched(account, connectedWallet) + ) + ); + + if (walletsNeedToBeAdded.length > 0) { + const newConnectedWallets = walletsNeedToBeAdded.map((account) => { + /* + * When a wallet is connecting, we will check if there is any `selected` wallet before, if not, we will consider this new wallet as connected. + * In this way, when user tries to swap, we selected a wallet by default and don't need to do an extra click in ConfirmWalletModal + */ + const shouldMarkWalletAsSelected = !connectedWallets.some( + (connectedWallet) => + connectedWallet.chain === account.chain && + connectedWallet.selected && + /** + * Sometimes, the connect function can be called multiple times for a particular wallet type when using the auto-connect feature. + * This check is there to make sure the chosen wallet doesn't end up unselected. + */ + connectedWallet.walletType !== account.walletType + ); + + return { + address: account.address, + chain: account.chain, + explorerUrl: null, + walletType: account.walletType, + selected: shouldMarkWalletAsSelected, + + loading: false, + error: false, + }; + }); + + set((state) => { + return { + connectedWallets: [...state.connectedWallets, ...newConnectedWallets], + }; + }); + } + }, + setWalletsAsSelected: (wallets) => { + const nextConnectedWalletsWithUpdatedSelectedStatus = + get().connectedWallets.map((connectedWallet) => { + const walletSelected = !!wallets.find( + (wallet) => + wallet.chain === connectedWallet.chain && + wallet.walletType !== connectedWallet.walletType && + connectedWallet.selected + ); + const walletNotSelected = !!wallets.find( + (wallet) => + wallet.chain === connectedWallet.chain && + wallet.walletType === connectedWallet.walletType && + !connectedWallet.selected + ); + if (walletSelected) { + return { ...connectedWallet, selected: false }; + } else if (walletNotSelected) { + return { ...connectedWallet, selected: true }; + } + + return connectedWallet; + }); + + set({ + connectedWallets: nextConnectedWalletsWithUpdatedSelectedStatus, + }); + }, + newWalletConnected: async (accounts) => { + eventEmitter.emit(WidgetEvents.WalletEvent, { + type: WalletEventTypes.CONNECT, + payload: { walletType: accounts[0].walletType, accounts }, + }); + + get().addConnectedWallet(accounts); + + void get().fetchBalances(accounts); + }, + removeBalancesForWallet: (walletType) => { + let walletsNeedsToBeRemoved = get().connectedWallets.filter( + (connectedWallet) => connectedWallet.walletType === walletType + ); + /* + * We only need to delete balances where there is no connected wallets with same chain and address for that balance. + * Consider both Metamask and Solana having support for `0xblahblahblahblah` for Ethereum. + * If Phantom is disconnecting, we should keep the balance since Metamask has access to same address yet. + * So we only delete balance when there is no connected wallet that has access to that specific chain and address. + */ + get().connectedWallets.forEach((connectedWallet) => { + if (connectedWallet.walletType !== walletType) { + walletsNeedsToBeRemoved = walletsNeedsToBeRemoved.filter((wallet) => { + const isAnotherWalletHasSameAddressAndChain = + wallet.chain === connectedWallet.chain && + wallet.address === connectedWallet.address; + return !isAnotherWalletHasSameAddressAndChain; + }); + } + }); + + const nextBalancesState: BalanceState = {}; + let nextAggregatedBalanceState: AggregatedBalanceState = + get()._aggregatedBalances; + const currentBalancesState = get()._balances; + const balanceKeys = Object.keys(currentBalancesState) as BalanceKey[]; + + balanceKeys.forEach((key) => { + const asset = extractAssetFromBalanceKey(key); + + const shouldBalanceBeRemoved = !!walletsNeedsToBeRemoved.find( + (wallet) => + createBalanceKey(wallet.address, { + address: asset.address, + blockchain: wallet.chain, + }) === key + ); + + if (!shouldBalanceBeRemoved) { + nextBalancesState[key] = currentBalancesState[key]; + } + + // if a balance should be removed, we need to remove its caches in _aggregatedBalances as wel. + if (shouldBalanceBeRemoved) { + nextAggregatedBalanceState = removeBalanceFromAggregatedBalance( + nextAggregatedBalanceState, + key + ); + } + }); + + set({ + _balances: nextBalancesState, + _aggregatedBalances: nextAggregatedBalanceState, + }); + }, + disconnectWallet: (walletType) => { + const isTargetWalletExistsInConnectedWallets = get().connectedWallets.find( + (wallet) => wallet.walletType === walletType + ); + if (isTargetWalletExistsInConnectedWallets) { + eventEmitter.emit(WidgetEvents.WalletEvent, { + type: WalletEventTypes.DISCONNECT, + payload: { walletType }, + }); + + // This should be called before updating connectedWallets since we need the old state to remove balances. + get().removeBalancesForWallet(walletType); + + let targetWalletWasSelectedForBlockchains = get() + .connectedWallets.filter( + (connectedWallet) => + connectedWallet.selected && + connectedWallet.walletType === walletType + ) + .map((connectedWallet) => connectedWallet.chain); + + // Remove target wallet from connectedWallets + let nextConnectedWallets = get().connectedWallets.filter( + (connectedWallet) => connectedWallet.walletType !== walletType + ); + + /* + * If we are disconnecting a wallet that has `selected` for some blockchains, + * For those blockchains we will fallback to first connected wallet + * which means selected wallet will change. + */ + if (targetWalletWasSelectedForBlockchains.length > 0) { + nextConnectedWallets = nextConnectedWallets.map((connectedWallet) => { + if ( + targetWalletWasSelectedForBlockchains.includes( + connectedWallet.chain + ) + ) { + targetWalletWasSelectedForBlockchains = + targetWalletWasSelectedForBlockchains.filter( + (blockchain) => blockchain !== connectedWallet.chain + ); + return { + ...connectedWallet, + selected: true, + }; + } + + return connectedWallet; + }); + } + + set({ + connectedWallets: nextConnectedWallets, + }); + } + }, + clearConnectedWallet: () => set({ connectedWallets: [] }), + fetchBalances: async (accounts, options) => { + // All the `accounts` have same `walletType` so we can pick the first one. + const walletType = accounts[0].walletType; + + get().setConnectedWalletAsRefetching(walletType); + + const addressesToFetch = accounts.map((account) => ({ + address: account.address, + blockchain: account.chain, + })); + const response = await httpService().getWalletsDetails(addressesToFetch); + + const listWalletsWithBalances = response.wallets; + + if (listWalletsWithBalances) { + const { retryOnFailedBalances = true } = options || {}; + if (retryOnFailedBalances) { + const failedWallets: Wallet[] = listWalletsWithBalances + .filter((wallet) => wallet.failed) + .map((wallet) => ({ + chain: wallet.blockChain, + walletType: walletType, + address: wallet.address, + })); + if (failedWallets.length > 0) { + void get().fetchBalances(failedWallets, { + retryOnFailedBalances: false, + }); + } + } + + let nextBalances: BalanceState = {}; + let nextAggregatedBalances: AggregatedBalanceState = + get()._aggregatedBalances; + listWalletsWithBalances.forEach((wallet) => { + if (wallet.failed) { + return; + } + + const balancesForWallet = createBalanceStateForNewAccount(wallet, get); + + nextAggregatedBalances = updateAggregatedBalanceStateForNewAccount( + nextAggregatedBalances, + balancesForWallet + ); + + nextBalances = { + ...nextBalances, + ...balancesForWallet, + }; + }); + + set((state) => ({ + _balances: { + ...state._balances, + ...nextBalances, + }, + _aggregatedBalances: nextAggregatedBalances, + })); + + get().setConnectedWalletRetrievedData(walletType); + } else { + get().setConnectedWalletHasError(walletType); + throw new Error( + `We couldn't fetch your account balances. Seem there is no information on blockchain for them yet.` + ); + } + }, + getBalances: () => { + return get()._balances; + }, + getBalanceFor: (token) => { + const balances = get().getBalances(); + + /* + * The old implementation wasn't considering user's address. + * it can be problematic when two separate address has same token, both of them will override on same key. + * + * For keeping the same behavior, here we pick the most amount and also will not consider user's address in key. + */ + + // Note: balance key is created using asset key + wallet address + const assetKey = createAssetKey(token); + const targetBalanceKeys = get()._aggregatedBalances[assetKey] || []; + + if (targetBalanceKeys.length === 0) { + return null; + } else if (targetBalanceKeys.length === 1) { + const targetKey = targetBalanceKeys[0]; + return balances[targetKey]; + } + + // If there are multiple balances for an specific token, we pick the maximum. + const firstTargetBalance = balances[targetBalanceKeys[0]]; + let maxBalance: Balance = firstTargetBalance; + targetBalanceKeys.forEach((targetBalanceKey) => { + const currentBalance = balances[targetBalanceKey]; + const currentBalanceAmount = new BigNumber(currentBalance.amount); + const prevBalanceAmount = new BigNumber(maxBalance.amount); + + if (currentBalanceAmount.isGreaterThan(prevBalanceAmount)) { + maxBalance = currentBalance; + } + }); + return maxBalance; + }, +}); diff --git a/widget/embedded/src/store/utils/wallets.ts b/widget/embedded/src/store/utils/wallets.ts new file mode 100644 index 0000000000..d997b39ccd --- /dev/null +++ b/widget/embedded/src/store/utils/wallets.ts @@ -0,0 +1,111 @@ +import type { Balance } from '../../types'; +import type { AppStoreState } from '../app'; +import type { + AggregatedBalanceState, + AssetKey, + BalanceKey, + BalanceState, +} from '../slices/wallets'; +import type { Asset, WalletDetail } from 'rango-types'; + +import BigNumber from 'bignumber.js'; + +import { ZERO } from '../../constants/numbers'; + +/** + * output format: BlockchainId-TokenAddress + */ +export function createAssetKey( + asset: Pick +): AssetKey { + return `${asset.blockchain}-${asset.address}`; +} + +/** + * output format: BlockchainId-TokenAddress-WalletAddress + */ +export function createBalanceKey( + accountAddress: string, + asset: Pick +): BalanceKey { + const assetKey = createAssetKey(asset); + return `${assetKey}-${accountAddress}`; +} + +export function extractAssetFromBalanceKey( + key: BalanceKey +): Pick { + const [assetChain, assetAddress] = key.split('-'); + + return { + address: assetAddress, + blockchain: assetChain, + }; +} + +export function createBalanceStateForNewAccount( + account: WalletDetail, + store: () => AppStoreState +): BalanceState { + const state: BalanceState = {}; + + account.balances?.forEach((accountBalance) => { + const key = createBalanceKey(account.address, accountBalance.asset); + const amount = accountBalance.amount.amount; + const decimals = accountBalance.amount.decimals; + + const usdPrice = store().findToken(accountBalance.asset)?.usdPrice; + const usdValue = usdPrice + ? new BigNumber(usdPrice ?? ZERO).multipliedBy(amount).toString() + : ''; + + const balance: Balance = { + amount, + decimals, + usdValue, + }; + + state[key] = balance; + }); + + return state; +} + +export function updateAggregatedBalanceStateForNewAccount( + aggregatedBalances: AggregatedBalanceState, + balanceState: BalanceState +) { + for (const balanceKey in balanceState) { + const asset = extractAssetFromBalanceKey(balanceKey as BalanceKey); + const assetKey = createAssetKey(asset); + + if (!aggregatedBalances[assetKey]) { + aggregatedBalances[assetKey] = []; + } + + if (!aggregatedBalances[assetKey].includes(balanceKey as BalanceKey)) { + aggregatedBalances[assetKey] = [ + ...aggregatedBalances[assetKey], + balanceKey as BalanceKey, + ]; + } + } + + return aggregatedBalances; +} + +export function removeBalanceFromAggregatedBalance( + aggregatedBalances: AggregatedBalanceState, + balanceKey: BalanceKey +) { + const asset = extractAssetFromBalanceKey(balanceKey); + const assetKey = createAssetKey(asset); + + if (aggregatedBalances[assetKey]) { + aggregatedBalances[assetKey] = aggregatedBalances[assetKey].filter( + (aggregatedBalance) => aggregatedBalance !== balanceKey + ); + } + + return aggregatedBalances; +} diff --git a/widget/embedded/src/store/wallets.ts b/widget/embedded/src/store/wallets.ts deleted file mode 100644 index 16cad88c92..0000000000 --- a/widget/embedded/src/store/wallets.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { FindToken } from './slices/data'; -import type { Balance, TokensBalance, Wallet } from '../types'; -import type { WalletType } from '@rango-dev/wallets-shared'; -import type { Token } from 'rango-sdk'; - -import { create } from 'zustand'; -import { subscribeWithSelector } from 'zustand/middleware'; - -import { eventEmitter } from '../services/eventEmitter'; -import { httpService } from '../services/httpService'; -import { WalletEventTypes, WidgetEvents } from '../types'; -import { createTokenHash } from '../utils/meta'; -import { - isAccountAndWalletMatched, - makeBalanceFor, - makeTokensBalance, - resetConnectedWalletState, -} from '../utils/wallets'; - -import createSelectors from './selectors'; - -export type TokenBalance = { - chain: string; - symbol: string; - ticker: string; - address: string | null; - rawAmount: string; - decimal: number | null; - amount: string; - logo: string | null; - usdPrice: number | null; -}; - -export interface ConnectedWallet extends Wallet { - balances: TokenBalance[] | null; - explorerUrl: string | null; - selected: boolean; - loading: boolean; - error: boolean; -} -interface WalletsStore { - connectedWallets: ConnectedWallet[]; - balances: TokensBalance; - loading: boolean; - connectWallet: (accounts: Wallet[], findToken: FindToken) => void; - disconnectWallet: (walletType: WalletType) => void; - selectWallets: (wallets: { walletType: string; chain: string }[]) => void; - clearConnectedWallet: () => void; - getWalletsDetails: ( - accounts: Wallet[], - findToken: FindToken, - shouldRetry?: boolean - ) => void; - getBalanceFor: (token: Token) => Balance | null; -} - -export const useWalletsStore = createSelectors( - create()( - subscribeWithSelector((set, get) => { - return { - connectedWallets: [], - balances: {}, - loading: false, - connectWallet: (accounts, findToken) => { - eventEmitter.emit(WidgetEvents.WalletEvent, { - type: WalletEventTypes.CONNECT, - payload: { walletType: accounts[0].walletType, accounts }, - }); - - const getWalletsDetails = get().getWalletsDetails; - set((state) => ({ - loading: true, - connectedWallets: state.connectedWallets - .filter((wallet) => wallet.walletType !== accounts[0].walletType) - .concat( - accounts.map((account) => { - const shouldMarkWalletAsSelected = - !state.connectedWallets.some( - (connectedWallet) => - connectedWallet.chain === account.chain && - connectedWallet.selected && - /** - * Sometimes, the connect function can be called multiple times for a particular wallet type when using the auto-connect feature. - * This check is there to make sure the chosen wallet doesn't end up unselected. - */ - connectedWallet.walletType !== account.walletType - ); - return { - balances: [], - address: account.address, - chain: account.chain, - explorerUrl: null, - walletType: account.walletType, - selected: shouldMarkWalletAsSelected, - loading: true, - error: false, - }; - }) - ), - })); - getWalletsDetails(accounts, findToken); - }, - disconnectWallet: (walletType) => { - set((state) => { - if ( - state.connectedWallets.find( - (wallet) => wallet.walletType === walletType - ) - ) { - eventEmitter.emit(WidgetEvents.WalletEvent, { - type: WalletEventTypes.DISCONNECT, - payload: { walletType }, - }); - } - - const selectedWallets = state.connectedWallets - .filter( - (connectedWallet) => - connectedWallet.selected && - connectedWallet.walletType !== walletType - ) - .map((selectedWallet) => selectedWallet.chain); - - const connectedWallets = state.connectedWallets - .filter( - (connectedWallet) => connectedWallet.walletType !== walletType - ) - .map((connectedWallet) => { - const anyWalletSelectedForBlockchain = selectedWallets.includes( - connectedWallet.chain - ); - if (anyWalletSelectedForBlockchain) { - return connectedWallet; - } - selectedWallets.push(connectedWallet.chain); - return { ...connectedWallet, selected: true }; - }); - - const balances = makeTokensBalance(connectedWallets); - - return { - balances, - connectedWallets, - }; - }); - }, - selectWallets: (wallets) => - set((state) => ({ - connectedWallets: state.connectedWallets.map((connectedWallet) => { - const walletSelected = !!wallets.find( - (wallet) => - wallet.chain === connectedWallet.chain && - wallet.walletType !== connectedWallet.walletType && - connectedWallet.selected - ); - const walletNotSelected = !!wallets.find( - (wallet) => - wallet.chain === connectedWallet.chain && - wallet.walletType === connectedWallet.walletType && - !connectedWallet.selected - ); - if (walletSelected) { - return { ...connectedWallet, selected: false }; - } else if (walletNotSelected) { - return { ...connectedWallet, selected: true }; - } - - return connectedWallet; - }), - })), - clearConnectedWallet: () => - set(() => ({ - connectedWallets: [], - selectedWallets: [], - })), - getWalletsDetails: async (accounts, findToken, shouldRetry = true) => { - const getWalletsDetails = get().getWalletsDetails; - set((state) => ({ - loading: true, - connectedWallets: state.connectedWallets.map((wallet) => { - return accounts.find((account) => - isAccountAndWalletMatched(account, wallet) - ) - ? { ...wallet, loading: true } - : wallet; - }), - })); - try { - const data = accounts.map(({ address, chain }) => ({ - address, - blockchain: chain, - })); - const response = await httpService().getWalletsDetails(data); - const retrievedBalance = response.wallets; - if (retrievedBalance) { - set((state) => { - const connectedWallets = state.connectedWallets.map( - (connectedWallet) => { - const matchedAccount = accounts.find((account) => - isAccountAndWalletMatched(account, connectedWallet) - ); - const retrievedBalanceAccount = retrievedBalance.find( - (balance) => - balance.address === connectedWallet.address && - balance.blockChain === connectedWallet.chain - ); - if ( - retrievedBalanceAccount?.failed && - matchedAccount && - shouldRetry - ) { - getWalletsDetails([matchedAccount], findToken, false); - } - return matchedAccount && retrievedBalanceAccount - ? { - ...connectedWallet, - explorerUrl: retrievedBalanceAccount.explorerUrl, - balances: makeBalanceFor( - retrievedBalanceAccount, - findToken - ), - loading: false, - error: false, - } - : connectedWallet; - } - ); - - const balances = makeTokensBalance(connectedWallets); - return { - loading: false, - balances, - connectedWallets, - }; - }); - } else { - throw new Error('Wallet not found'); - } - } catch (error) { - set((state) => { - const connectedWallets = state.connectedWallets.map((balance) => { - return accounts.find((account) => - isAccountAndWalletMatched(account, balance) - ) - ? resetConnectedWalletState(balance) - : balance; - }); - - const balances = makeTokensBalance(connectedWallets); - - return { - loading: false, - balances, - connectedWallets, - }; - }); - } - }, - getBalanceFor: (token) => { - const { balances } = get(); - const tokenHash = createTokenHash(token); - const balance = balances[tokenHash]; - return balance ?? null; - }, - }; - }) - ) -); diff --git a/widget/embedded/src/types/config.ts b/widget/embedded/src/types/config.ts index e6cfe9ed04..2a725c16f2 100644 --- a/widget/embedded/src/types/config.ts +++ b/widget/embedded/src/types/config.ts @@ -1,5 +1,5 @@ import type { Language, theme } from '@rango-dev/ui'; -import type { ProviderInterface } from '@rango-dev/wallets-react'; +import type { LegacyProviderInterface } from '@rango-dev/wallets-core/dist/legacy/mod'; import type { WalletType } from '@rango-dev/wallets-shared'; import type { Asset } from 'rango-sdk'; @@ -132,6 +132,9 @@ export type SignersConfig = { * * @property {'visible' | 'hidden'} [liquiditySource] * - The visibility state for the liquiditySource feature. Optional property. + * + * @property {'disabled' | 'enabled'} [experimentalWallet] + * - Enable our experimental version of wallets. Default: disable on production, enabled on dev. */ export type Features = Partial< Record< @@ -144,7 +147,8 @@ export type Features = Partial< 'visible' | 'hidden' > > & - Partial>; + Partial> & + Partial>; export type TrezorManifest = { appUrl: string; @@ -221,7 +225,7 @@ export type WidgetConfig = { from?: BlockchainAndTokenConfig; to?: BlockchainAndTokenConfig; liquiditySources?: string[]; - wallets?: (WalletType | ProviderInterface)[]; + wallets?: (WalletType | LegacyProviderInterface)[]; multiWallets?: boolean; customDestination?: boolean; defaultCustomDestinations?: { [blockchain: string]: string }; diff --git a/widget/embedded/src/types/wallets.ts b/widget/embedded/src/types/wallets.ts index b15dedcb2b..19e60748d6 100644 --- a/widget/embedded/src/types/wallets.ts +++ b/widget/embedded/src/types/wallets.ts @@ -10,7 +10,7 @@ export interface Wallet { export type Balance = { amount: string; decimals: number; - usdValue: string; + usdValue: string | null; }; export type Blockchain = string; diff --git a/widget/embedded/src/utils/hub.ts b/widget/embedded/src/utils/hub.ts new file mode 100644 index 0000000000..3a97469ffc --- /dev/null +++ b/widget/embedded/src/utils/hub.ts @@ -0,0 +1,22 @@ +import type { CommonNamespaceKeys } from '@rango-dev/wallets-core'; + +import { Namespace } from '@rango-dev/wallets-shared'; + +export function convertCommonNamespacesKeysToLegacyNamespace( + namespaces: CommonNamespaceKeys[] +): Namespace[] { + return namespaces.map((namespace) => { + switch (namespace) { + case 'evm': + return Namespace.Evm; + case 'solana': + return Namespace.Solana; + case 'cosmos': + return Namespace.Cosmos; + } + + throw new Error( + 'Can not convert this common namespace key to a proper legacy key.' + ); + }); +} diff --git a/widget/embedded/src/utils/providers.ts b/widget/embedded/src/utils/providers.ts index 03484a1ff4..a7297092a3 100644 --- a/widget/embedded/src/utils/providers.ts +++ b/widget/embedded/src/utils/providers.ts @@ -1,7 +1,13 @@ import type { WidgetConfig } from '../types'; -import type { ProviderInterface } from '@rango-dev/wallets-react'; +import type { LegacyProviderInterface } from '@rango-dev/wallets-core/legacy'; import { allProviders } from '@rango-dev/provider-all'; +import { + defineVersions, + pickVersion, + Provider, + type VersionedProviders, +} from '@rango-dev/wallets-core'; export interface ProvidersOptions { walletConnectProjectId?: WidgetConfig['walletConnectProjectId']; @@ -9,32 +15,33 @@ export interface ProvidersOptions { WidgetConfig['__UNSTABLE_OR_INTERNAL__'] >['walletConnectListedDesktopWalletLink']; trezorManifest: WidgetConfig['trezorManifest']; + experimentalWallet?: 'enabled' | 'disabled'; } /** * * Generate a list of providers by passing a provider name (e.g. metamask) or a custom provider which implemented ProviderInterface. - * @returns ProviderInterface[] a list of ProviderInterface + * @returns BothProvidersInterface[] a list of BothProvidersInterface * */ +type BothProvidersInterface = LegacyProviderInterface | Provider; export function matchAndGenerateProviders( providers: WidgetConfig['wallets'], options?: ProvidersOptions -): ProviderInterface[] { - const all = allProviders({ +): VersionedProviders[] { + const envs = { walletconnect2: { WC_PROJECT_ID: options?.walletConnectProjectId || '', DISABLE_MODAL_AND_OPEN_LINK: options?.walletConnectListedDesktopWalletLink, }, selectedProviders: providers, - trezor: options?.trezorManifest - ? { manifest: options.trezorManifest } - : undefined, - }); + }; + + const all = allProviders(envs); if (providers) { - const selectedProviders: ProviderInterface[] = []; + const selectedProviders: VersionedProviders[] = []; providers.forEach((requestedProvider) => { /* @@ -43,11 +50,24 @@ export function matchAndGenerateProviders( * The second way is passing a custom provider which implemented ProviderInterface. */ if (typeof requestedProvider === 'string') { - const result: ProviderInterface | undefined = all.find((provider) => { - return provider.config.type === requestedProvider; - }); + const result: BothProvidersInterface | undefined = + pickVersionWithFallbackToLegacy(all, options).find((provider) => { + if (provider instanceof Provider) { + return provider.id === requestedProvider; + } + return provider.config.type === requestedProvider; + }); + if (result) { - selectedProviders.push(result); + if (result instanceof Provider) { + selectedProviders.push( + defineVersions().version('1.0.0', result).build() + ); + } else { + selectedProviders.push( + defineVersions().version('0.0.0', result).build() + ); + } } else { console.warn( `Couldn't find ${requestedProvider} provider. Please make sure you are passing the correct name.` @@ -55,21 +75,54 @@ export function matchAndGenerateProviders( } } else { // It's a custom provider so we directly push it to the list. - selectedProviders.push(requestedProvider); + if (requestedProvider instanceof Provider) { + selectedProviders.push( + defineVersions().version('1.0.0', requestedProvider).build() + ); + } else { + selectedProviders.push( + defineVersions().version('0.0.0', requestedProvider).build() + ); + } } }); + return selectedProviders; } return all; } +// TODO: this is a duplication with what we do in core. +function pickVersionWithFallbackToLegacy( + providers: VersionedProviders[], + options?: ProvidersOptions +): BothProvidersInterface[] { + const { experimentalWallet = 'disabled' } = options || {}; + + return providers.map((provider) => { + const version = experimentalWallet == 'disabled' ? '0.0.0' : '1.0.0'; + try { + return pickVersion(provider, version)[1]; + } catch { + // Fallback to legacy version, if target version doesn't exists. + return pickVersion(provider, '0.0.0')[1]; + } + }); +} + export function configWalletsToWalletName( config: WidgetConfig['wallets'], options?: ProvidersOptions ): string[] { - const providers = matchAndGenerateProviders(config, options); + const providers = pickVersionWithFallbackToLegacy( + matchAndGenerateProviders(config, options), + options + ); const names = providers.map((provider) => { + if (provider instanceof Provider) { + return provider.id; + } return provider.config.type; }); return names; diff --git a/widget/embedded/src/utils/swap.ts b/widget/embedded/src/utils/swap.ts index 94f1577ad7..cd026a7b93 100644 --- a/widget/embedded/src/utils/swap.ts +++ b/widget/embedded/src/utils/swap.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ import type { FeesGroup, NameOfFees } from '../constants/quote'; import type { FetchStatus, FindToken } from '../store/slices/data'; -import type { ConnectedWallet } from '../store/wallets'; +import type { ConnectedWallet } from '../store/slices/wallets'; import type { ConvertedToken, QuoteError, diff --git a/widget/embedded/src/utils/wallets.ts b/widget/embedded/src/utils/wallets.ts index adf4497e7d..8ae3410ff1 100644 --- a/widget/embedded/src/utils/wallets.ts +++ b/widget/embedded/src/utils/wallets.ts @@ -1,26 +1,18 @@ -import type { FindToken } from '../store/slices/data'; -import type { ConnectedWallet, TokenBalance } from '../store/wallets'; +import type { BalanceState, ConnectedWallet } from '../store/slices/wallets'; import type { Balance, SelectedQuote, - TokensBalance, Wallet, WalletInfoWithExtra, } from '../types'; -import type { WalletInfo as ModalWalletInfo } from '@rango-dev/ui'; +import type { ExtendedWalletInfo } from '@rango-dev/wallets-react'; import type { Network, - WalletInfo, WalletState, WalletType, WalletTypes, } from '@rango-dev/wallets-shared'; -import type { - BlockchainMeta, - Token, - TransactionType, - WalletDetail, -} from 'rango-sdk'; +import type { BlockchainMeta, Token, TransactionType } from 'rango-sdk'; import { BlockchainCategories, @@ -47,9 +39,11 @@ import { import { EXCLUDED_WALLETS } from '../constants/wallets'; import { isBlockchainTypeInCategory, removeDuplicateFrom } from './common'; -import { createTokenHash } from './meta'; import { numberToString } from './numbers'; +export type ExtendedModalWalletInfo = WalletInfoWithExtra & + Pick; + export function mapStatusToWalletState(state: WalletState): WalletStatus { switch (true) { case state.connected: @@ -65,10 +59,10 @@ export function mapStatusToWalletState(state: WalletState): WalletStatus { export function mapWalletTypesToWalletInfo( getState: (type: WalletType) => WalletState, - getWalletInfo: (type: WalletType) => WalletInfo, + getWalletInfo: (type: WalletType) => ExtendedWalletInfo, list: WalletType[], chain?: string -): WalletInfoWithExtra[] { +): ExtendedModalWalletInfo[] { return list .filter((wallet) => !EXCLUDED_WALLETS.includes(wallet as WalletTypes)) .filter((wallet) => { @@ -97,6 +91,8 @@ export function mapWalletTypesToWalletInfo( singleNamespace, supportedChains, needsDerivationPath, + properties, + isHub, } = getWalletInfo(type); const blockchainTypes = removeDuplicateFrom( supportedChains.map((item) => item.type) @@ -114,6 +110,8 @@ export function mapWalletTypesToWalletInfo( singleNamespace, blockchainTypes, needsDerivationPath, + properties, + isHub, }; }); } @@ -265,8 +263,6 @@ export function getQuoteWallets(params: { return Array.from(wallets); } -type Blockchain = { name: string; accounts: ConnectedWallet[] }; - export function isAccountAndWalletMatched( account: Wallet, connectedWallet: ConnectedWallet @@ -278,74 +274,18 @@ export function isAccountAndWalletMatched( ); } -export function makeBalanceFor( - retrievedBalance: WalletDetail, - findToken: FindToken -): TokenBalance[] { - const { blockChain: chain, balances = [] } = retrievedBalance; - return ( - balances?.map((tokenBalance) => ({ - chain, - symbol: tokenBalance.asset.symbol, - ticker: tokenBalance.asset.symbol, - address: tokenBalance.asset.address || null, - rawAmount: tokenBalance.amount.amount, - decimal: tokenBalance.amount.decimals, - amount: new BigNumber(tokenBalance.amount.amount) - .shiftedBy(-tokenBalance.amount.decimals) - .toFixed(), - logo: '', - usdPrice: findToken(tokenBalance.asset)?.usdPrice || null, - })) || [] - ); -} - export function resetConnectedWalletState( connectedWallet: ConnectedWallet ): ConnectedWallet { return { ...connectedWallet, loading: false, error: true }; } -export const calculateWalletUsdValue = (connectedWallet: ConnectedWallet[]) => { - const uniqueAccountAddresses = new Set(); - const uniqueBalance: ConnectedWallet[] = connectedWallet?.reduce( - (acc: ConnectedWallet[], current: ConnectedWallet) => { - return acc.findIndex( - (i) => i.address === current.address && i.chain === current.chain - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - ) === -1 - ? [...acc, current] - : acc; - }, - [] - ); +export const calculateWalletUsdValue = (balances: BalanceState) => { + const total = Object.values(balances).reduce((prev, balance) => { + return balance.usdValue ? prev.plus(balance.usdValue) : prev; + }, new BigNumber(ZERO)); - const modifiedWalletBlockchains = uniqueBalance?.map((chain) => { - const modifiedWalletBlockchain: Blockchain = { - name: chain.chain, - accounts: [], - }; - if (!uniqueAccountAddresses.has(chain.address)) { - uniqueAccountAddresses.add(chain.address); - } - uniqueAccountAddresses.forEach((accountAddress) => { - if (chain.address === accountAddress) { - modifiedWalletBlockchain.accounts.push(chain); - } - }); - return modifiedWalletBlockchain; - }); - const total = numberToString( - modifiedWalletBlockchains - ?.flatMap((b) => b.accounts) - ?.flatMap((a) => a?.balances) - ?.map((b) => - new BigNumber(b?.amount || ZERO).multipliedBy(b?.usdPrice || 0) - ) - ?.reduce((a, b) => a.plus(b), ZERO) || ZERO - ).toString(); - - return numberWithThousandSeparator(total); + return numberWithThousandSeparator(total.toString()); }; function numberWithThousandSeparator(number: string | number): string { @@ -384,19 +324,31 @@ export const getKeplrCompatibleConnectedWallets = ( }; export function formatBalance(balance: Balance | null): Balance | null { + if (!balance) { + return null; + } + + const amount = new BigNumber(balance.amount) + .shiftedBy(-balance.decimals) + .toFixed(); + const usdValue = balance.usdValue + ? new BigNumber(balance.usdValue).shiftedBy(-balance.decimals).toFixed() + : null; + const formattedAmount = numberToString( + amount, + BALANCE_MIN_DECIMALS, + BALANCE_MAX_DECIMALS + ); + // null is using for detecing uknown prices + const formattedUsdValue = usdValue + ? numberToString(usdValue, USD_VALUE_MIN_DECIMALS, USD_VALUE_MAX_DECIMALS) + : null; + const formattedBalance: Balance | null = balance ? { ...balance, - amount: numberToString( - balance.amount, - BALANCE_MIN_DECIMALS, - BALANCE_MAX_DECIMALS - ), - usdValue: numberToString( - balance.usdValue, - USD_VALUE_MIN_DECIMALS, - USD_VALUE_MAX_DECIMALS - ), + amount: formattedAmount, + usdValue: formattedUsdValue, } : null; @@ -408,17 +360,39 @@ export function compareTokenBalance( token2Balance: Balance | null ): number { if (token1Balance?.usdValue || token2Balance?.usdValue) { - return ( - parseFloat(token2Balance?.usdValue || '0') - - parseFloat(token1Balance?.usdValue || '0') - ); + const token1UsdValue = + !!token1Balance && !!token1Balance.usdValue + ? new BigNumber(token1Balance.usdValue).shiftedBy( + -token1Balance.decimals + ) + : ZERO; + const token2UsdValue = + !!token2Balance && !!token2Balance.usdValue + ? new BigNumber(token2Balance.usdValue).shiftedBy( + -token2Balance.decimals + ) + : ZERO; + + if (token1UsdValue.isEqualTo(token2UsdValue)) { + return 0; + } + return token1UsdValue.isGreaterThan(token2UsdValue) ? -1 : 1; } if (token1Balance?.amount || token2Balance?.amount) { - return ( - parseFloat(token2Balance?.amount || '0') - - parseFloat(token1Balance?.amount || '0') - ); + const token1Amount = + !!token1Balance && !!token1Balance.amount + ? new BigNumber(token1Balance.amount).shiftedBy(-token1Balance.decimals) + : ZERO; + const token2Amount = + !!token2Balance && !!token2Balance.amount + ? new BigNumber(token2Balance.amount).shiftedBy(-token2Balance.decimals) + : ZERO; + + if (token1Amount.isEqualTo(token2Amount)) { + return 0; + } + return token1Amount.isGreaterThan(token2Amount) ? -1 : 1; } return 0; @@ -436,8 +410,8 @@ export function areTokensEqual( } export function sortWalletsBasedOnConnectionState( - wallets: WalletInfoWithExtra[] -): WalletInfoWithExtra[] { + wallets: ExtendedModalWalletInfo[] +): ExtendedModalWalletInfo[] { return wallets.sort( (a, b) => Number(b.state === WalletStatus.CONNECTED) - @@ -484,43 +458,6 @@ export function getAddress({ )?.address; } -export function makeTokensBalance(connectedWallets: ConnectedWallet[]) { - return connectedWallets - .flatMap((wallet) => wallet.balances) - .reduce((balances: TokensBalance, balance) => { - const currentBalance = { - amount: balance?.amount ?? '', - decimals: balance?.decimal ?? 0, - usdValue: balance?.usdPrice - ? new BigNumber(balance?.usdPrice ?? ZERO) - .multipliedBy(balance?.amount) - .toString() - : '', - }; - - const tokenHash = balance - ? createTokenHash({ - symbol: balance.symbol, - blockchain: balance.chain, - address: balance.address, - }) - : null; - - const prevBalance = tokenHash ? balances[tokenHash] : null; - - const shouldUpdateBalance = - tokenHash && - (!prevBalance || - (prevBalance && prevBalance.amount < currentBalance.amount)); - - if (shouldUpdateBalance) { - balances[tokenHash] = currentBalance; - } - - return balances; - }, {}); -} - export const isFetchingBalance = ( connectedWallets: ConnectedWallet[], blockchain: string @@ -529,12 +466,12 @@ export const isFetchingBalance = ( (wallet) => wallet.chain === blockchain && wallet.loading ); -export function hashWalletsState(walletsInfo: ModalWalletInfo[]) { +export function hashWalletsState(walletsInfo: WalletInfoWithExtra[]) { return walletsInfo.map((w) => w.state).join('-'); } export function filterBlockchainsByWalletTypes( - wallets: ModalWalletInfo[], + wallets: WalletInfoWithExtra[], blockchains: BlockchainMeta[] ) { const uniqueBlockchainTypes = new Set(); @@ -551,7 +488,7 @@ export function filterBlockchainsByWalletTypes( } export function filterWalletsByCategory( - wallets: WalletInfoWithExtra[], + wallets: ExtendedModalWalletInfo[], category: string ) { if (category === BlockchainCategories.ALL) {