From 6f7fbf3858c82417933a5e5595a919c0ec0487c7 Mon Sep 17 00:00:00 2001 From: Richard Moore Date: Fri, 10 Jan 2020 19:59:20 -0500 Subject: [PATCH] Browser support (with dist files) for Ledger. --- packages/hardware-wallets/package.json | 14 +- .../hardware-wallets/src.ts/browser-ethers.ts | 12 ++ .../src.ts/browser-ledger-transport.ts | 12 ++ packages/hardware-wallets/src.ts/ledger.ts | 123 ++++++++++-------- rollup-ancillary.config.js | 53 ++++++++ 5 files changed, 151 insertions(+), 63 deletions(-) create mode 100644 packages/hardware-wallets/src.ts/browser-ethers.ts create mode 100644 packages/hardware-wallets/src.ts/browser-ledger-transport.ts create mode 100644 rollup-ancillary.config.js diff --git a/packages/hardware-wallets/package.json b/packages/hardware-wallets/package.json index 2fe7129f..75656e68 100644 --- a/packages/hardware-wallets/package.json +++ b/packages/hardware-wallets/package.json @@ -1,17 +1,15 @@ { "author": "Richard Moore ", + "browser": { + "./lib.esm/ledger-transport.js": "./lib.esm/browser-ledger-transport.js", + "ethers": "./lib.esm/browser-ethers.js" + }, "dependencies": { - "@ethersproject/abstract-provider": ">=5.0.0-beta.136", - "@ethersproject/abstract-signer": ">=5.0.0-beta.137", - "@ethersproject/address": ">=5.0.0-beta.134", - "@ethersproject/bytes": ">=5.0.0-beta.134", - "@ethersproject/properties": ">=5.0.0-beta.136", - "@ethersproject/strings": ">=5.0.0-beta.135", - "@ethersproject/transactions": ">=5.0.0-beta.133", "@ledgerhq/hw-app-eth": "5.3.0", "@ledgerhq/hw-transport": "5.3.0", "@ledgerhq/hw-transport-node-hid": "5.3.0", - "@ledgerhq/hw-transport-u2f": "5.3.0" + "@ledgerhq/hw-transport-u2f": "5.3.0", + "ethers": "5.0.0-beta.166" }, "description": "Hardware Wallet support for ethers.", "devDependencies": { diff --git a/packages/hardware-wallets/src.ts/browser-ethers.ts b/packages/hardware-wallets/src.ts/browser-ethers.ts new file mode 100644 index 00000000..a755756a --- /dev/null +++ b/packages/hardware-wallets/src.ts/browser-ethers.ts @@ -0,0 +1,12 @@ +"use strict"; + +let ethers: any = { }; + +const w = (window as any); +if (w._ethers == null) { + console.log("WARNING: @ethersproject/hardware-wallet requires ethers loaded first"); +} else { + ethers = w._ethers; +} + +export { ethers } diff --git a/packages/hardware-wallets/src.ts/browser-ledger-transport.ts b/packages/hardware-wallets/src.ts/browser-ledger-transport.ts new file mode 100644 index 00000000..0f057a68 --- /dev/null +++ b/packages/hardware-wallets/src.ts/browser-ledger-transport.ts @@ -0,0 +1,12 @@ +"use strict"; + +import u2f from "@ledgerhq/hw-transport-u2f"; + +export type TransportCreator = { + create: () => Promise; +}; + +export const transports: { [ name: string ]: TransportCreator } = { + "u2f": u2f, + "default": u2f +}; diff --git a/packages/hardware-wallets/src.ts/ledger.ts b/packages/hardware-wallets/src.ts/ledger.ts index faf58df8..32e3b064 100644 --- a/packages/hardware-wallets/src.ts/ledger.ts +++ b/packages/hardware-wallets/src.ts/ledger.ts @@ -1,12 +1,9 @@ "use strict"; -import { getAddress } from "@ethersproject/address"; -import { Bytes, hexlify, joinSignature } from "@ethersproject/bytes"; -import { Signer } from "@ethersproject/abstract-signer"; -import { Provider, TransactionRequest } from "@ethersproject/abstract-provider"; -import { defineReadOnly, resolveProperties } from "@ethersproject/properties"; -import { toUtf8Bytes } from "@ethersproject/strings"; -import { serialize as serializeTransaction } from "@ethersproject/transactions"; +import { ethers } from "ethers"; + +import { version } from "./_version"; +const logger = new ethers.utils.Logger(version); import Eth from "@ledgerhq/hw-app-eth"; @@ -17,25 +14,31 @@ import { transports } from "./ledger-transport"; const defaultPath = "m/44'/60'/0'/0/0"; -export class LedgerSigner extends Signer { +function waiter(duration: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, duration); + }); +} + +export class LedgerSigner extends ethers.Signer { readonly type: string; readonly path: string readonly _eth: Promise; - constructor(provider?: Provider, type?: string, path?: string) { + constructor(provider?: ethers.providers.Provider, type?: string, path?: string) { super(); if (path == null) { path = defaultPath; } if (type == null) { type = "default"; } - defineReadOnly(this, "path", path); - defineReadOnly(this, "type", type); - defineReadOnly(this, "provider", provider || null); + ethers.utils.defineReadOnly(this, "path", path); + ethers.utils.defineReadOnly(this, "type", type); + ethers.utils.defineReadOnly(this, "provider", provider || null); const transport = transports[type]; - if (!transport) { throw new Error("unknown or unsupport type"); } + if (!transport) { logger.throwArgumentError("unknown or unsupport type", "type", type); } - defineReadOnly(this, "_eth", transport.create().then((transport) => { + ethers.utils.defineReadOnly(this, "_eth", transport.create().then((transport) => { const eth = new Eth(transport); return eth.getAppConfiguration().then((config) => { return eth; @@ -47,53 +50,63 @@ export class LedgerSigner extends Signer { })); } - async getAddress(): Promise { - const eth = await this._eth; - if (eth == null) { throw new Error("failed to connect"); } - const o = await eth.getAddress(this.path); - return getAddress(o.address); - } + _retry(callback: (eth: Eth) => Promise, timeout?: number): Promise { + return new Promise(async (resolve, reject) => { + if (timeout && timeout > 0) { + setTimeout(() => { reject(new Error("timeout")); }, timeout); + } - async signMessage(message: Bytes | string): Promise { - if (typeof(message) === 'string') { - message = toUtf8Bytes(message); - } + const eth = await this._eth; - const messageHex = hexlify(message).substring(2); + // Wait up to 5 seconds + for (let i = 0; i < 50; i++) { + try { + const result = await callback(eth); + return resolve(result); + } catch (error) { + if (error.id !== "TransportLocked") { + return reject(error); + } + } + await waiter(100); + } - const eth = await this._eth; - const sig = await eth.signPersonalMessage(this.path, messageHex); - sig.r = '0x' + sig.r; - sig.s = '0x' + sig.s; - return joinSignature(sig); - } - - async signTransaction(transaction: TransactionRequest): Promise { - const eth = await this._eth; - return resolveProperties(transaction).then((tx) => { - const unsignedTx = serializeTransaction(tx).substring(2); - return eth.signTransaction(this.path, unsignedTx).then((sig) => { - return serializeTransaction(tx, { - v: sig.v, - r: ("0x" + sig.r), - s: ("0x" + sig.s), - }); - }); + return reject(new Error("timeout")); }); } - connect(provider: Provider): Signer { + async getAddress(): Promise { + const account = await this._retry((eth) => eth.getAddress(this.path)); + return ethers.utils.getAddress(account.address); + } + + async signMessage(message: ethers.utils.Bytes | string): Promise { + if (typeof(message) === 'string') { + message = ethers.utils.toUtf8Bytes(message); + } + + const messageHex = ethers.utils.hexlify(message).substring(2); + + const sig = await this._retry((eth) => eth.signPersonalMessage(this.path, messageHex)); + sig.r = '0x' + sig.r; + sig.s = '0x' + sig.s; + return ethers.utils.joinSignature(sig); + } + + async signTransaction(transaction: ethers.providers.TransactionRequest): Promise { + const tx = transaction = await ethers.utils.resolveProperties(transaction); + const unsignedTx = ethers.utils.serializeTransaction(tx).substring(2); + + const sig = await this._retry((eth) => eth.signTransaction(this.path, unsignedTx)); + + return ethers.utils.serializeTransaction(tx, { + v: sig.v, + r: ("0x" + sig.r), + s: ("0x" + sig.s), + }); + } + + connect(provider: ethers.providers.Provider): ethers.Signer { return new LedgerSigner(provider, this.type, this.path); } } - -(async function() { - const signer = new LedgerSigner(); - console.log(signer); - try { - const sig = await signer.signMessage("Hello World"); - console.log(sig); - } catch (error) { - console.log("ERR", error); - } -})(); diff --git a/rollup-ancillary.config.js b/rollup-ancillary.config.js new file mode 100644 index 00000000..3515078f --- /dev/null +++ b/rollup-ancillary.config.js @@ -0,0 +1,53 @@ +"use strict"; + +import resolve from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; +import _globals from 'rollup-plugin-node-globals'; + +import { terser } from "rollup-plugin-terser"; + +function getConfig(project, minify) { + + const suffix = [ "esm" ]; + + const plugins = [ + resolve({ + mainFields: [ "browser", "module", "main" ], + preferBuiltins: false + }), + commonjs({ + namedExports: { + "bn.js": [ "BN" ], + "elliptic": [ "ec" ], + "scrypt-js": [ "scrypt" ], + "u2f-api": [ "isSupported", "sign" ], + "js-sha3": [ null ] + }, + }), + _globals(), + ]; + + if (minify) { + suffix.push("min"); + plugins.push(terser()); + } + + return { + input: `packages/${ project }/lib.esm/index.js`, + output: { + file: `packages/${ project }/dist/hardware-wallets.${ suffix.join(".") }.js`, + format: "esm", + name: "_ethersAncillary", + exports: "named" + }, + context: "window", + treeshake: false, + plugins: plugins + }; +} + +export default [ + getConfig("hardware-wallets", false), + getConfig("hardware-wallets", true), +] +