UNPKG

saepenatus

Version:

Web3-Onboard makes it simple to connect Ethereum hardware and software wallets to your dapp. Features standardised spec compliant web3 providers for all supported wallets, framework agnostic modern javascript UI with code splitting, CSS customization, mul

493 lines (420 loc) 15.5 kB
import type { Chain, Platform, WalletInit } from '@web3-onboard/common' import type { StaticJsonRpcProvider } from '@ethersproject/providers' import type { ETHAccountPath } from '@shapeshiftoss/hdwallet-core' import type { KeepKeyHDWallet } from '@shapeshiftoss/hdwallet-keepkey' import type { ScanAccountsOptions, Account, Asset } from '@web3-onboard/hw-common' const DEFAULT_PATH = `m/44'/60'/0'/0/0` const DEFAULT_BASE_PATHS = [ { label: 'Ethereum Mainnet', value: DEFAULT_PATH } ] const assets = [ { label: 'ETH' } ] const ERROR_BUSY: ErrorCode = 'busy' const ERROR_PAIRING: ErrorCode = 'pairing' const errorMessages = { [ERROR_BUSY]: `Your KeepKey is currently connected to another application. Please close any other browser tabs or applications that may be connected to your device and try again.`, [ERROR_PAIRING]: 'There was an error pairing the device. Please disconnect and reconnect the device and try again.' } type ErrorCode = 'busy' | 'pairing' function keepkey({ filter, containerElement }: { filter?: Platform[]; containerElement?: string } = {}): WalletInit { const getIcon = async () => (await import('./icon.js')).default return ({ device }) => { let accounts: Account[] | undefined const filtered = Array.isArray(filter) && (filter.includes(device.type) || filter.includes(device.os.name)) if (filtered) return null return { label: 'KeepKey', getIcon, getInterface: async ({ EventEmitter, chains }) => { const { WebUSBKeepKeyAdapter } = await import( '@shapeshiftoss/hdwallet-keepkey-webusb' ) const { Keyring, Events, bip32ToAddressNList, addressNListToBIP32, HDWalletErrorType } = await import('@shapeshiftoss/hdwallet-core') const { createEIP1193Provider, ProviderRpcError } = await import( '@web3-onboard/common' ) const { accountSelect, entryModal } = await import( '@web3-onboard/hw-common' ) const { bigNumberFieldsToStrings, getHardwareWalletProvider } = await import('@web3-onboard/hw-common') const { utils } = await import('ethers') const { StaticJsonRpcProvider } = await import( '@ethersproject/providers' ) const ethUtil = await import('ethereumjs-util') const keyring = new Keyring() const keepKeyAdapter = WebUSBKeepKeyAdapter.useKeyring(keyring) const eventEmitter = new EventEmitter() let keepKeyWallet: KeepKeyHDWallet let currentChain: Chain = chains[0] keyring.on(['*', '*', Events.DISCONNECT], async () => { eventEmitter.emit('accountsChanged', []) }) // If the wallet asks for a PIN, open the PIN modal keyring.on(['*', '*', Events.PIN_REQUEST], () => { entryModal( 'pin', val => keepKeyWallet.sendPin(val), () => keepKeyWallet.cancel() ) }) // If the wallet asks for a PIN, open the PIN modal keyring.on(['*', '*', Events.PASSPHRASE_REQUEST], () => { entryModal( 'passphrase', val => keepKeyWallet.sendPassphrase(val), () => keepKeyWallet.cancel() ) }) const getAccountIdx = (derivationPath: string) => { // Get the account index from the derivation path const { accountIdx } = keepKeyWallet.describePath({ path: bip32ToAddressNList(derivationPath), coin: 'Ethereum' }) if (accountIdx === undefined) throw new Error( `Could not derive account from path: ${derivationPath}` ) return accountIdx } const getPaths = (accountIdx: number): ETHAccountPath => { // Retrieve the array form of the derivation path for a given account index const [paths] = keepKeyWallet.ethGetAccountPaths({ coin: 'Ethereum', accountIdx }) return paths } const getAccount = async ({ accountIdx, provider, asset }: { accountIdx: number provider: StaticJsonRpcProvider asset: Asset }) => { const paths = getPaths(accountIdx) // Retrieve the address associated with the given account index const address = await keepKeyWallet.ethGetAddress({ addressNList: paths.addressNList, showDisplay: false }) const balance = await provider.getBalance(address) return { derivationPath: addressNListToBIP32(paths.addressNList), address, balance: { asset: asset.label, value: balance } } } const getAllAccounts = async ({ derivationPath, asset, provider }: { derivationPath: string asset: Asset provider: StaticJsonRpcProvider }) => { try { let index = getAccountIdx(derivationPath) let zeroBalanceAccounts = 0 const accounts = [] // Iterates until a 0 balance account is found // Then adds 4 more 0 balance accounts to the array while (zeroBalanceAccounts < 5) { const acc = await getAccount({ accountIdx: index, provider, asset }) if ( acc && acc.balance && acc.balance.value && acc.balance.value.isZero() ) { zeroBalanceAccounts++ accounts.push(acc) } else { accounts.push(acc) // Reset the number of 0 balance accounts zeroBalanceAccounts = 0 } index++ } return accounts } catch (error) { throw new Error( (error as { message: { message: string } }).message.message ) } } let ethersProvider: StaticJsonRpcProvider const scanAccounts = async ({ derivationPath, chainId, asset }: ScanAccountsOptions): Promise<Account[]> => { if (!keepKeyWallet) throw new Error('Device must be connected before scanning accounts') currentChain = chains.find(({ id }) => id === chainId) || currentChain ethersProvider = new StaticJsonRpcProvider(currentChain.rpcUrl) // Checks to see if this is a custom derivation path // If it is then just return the single account if ( !DEFAULT_BASE_PATHS.find(({ value }) => value === derivationPath) ) { try { const accountIdx = getAccountIdx(derivationPath) const account = await getAccount({ accountIdx, provider: ethersProvider, asset }) return [account] } catch (error) { throw new Error('Invalid derivation path') } } return getAllAccounts({ derivationPath, asset, provider: ethersProvider }) } const getAccounts = async () => { accounts = await accountSelect({ basePaths: DEFAULT_BASE_PATHS, assets, chains, scanAccounts, containerElement }) if (!accounts) throw new Error('No accounts were found') if (accounts.length) { eventEmitter.emit('accountsChanged', [accounts[0].address]) } return accounts } const signMessage = async (address: string, message: string) => { if ( !accounts || !Array.isArray(accounts) || !(accounts.length && accounts.length > 0) ) throw new Error( 'No account selected. Must call eth_requestAccounts first.' ) const account = accounts.find(account => account.address === address) || accounts[0] const { derivationPath } = account const accountIdx = getAccountIdx(derivationPath) const { addressNList } = getPaths(accountIdx) const { signature } = await keepKeyWallet.ethSignMessage({ addressNList, message: message.slice(0, 2) === '0x' ? // @ts-ignore - commonjs weirdness (ethUtil.default || ethUtil) .toBuffer(message) .toString('utf8') : message }) return signature } const keepKeyProvider = getHardwareWalletProvider( () => currentChain.rpcUrl ) const provider = createEIP1193Provider(keepKeyProvider, { eth_requestAccounts: async () => { if (keepKeyWallet && typeof keepKeyWallet.cancel === 'function') { // cancel any current actions on device keepKeyWallet.cancel() } try { keepKeyWallet = (await keepKeyAdapter.pairDevice()) as KeepKeyHDWallet } catch (error) { const { name } = error as { name: string } // This error indicates that the keepkey is paired with another app if (name === HDWalletErrorType.ConflictingApp) { throw new ProviderRpcError({ code: 4001, message: errorMessages[ERROR_BUSY] }) // This error indicates that for some reason we can't claim the usb device } else if (name === HDWalletErrorType.WebUSBCouldNotPair) { throw new ProviderRpcError({ code: 4001, message: errorMessages[ERROR_PAIRING] }) } } // Triggers the account select modal if no accounts have been selected const accounts = await getAccounts() if (!accounts || !Array.isArray(accounts)) { throw new Error('No accounts were returned from Keepkey device') } if (!accounts.length) { throw new ProviderRpcError({ code: 4001, message: 'User rejected the request.' }) } if (!accounts[0].hasOwnProperty('address')) { throw new Error( 'The account returned does not have a required address field' ) } return [accounts[0].address] }, eth_selectAccounts: async () => { const accounts = await getAccounts() return accounts.map(({ address }) => address) }, eth_accounts: async () => { if (!accounts || !Array.isArray(accounts)) { throw new Error('No accounts were returned from Keepkey device') } return accounts[0].hasOwnProperty('address') ? [accounts[0].address] : [] }, eth_chainId: async () => { return currentChain && currentChain.id != undefined ? currentChain.id : '0x0' }, eth_signTransaction: async ({ params: [transactionObject] }) => { if (!accounts || !Array.isArray(accounts) || !accounts.length) throw new Error( 'No account selected. Must call eth_requestAccounts first.' ) // Per the code above if accounts is empty or undefined then this line of code won't execute // ∴ account must be defined here which is why it is cast without the 'undefined' type const account = !transactionObject || !transactionObject.hasOwnProperty('from') ? accounts[0] : (accounts.find( account => account.address.toLocaleLowerCase() === transactionObject.from.toLocaleLowerCase() ) as Account) const { derivationPath, address } = account const addressNList = bip32ToAddressNList(derivationPath) const signer = ethersProvider.getSigner(address) transactionObject.gasLimit = transactionObject.gas || transactionObject.gasLimit // 'gas' is an invalid property for the TransactionRequest type delete transactionObject.gas transactionObject.gasLimit = undefined let populatedTransaction = await signer.populateTransaction( transactionObject ) const { to, value, nonce, gasLimit, gasPrice, maxFeePerGas, maxPriorityFeePerGas, data } = bigNumberFieldsToStrings(populatedTransaction) const gasData = gasPrice ? { gasPrice } : { maxFeePerGas, maxPriorityFeePerGas } const txn = { addressNList, chainId: parseInt(currentChain.id), to: to || '', value: value || '', nonce: utils.hexValue(nonce), gasLimit: gasLimit || '0x0', data: (data || '').toString(), ...gasData } let serialized try { ;({ serialized } = await keepKeyWallet.ethSignTx(txn)) } catch (error: any) { if (error.message && error.message.message) { throw new Error(error.message.message) } else { throw new Error(error) } } return serialized }, eth_sendTransaction: async ({ baseRequest, params }) => { const signedTx = await provider.request({ method: 'eth_signTransaction', params }) const transactionHash = await baseRequest({ method: 'eth_sendRawTransaction', params: [signedTx] }) return transactionHash as string }, eth_sign: async ({ params: [address, message] }) => signMessage(address, message), personal_sign: async ({ params: [message, address] }) => signMessage(address, message), eth_signTypedData: null, wallet_switchEthereumChain: async ({ params: [{ chainId }] }) => { currentChain = chains.find(({ id }) => id === chainId) || currentChain if (!currentChain) throw new Error('chain must be set before switching') eventEmitter.emit('chainChanged', currentChain.id) return null }, wallet_addEthereumChain: null }) provider.on = eventEmitter.on.bind(eventEmitter) return { provider, instance: { selectAccount: getAccounts } } } } } } export default keepkey