Port subproviders over to mono repo, refactor LedgerSubprovider to no longer rely on hookedWalletSubprovider. Added unit and integration tests.

This commit is contained in:
Fabio Berger
2017-12-05 15:45:35 -06:00
parent 47789d770d
commit 038668efdf
17 changed files with 1289 additions and 3 deletions

View File

@@ -0,0 +1,39 @@
Subproviders
-----------
A few useful subproviders.
## Installation
```
npm install @0xproject/subproviders --save
```
## Subproviders
#### Ledger Nano S subprovider
A subprovider that enables your dApp to send signing requests to a user's Ledger Nano S hardware wallet. These can be requests to sign transactions or messages.
#### Redundant RPC subprovider
A subprovider which attempts to send an RPC call to a list of RPC endpoints sequentially, until one of them returns a successful response.
#### Injected Web3 subprovider
A subprovider that relays all signing related requests to a particular provider (in our case the provider injected onto the web page), while sending all other requests to a different provider (perhaps your own backing Ethereum node or Infura).
### Integration tests
In order to run the integration tests, make sure you have a Ledger Nano S available.
- Plug it into your computer
- Unlock the device
- Open the Ethereum app
- Make sure "browser support" is disabled
Then run:
```
yarn test:integration
```

View File

@@ -0,0 +1,60 @@
{
"name": "@0xproject/subproviders",
"version": "0.0.0",
"main": "lib/src/index.js",
"types": "lib/src/index.d.ts",
"license": "Apache-2.0",
"scripts": {
"prebuild": "npm run clean",
"build": "run-p build:umd:dev build:commonjs; exit 0;",
"clean": "shx rm -rf lib",
"build:umd:dev": "webpack",
"build:umd:prod": "NODE_ENV=production webpack",
"build:commonjs": "tsc",
"lint": "tslint --project . 'src/**/*.ts' 'test/**/*.ts'",
"pretest:umd": "run-s clean build:umd:dev build:commonjs",
"substitute_umd_bundle": "shx mv _bundles/* lib/src",
"run_mocha_all": "mocha lib/test/**/*_test.js --timeout 10000 --bail --exit",
"run_mocha_unit": "mocha lib/test/unit/**/*_test.js --timeout 10000 --bail --exit",
"run_mocha_integration": "mocha lib/test/integration/**/*_test.js --timeout 10000 --bail --exit",
"test": "run-s clean build:commonjs run_mocha_all",
"test:unit": "run-s clean build:commonjs run_mocha_unit",
"test:integration": "run-s clean build:commonjs run_mocha_integration"
},
"dependencies": {
"@0xproject/assert": "^0.0.6",
"bn.js": "^4.11.8",
"es6-promisify": "^5.0.0",
"ethereum-address": "^0.0.4",
"ethereumjs-tx": "^1.3.3",
"ethereumjs-util": "^5.1.1",
"ledgerco": "0xProject/ledger-node-js-api",
"lodash": "^4.17.4",
"semaphore-async-await": "^1.5.1",
"sinon": "^4.0.0",
"web3": "^0.20.0",
"web3-provider-engine": "^13.0.1"
},
"devDependencies": {
"@0xproject/tslint-config": "^0.2.0",
"@types/lodash": "^4.14.64",
"@types/mocha": "^2.2.44",
"@types/node": "^8.0.1",
"@types/sinon": "^2.2.2",
"awesome-typescript-loader": "^3.1.3",
"chai": "^4.0.1",
"chai-as-promised": "^7.1.0",
"chai-as-promised-typescript-typings": "^0.0.3",
"chai-typescript-typings": "^0.0.1",
"dirty-chai": "^2.0.1",
"mocha": "^4.0.0",
"npm-run-all": "^4.1.2",
"shx": "^0.2.2",
"tslint": "5.8.0",
"types-bn": "^0.0.1",
"types-ethereumjs-util": "0xproject/types-ethereumjs-util",
"typescript": "^2.6.1",
"web3-typescript-typings": "^0.7.2",
"webpack": "^3.1.0"
}
}

68
packages/subproviders/src/globals.d.ts vendored Normal file
View File

@@ -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

View File

@@ -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;
}

View File

@@ -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
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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',
}

View File

@@ -0,0 +1,11 @@
import * as chai from 'chai';
import chaiAsPromised = require('chai-as-promised');
import * as dirtyChai from 'dirty-chai';
export const chaiSetup = {
configure() {
chai.config.includeStack = true;
chai.use(dirtyChai);
chai.use(chaiAsPromised);
},
};

View File

