import { type Account, type BtcRpcRequestFn, type ClientConfig as base_ClientConfig, type Transport as base_Transport, type Chain, type ChainId, type Client, type Compute, createClient, type ExactPartial, type LooseOmit, type OneOf, type RemoveUndefined, uid, version, } from '@bigmi/core' import { persist, subscribeWithSelector } from 'zustand/middleware' import { createStore, type Mutate, type StoreApi } from 'zustand/vanilla' import { ChainNotConfiguredError } from '../errors/config.js' import type { ConnectorEventMap, CreateConnectorFn, } from '../types/connector.js' import type { Storage } from '../types/storage.js' import { createEmitter, type Emitter, type EventData } from './createEmitter.js' import { createStorage, getDefaultStorage } from './createStorage.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({ key: 'bigmi', storage: getDefaultStorage(), }), syncConnectedChain = true, ssr = false, ...rest } = parameters ///////////////////////////////////////////////////////////////////////////////////////////////// // Set up connectors, clients, etc. ///////////////////////////////////////////////////////////////////////////////////////////////// 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) } } } 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 } 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, 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 ) { persistedState.status = undefined } // 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: ChainId ) { const chainId = persistedState && typeof persistedState === 'object' && 'chainId' in persistedState && typeof persistedState.chainId === 'string' && chains.getState().some((x) => x.id === persistedState.chainId) ? persistedState.chainId : defaultChainId return chainId as ChainId } ///////////////////////////////////////////////////////////////////////////////////////////////// // 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, })) } ) } ///////////////////////////////////////////////////////////////////////////////////////////////// // 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 [Account, ...Account[]]) ?? 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 [Account, ...Account[]], 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 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: { 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: { 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 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 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: { 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 [Account, ...Account[]] chainId: ChainId connector: Connector } export type Connector< createConnectorFn extends CreateConnectorFn = CreateConnectorFn, > = ReturnType & { emitter: Emitter uid: string } export type Transport< type extends string = string, rpcAttributes = Record, btcRequestFn extends BtcRpcRequestFn = BtcRpcRequestFn, > = ( params: Parameters>[0] & { connectors?: StoreApi | undefined } ) => ReturnType> type ClientConfig = LooseOmit< base_ClientConfig, 'account' | 'chain' | 'key' | 'name' | 'transport' | 'type' >