import { createStore as createMipd, type EIP6963ProviderDetail, type Store as MipdStore, } from 'mipd' import { type Address, type Chain, type Client, createClient, type EIP1193RequestFn, type ClientConfig as viem_ClientConfig, type Transport as viem_Transport, } from 'viem' import { persist, subscribeWithSelector } from 'zustand/middleware' import { createStore, type Mutate, type StoreApi } from 'zustand/vanilla' import type { ConnectorEventMap, CreateConnectorFn, } from './connectors/createConnector.js' import { injected } from './connectors/injected.js' import { createEmitter, type Emitter, type EventData } from './createEmitter.js' import { createStorage, getDefaultStorage, type Storage, } from './createStorage.js' import { ChainNotConfiguredError } from './errors/config.js' import type { Compute, ExactPartial, LooseOmit, OneOf, RemoveUndefined, } from './types/utils.js' import { uid } from './utils/uid.js' import { version } from './version.js' export function createConfig< const chains extends readonly [Chain, ...Chain[]], transports extends Record, const connectorFns extends readonly CreateConnectorFn[], >( parameters: CreateConfigParameters, ): Config { const { multiInjectedProviderDiscovery = true, storage = createStorage({ storage: getDefaultStorage(), }), syncConnectedChain = true, ssr = false, ...rest } = parameters ///////////////////////////////////////////////////////////////////////////////////////////////// // Set up connectors, clients, etc. ///////////////////////////////////////////////////////////////////////////////////////////////// const mipd = typeof window !== 'undefined' && multiInjectedProviderDiscovery ? createMipd() : undefined const chains = createStore(() => rest.chains) const connectors = createStore(() => { const collection = [] const rdnsSet = new Set() for (const connectorFns of rest.connectors ?? []) { const connector = setup(connectorFns) collection.push(connector) if (!ssr && connector.rdns) { const rdnsValues = typeof connector.rdns === 'string' ? [connector.rdns] : connector.rdns for (const rdns of rdnsValues) { rdnsSet.add(rdns) } } } if (!ssr && mipd) { const providers = mipd.getProviders() for (const provider of providers) { if (rdnsSet.has(provider.info.rdns)) continue collection.push(setup(providerDetailToConnector(provider))) } } return collection }) function setup(connectorFn: CreateConnectorFn): Connector { // Set up emitter with uid and add to connector so they are "linked" together. const emitter = createEmitter(uid()) const connector = { ...connectorFn({ emitter, chains: chains.getState(), storage, transports: rest.transports, }), emitter, uid: emitter.uid, } // Start listening for `connect` events on connector setup // This allows connectors to "connect" themselves without user interaction (e.g. MetaMask's "Manually connect to current site") emitter.on('connect', connect) connector.setup?.() return connector } function providerDetailToConnector(providerDetail: EIP6963ProviderDetail) { const { info } = providerDetail const provider = providerDetail.provider as any return injected({ target: { ...info, id: info.rdns, provider } }) } const clients = new Map>() function getClient( config: { chainId?: chainId | chains[number]['id'] | undefined } = {}, ): Client> { const chainId = config.chainId ?? store.getState().chainId const chain = chains.getState().find((x) => x.id === chainId) // chainId specified and not configured if (config.chainId && !chain) throw new ChainNotConfiguredError() // If the target chain is not configured, use the client of the current chain. type Return = Client> { const client = clients.get(store.getState().chainId) if (client && !chain) return client as Return if (!chain) throw new ChainNotConfiguredError() } // If a memoized client exists for a chain id, use that. { const client = clients.get(chainId) if (client) return client as Return } let client: Client if (rest.client) client = rest.client({ chain }) else { const chainId = chain.id as chains[number]['id'] const chainIds = chains.getState().map((x) => x.id) // Grab all properties off `rest` and resolve for use in `createClient` const properties: Partial = {} const entries = Object.entries(rest) as [keyof typeof rest, any][] for (const [key, value] of entries) { if ( key === 'chains' || key === 'client' || key === 'connectors' || key === 'transports' ) continue if (typeof value === 'object') { // check if value is chainId-specific since some values can be objects // e.g. { batch: { multicall: { batchSize: 1024 } } } if (chainId in value) properties[key] = value[chainId] else { // check if value is chainId-specific, but does not have value for current chainId const hasChainSpecificValue = chainIds.some((x) => x in value) if (hasChainSpecificValue) continue properties[key] = value } } else properties[key] = value } client = createClient({ ...properties, chain, batch: properties.batch ?? { multicall: true }, transport: (parameters) => rest.transports[chainId]({ ...parameters, connectors }), }) } clients.set(chainId, client) return client as Return } ///////////////////////////////////////////////////////////////////////////////////////////////// // Create store ///////////////////////////////////////////////////////////////////////////////////////////////// function getInitialState(): State { return { chainId: chains.getState()[0].id, connections: new Map(), current: null, status: 'disconnected', } } let currentVersion: number const prefix = '0.0.0-canary-' if (version.startsWith(prefix)) currentVersion = Number.parseInt(version.replace(prefix, ''), 10) // use package major version to version store else currentVersion = Number.parseInt(version.split('.')[0] ?? '0', 10) const store = createStore( subscribeWithSelector( // only use persist middleware if storage exists storage ? persist(getInitialState, { migrate(persistedState, version) { if (version === currentVersion) return persistedState as State const initialState = getInitialState() const chainId = validatePersistedChainId( persistedState, initialState.chainId, ) return { ...initialState, chainId } }, name: 'store', partialize(state) { // Only persist "critical" store properties to preserve storage size. return { connections: { __type: 'Map', value: Array.from(state.connections.entries()).map( ([key, connection]) => { const { id, name, type, uid } = connection.connector const connector = { id, name, type, uid } return [key, { ...connection, connector }] }, ), } as unknown as PartializedState['connections'], chainId: state.chainId, current: state.current, } satisfies PartializedState }, merge(persistedState, currentState) { // `status` should not be persisted as it messes with reconnection if ( typeof persistedState === 'object' && persistedState && 'status' in persistedState ) delete persistedState.status // Make sure persisted `chainId` is valid const chainId = validatePersistedChainId( persistedState, currentState.chainId, ) return { ...currentState, ...(persistedState as object), chainId, } }, skipHydration: ssr, storage: storage as Storage>, version: currentVersion, }) : getInitialState, ), ) store.setState(getInitialState()) function validatePersistedChainId( persistedState: unknown, defaultChainId: number, ) { return persistedState && typeof persistedState === 'object' && 'chainId' in persistedState && typeof persistedState.chainId === 'number' && chains.getState().some((x) => x.id === persistedState.chainId) ? persistedState.chainId : defaultChainId } ///////////////////////////////////////////////////////////////////////////////////////////////// // Subscribe to changes ///////////////////////////////////////////////////////////////////////////////////////////////// // Update default chain when connector chain changes if (syncConnectedChain) store.subscribe( ({ connections, current }) => current ? connections.get(current)?.chainId : undefined, (chainId) => { // If chain is not configured, then don't switch over to it. const isChainConfigured = chains .getState() .some((x) => x.id === chainId) if (!isChainConfigured) return return store.setState((x) => ({ ...x, chainId: chainId ?? x.chainId, })) }, ) // EIP-6963 subscribe for new wallet providers mipd?.subscribe((providerDetails) => { const connectorIdSet = new Set() const connectorRdnsSet = new Set() for (const connector of connectors.getState()) { connectorIdSet.add(connector.id) if (connector.rdns) { const rdnsValues = typeof connector.rdns === 'string' ? [connector.rdns] : connector.rdns for (const rdns of rdnsValues) { connectorRdnsSet.add(rdns) } } } const newConnectors: Connector[] = [] for (const providerDetail of providerDetails) { if (connectorRdnsSet.has(providerDetail.info.rdns)) continue const connector = setup(providerDetailToConnector(providerDetail)) if (connectorIdSet.has(connector.id)) continue newConnectors.push(connector) } if (storage && !store.persist.hasHydrated()) return connectors.setState((x) => [...x, ...newConnectors], true) }) ///////////////////////////////////////////////////////////////////////////////////////////////// // Emitter listeners ///////////////////////////////////////////////////////////////////////////////////////////////// function change(data: EventData) { store.setState((x) => { const connection = x.connections.get(data.uid) if (!connection) return x return { ...x, connections: new Map(x.connections).set(data.uid, { accounts: (data.accounts as readonly [Address, ...Address[]]) ?? connection.accounts, chainId: data.chainId ?? connection.chainId, connector: connection.connector, }), } }) } function connect(data: EventData) { // Disable handling if reconnecting/connecting if ( store.getState().status === 'connecting' || store.getState().status === 'reconnecting' ) return store.setState((x) => { const connector = connectors.getState().find((x) => x.uid === data.uid) if (!connector) return x if (connector.emitter.listenerCount('connect')) connector.emitter.off('connect', change) if (!connector.emitter.listenerCount('change')) connector.emitter.on('change', change) if (!connector.emitter.listenerCount('disconnect')) connector.emitter.on('disconnect', disconnect) return { ...x, connections: new Map(x.connections).set(data.uid, { accounts: data.accounts as readonly [Address, ...Address[]], chainId: data.chainId, connector: connector, }), current: data.uid, status: 'connected', } }) } function disconnect(data: EventData) { store.setState((x) => { const connection = x.connections.get(data.uid) if (connection) { const connector = connection.connector if (connector.emitter.listenerCount('change')) connection.connector.emitter.off('change', change) if (connector.emitter.listenerCount('disconnect')) connection.connector.emitter.off('disconnect', disconnect) if (!connector.emitter.listenerCount('connect')) connection.connector.emitter.on('connect', connect) } x.connections.delete(data.uid) if (x.connections.size === 0) return { ...x, connections: new Map(), current: null, status: 'disconnected', } const nextConnection = x.connections.values().next().value as Connection return { ...x, connections: new Map(x.connections), current: nextConnection.connector.uid, } }) } return { get chains() { return chains.getState() as chains }, get connectors() { return connectors.getState() as Readonly<{ [key in keyof connectorFns]: Connector }> }, storage, getClient, get state() { return store.getState() as unknown as State }, setState(value) { let newState: State if (typeof value === 'function') newState = value(store.getState() as any) else newState = value // Reset state if it got set to something not matching the base state const initialState = getInitialState() if (typeof newState !== 'object') newState = initialState const isCorrupt = Object.keys(initialState).some((x) => !(x in newState)) if (isCorrupt) newState = initialState store.setState(newState, true) }, subscribe(selector, listener, options) { return store.subscribe( selector as unknown as (state: State) => any, listener, options ? ({ ...options, fireImmediately: options.emitImmediately, // Workaround cast since Zustand does not support `'exactOptionalPropertyTypes'` } as RemoveUndefined) : undefined, ) }, _internal: { mipd, async revalidate() { // Check connections to see if they are still active const state = store.getState() const connections = state.connections let current = state.current for (const [, connection] of connections) { const connector = connection.connector // check if `connect.isAuthorized` exists // partial connectors in storage do not have it const isAuthorized = connector.isAuthorized ? await connector.isAuthorized() : false if (isAuthorized) continue // Remove stale connection connections.delete(connector.uid) if (current === connector.uid) current = null } // set connections store.setState((x) => ({ ...x, connections, current })) }, store, ssr: Boolean(ssr), syncConnectedChain, transports: rest.transports as transports, chains: { setState(value) { const nextChains = ( typeof value === 'function' ? value(chains.getState()) : value ) as chains if (nextChains.length === 0) return return chains.setState(nextChains, true) }, subscribe(listener) { return chains.subscribe(listener) }, }, connectors: { providerDetailToConnector, setup: setup as ( connectorFn: connectorFn, ) => Connector, setState(value) { return connectors.setState( typeof value === 'function' ? value(connectors.getState()) : value, true, ) }, subscribe(listener) { return connectors.subscribe(listener) }, }, events: { change, connect, disconnect }, }, } } ///////////////////////////////////////////////////////////////////////////////////////////////// // Types ///////////////////////////////////////////////////////////////////////////////////////////////// export type CreateConfigParameters< chains extends readonly [Chain, ...Chain[]] = readonly [Chain, ...Chain[]], transports extends Record = Record< chains[number]['id'], Transport >, connectorFns extends readonly CreateConnectorFn[] = readonly CreateConnectorFn[], > = Compute< { chains: chains connectors?: connectorFns | undefined multiInjectedProviderDiscovery?: boolean | undefined storage?: Storage | null | undefined ssr?: boolean | undefined syncConnectedChain?: boolean | undefined } & OneOf< | ({ transports: transports } & { [key in keyof ClientConfig]?: | ClientConfig[key] | { [_ in chains[number]['id']]?: ClientConfig[key] | undefined } | undefined }) | { client(parameters: { chain: chains[number] }): Client } > > export type Config< chains extends readonly [Chain, ...Chain[]] = readonly [Chain, ...Chain[]], transports extends Record = Record< chains[number]['id'], Transport >, connectorFns extends readonly CreateConnectorFn[] = readonly CreateConnectorFn[], > = { readonly chains: chains readonly connectors: Readonly<{ [key in keyof connectorFns]: Connector }> readonly storage: Storage | null readonly state: State setState( value: State | ((state: State) => State), ): void subscribe( selector: (state: State) => state, listener: (state: state, previousState: state) => void, options?: | { emitImmediately?: boolean | undefined equalityFn?: ((a: state, b: state) => boolean) | undefined } | undefined, ): () => void getClient(parameters?: { chainId?: chainId | chains[number]['id'] | undefined }): Client> /** * Not part of versioned API, proceed with caution. * @internal */ _internal: Internal } type Internal< chains extends readonly [Chain, ...Chain[]] = readonly [Chain, ...Chain[]], transports extends Record = Record< chains[number]['id'], Transport >, > = { readonly mipd: MipdStore | undefined revalidate: () => Promise readonly store: Mutate, [['zustand/persist', any]]> readonly ssr: boolean readonly syncConnectedChain: boolean readonly transports: transports chains: { setState( value: | readonly [Chain, ...Chain[]] | (( state: readonly [Chain, ...Chain[]], ) => readonly [Chain, ...Chain[]]), ): void subscribe( listener: ( state: readonly [Chain, ...Chain[]], prevState: readonly [Chain, ...Chain[]], ) => void, ): () => void } connectors: { providerDetailToConnector( providerDetail: EIP6963ProviderDetail, ): CreateConnectorFn setup( connectorFn: connectorFn, ): Connector setState(value: Connector[] | ((state: Connector[]) => Connector[])): void subscribe( listener: (state: Connector[], prevState: Connector[]) => void, ): () => void } events: { change(data: EventData): void connect(data: EventData): void disconnect(data: EventData): void } } export type State< chains extends readonly [Chain, ...Chain[]] = readonly [Chain, ...Chain[]], > = { chainId: chains[number]['id'] connections: Map current: string | null status: 'connected' | 'connecting' | 'disconnected' | 'reconnecting' } export type PartializedState = Compute< ExactPartial> > export type Connection = { accounts: readonly [Address, ...Address[]] chainId: number connector: Connector } export type Connector< createConnectorFn extends CreateConnectorFn = CreateConnectorFn, > = ReturnType & { emitter: Emitter uid: string } export type Transport< type extends string = string, rpcAttributes = Record, eip1193RequestFn extends EIP1193RequestFn = EIP1193RequestFn, > = ( params: Parameters< viem_Transport >[0] & { connectors?: StoreApi | undefined }, ) => ReturnType> type ClientConfig = LooseOmit< viem_ClientConfig, 'account' | 'chain' | 'key' | 'name' | 'transport' | 'type' >