// 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 => 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 => { 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 => { 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 => { 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