@@ -0,0 +1,171 @@
import * as chai from 'chai';
import promisify = require('es6-promisify');
import * as ethUtils from 'ethereumjs-util';
import * as _ from 'lodash';
import * as mocha from 'mocha';
import Web3 = require('web3');
import Web3ProviderEngine = require('web3-provider-engine');
import RpcSubprovider = require('web3-provider-engine/subproviders/rpc');
import {
ECSignature,
ledgerEthereumNodeJsClientFactoryAsync,
LedgerSubprovider,
} from '../../src';
import {
DoneCallback,
LedgerGetAddressResult,
PartialTxParams,
} from '../../src/types';
import { chaiSetup } from '../chai_setup';
import {reportCallbackErrors} from '../utils/report_callback_errors';
const expect = chai.expect;
const TEST_RPC_ACCOUNT_0 = '0x5409ed021d9299bf6814279a6a1411a7e866a631';
describe('LedgerSubprovider', () => {
let ledgerSubprovider: LedgerSubprovider;
const networkId: number = 42;
before(async () => {
ledgerSubprovider = new LedgerSubprovider({
networkId,
ledgerEthereumClientFactoryAsync: ledgerEthereumNodeJsClientFactoryAsync,
});
});
describe('direct method calls', () => {
it('returns a list of accounts', async () => {
const accounts = await ledgerSubprovider.getAccountsAsync();
expect(accounts[0]).to.not.be.an('undefined');
expect(accounts.length).to.be.equal(10);
});
it('signs a personal message', async () => {
const data = ethUtils.bufferToHex(ethUtils.toBuffer('hello world'));
const ecSignatureHex = await ledgerSubprovider.signPersonalMessageAsync({data});
expect(ecSignatureHex.length).to.be.equal(132);
expect(ecSignatureHex.substr(0, 2)).to.be.equal('0x');
});
it('signs a transaction', async () => {
const tx = {
nonce: '0x00',
gas: '0x2710',
to: '0x0000000000000000000000000000000000000000',
value: '0x00',
chainId: 3,
};
const txHex = await ledgerSubprovider.signTransactionAsync(tx);
// tslint:disable-next-line:max-line-length
expect(txHex).to.be.equal('0xf85f8080822710940000000000000000000000000000000000000000808077a088a95ef1378487bc82be558e82c8478baf840c545d5b887536bb1da63673a98ba0019f4a4b9a107d1e6752bf7f701e275f28c13791d6e76af895b07373462cefaa');
});
});
describe('calls through a provider', () => {
let defaultProvider: Web3ProviderEngine;
let ledgerProvider: Web3ProviderEngine;
before(() => {
ledgerProvider = new Web3ProviderEngine();
ledgerProvider.addProvider(ledgerSubprovider);
const httpProvider = new RpcSubprovider({
rpcUrl: 'http://localhost:8545',
});
ledgerProvider.addProvider(httpProvider);
ledgerProvider.start();
defaultProvider = new Web3ProviderEngine();
defaultProvider.addProvider(httpProvider);
defaultProvider.start();
});
it('returns a list of accounts', (done: DoneCallback) => {
const payload = {
jsonrpc: '2.0',
method: 'eth_accounts',
params: [],
id: 1,
};
const callback = reportCallbackErrors(done)((err: Error, response: Web3.JSONRPCResponsePayload) => {
expect(err).to.be.a('null');
expect(response.result.length).to.be.equal(10);
done();
});
ledgerProvider.sendAsync(payload, callback);
});
it('signs a personal message', (done: DoneCallback) => {
(async () => {
const messageHex = ethUtils.bufferToHex(ethUtils.toBuffer('hello world'));
const accounts = await ledgerSubprovider.getAccountsAsync();
const signer = accounts[0];
const payload = {
jsonrpc: '2.0',
method: 'personal_sign',
params: [messageHex, signer],
id: 1,
};
const callback = reportCallbackErrors(done)((err: Error, response: Web3.JSONRPCResponsePayload) => {
expect(err).to.be.a('null');
expect(response.result.length).to.be.equal(132);
expect(response.result.substr(0, 2)).to.be.equal('0x');
done();
});
ledgerProvider.sendAsync(payload, callback);
})().catch(done);
});
it('signs a transaction', (done: DoneCallback) => {
const tx = {
to: '0xafa3f8684e54059998bc3a7b0d2b0da075154d66',
value: '0x00',
};
const payload = {
jsonrpc: '2.0',
method: 'eth_signTransaction',
params: [tx],
id: 1,
};
const callback = reportCallbackErrors(done)((err: Error, response: Web3.JSONRPCResponsePayload) => {
expect(err).to.be.a('null');
expect(response.result.raw.length).to.be.equal(206);
expect(response.result.raw.substr(0, 2)).to.be.equal('0x');
done();
});
ledgerProvider.sendAsync(payload, callback);
});
it('signs and sends a transaction', (done: DoneCallback) => {
(async () => {
const accounts = await ledgerSubprovider.getAccountsAsync();
// Give first account on Ledger sufficient ETH to complete tx send
let tx = {
to: accounts[0],
from: TEST_RPC_ACCOUNT_0,
value: '0x8ac7230489e80000', // 10 ETH
};
let payload = {
jsonrpc: '2.0',
method: 'eth_sendTransaction',
params: [tx],
id: 1,
};
await promisify(defaultProvider.sendAsync, defaultProvider)(payload);
// Send transaction from Ledger
tx = {
to: '0xafa3f8684e54059998bc3a7b0d2b0da075154d66',
from: accounts[0],
value: '0xde0b6b3a7640000',
};
payload = {
jsonrpc: '2.0',
method: 'eth_sendTransaction',
params: [tx],
id: 1,
};
const callback = reportCallbackErrors(done)((err: Error, response: Web3.JSONRPCResponsePayload) => {
expect(err).to.be.a('null');
const result = response.result.result;
expect(result.length).to.be.equal(66);
expect(result.substr(0, 2)).to.be.equal('0x');
done();
});
ledgerProvider.sendAsync(payload, callback);
})().catch(done);
});
});
});

