实现一个React的BTC钱包连接管理器(支持Unisat、Okx)

1,318 阅读3分钟

实现一个React上的BTC钱包连接管理器。采用插件式架构,目前支持Unisat和Okx钱包,可以仿照已有的钱包连接器支持更多的钱包。提供连接、断连、获取地址、获取公钥、监听地址变化、监听链变化、签名等功能。

以下是代码实现。

目录结构

.
├── connectors
│   ├── Okx.ts
│   ├── Unisat.ts
│   └── types.ts
├── context.tsx
├── errors.ts
├── index.ts
└── types.ts

index.ts

export * from './context'
export * from './types'
export * from './errors'

context.tsx

import React, { createContext, useCallback, useContext, useMemo, useReducer } from 'react'
import { BtcConnectorName, Network } from './types'
import { Connector, ConnectorOptions } from './connectors/types'
import { UnisatConnector } from './connectors/Unisat'
import { OkxConnector } from './connectors/Okx'

type Action =
  | { type: 'on connect'; connectorName: BtcConnectorName }
  | { type: 'connect failed' }
  | { type: 'connected'; connectorName: BtcConnectorName; address: string; publicKey: string; network: Network }
  | { type: 'account changed'; address: string; publicKey: string }
  | { type: 'network changed'; network: Network }
  | { type: 'disconnected' }

type Dispatch = (action: Action) => void

interface State {
  isConnecting: boolean
  isConnected: boolean
  address?: string
  publicKey?: string
  connectorName?: BtcConnectorName
  network?: Network
}

type BtcProviderProps = { children: React.ReactNode }

const BtcContext = createContext<{ state: State; dispatch: Dispatch } | undefined>(undefined)

const btcReducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'on connect': {
      return {
        ...state,
        isConnecting: true,
        connectorName: action.connectorName,
      }
    }

    case 'connect failed': {
      return {
        ...state,
        isConnecting: false,
        connectorName: undefined,
      }
    }

    case 'connected': {
      console.log('state: ', state)
      return {
        isConnecting: false,
        isConnected: true,
        connectorName: action.connectorName,
        address: action.address,
        publicKey: action.publicKey,
        network: action.network,
      }
    }

    case 'disconnected': {
      return {
        isConnecting: false,
        isConnected: false,
        connectorName: undefined,
        address: undefined,
        publicKey: undefined,
        network: undefined,
      }
    }

    case 'account changed': {
      return { ...state, address: action.address, publicKey: action.publicKey }
    }

    case 'network changed': {
      return { ...state, network: action.network }
    }

    default: {
      throw new Error(`Unhandled action type`)
    }
  }
}

export const BtcProvider = ({ children }: BtcProviderProps) => {
  const [state, dispatch] = useReducer(btcReducer, {
    isConnecting: false,
    isConnected: false,
    connectorName: undefined,
    address: undefined,
    publicKey: undefined,
    network: undefined,
  })

  return <BtcContext.Provider value={{ state, dispatch }}>{children}</BtcContext.Provider>
}

const useBtcContext = () => {
  const ctx = useContext(BtcContext)
  if (ctx === undefined) {
    throw new Error('useBtc must be used within a BtcProvider')
  }

  return ctx
}

export const useBtc = () => {
  const ctx = useBtcContext()

  const defaultConnectorOptions: ConnectorOptions = useMemo(
    () => ({
      onAccountsChanged: (address, publicKey) => {
        ctx.dispatch({
          type: 'account changed',
          address,
          publicKey,
        })
      },
      onNetworkChanged: (network) => {
        ctx.dispatch({
          type: 'network changed',
          network,
        })
      },
      onDisconnect: () => {
        ctx.dispatch({ type: 'disconnected' })
      },
    }),
    [ctx],
  )

  const ConnectorMap: Record<BtcConnectorName, Connector> = useMemo(
    () => ({
      Unisat: new UnisatConnector(defaultConnectorOptions),
      OKX: new OkxConnector(defaultConnectorOptions),
    }),
    [defaultConnectorOptions],
  )

  const connector = useMemo(() => {
    return ConnectorMap[ctx.state.connectorName]
  }, [ConnectorMap, ctx.state.connectorName])

  const disconnect = useCallback(() => {
    ctx.dispatch({ type: 'disconnected' })
    connector.disconnect()
  }, [connector, ctx])

  const connect = useCallback(
    async (connectorName: BtcConnectorName) => {
      try {
        if (ctx.state.isConnected) {
          disconnect()
        }

        // TODO: avoid dispatch if is connected
        ctx.dispatch({
          type: 'on connect',
          connectorName,
        })

        const { address, publicKey, network } = await ConnectorMap[connectorName].connect()

        ctx.dispatch({
          type: 'connected',
          connectorName,
          address,
          publicKey,
          network,
        })
      } catch (error) {
        ctx.dispatch({ type: 'connect failed' })
        throw error
      }
    },
    [ConnectorMap, ctx, disconnect],
  )

  const signMessage = useCallback(
    async (message?: string) => {
      return connector.signMessage(message)
    },
    [connector],
  )

  return { ...ctx.state, connect, disconnect, connector, signMessage }
}

types.ts

export type BtcConnectorName = 'Unisat' | 'OKX'
export type Network = 'livenet' | 'testnet'

