实现一个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()