View File

@@ -0,0 +1,229 @@
import * as chai from 'chai';
import * as ethUtils from 'ethereumjs-util';
import * as _ from 'lodash';
import * as mocha from 'mocha';
import Web3 = require('web3');
import Web3ProviderEngine = require('web3-provider-engine');
import RpcSubprovider = require('web3-provider-engine/subproviders/rpc');
import {
ECSignature,
LedgerSubprovider,
} from '../../src';
import {
DoneCallback,
ECSignatureString,
LedgerCommunicationClient,
LedgerGetAddressResult,
LedgerSubproviderErrors,
} from '../../src/types';
import { chaiSetup } from '../chai_setup';
import {reportCallbackErrors} from '../utils/report_callback_errors';
const expect = chai.expect;
const FAKE_ADDRESS = '0x9901c66f2d4b95f7074b553da78084d708beca70';
describe('LedgerSubprovider', () => {
const networkId: number = 42;
let ledgerSubprovider: LedgerSubprovider;
before(async () => {
const ledgerEthereumClientFactoryAsync = async () => {
const ledgerEthClient = {
getAddress_async: async () => {
return {
address: FAKE_ADDRESS,
};
},
signPersonalMessage_async: async () => {
const ecSignature: ECSignature = {
v: 28,
r: 'a6cc284bff14b42bdf5e9286730c152be91719d478605ec46b3bebcd0ae49148',
s: '0652a1a7b742ceb0213d1e744316e285f41f878d8af0b8e632cbca4c279132d0',
};
return ecSignature;
},
signTransaction_async: async (derivationPath: string, txHex: string) => {
const ecSignature: ECSignatureString = {
v: '77',
r: '88a95ef1378487bc82be558e82c8478baf840c545d5b887536bb1da63673a98b',
s: '019f4a4b9a107d1e6752bf7f701e275f28c13791d6e76af895b07373462cefaa',
};
return ecSignature;
},
comm: {
close_async: _.noop,
} as LedgerCommunicationClient,
};
return ledgerEthClient;
};
ledgerSubprovider = new LedgerSubprovider({
networkId,
ledgerEthereumClientFactoryAsync,
});
});
describe('direct method calls', () => {
describe('success cases', () => {
it('returns a list of accounts', async () => {
const accounts = await ledgerSubprovider.getAccountsAsync();
expect(accounts[0]).to.be.equal(FAKE_ADDRESS);
expect(accounts.length).to.be.equal(10);
});
it('signs a personal message', async () => {
const data = ethUtils.bufferToHex(ethUtils.toBuffer('hello world'));
const msgParams = {data};
const ecSignatureHex = await ledgerSubprovider.signPersonalMessageAsync(msgParams);
// tslint:disable-next-line:max-line-length
expect(ecSignatureHex).to.be.equal('0xa6cc284bff14b42bdf5e9286730c152be91719d478605ec46b3bebcd0ae491480652a1a7b742ceb0213d1e744316e285f41f878d8af0b8e632cbca4c279132d001');
});
});
describe('failure cases', () => {
it('cannot open multiple simultaneous connections to the Ledger device', async () => {
const data = ethUtils.bufferToHex(ethUtils.toBuffer('hello world'));
const msgParams = {data};
try {
const result = await Promise.all([
ledgerSubprovider.getAccountsAsync(),
ledgerSubprovider.signPersonalMessageAsync(msgParams),
]);
throw new Error('Multiple simultaneous calls succeeded when they should have failed');
} catch (err) {
expect(err.message).to.be.equal(LedgerSubproviderErrors.MultipleOpenConnectionsDisallowed);
}
});
});
});
describe('calls through a provider', () => {
let provider: Web3ProviderEngine;
before(() => {
provider = new Web3ProviderEngine();
provider.addProvider(ledgerSubprovider);
const httpProvider = new RpcSubprovider({
rpcUrl: 'http://localhost:8545',
});
provider.addProvider(httpProvider);
provider.start();
});
describe('success cases', () => {
it('returns a list of accounts', (done: DoneCallback) => {
const payload = {
jsonrpc: '2.0',
method: 'eth_accounts',
params: [],
id: 1,
};
const callback = reportCallbackErrors(done)((err: Error, response: Web3.JSONRPCResponsePayload) => {
expect(err).to.be.a('null');
expect(response.result.length).to.be.equal(10);
expect(response.result[0]).to.be.equal(FAKE_ADDRESS);
done();
});
provider.sendAsync(payload, callback);
});
it('signs a personal message', (done: DoneCallback) => {
const messageHex = ethUtils.bufferToHex(ethUtils.toBuffer('hello world'));
const payload = {
jsonrpc: '2.0',
method: 'personal_sign',
params: [messageHex, '0x0000000000000000000000000000000000000000'],
id: 1,
};
const callback = reportCallbackErrors(done)((err: Error, response: Web3.JSONRPCResponsePayload) => {
expect(err).to.be.a('null');
// tslint:disable-next-line:max-line-length
expect(response.result).to.be.equal('0xa6cc284bff14b42bdf5e9286730c152be91719d478605ec46b3bebcd0ae491480652a1a7b742ceb0213d1e744316e285f41f878d8af0b8e632cbca4c279132d001');
done();
});
provider.sendAsync(payload, callback);
});
it('signs a transaction', (done: DoneCallback) => {
const tx = {
to: '0xafa3f8684e54059998bc3a7b0d2b0da075154d66',
value: '0x00',
};
const payload = {
jsonrpc: '2.0',
method: 'eth_signTransaction',
params: [tx],
id: 1,
};
const callback = reportCallbackErrors(done)((err: Error, response: Web3.JSONRPCResponsePayload) => {
expect(err).to.be.a('null');
expect(response.result.raw.length).to.be.equal(206);
expect(response.result.raw.substr(0, 2)).to.be.equal('0x');
done();
});
provider.sendAsync(payload, callback);
});
});
describe('failure cases', () => {
it('should throw if `from` param missing when calling personal_sign', (done: DoneCallback) => {
const messageHex = ethUtils.bufferToHex(ethUtils.toBuffer('hello world'));
const payload = {
jsonrpc: '2.0',
method: 'personal_sign',
params: [messageHex], // Missing from param
id: 1,
};
const callback = reportCallbackErrors(done)((err: Error, response: Web3.JSONRPCResponsePayload) => {
expect(err).to.not.be.a('null');
expect(err.message).to.be.equal(LedgerSubproviderErrors.FromAddressMissingOrInvalid);
done();
});
provider.sendAsync(payload, callback);
});
it('should throw if `data` param not hex when calling personal_sign', (done: DoneCallback) => {
const nonHexMessage = 'hello world';
const payload = {
jsonrpc: '2.0',
method: 'personal_sign',
params: [nonHexMessage, '0x0000000000000000000000000000000000000000'],
id: 1,
};
const callback = reportCallbackErrors(done)((err: Error, response: Web3.JSONRPCResponsePayload) => {
expect(err).to.not.be.a('null');
expect(err.message).to.be.equal(LedgerSubproviderErrors.DataNotValidHexForSignPersonalMessage);
done();
});
provider.sendAsync(payload, callback);
});
it('should throw if `from` param missing when calling eth_sendTransaction', (done: DoneCallback) => {
const tx = {
to: '0xafa3f8684e54059998bc3a7b0d2b0da075154d66',
value: '0xde0b6b3a7640000',
};
const payload = {
jsonrpc: '2.0',
method: 'eth_sendTransaction',
params: [tx],
id: 1,
};
const callback = reportCallbackErrors(done)((err: Error, response: Web3.JSONRPCResponsePayload) => {
expect(err).to.not.be.a('null');
expect(err.message).to.be.equal(LedgerSubproviderErrors.SenderInvalidOrNotSupplied);
done();
});
provider.sendAsync(payload, callback);
});
it('should throw if `from` param invalid address when calling eth_sendTransaction',
(done: DoneCallback) => {
const tx = {
to: '0xafa3f8684e54059998bc3a7b0d2b0da075154d66',
from: '0xIncorrectEthereumAddress',
value: '0xde0b6b3a7640000',
};
const payload = {
jsonrpc: '2.0',
method: 'eth_sendTransaction',
params: [tx],
id: 1,
};
const callback = reportCallbackErrors(done)((err: Error, response: Web3.JSONRPCResponsePayload) => {
expect(err).to.not.be.a('null');
expect(err.message).to.be.equal(LedgerSubproviderErrors.SenderInvalidOrNotSupplied);
done();
});
provider.sendAsync(payload, callback);
});
});
});
});

