UNPKG

web3-onboard-monorepo

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

439 lines (371 loc) 13.6 kB
// these cannot be dynamically imported import { TypedDataUtils } from '@metamask/eth-sig-util' import type Transport from '@ledgerhq/hw-transport' import type { StaticJsonRpcProvider } from '@ethersproject/providers' import type Eth from '@ledgerhq/hw-app-eth' import type { Chain, WalletInit, GetInterfaceHelpers, Platform } from '@web3-onboard/common' import type { CustomNetwork, ScanAccountsOptions, Account, Asset } from '@web3-onboard/hw-common' const LEDGER_LIVE_PATH = `m/44'/60'` const LEDGER_DEFAULT_PATH = `m/44'/60'/0'` const DEFAULT_BASE_PATHS = [ { label: 'Ledger Live', value: LEDGER_LIVE_PATH }, { label: 'Ledger Legacy', value: LEDGER_DEFAULT_PATH } ] const assets = [ { label: 'ETH' } ] type CustomNavigator = Navigator & { usb: { getDevices(): void } } const supportsWebUSB = (): Promise<boolean> => Promise.resolve( !!navigator && !!(navigator as CustomNavigator).usb && typeof (navigator as CustomNavigator).usb.getDevices === 'function' ) /** * Returns the correct ledger transport based on browser compatibility for webUSB. * @returns */ const getTransport = async () => ((await supportsWebUSB()) ? (await import('@ledgerhq/hw-transport-webusb')).default : (await import('@ledgerhq/hw-transport-u2f')).default ).create() const getAccount = async ( derivationPath: string, asset: Asset, index: number, provider: StaticJsonRpcProvider, eth: Eth ): Promise<Account> => { const dPath = derivationPath === LEDGER_LIVE_PATH ? `${derivationPath}/${index}'/0/0` : `${derivationPath}/${index}` const { address } = await eth.getAddress(dPath) return { derivationPath: dPath, address, balance: { asset: asset.label, value: await provider.getBalance(address) } } } const getAddresses = async ( derivationPath: string, asset: Asset, provider: StaticJsonRpcProvider, eth: Eth ): Promise<Account[]> => { const accounts = [] let index = 0 let zeroBalanceAccounts = 0 // 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(derivationPath, asset, index, provider, eth) if (acc.balance.value.isZero()) { zeroBalanceAccounts++ accounts.push(acc) } else { accounts.push(acc) // Reset the number of 0 balance accounts zeroBalanceAccounts = 0 } index++ } return accounts } function ledger({ customNetwork, filter }: { customNetwork?: CustomNetwork filter?: Platform[] } = {}): 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: 'Ledger', getIcon, getInterface: async ({ EventEmitter, chains }: GetInterfaceHelpers) => { const Eth = (await import('@ledgerhq/hw-app-eth')).default const ethUtil = await import('ethereumjs-util') const { SignTypedDataVersion } = await import('@metamask/eth-sig-util') const { StaticJsonRpcProvider } = await import( '@ethersproject/providers' ) const { createEIP1193Provider, ProviderRpcError } = await import( '@web3-onboard/common' ) const { accountSelect } = await import('@web3-onboard/hw-common') const { getCommon, bigNumberFieldsToStrings, getHardwareWalletProvider } = await import('@web3-onboard/hw-common') const { TransactionFactory: Transaction, Capability } = await import( '@ethereumjs/tx' ) const transport: Transport = await getTransport() const eth = new Eth(transport) const eventEmitter = new EventEmitter() let ethersProvider: StaticJsonRpcProvider let currentChain: Chain = chains[0] const scanAccounts = async ({ derivationPath, chainId, asset }: ScanAccountsOptions): Promise<Account[]> => { try { currentChain = chains.find(({ id }: Chain) => 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 ( derivationPath !== LEDGER_LIVE_PATH && derivationPath !== LEDGER_DEFAULT_PATH ) { const { address } = await eth.getAddress(derivationPath) return [ { derivationPath, address, balance: { asset: asset.label, value: await ethersProvider.getBalance(address) } } ] } const accounts = await getAddresses( derivationPath, asset, ethersProvider, eth ) return accounts } catch (error) { const { statusText } = error as { statusText: string } throw new Error( statusText === 'UNKNOWN_ERROR' ? 'Ledger device is locked, please unlock to continue' : statusText ) } } const getAccounts = async () => { accounts = await accountSelect({ basePaths: DEFAULT_BASE_PATHS, assets, chains, scanAccounts }) if (accounts && accounts.length) { eventEmitter.emit('accountsChanged', [accounts[0].address]) } return accounts } const signMessage = async (address: string, message: string) => { if (!(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] return eth .signPersonalMessage( account.derivationPath, ethUtil.stripHexPrefix(message) ) .then(result => { let v = (result['v'] - 27).toString(16) if (v.length < 2) { v = '0' + v } return `0x${result['r']}${result['s']}${v}` }) } const ledgerProvider = getHardwareWalletProvider( () => currentChain?.rpcUrl ) const provider = createEIP1193Provider(ledgerProvider, { eth_requestAccounts: async () => { // Triggers the account select modal if no accounts have been selected const accounts = await getAccounts() if (!Array.isArray(accounts)) throw new Error( 'No account selected. Must call eth_requestAccounts first.' ) if (accounts.length === 0) { throw new ProviderRpcError({ code: 4001, message: 'User rejected the request.' }) } if (!accounts[0].hasOwnProperty('address')) throw new Error( 'No address property associated with the selected account' ) return [accounts[0].address] }, eth_selectAccounts: async () => { const accounts = await getAccounts() return accounts.map(({ address }) => address) }, eth_accounts: async () => { return Array.isArray(accounts) && accounts.length && accounts[0].hasOwnProperty('address') ? [accounts[0].address] : [] }, eth_chainId: async () => { return (currentChain && currentChain.id) || '' }, eth_signTransaction: async ({ params: [transactionObject] }) => { if (!accounts || !Array.isArray(accounts) || !accounts.length) throw new Error( 'No account selected. Must call eth_requestAccounts first.' ) let account if (transactionObject.hasOwnProperty('from')) { account = accounts.find( account => account.address === transactionObject.from ) } account = account ? account : accounts[0] const { address: from, derivationPath } = account // Set the `from` field to the currently selected account transactionObject = { ...transactionObject, from } const chainId = currentChain.hasOwnProperty('id') ? Number.parseInt(currentChain.id) : 1 const common = await getCommon({ customNetwork, chainId }) transactionObject.gasLimit = transactionObject.gas || transactionObject.gasLimit // 'gas' is an invalid property for the TransactionRequest type delete transactionObject.gas const signer = ethersProvider.getSigner(from) let populatedTransaction = await signer.populateTransaction( transactionObject ) populatedTransaction = bigNumberFieldsToStrings(populatedTransaction) const transaction = Transaction.fromTxData(populatedTransaction, { common }) let unsignedTx = transaction.getMessageToSign(false) // If this is not an EIP1559 transaction then it is legacy and it needs to be // rlp encoded before being passed to ledger if (!transaction.supports(Capability.EIP1559FeeMarket)) { unsignedTx = ethUtil.rlp.encode(unsignedTx) } const { v, r, s } = await eth.signTransaction( derivationPath, unsignedTx.toString('hex') ) // Reconstruct the signed transaction const signedTx = Transaction.fromTxData( { ...populatedTransaction, v: `0x${v}`, r: `0x${r}`, s: `0x${s}` }, { common } ) return signedTx ? `0x${signedTx.serialize().toString('hex')}` : '' }, 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: async ({ params: [address, typedData] }) => { if (!(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 domainHash = TypedDataUtils.hashStruct( 'EIP712Domain', typedData.domain, typedData.types, SignTypedDataVersion.V3 ).toString('hex') const messageHash = TypedDataUtils.hashStruct( typedData.primaryType, typedData.message, typedData.types, SignTypedDataVersion.V3 ).toString('hex') return eth .signEIP712HashedMessage( account.derivationPath, domainHash, messageHash ) .then(result => { let v = (result['v'] - 27).toString(16) if (v.length < 2) { v = '0' + v } return `0x${result['r']}${result['s']}${v}` }) }, 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 } } } } } export default ledger