@bigmi/client
Version:
Reactive primitives for Bitcoin apps.
257 lines (239 loc) • 8.68 kB
text/typescript
import type { Account, Address, SignPsbtParameters } from '@bigmi/core'
import {
getAddressChainId,
MethodNotSupportedRpcError,
ProviderNotFoundError,
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 {
ProviderRequestParams,
UTXOConnectorParameters,
UTXOWalletProvider,
} from './types.js'
export type LeatherBitcoinEventMap = {
accountChange(accounts: Account[]): void
}
export type LeatherBitcoinEvents = {
addListener<TEvent extends keyof LeatherBitcoinEventMap>(
event: TEvent,
listener: LeatherBitcoinEventMap[TEvent]
): void
removeListener?<TEvent extends keyof LeatherBitcoinEventMap>(
event: TEvent,
listener: LeatherBitcoinEventMap[TEvent]
): void
}
type LeatherConnectorProperties = {
getAccounts(): Promise<readonly (Account | Address)[]>
getInternalProvider(): Promise<LeatherBitcoinProvider>
} & UTXOWalletProvider
type Error = { code: number; message: string }
interface GetAccountsResponse {
result?: { addresses: Account[] }
error?: Error
}
type LeatherBitcoinProvider = {
request(
method: 'signPsbt',
options: {
hex: string
allowedSignHash: number
signAtIndex?: number | number[]
broadcast: boolean
}
): Promise<{ result: { hex: string } }>
request(method: 'getAddresses'): Promise<GetAccountsResponse>
} & LeatherBitcoinEvents
export function leather(
parameters: UTXOConnectorParameters = {}
): CreateConnectorFn<
UTXOWalletProvider | undefined,
LeatherConnectorProperties
> {
const { chainId, shimDisconnect = true } = parameters
return createConnector<
UTXOWalletProvider | undefined,
LeatherConnectorProperties
>((config) => ({
id: 'LeatherProvider',
name: 'Leather',
type: leather.type,
icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSJub25lIj4KICAgIDxwYXRoIGZpbGw9IiMxMjEwMEYiIGQ9Ik0wIDBoMTI4djEyOEgweiIvPgogICAgPHBhdGggZmlsbD0iI0Y1RjFFRCIgZD0iTTc0LjkxNyA1Mi43MTFjNy41Ni0xLjE3IDE4LjQ5Mi05LjEzIDE4LjQ5Mi0xNS4zMzUgMC0xLjg3My0xLjUxMi0zLjE2LTMuNzIyLTMuMTYtNC4xODcgMC0xMS4yOCA2LjMyLTE0Ljc3IDE4LjQ5NU0zOS45MTEgODMuNWMtOS44ODUgMC0xMC43IDkuODMzLS44MTQgOS44MzMgNC40MiAwIDkuNzctMS43NTYgMTIuNTYtNC45MTYtNC4wNy0zLjUxMi03LjQ0My00LjkxNy0xMS43NDYtNC45MTdtNjIuOTE4LTQuMjE0Yy41ODEgMTYuNTA2LTcuNzkyIDI1Ljc1NC0yMS45OCAyNS43NTQtOC4zNzQgMC0xMi41Ni0zLjE2MS0yMS41MTYtOS4wMTQtNC42NTIgNS4xNTEtMTMuNDkgOS4wMTQtMjAuODE4IDkuMDE0LTI1LjIzNiAwLTI0LjE5LTMyLjE5MyAxLjUxMi0zMi4xOTMgNS4zNSAwIDkuODg2IDEuNDA1IDE1LjcgNS4wMzRsMy44MzktMTMuNDYyQzQzLjc0OSA2MC4wODYgMzUuODQgNDcuOTEyIDQzLjYzMyAzMC40NjloMTIuNTZjLTYuOTc4IDExLjU5LTIuMjEgMjEuMTg5IDYuNjI5IDIyLjI0MkM2Ny41OSAzNS43MzcgNzcuODI1IDIyLjUxIDkxLjQzMiAyMi41MWM3LjY3NSAwIDEzLjcyMyA1LjAzNCAxMy43MjMgMTQuMTY1IDAgMTQuNjMzLTE5LjA3MyAyNi41NzMtMzMuNDk0IDI3Ljc0NEw2NS43MyA4NS4zNzJjNi43NDUgNy44NDMgMjUuNDY5IDE1LjQ1MiAyNS40NjktNi4wODd6Ii8+Cjwvc3ZnPg==',
async setup() {
//
},
async getInternalProvider() {
if (typeof window === 'undefined') {
return undefined
}
if ('LeatherProvider' in window) {
const anyWindow: any = window
const provider = anyWindow.LeatherProvider
return provider
}
},
async getProvider() {
const internalProvider = await this.getInternalProvider()
if (!internalProvider) {
return
}
const provider = {
request: this.request.bind(internalProvider),
}
return provider
},
async request(
this: LeatherBitcoinProvider | any,
{ method, params }: ProviderRequestParams
): Promise<any> {
switch (method) {
case 'signPsbt': {
const { psbt, ...options } = params as SignPsbtParameters
const signedPsbt = await this.request('signPsbt', {
hex: psbt,
broadcast: options.finalize,
})
if (signedPsbt?.error) {
throw signedPsbt?.error
}
if (!signedPsbt?.result?.hex) {
throw new Error('Missing hex result from signed PSBT')
}
return signedPsbt.result.hex
}
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)
// Remove disconnected shim if it exists
if (shimDisconnect) {
await Promise.all([
config.storage?.setItem(`${this.id}.connected`, true),
config.storage?.setItem(
`${this.id}.lastConnected`,
Date.now().toString()
),
config.storage?.removeItem(`${this.id}.disconnected`),
])
}
return { accounts, chainId }
} catch (error: any) {
throw new UserRejectedRequestError(error.message)
}
},
async disconnect() {
const provider = await this.getInternalProvider()
if (!provider) {
throw new ProviderNotFoundError()
}
// Add shim signalling connector is disconnected
if (shimDisconnect) {
await Promise.all([
config.storage?.setItem(`${this.id}.disconnected`, true),
config.storage?.removeItem(`${this.id}.connected`),
config.storage?.removeItem(`${this.id}.accounts`),
config.storage?.removeItem(`${this.id}.lastConnected`),
])
}
},
async getAccounts() {
const provider = await this.getInternalProvider()
if (!provider) {
throw new ProviderNotFoundError()
}
if (shimDisconnect && (await this.isAuthorized())) {
const accounts = await config.storage?.getItem(`${this.id}.accounts`)
if (accounts && Array.isArray(accounts) && accounts.length > 0) {
return accounts as Account[]
}
}
const accounts = await provider.request('getAddresses')
if (!accounts.result) {
throw new UserRejectedRequestError(
accounts.error?.message ?? 'Unknown error'
)
}
if (shimDisconnect) {
await config.storage?.setItem(
`${this.id}.accounts`,
accounts.result.addresses
)
}
return accounts.result.addresses
},
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 {
if (!shimDisconnect) {
return false
}
const lastConnected: string | undefined | null =
await config.storage?.getItem(`${this.id}.lastConnected`)
if (!lastConnected) {
return false
}
const timestamp = parseInt(lastConnected, 10)
if (Number.isNaN(timestamp) || timestamp <= 0) {
// Invalid timestamp, clean up storage
await Promise.all([
config.storage?.setItem(`${this.id}.disconnected`, true),
config.storage?.removeItem(`${this.id}.connected`),
config.storage?.removeItem(`${this.id}.accounts`),
config.storage?.removeItem(`${this.id}.lastConnected`),
])
return false
}
const oneDayAgo = 24 * 60 * 60 * 1000 // 24 hours
const isExpired = Date.now() - timestamp > oneDayAgo
if (isExpired) {
await Promise.all([
config.storage?.setItem(`${this.id}.disconnected`, true),
config.storage?.removeItem(`${this.id}.connected`),
config.storage?.removeItem(`${this.id}.accounts`),
config.storage?.removeItem(`${this.id}.lastConnected`),
])
return false
}
return !isExpired
} catch {
return false
}
},
async onAccountsChanged() {
const { accounts } = await this.connect()
config.emitter.emit('change', {
accounts,
})
},
onChainChanged(chainId) {
config.emitter.emit('change', { chainId })
},
async onDisconnect(_error) {
config.emitter.emit('disconnect')
},
}))
}
export declare namespace leather {
export var type: 'UTXO'
}
leather.type = 'UTXO' as const