View File

@@ -0,0 +1,14 @@
import { DoneCallback } from '../../src/types';
export const reportCallbackErrors = (done: DoneCallback) => {
return (f: (...args: any[]) => void) => {
const wrapped = async (...args: any[]) => {
try {
f(...args);
} catch (err) {
done(err);
}
};
return wrapped;
};
};

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"lib": [ "es2015", "dom" ],
"outDir": "lib",
"sourceMap": true,
"declaration": true,
"noImplicitAny": true,
"experimentalDecorators": true,
"strictNullChecks": true
},
"include": [
"./src/**/*",
"./test/**/*",
"../../node_modules/web3-typescript-typings/index.d.ts",
"../../node_modules/chai-typescript-typings/index.d.ts",
"../../node_modules/types-bn/index.d.ts",
"../../node_modules/types-ethereumjs-util/index.d.ts",
"../../node_modules/chai-as-promised-typescript-typings/index.d.ts"
]
}

View File

@@ -0,0 +1,5 @@
{
"extends": [
"@0xproject/tslint-config"
]
}

View File

@@ -0,0 +1,56 @@
/**
* This is to generate the umd bundle only
*/
const _ = require('lodash');
const webpack = require('webpack');
const path = require('path');
const production = process.env.NODE_ENV === 'production';
let entry = {
'index': './src/index.ts',
};
if (production) {
entry = _.assign({}, entry, {'index.min': './src/index.ts'});
}
module.exports = {
entry,
output: {
path: path.resolve(__dirname, '_bundles'),
filename: '[name].js',
libraryTarget: 'umd',
library: '0x Subproviders',
umdNamedDefine: true,
},
resolve: {
extensions: ['.ts', '.js', '.json'],
},
devtool: 'source-map',
plugins: [
new webpack.optimize.UglifyJsPlugin({
minimize: true,
sourceMap: true,
include: /\.min\.js$/,
}),
],
module: {
rules: [
{
test: /\.ts$/,
use: [
{
loader: 'awesome-typescript-loader',
query: {
declaration: false,
},
},
],
exclude: /node_modules/,
},
{
test: /\.json$/,
loader: 'json-loader',
},
],
},
};

