diff options
Diffstat (limited to 'packages/subproviders/src')
-rw-r--r-- | packages/subproviders/src/globals.d.ts | 68 | ||||
-rw-r--r-- | packages/subproviders/src/index.ts | 30 | ||||
-rw-r--r-- | packages/subproviders/src/subproviders/injected_web3.ts | 47 | ||||
-rw-r--r-- | packages/subproviders/src/subproviders/ledger.ts | 320 | ||||
-rw-r--r-- | packages/subproviders/src/subproviders/redundant_rpc.ts | 46 | ||||
-rw-r--r-- | packages/subproviders/src/subproviders/subprovider.ts | 45 | ||||
-rw-r--r-- | packages/subproviders/src/types.ts | 115 |
7 files changed, 671 insertions, 0 deletions
diff --git a/packages/subproviders/src/globals.d.ts b/packages/subproviders/src/globals.d.ts new file mode 100644 index 000000000..1f9c6e8a6 --- /dev/null +++ b/packages/subproviders/src/globals.d.ts @@ -0,0 +1,68 @@ +/// <reference types='chai-typescript-typings' /> +/// <reference types='chai-as-promised-typescript-typings' /> +declare module 'bn.js'; +declare module 'dirty-chai'; +declare module 'ledgerco'; +declare module 'ethereumjs-tx'; +declare module 'es6-promisify'; +declare module 'ethereum-address'; +declare module 'debug'; + +// tslint:disable:max-classes-per-file +// tslint:disable:class-name +// tslint:disable:completed-docs +declare module 'ledgerco' { + interface comm { + close_async: Promise<void>; + create_async: Promise<void>; + } + export class comm_node implements comm { + public create_async: Promise<void>; + public close_async: Promise<void>; + } + export class comm_u2f implements comm { + public create_async: Promise<void>; + public close_async: Promise<void>; + } +} + +// Semaphore-async-await declarations +declare module 'semaphore-async-await' { + class Semaphore { + constructor(permits: number); + public wait(): void; + public signal(): void; + } + export default Semaphore; +} + +// web3-provider-engine declarations +declare module 'web3-provider-engine/subproviders/subprovider' { + class Subprovider {} + export = Subprovider; +} +declare module 'web3-provider-engine/subproviders/rpc' { + import * as Web3 from 'web3'; + class RpcSubprovider { + constructor(options: {rpcUrl: string}); + public handleRequest( + payload: Web3.JSONRPCRequestPayload, next: () => void, end: (err: Error|null, data?: any) => void, + ): void; + } + export = RpcSubprovider; +} + +declare module 'web3-provider-engine' { + class Web3ProviderEngine { + public on(event: string, handler: () => void): void; + public send(payload: any): void; + public sendAsync(payload: any, callback: (error: any, response: any) => void): void; + public addProvider(provider: any): void; + public start(): void; + public stop(): void; + } + export = Web3ProviderEngine; +} +// tslint:enable:max-classes-per-file +// tslint:enable:class-name +// tslint:enable:completed-docs diff --git a/packages/subproviders/src/index.ts b/packages/subproviders/src/index.ts new file mode 100644 index 000000000..9560c3597 --- /dev/null +++ b/packages/subproviders/src/index.ts @@ -0,0 +1,30 @@ +import { + comm_node as LedgerNodeCommunication, + comm_u2f as LedgerBrowserCommunication, + eth as LedgerEthereumClientFn, +} from 'ledgerco'; + +import {LedgerEthereumClient} from './types'; + +export {InjectedWeb3Subprovider} from './subproviders/injected_web3'; +export {RedundantRPCSubprovider} from './subproviders/redundant_rpc'; +export { + LedgerSubprovider, +} from './subproviders/ledger'; +export { + ECSignature, + LedgerWalletSubprovider, + LedgerCommunicationClient, +} from './types'; + +export async function ledgerEthereumBrowserClientFactoryAsync(): Promise<LedgerEthereumClient> { + const ledgerConnection = await LedgerBrowserCommunication.create_async(); + const ledgerEthClient = new LedgerEthereumClientFn(ledgerConnection); + return ledgerEthClient; +} + +export async function ledgerEthereumNodeJsClientFactoryAsync(): Promise<LedgerEthereumClient> { + const ledgerConnection = await LedgerNodeCommunication.create_async(); + const ledgerEthClient = new LedgerEthereumClientFn(ledgerConnection); + return ledgerEthClient; +} diff --git a/packages/subproviders/src/subproviders/injected_web3.ts b/packages/subproviders/src/subproviders/injected_web3.ts new file mode 100644 index 000000000..a3308d142 --- /dev/null +++ b/packages/subproviders/src/subproviders/injected_web3.ts @@ -0,0 +1,47 @@ +import * as _ from 'lodash'; +import Web3 = require('web3'); +import Web3ProviderEngine = require('web3-provider-engine'); + +/* + * This class implements the web3-provider-engine subprovider interface and forwards + * requests involving user accounts (getAccounts, sendTransaction, etc...) to the injected + * web3 instance in their browser. + * Source: https://github.com/MetaMask/provider-engine/blob/master/subproviders/subprovider.js + */ +export class InjectedWeb3Subprovider { + private injectedWeb3: Web3; + constructor(injectedWeb3: Web3) { + this.injectedWeb3 = injectedWeb3; + } + public handleRequest( + payload: Web3.JSONRPCRequestPayload, next: () => void, end: (err: Error, result: any) => void, + ) { + switch (payload.method) { + case 'web3_clientVersion': + this.injectedWeb3.version.getNode(end); + return; + case 'eth_accounts': + this.injectedWeb3.eth.getAccounts(end); + return; + + case 'eth_sendTransaction': + const [txParams] = payload.params; + this.injectedWeb3.eth.sendTransaction(txParams, end); + return; + + case 'eth_sign': + const [address, message] = payload.params; + this.injectedWeb3.eth.sign(address, message, end); + return; + + default: + next(); + return; + } + } + // Required to implement this method despite not needing it for this subprovider + // tslint:disable-next-line:prefer-function-over-method + public setEngine(engine: Web3ProviderEngine) { + // noop + } +} diff --git a/packages/subproviders/src/subproviders/ledger.ts b/packages/subproviders/src/subproviders/ledger.ts new file mode 100644 index 000000000..931199ed1 --- /dev/null +++ b/packages/subproviders/src/subproviders/ledger.ts @@ -0,0 +1,320 @@ +import promisify = require('es6-promisify'); +import {isAddress} from 'ethereum-address'; +import * as EthereumTx from 'ethereumjs-tx'; +import ethUtil = require('ethereumjs-util'); +import * as ledger from 'ledgerco'; +import * as _ from 'lodash'; +import Semaphore from 'semaphore-async-await'; +import Web3 = require('web3'); + +import { + LedgerEthereumClient, + LedgerEthereumClientFactoryAsync, + LedgerSubproviderConfigs, + LedgerSubproviderErrors, + PartialTxParams, + ResponseWithTxParams, + SignPersonalMessageParams, +} from '../types'; + +import {Subprovider} from './subprovider'; + +const DEFAULT_DERIVATION_PATH = `44'/60'/0'`; +const NUM_ADDRESSES_TO_FETCH = 10; +const ASK_FOR_ON_DEVICE_CONFIRMATION = false; +const SHOULD_GET_CHAIN_CODE = false; +const HEX_REGEX = /^[0-9A-Fa-f]+$/g; + +export class LedgerSubprovider extends Subprovider { + private _nonceLock: Semaphore; + private _connectionLock: Semaphore; + private _networkId: number; + private _derivationPath: string; + private _derivationPathIndex: number; + private _ledgerEthereumClientFactoryAsync: LedgerEthereumClientFactoryAsync; + private _ledgerClientIfExists?: LedgerEthereumClient; + private _shouldAlwaysAskForConfirmation: boolean; + private static isValidHex(data: string) { + if (!_.isString(data)) { + return false; + } + const isHexPrefixed = data.slice(0, 2) === '0x'; + if (!isHexPrefixed) { + return false; + } + const nonPrefixed = data.slice(2); + const isValid = nonPrefixed.match(HEX_REGEX); + return isValid; + } + private static validatePersonalMessage(msgParams: PartialTxParams) { + if (_.isUndefined(msgParams.from) || !isAddress(msgParams.from)) { + throw new Error(LedgerSubproviderErrors.FromAddressMissingOrInvalid); + } + if (_.isUndefined(msgParams.data)) { + throw new Error(LedgerSubproviderErrors.DataMissingForSignPersonalMessage); + } + if (!LedgerSubprovider.isValidHex(msgParams.data)) { + throw new Error(LedgerSubproviderErrors.DataNotValidHexForSignPersonalMessage); + } + } + private static validateSender(sender: string) { + if (_.isUndefined(sender) || !isAddress(sender)) { + throw new Error(LedgerSubproviderErrors.SenderInvalidOrNotSupplied); + } + } + constructor(config: LedgerSubproviderConfigs) { + super(); + this._nonceLock = new Semaphore(1); + this._connectionLock = new Semaphore(1); + this._networkId = config.networkId; + this._ledgerEthereumClientFactoryAsync = config.ledgerEthereumClientFactoryAsync; + this._derivationPath = config.derivationPath || DEFAULT_DERIVATION_PATH; + this._shouldAlwaysAskForConfirmation = !_.isUndefined(config.accountFetchingConfigs) && + !_.isUndefined( + config.accountFetchingConfigs.shouldAskForOnDeviceConfirmation, + ) ? + config.accountFetchingConfigs.shouldAskForOnDeviceConfirmation : + ASK_FOR_ON_DEVICE_CONFIRMATION; + this._derivationPathIndex = 0; + } + public getPath(): string { + return this._derivationPath; + } + public setPath(derivationPath: string) { + this._derivationPath = derivationPath; + } + public setPathIndex(pathIndex: number) { + this._derivationPathIndex = pathIndex; + } + public async handleRequest( + payload: Web3.JSONRPCRequestPayload, next: () => void, end: (err: Error|null, result?: any) => void, + ) { + let accounts; + let txParams; + switch (payload.method) { + case 'eth_coinbase': + try { + accounts = await this.getAccountsAsync(); + end(null, accounts[0]); + } catch (err) { + end(err); + } + return; + + case 'eth_accounts': + try { + accounts = await this.getAccountsAsync(); + end(null, accounts); + } catch (err) { + end(err); + } + return; + + case 'eth_sendTransaction': + txParams = payload.params[0]; + try { + LedgerSubprovider.validateSender(txParams.from); + const result = await this.sendTransactionAsync(txParams); + end(null, result); + } catch (err) { + end(err); + } + return; + + case 'eth_signTransaction': + txParams = payload.params[0]; + try { + const result = await this.signTransactionWithoutSendingAsync(txParams); + end(null, result); + } catch (err) { + end(err); + } + return; + + case 'personal_sign': + // non-standard "extraParams" to be appended to our "msgParams" obj + // good place for metadata + const extraParams = payload.params[2] || {}; + const msgParams = _.assign({}, extraParams, { + from: payload.params[1], + data: payload.params[0], + }); + + try { + LedgerSubprovider.validatePersonalMessage(msgParams); + const ecSignatureHex = await this.signPersonalMessageAsync(msgParams); + end(null, ecSignatureHex); + } catch (err) { + end(err); + } + return; + + default: + next(); + return; + } + } + public async getAccountsAsync(): Promise<string[]> { + this._ledgerClientIfExists = await this.createLedgerClientAsync(); + + const accounts = []; + for (let i = 0; i < NUM_ADDRESSES_TO_FETCH; i++) { + try { + const derivationPath = `${this._derivationPath}/${i + this._derivationPathIndex}`; + const result = await this._ledgerClientIfExists.getAddress_async( + derivationPath, this._shouldAlwaysAskForConfirmation, SHOULD_GET_CHAIN_CODE, + ); + accounts.push(result.address.toLowerCase()); + } catch (err) { + await this.destoryLedgerClientAsync(); + throw err; + } + } + await this.destoryLedgerClientAsync(); + return accounts; + } + public async signTransactionAsync(txParams: PartialTxParams): Promise<string> { + this._ledgerClientIfExists = await this.createLedgerClientAsync(); + + const tx = new EthereumTx(txParams); + + // Set the EIP155 bits + tx.raw[6] = Buffer.from([this._networkId]); // v + tx.raw[7] = Buffer.from([]); // r + tx.raw[8] = Buffer.from([]); // s + + const txHex = tx.serialize().toString('hex'); + try { + const derivationPath = this.getDerivationPath(); + const result = await this._ledgerClientIfExists.signTransaction_async(derivationPath, txHex); + // Store signature in transaction + tx.r = Buffer.from(result.r, 'hex'); + tx.s = Buffer.from(result.s, 'hex'); + tx.v = Buffer.from(result.v, 'hex'); + + // EIP155: v should be chain_id * 2 + {35, 36} + const signedChainId = Math.floor((tx.v[0] - 35) / 2); + if (signedChainId !== this._networkId) { + await this.destoryLedgerClientAsync(); + const err = new Error(LedgerSubproviderErrors.TooOldLedgerFirmware); + throw err; + } + + const signedTxHex = `0x${tx.serialize().toString('hex')}`; + await this.destoryLedgerClientAsync(); + return signedTxHex; + } catch (err) { + await this.destoryLedgerClientAsync(); + throw err; + } + } + public async signPersonalMessageAsync(msgParams: SignPersonalMessageParams): Promise<string> { + this._ledgerClientIfExists = await this.createLedgerClientAsync(); + try { + const derivationPath = this.getDerivationPath(); + const result = await this._ledgerClientIfExists.signPersonalMessage_async( + derivationPath, ethUtil.stripHexPrefix(msgParams.data)); + const v = result.v - 27; + let vHex = v.toString(16); + if (vHex.length < 2) { + vHex = `0${v}`; + } + const signature = `0x${result.r}${result.s}${vHex}`; + await this.destoryLedgerClientAsync(); + return signature; + } catch (err) { + await this.destoryLedgerClientAsync(); + throw err; + } + } + private getDerivationPath() { + const derivationPath = `${this.getPath()}/${this._derivationPathIndex}`; + return derivationPath; + } + private async createLedgerClientAsync(): Promise<LedgerEthereumClient> { + await this._connectionLock.wait(); + if (!_.isUndefined(this._ledgerClientIfExists)) { + this._connectionLock.signal(); + throw new Error(LedgerSubproviderErrors.MultipleOpenConnectionsDisallowed); + } + const ledgerEthereumClient = await this._ledgerEthereumClientFactoryAsync(); + this._connectionLock.signal(); + return ledgerEthereumClient; + } + private async destoryLedgerClientAsync() { + await this._connectionLock.wait(); + if (_.isUndefined(this._ledgerClientIfExists)) { + this._connectionLock.signal(); + return; + } + await this._ledgerClientIfExists.comm.close_async(); + this._ledgerClientIfExists = undefined; + this._connectionLock.signal(); + } + private async sendTransactionAsync(txParams: PartialTxParams): Promise<any> { + await this._nonceLock.wait(); + try { + // fill in the extras + const filledParams = await this.populateMissingTxParamsAsync(txParams); + // sign it + const signedTx = await this.signTransactionAsync(filledParams); + // emit a submit + const payload = { + method: 'eth_sendRawTransaction', + params: [signedTx], + }; + const result = await this.emitPayloadAsync(payload); + this._nonceLock.signal(); + return result; + } catch (err) { + this._nonceLock.signal(); + throw err; + } + } + private async signTransactionWithoutSendingAsync(txParams: PartialTxParams): Promise<ResponseWithTxParams> { + await this._nonceLock.wait(); + try { + // fill in the extras + const filledParams = await this.populateMissingTxParamsAsync(txParams); + // sign it + const signedTx = await this.signTransactionAsync(filledParams); + + this._nonceLock.signal(); + const result = { + raw: signedTx, + tx: txParams, + }; + return result; + } catch (err) { + this._nonceLock.signal(); + throw err; + } + } + private async populateMissingTxParamsAsync(txParams: PartialTxParams): Promise<PartialTxParams> { + if (_.isUndefined(txParams.gasPrice)) { + const gasPriceResult = await this.emitPayloadAsync({ + method: 'eth_gasPrice', + params: [], + }); + const gasPrice = gasPriceResult.result.toString(); + txParams.gasPrice = gasPrice; + } + if (_.isUndefined(txParams.nonce)) { + const nonceResult = await this.emitPayloadAsync({ + method: 'eth_getTransactionCount', + params: [txParams.from, 'pending'], + }); + const nonce = nonceResult.result; + txParams.nonce = nonce; + } + if (_.isUndefined(txParams.gas)) { + const gasResult = await this.emitPayloadAsync({ + method: 'eth_estimateGas', + params: [txParams], + }); + const gas = gasResult.result.toString(); + txParams.gas = gas; + } + return txParams; + } +} diff --git a/packages/subproviders/src/subproviders/redundant_rpc.ts b/packages/subproviders/src/subproviders/redundant_rpc.ts new file mode 100644 index 000000000..43d711ee6 --- /dev/null +++ b/packages/subproviders/src/subproviders/redundant_rpc.ts @@ -0,0 +1,46 @@ +import promisify = require('es6-promisify'); +import * as _ from 'lodash'; +import RpcSubprovider = require('web3-provider-engine/subproviders/rpc'); +import Subprovider = require('web3-provider-engine/subproviders/subprovider'); + +import {JSONRPCPayload} from '../types'; + +export class RedundantRPCSubprovider extends Subprovider { + private rpcs: RpcSubprovider[]; + private static async firstSuccessAsync( + rpcs: RpcSubprovider[], payload: JSONRPCPayload, next: () => void, + ): Promise<any> { + let lastErr: Error|undefined; + for (const rpc of rpcs) { + try { + const data = await promisify(rpc.handleRequest.bind(rpc))(payload, next); + return data; + } catch (err) { + lastErr = err; + continue; + } + } + if (!_.isUndefined(lastErr)) { + throw lastErr; + } + } + constructor(endpoints: string[]) { + super(); + this.rpcs = _.map(endpoints, endpoint => { + return new RpcSubprovider({ + rpcUrl: endpoint, + }); + }); + } + public async handleRequest(payload: JSONRPCPayload, next: () => void, + end: (err?: Error, data?: any) => void): Promise<void> { + const rpcsCopy = this.rpcs.slice(); + try { + const data = await RedundantRPCSubprovider.firstSuccessAsync(rpcsCopy, payload, next); + end(undefined, data); + } catch (err) { + end(err); + } + + } +} diff --git a/packages/subproviders/src/subproviders/subprovider.ts b/packages/subproviders/src/subproviders/subprovider.ts new file mode 100644 index 000000000..07f4d6353 --- /dev/null +++ b/packages/subproviders/src/subproviders/subprovider.ts @@ -0,0 +1,45 @@ +import promisify = require('es6-promisify'); +import Web3 = require('web3'); + +import { + JSONRPCPayload, +} from '../types'; +/* + * A version of the base class Subprovider found in providerEngine + * This one has an async/await `emitPayloadAsync` and also defined types. + * Altered version of: https://github.com/MetaMask/provider-engine/blob/master/subproviders/subprovider.js + */ +export class Subprovider { + private engine: any; + private currentBlock: any; + private static getRandomId() { + const extraDigits = 3; + // 13 time digits + const datePart = new Date().getTime() * Math.pow(10, extraDigits); + // 3 random digits + const extraPart = Math.floor(Math.random() * Math.pow(10, extraDigits)); + // 16 digits + return datePart + extraPart; + } + private static createFinalPayload(payload: JSONRPCPayload): Web3.JSONRPCRequestPayload { + const finalPayload = { + // defaults + id: Subprovider.getRandomId(), + jsonrpc: '2.0', + params: [], + ...payload, + }; + return finalPayload; + } + public setEngine(engine: any): void { + this.engine = engine; + engine.on('block', (block: any) => { + this.currentBlock = block; + }); + } + public async emitPayloadAsync(payload: JSONRPCPayload): Promise<any> { + const finalPayload = Subprovider.createFinalPayload(payload); + const response = await promisify(this.engine.sendAsync, this.engine)(finalPayload); + return response; + } +} diff --git a/packages/subproviders/src/types.ts b/packages/subproviders/src/types.ts new file mode 100644 index 000000000..4564c5229 --- /dev/null +++ b/packages/subproviders/src/types.ts @@ -0,0 +1,115 @@ +import * as _ from 'lodash'; +import * as Web3 from 'web3'; + +export interface LedgerCommunicationClient { + exchange: (apduHex: string, statusList: number[]) => Promise<any[]>; + setScrambleKey: (key: string) => void; + close_async: () => Promise<void>; +} + +/* + * The LedgerEthereumClient sends Ethereum-specific requests to the Ledger Nano S + * It uses an internal LedgerCommunicationClient to relay these requests. Currently + * NodeJs and Browser communication are supported. + */ +export interface LedgerEthereumClient { + getAddress_async: (derivationPath: string, askForDeviceConfirmation: boolean, + shouldGetChainCode: boolean) => Promise<LedgerGetAddressResult>; + signPersonalMessage_async: (derivationPath: string, messageHex: string) => Promise<ECSignature>; + signTransaction_async: (derivationPath: string, txHex: string) => Promise<ECSignatureString>; + comm: LedgerCommunicationClient; +} + +export interface ECSignatureString { + v: string; + r: string; + s: string; +} + +export interface ECSignature { + v: number; + r: string; + s: string; +} + +export type LedgerEthereumClientFactoryAsync = () => Promise<LedgerEthereumClient>; + +/* + * networkId: The ethereum networkId to set as the chainId from EIP155 + * ledgerConnectionType: Environment in which you wish to connect to Ledger (nodejs or browser) + * derivationPath: Initial derivation path to use e.g 44'/60'/0' + * accountFetchingConfigs: configs related to fetching accounts from a Ledger + */ +export interface LedgerSubproviderConfigs { + networkId: number; + ledgerEthereumClientFactoryAsync: LedgerEthereumClientFactoryAsync; + derivationPath?: string; + accountFetchingConfigs?: AccountFetchingConfigs; +} + +/* + * numAddressesToReturn: Number of addresses to return from 'eth_accounts' call + * shouldAskForOnDeviceConfirmation: Whether you wish to prompt the user on their Ledger + * before fetching their addresses + */ +export interface AccountFetchingConfigs { + numAddressesToReturn?: number; + shouldAskForOnDeviceConfirmation?: boolean; +} + +export interface SignatureData { + hash: string; + r: string; + s: string; + v: number; +} + +export interface LedgerGetAddressResult { + address: string; +} + +export interface LedgerWalletSubprovider { + getPath: () => string; + setPath: (path: string) => void; + setPathIndex: (pathIndex: number) => void; +} + +export interface SignPersonalMessageParams { + data: string; +} + +export interface PartialTxParams { + nonce: string; + gasPrice?: string; + gas: string; + to: string; + from?: string; + value?: string; + data?: string; + chainId: number; // EIP 155 chainId - mainnet: 1, ropsten: 3 +} + +export type DoneCallback = (err?: Error) => void; + +export interface JSONRPCPayload { + params: any[]; + method: string; +} + +export interface LedgerCommunication { + close_async: () => Promise<void>; +} + +export interface ResponseWithTxParams { + raw: string; + tx: PartialTxParams; +} + +export enum LedgerSubproviderErrors { + TooOldLedgerFirmware = 'TOO_OLD_LEDGER_FIRMWARE', + FromAddressMissingOrInvalid = 'FROM_ADDRESS_MISSING_OR_INVALID', + DataMissingForSignPersonalMessage = 'DATA_MISSING_FOR_SIGN_PERSONAL_MESSAGE', + DataNotValidHexForSignPersonalMessage = 'DATA_NOT_VALID_HEX_FOR_SIGN_PERSONAL_MESSAGE', + SenderInvalidOrNotSupplied = 'SENDER_INVALID_OR_NOT_SUPPLIED', + MultipleOpenConnectionsDisallowed = 'MULTIPLE_OPEN_CONNECTIONS_DISALLOWED', +} |