@bigmi/client
Version:
Reactive primitives for Bitcoin apps.
259 lines (240 loc) • 8.73 kB
text/typescript
import {
type Account,
type Address,
type AddressPurpose,
type AddressType,
BaseError,
base64ToHex,
getAddressChainId,
hexToBase64,
MethodNotSupportedRpcError,
ProviderNotFoundError,
type SignPsbtParameters,
UserRejectedRequestError,
} from '@bigmi/core'
import { ConnectorChainIdDetectionError } from '../errors/connectors.js'
import { createConnector } from '../factories/createConnector.js'
import type { CreateConnectorFn } from '../types/connector.js'
import type { UTXOConnectorParameters, UTXOWalletProvider } from './types.js'
interface GetAccountsRequest {
purposes: AddressPurpose[]
}
type CtrlResponse<T> =
| {
status: 'success'
result: T
error?: never
}
| {
status: 'error'
error: string
result?: never
}
interface CtrlSignPsbtResult {
psbt: string
txid: string
}
interface CtrlAccount {
address: string
publicKey: string
purpose: AddressPurpose
addressType: string
walletType: string
}
export type CtrlBitcoinEventMap = {
accountsChanged(accounts: Address[]): void
}
export type CtrlBitcoinEvents = {
addListener<TEvent extends keyof CtrlBitcoinEventMap>(
event: TEvent,
listener: CtrlBitcoinEventMap[TEvent]
): void
removeListener<TEvent extends keyof CtrlBitcoinEventMap>(
event: TEvent,
listener: CtrlBitcoinEventMap[TEvent]
): void
}
type CtrlConnectorProperties = {
getAccounts(): Promise<readonly Account[]>
onAccountsChanged(accounts: Address[]): void
getInternalProvider(): Promise<CtrlBitcoinProvider>
} & UTXOWalletProvider
type CtrlBitcoinProvider = {
signPsbt({
psbt,
}: {
psbt: string
broadcast: boolean
}): Promise<CtrlResponse<CtrlSignPsbtResult>>
requestAccounts(): Promise<Address[]>
getAccounts(): Promise<Address[]>
request({
method,
params,
}: {
method: 'request_accounts_and_keys'
params: GetAccountsRequest
}): Promise<CtrlResponse<CtrlAccount[]>>
} & CtrlBitcoinEvents
export function ctrl(
parameters: UTXOConnectorParameters = {}
): CreateConnectorFn<UTXOWalletProvider | undefined, CtrlConnectorProperties> {
const { chainId, shimDisconnect = true } = parameters
let accountsChanged: ((accounts: Address[]) => void) | undefined
return createConnector<
UTXOWalletProvider | undefined,
CtrlConnectorProperties
>((config) => ({
id: 'io.xdefi',
name: 'Ctrl Wallet',
type: ctrl.type,
icon: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTc1IiBoZWlnaHQ9IjE3NSIgdmlld0JveD0iMCAwIDE3NSAxNzUiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNzUiIGhlaWdodD0iMTc1IiByeD0iODcuNSIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTY3LjMzMyAxMTYuMzI1VjE0Mi4zMzdDNjcuMzMzIDE0Ni41NzYgNzAuNzY3MSAxNTAuMDAxIDc0Ljk5NTMgMTUwLjAwMUgxMDEuMDA0QzEwNS4yNDIgMTUwLjAwMSAxMDguNjY2IDE0Ni41NjYgMTA4LjY2NiAxNDIuMzM3VjEyOS4zMzFDMTA4LjY2NiAxMjMuNjE3IDExMC45NzYgMTE4LjQ1IDExNC43MiAxMTQuNzE2QzExOC40NjMgMTEwLjk3MiAxMjMuNjMgMTA4LjY2MiAxMjkuMzMzIDEwOC42NjJIMTQyLjMzN0MxNDYuNTc2IDEwOC42NjIgMTUwIDEwNS4yMjggMTUwIDEwMC45OTlWNzQuOTk3M0MxNTAgNzAuNzU4MiAxNDYuNTY2IDY3LjMzNCAxNDIuMzM3IDY3LjMzNEgxMTYuMzM5QzExMi4xIDY3LjMzNCAxMDguNjc3IDcwLjc2ODUgMTA4LjY3NyA3NC45OTczVjg4LjYzMjRDMTA4LjY3NyA5OS42OTkzIDk5LjcwNDYgMTA4LjY2MiA4OC42NDk0IDEwOC42NjJINzUuMDE2QzcwLjc3NzQgMTA4LjY2MiA2Ny4zNTM2IDExMi4wOTcgNjcuMzUzNiAxMTYuMzI1SDY3LjMzM1oiIGZpbGw9IiMwMDE0MDUiLz4KPHBhdGggZD0iTTI2IDc0Ljk5NTdWMTAxLjAwNEMyNiAxMDUuMjQzIDI5LjQzNDEgMTA4LjY2NyAzMy42NjIzIDEwOC42NjdINTkuNjcxQzYzLjkwOTUgMTA4LjY2NyA2Ny4zMzMzIDEwNS4yMzMgNjcuMzMzMyAxMDEuMDA0Vjg4QzY3LjMzMzMgODIuMjg2OCA2OS42NDM0IDc3LjEyMDEgNzMuMzg2OSA3My4zODY5Qzc3LjEzMDQgNjkuNjQzNCA4Mi4yOTcxIDY3LjMzMzMgODggNjcuMzMzM0gxMDEuMDA0QzEwNS4yNDMgNjcuMzMzMyAxMDguNjY3IDYzLjg5OTIgMTA4LjY2NyA1OS42NzFWMzMuNjYyM0MxMDguNjY3IDI5LjQyMzggMTA1LjIzMyAyNiAxMDEuMDA0IDI2SDc1LjAwNkM3MC43Njc1IDI2IDY3LjM0MzYgMjkuNDM0MSA2Ny4zNDM2IDMzLjY2MjNWNDcuMjk1N0M2Ny4zNDM2IDU4LjM2MTMgNTguMzcxNiA2Ny4zMjMgNDcuMzE2NCA2Ny4zMjNIMzMuNjYyM0MyOS40MjM4IDY3LjMyMyAyNiA3MC43NTcyIDI2IDc0Ljk4NTRWNzQuOTk1N1oiIGZpbGw9IiMwMDE0MDUiLz4KPC9zdmc+Cg==',
async setup() {
//
},
async getInternalProvider() {
if (typeof window === 'undefined') {
return
}
if ('xfi' in window) {
const anyWindow: any = window
return anyWindow.xfi?.bitcoin
}
},
async getProvider() {
const internalProvider = await this.getInternalProvider()
if (!internalProvider) {
return
}
const provider = {
request: this.request.bind(internalProvider),
}
return provider
},
async request(this: CtrlBitcoinProvider, { method, params }): Promise<any> {
switch (method) {
case 'signPsbt': {
const { psbt, ...options } = params as SignPsbtParameters
const psbt64 = hexToBase64(psbt)
const response = await this.signPsbt({
psbt: psbt64,
broadcast: Boolean(options.finalize),
})
if (response.status === 'success') {
const signedHex = base64ToHex(response.result.psbt)
return signedHex
}
throw new BaseError(response.error)
}
default:
throw new MethodNotSupportedRpcError(method)
}
},
async connect() {
const provider = await this.getInternalProvider()
if (!provider) {
throw new ProviderNotFoundError()
}
try {
const accounts = await this.getAccounts()
const chainId = getAddressChainId(accounts[0].address)
if (!accountsChanged) {
accountsChanged = this.onAccountsChanged.bind(this)
provider.addListener('accountsChanged', accountsChanged)
}
// Remove disconnected shim if it exists
if (shimDisconnect) {
await Promise.all([
config.storage?.setItem(`${this.id}.connected`, true),
config.storage?.removeItem(`${this.id}.disconnected`),
])
}
return { accounts, chainId }
} catch (error: any) {
console.error({
error,
})
throw new UserRejectedRequestError(error.message)
}
},
async disconnect() {
const provider = await this.getInternalProvider()
if (accountsChanged) {
provider?.removeListener('accountsChanged', accountsChanged)
accountsChanged = undefined
}
// Add shim signalling connector is disconnected
if (shimDisconnect) {
await Promise.all([
config.storage?.setItem(`${this.id}.disconnected`, true),
config.storage?.removeItem(`${this.id}.connected`),
])
}
},
async getAccounts() {
const provider = await this.getInternalProvider()
if (!provider) {
throw new ProviderNotFoundError()
}
const { status, result } = await provider.request({
method: 'request_accounts_and_keys',
params: {
purposes: ['payment'],
},
})
if (status === 'success') {
return result.map((account) => ({
address: account.address,
addressType: account.addressType.toLowerCase() as AddressType,
publicKey: account.publicKey,
purpose: account.purpose,
}))
}
throw new BaseError('Error getting accounts')
},
async getChainId() {
if (chainId) {
return chainId
}
const accounts = await this.getAccounts()
if (accounts.length === 0) {
throw new ConnectorChainIdDetectionError({ connector: this.name })
}
return getAddressChainId(accounts[0].address)
},
async isAuthorized() {
try {
const isConnected =
shimDisconnect &&
// If shim exists in storage, connector is disconnected
Boolean(await config.storage?.getItem(`${this.id}.connected`))
return isConnected
} catch {
return false
}
},
async onAccountsChanged(accounts) {
if (accounts.length === 0) {
this.onDisconnect()
} else {
const newAccounts = await this.getAccounts()
config.emitter.emit('change', {
accounts: newAccounts,
})
}
},
onChainChanged(chainId) {
config.emitter.emit('change', { chainId })
},
async onDisconnect(_error) {
// No need to remove `${this.id}.disconnected` from storage because `onDisconnect` is typically
// only called when the wallet is disconnected through the wallet's interface, meaning the wallet
// actually disconnected and we don't need to simulate it.
config.emitter.emit('disconnect')
},
}))
}
export declare namespace ctrl {
export var type: 'UTXO'
}
ctrl.type = 'UTXO' as const