View File

@@ -1285,7 +1285,7 @@ bn.js@4.11.7:
version "4.11.7"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.7.tgz#ddb048e50d9482790094c13eb3fcfc833ce7ab46"
bn.js@4.11.8, bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.10.0, bn.js@^4.11.3, bn.js@^4.11.7, bn.js@^4.4.0, bn.js@^4.8.0:
bn.js@4.11.8, bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.10.0, bn.js@^4.11.3, bn.js@^4.11.7, bn.js@^4.11.8, bn.js@^4.4.0, bn.js@^4.8.0:
version "4.11.8"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
@@ -8066,8 +8066,16 @@ types-bn@^0.0.1:
bn.js "4.11.7"
types-ethereumjs-util@0xProject/types-ethereumjs-util:
version "0.0.5"
resolved "https://codeload.github.com/0xProject/types-ethereumjs-util/tar.gz/b9ae55d2c2711d89f63f7fc53a78579f2d4fbd74"
version "0.0.6"
resolved "https://codeload.github.com/0xProject/types-ethereumjs-util/tar.gz/a3b236df39d9fbfcb3b832a1fea7110649eeb616"
dependencies:
bn.js "^4.11.7"
buffer "^5.0.6"
rlp "^2.0.0"
types-ethereumjs-util@0xproject/types-ethereumjs-util:
version "0.0.6"
resolved "https://codeload.github.com/0xproject/types-ethereumjs-util/tar.gz/a3b236df39d9fbfcb3b832a1fea7110649eeb616"
dependencies:
bn.js "^4.11.7"
buffer "^5.0.6"