errors.ts

export class ConnectorNotFoundError extends Error {
  constructor() {
    super('Connector not found, probably because the plugin is not installed.')
    this.name = 'ConnectorNotFoundError'
  }
}

export class UserRejectError extends Error {
  static code = 4001

  constructor() {
    super('User rejected the request.')
    this.name = 'UserRejectError'
  }
}

连接器

types.ts

import { BtcConnectorName, Network } from '../types'

export type AccountsChangedHandler = (address: string, publicKey: string) => void
export type NetworkChangedHandler = (network: Network) => void
export type DisconnectHandler = () => void

export interface ConnectorOptions {
  onAccountsChanged?: AccountsChangedHandler
  onNetworkChanged?: NetworkChangedHandler
  onDisconnect?: DisconnectHandler
}

export interface Connection {
  address: string
  publicKey: string
  network: Network
}

export interface Connector {
  name: BtcConnectorName
  getProvider(): unknown
  connect(options?: ConnectorOptions): Promise<Connection>
  disconnect(): void
  signMessage: (message?: string) => Promise<string>
}

Unisat.ts

import { ConnectorNotFoundError } from '../errors'
import { BtcConnectorName, Network } from '../types'
import { AccountsChangedHandler, Connector, ConnectorOptions, DisconnectHandler, NetworkChangedHandler } from './types'

export class UnisatConnector implements Connector {
  name: BtcConnectorName
  onAccountsChanged?: AccountsChangedHandler
  onNetworkChanged?: NetworkChangedHandler
  onDisconnect?: DisconnectHandler

  constructor(options?: ConnectorOptions) {
    this.name = 'Unisat'
    this.onAccountsChanged = options?.onAccountsChanged
    this.onNetworkChanged = options?.onNetworkChanged
    this.onDisconnect = options?.onDisconnect
  }

  getProvider() {
    if (typeof window === 'undefined') return
    if (typeof window.unisat === 'undefined') {
      throw new ConnectorNotFoundError()
    }

    return window.unisat
  }

  async connect() {
    try {
      const provider = this.getProvider()

      if (provider.on) {
        provider.on('accountsChanged', async (accounts: string[]) => {
          if (!!accounts && accounts.length > 0) {
            const publicKey: string = await provider.getPublicKey()
            this.onAccountsChanged(accounts[0], publicKey)
          } else {
            provider.removeAllListeners()
            this.onDisconnect()
          }
        })
        provider.on('networkChanged', (network: Network) => {
          this.onNetworkChanged(network)
        })
      }

      const accounts: string[] = await provider.requestAccounts()
      const publicKey: string = await provider.getPublicKey()
      const network: Network = await provider.getNetwork()

      return { address: accounts[0], publicKey, network }
    } catch (error) {
      console.log('connnector error: ', error)
      throw error
    }
  }

  // Unisat does not provide a disconnect method at this time
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  disconnect(): void {}

  signMessage: (message?: string) => Promise<string> = (message) => {
    const provider = this.getProvider()
    return provider.signMessage(message) as Promise<string>
  }
}

Okx.ts

import { ConnectorNotFoundError } from '../errors'
import { BtcConnectorName, Network } from '../types'
import { AccountsChangedHandler, Connector, ConnectorOptions, DisconnectHandler, NetworkChangedHandler } from './types'

export class OkxConnector implements Connector {
  name: BtcConnectorName
  onAccountsChanged?: AccountsChangedHandler
  onNetworkChanged?: NetworkChangedHandler
  onDisconnect?: DisconnectHandler

  constructor(options?: ConnectorOptions) {
    this.name = 'OKX'
    this.onAccountsChanged = options?.onAccountsChanged
    this.onNetworkChanged = options?.onNetworkChanged
    this.onDisconnect = options?.onDisconnect
  }

  getProvider() {
    if (typeof window === 'undefined') return
    if (typeof window.okxwallet.bitcoin === 'undefined') {
      throw new ConnectorNotFoundError()
    }

    return window.okxwallet.bitcoin
  }

  async connect() {
    try {
      const provider = this.getProvider()

      if (provider.on) {
        provider.on(
          'connect',
          async ({ address, compressedPublicKey }: { address: string; compressedPublicKey: string }) => {
            if (address && compressedPublicKey) {
              this.onAccountsChanged(address, compressedPublicKey)
            }
          },
        )
        provider.on('disconnect', async () => {
          provider.removeAllListeners()
          this.onDisconnect()
        })
      }

      const { address, compressedPublicKey }: { address: string; compressedPublicKey: string } =
        await provider.connect()

      return { address, publicKey: compressedPublicKey, network: 'livenet' as Network }
    } catch (error) {
      console.log('connnector error: ', error)
      throw error
    }
  }

  disconnect(): void {
    const provider = this.getProvider()
    provider.disconnect()
  }

  signMessage: (message?: string) => Promise<string> = (message) => {
    const provider = this.getProvider()
    const { address } = provider.selectedAccount
    return provider.signMessage(message, { from: address }) as Promise<string>
  }
}

用法

const {
    isConnected,
    address,
    publicKey,
    network,
    connect,
    disconnect,
    connector,
    connectorName,
    isConnecting,
    signMessage,
  } = useBtc()