diff --git a/index.js b/index.js index e62026df..dbf6f579 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,12 @@ const type = 'Ledger Hardware' const BRIDGE_URL = 'https://metamask.github.io/eth-ledger-bridge-keyring' const pathBase = 'm' const MAX_INDEX = 1000 +const NETWORK_API_URLS = { + ropsten: 'http://api-ropsten.etherscan.io', + kovan: 'http://api-kovan.etherscan.io', + rinkeby: 'https://api-rinkeby.etherscan.io', + mainnet: 'https://api.etherscan.io' +} class LedgerBridgeKeyring extends EventEmitter { constructor (opts = {}) { @@ -21,6 +27,7 @@ class LedgerBridgeKeyring extends EventEmitter { this.hdk = new HDKey() this.paths = {} this.iframe = null + this.network = 'mainnet' this.deserialize(opts) this._setupIframe() } @@ -32,35 +39,33 @@ class LedgerBridgeKeyring extends EventEmitter { deserialize (opts = {}) { this.hdPath = opts.hdPath || hdPathString this.bridgeUrl = opts.bridgeUrl || BRIDGE_URL - this.unlocked = opts.unlocked || false this.accounts = opts.accounts || [] return Promise.resolve() } isUnlocked () { - return this.unlocked + return this.hdk && this.hdk.publicKey ? true : false } setAccountToUnlock (index) { this.unlockedAccount = parseInt(index, 10) } - unlock () { - - if (this.isUnlocked()) return Promise.resolve('already unlocked') + unlock (hdPath) { + if (this.isUnlocked() && !hdPath) return Promise.resolve('already unlocked') return new Promise((resolve, reject) => { this._sendMessage({ action: 'ledger-unlock', params: { - hdPath: this.hdPath, + hdPath: this._toLedgerPath(hdPath ? hdPath : this.hdPath), }, }, ({success, payload}) => { if (success) { this.hdk.publicKey = new Buffer(payload.publicKey, 'hex') this.hdk.chainCode = new Buffer(payload.chainCode, 'hex') - resolve('just unlocked') + resolve(payload.address) } else { reject(payload.error || 'Unknown error') } @@ -72,13 +77,18 @@ class LedgerBridgeKeyring extends EventEmitter { return new Promise((resolve, reject) => { this.unlock() - .then(_ => { + .then(async _ => { const from = this.unlockedAccount const to = from + n this.accounts = [] - for (let i = from; i < to; i++) { - const address = this._addressFromIndex(pathBase, i) + let address + if(this._isBIP44()){ + const path = this._getPathForIndex(i) + address = await this.unlock(path) + }else{ + address = this._addressFromIndex(pathBase, i) + } this.accounts.push(address) this.page = 0 } @@ -114,9 +124,8 @@ class LedgerBridgeKeyring extends EventEmitter { this.accounts = this.accounts.filter(a => a.toLowerCase() !== address.toLowerCase()) } - // tx is an instance of the ethereumjs-transaction class. - async signTransaction (address, tx) { + signTransaction (address, tx) { return new Promise((resolve, reject) => { this.unlock() .then(_ => { @@ -135,11 +144,18 @@ class LedgerBridgeKeyring extends EventEmitter { s: '0x00', }) + let hdPath + if(this._isBIP44()){ + hdPath = this._getPathForIndex(this.unlockedAccount) + }else{ + hdPath = this._toLedgerPath(this._pathFromAddress(address)) + } + this._sendMessage({ action: 'ledger-sign-transaction', params: { tx: newTx.serialize().toString('hex'), - hdPath: this._pathFromAddress(address), + hdPath, }, }, ({success, payload}) => { @@ -163,21 +179,28 @@ class LedgerBridgeKeyring extends EventEmitter { }) } - async signMessage (withAccount, data) { + signMessage (withAccount, data) { throw new Error('Not supported on this device') } // For personal_sign, we need to prefix the message: - async signPersonalMessage (withAccount, message) { + signPersonalMessage (withAccount, message) { const humanReadableMsg = this._toAscii(message) const bufferMsg = Buffer.from(humanReadableMsg).toString('hex') return new Promise((resolve, reject) => { this.unlock() .then(_ => { + let hdPath + if(this._isBIP44()){ + hdPath = this._getPathForIndex(this.unlockedAccount) + }else{ + hdPath = this._toLedgerPath(this._pathFromAddress(withAccount)) + } + this._sendMessage({ action: 'ledger-sign-personal-message', params: { - hdPath: this._pathFromAddress(withAccount), + hdPath, message: bufferMsg, }, }, @@ -202,20 +225,20 @@ class LedgerBridgeKeyring extends EventEmitter { }) } - async signTypedData (withAccount, typedData) { + signTypedData (withAccount, typedData) { throw new Error('Not supported on this device') } - async exportAccount (address) { + exportAccount (address) { throw new Error('Not supported on this device') } forgetDevice () { this.accounts = [] - this.unlocked = false this.page = 0 this.unlockedAccount = 0 this.paths = {} + this.hdk = null } /* PRIVATE METHODS */ @@ -247,25 +270,17 @@ class LedgerBridgeKeyring extends EventEmitter { this.page += increment if (this.page <= 0) { this.page = 1 } - - return new Promise((resolve, reject) => { - this.unlock() - .then(_ => { - - const from = (this.page - 1) * this.perPage - const to = from + this.perPage - - const accounts = [] - - for (let i = from; i < to; i++) { - const address = this._addressFromIndex(pathBase, i) - accounts.push({ - address: address, - balance: null, - index: i, - }) - this.paths[ethUtil.toChecksumAddress(address)] = i - + const from = (this.page - 1) * this.perPage + const to = from + this.perPage + + return new Promise((resolve, reject) => { + this.unlock(from) + .then( async _ => { + let accounts + if(this._isBIP44()){ + accounts = await this._getAccountsBIP44(from, to) + }else{ + accounts = this._getAccountsLegacy(from, to) } resolve(accounts) }) @@ -275,6 +290,43 @@ class LedgerBridgeKeyring extends EventEmitter { }) } + async _getAccountsBIP44(from, to) { + const accounts = [] + + for (let i = from; i < to; i++) { + const path = this._getPathForIndex(i) + const address = await this.unlock(path) + const valid = await this._hasPreviousTransactions(address) + accounts.push({ + address: address, + balance: null, + index: i, + }) + // PER BIP44 + // "Software should prevent a creation of an account if + // a previous account does not have a transaction history + // (meaning none of its addresses have been used before)." + if(!valid){ + break + } + } + return accounts + } + + _getAccountsLegacy(from, to){ + const accounts = [] + + for (let i = from; i < to; i++) { + const address = this._addressFromIndex(pathBase, i) + accounts.push({ + address: address, + balance: null, + index: i, + }) + this.paths[ethUtil.toChecksumAddress(address)] = i + } + return accounts + } _padLeftEven (hex) { return hex.length % 2 !== 0 ? `0${hex}` : hex @@ -307,7 +359,7 @@ class LedgerBridgeKeyring extends EventEmitter { if (typeof index === 'undefined') { throw new Error('Unknown address') } - return `${this.hdPath}/${index}` + return this._getPathForIndex(index) } _toAscii (hex) { @@ -324,6 +376,33 @@ class LedgerBridgeKeyring extends EventEmitter { return str } + _getPathForIndex(index){ + // Check if the path is BIP 44 (Ledger Live) + return this._isBIP44() ? `m/44'/60'/${index}'/0/0` : `${this.hdPath}/${index}` + } + + _isBIP44(){ + return this.hdPath === `m/44'/60'/0'/0/0` ? true : false + } + + _toLedgerPath(path){ + return path.toString().replace('m/','') + } + + async _hasPreviousTransactions(address) { + const apiUrl = this._getApiUrl() + const response = await fetch(`${apiUrl}/api?module=account&action=txlist&address=${address}&tag=latest&page=1&offset=1`) + const parsedResponse = await response.json() + if (parsedResponse.status!=='0' && parsedResponse.result.length > 0) { + return true + } + return false + } + + _getApiUrl(){ + return NETWORK_API_URLS[this.network] ? NETWORK_API_URLS[this.network] : NETWORK_API_URLS['mainnet'] + } + } LedgerBridgeKeyring.type = type diff --git a/test/document.shim.js b/test/document.shim.js new file mode 100644 index 00000000..7dbb610a --- /dev/null +++ b/test/document.shim.js @@ -0,0 +1,27 @@ +try { + module.exports = document || { + head: { + appendChild: _ => false, + }, + createElement: _ => ({ + src: false, + contentWindow: { + postMessage: _ => false, + }, + }), + addEventListener: _ => false, + } +} catch (e) { + module.exports = { + head: { + appendChild: _ => false, + }, + createElement: _ => ({ + src: false, + contentWindow: { + postMessage: _ => false, + }, + }), + addEventListener: _ => false, + } +} \ No newline at end of file diff --git a/test/test-eth-ledger-bridge-keyring.js b/test/test-eth-ledger-bridge-keyring.js index 6df44ff2..a141fb09 100644 --- a/test/test-eth-ledger-bridge-keyring.js +++ b/test/test-eth-ledger-bridge-keyring.js @@ -1,3 +1,5 @@ +global.document = require('./document.shim') +global.window = require('./window.shim') const chai = require('chai') const spies = require('chai-spies') const {expect} = chai @@ -44,7 +46,7 @@ describe('LedgerBridgeKeyring', function () { let keyring - beforeEach(async function () { + beforeEach(() => { keyring = new LedgerBridgeKeyring() keyring.hdk = fakeHdKey }) @@ -64,7 +66,7 @@ describe('LedgerBridgeKeyring', function () { describe('constructor', function () { it('constructs', function (done) { - const t = new LedgerBridgeKeyring({hdPath: `m/44'/60'/0'/0`}) + const t = new LedgerBridgeKeyring({hdPath: `44'/60'/0'`}) assert.equal(typeof t, 'object') t.getAccounts() .then(accounts => { @@ -78,8 +80,8 @@ describe('LedgerBridgeKeyring', function () { it('serializes an instance', function (done) { keyring.serialize() .then((output) => { - assert.equal(output.page, 0) - assert.equal(output.hdPath, `m/44'/60'/0'/0`) + assert.equal(output.bridgeUrl, 'https://metamask.github.io/eth-ledger-bridge-keyring') + assert.equal(output.hdPath, `44'/60'/0'`) assert.equal(Array.isArray(output.accounts), true) assert.equal(output.accounts.length, 0) done() @@ -90,7 +92,7 @@ describe('LedgerBridgeKeyring', function () { describe('deserialize', function () { it('serializes what it deserializes', function (done) { - const someHdPath = `m/44'/60'/0'/1` + const someHdPath = `44'/60'/0'/1` keyring.deserialize({ page: 10, @@ -101,7 +103,7 @@ describe('LedgerBridgeKeyring', function () { return keyring.serialize() }).then((serialized) => { assert.equal(serialized.accounts.length, 0, 'restores 0 accounts') - assert.equal(serialized.page, 10, 'restores page') + assert.equal(serialized.bridgeUrl, 'https://metamask.github.io/eth-ledger-bridge-keyring', 'restores bridgeUrl') assert.equal(serialized.hdPath, someHdPath, 'restores hdPath') done() }) @@ -121,20 +123,6 @@ describe('LedgerBridgeKeyring', function () { done() }) }) - - /*chai.spy.on(keyring, 'sendMessage') - - it('should call keyring.sendMessage if we dont have a public key', async function () { - keyring.hdk = new HDKey() - try { - await keyring.unlock() - } catch (e) { - // because we're trying to open the trezor popup in node - // it will throw an exception - } finally { - expect(keyring.sendMessage).to.have.been.called() - } - })*/ }) describe('setAccountToUnlock', function () { @@ -234,19 +222,6 @@ describe('LedgerBridgeKeyring', function () { expect(accounts[3].address, fakeAccounts[3]) expect(accounts[4].address, fakeAccounts[4]) }) - - it('should be able to advance to the next page', async function () { - // manually advance 1 page - await keyring.getNextPage() - - const accounts = await keyring.getNextPage() - expect(accounts.length, keyring.perPage) - expect(accounts[0].address, fakeAccounts[keyring.perPage + 0]) - expect(accounts[1].address, fakeAccounts[keyring.perPage + 1]) - expect(accounts[2].address, fakeAccounts[keyring.perPage + 2]) - expect(accounts[3].address, fakeAccounts[keyring.perPage + 3]) - expect(accounts[4].address, fakeAccounts[keyring.perPage + 4]) - }) }) describe('getPreviousPage', function () { @@ -297,21 +272,7 @@ describe('LedgerBridgeKeyring', function () { const expectedAccount = fakeAccounts[accountIndex] assert.equal(accounts[0], expectedAccount) }) - }) - - describe('signTransaction', function () { - it('should call TrezorConnect.ethereumSignTransaction', function (done) { - - chai.spy.on(TrezorConnect, 'ethereumSignTransaction') - - keyring.signTransaction(fakeAccounts[0], fakeTx).catch(e => { - // we expect this to be rejected because - // we are trying to open a popup from node - expect(TrezorConnect.ethereumSignTransaction).to.have.been.called() - done() - }) - }) - }) + }) describe('signMessage', function () { it('should throw an error because it is not supported', function () { @@ -321,19 +282,6 @@ describe('LedgerBridgeKeyring', function () { }) }) - describe('signPersonalMessage', function () { - it('should call TrezorConnect.ethereumSignMessage', function (done) { - - chai.spy.on(TrezorConnect, 'ethereumSignMessage') - keyring.signPersonalMessage(fakeAccounts[0], 'some msg').catch(e => { - // we expect this to be rejected because - // we are trying to open a popup from node - expect(TrezorConnect.ethereumSignMessage).to.have.been.called() - done() - }) - }) - }) - describe('signTypedData', function () { it('should throw an error because it is not supported', function () { expect(_ => { @@ -366,4 +314,31 @@ describe('LedgerBridgeKeyring', function () { }) }) + describe('signTransaction', function () { + it('should call window.addEventListener', function (done) { + + chai.spy.on(window, 'addEventListener') + setTimeout(_ => { + keyring.signTransaction(fakeAccounts[0], fakeTx) + expect(window.addEventListener).to.have.been.calledWith('message') + }, 1800) + chai.spy.restore(window, 'addEventListener') + done() + + }) + }) + + describe('signPersonalMessage', function () { + it('should call TrezorConnect.ethereumSignMessage', function (done) { + + chai.spy.on(window, 'addEventListener') + setTimeout(_ => { + keyring.signPersonalMessage(fakeAccounts[0], 'some msg') + expect(window.addEventListener).to.have.been.calledWith('message') + }) + chai.spy.restore(window, 'addEventListener') + done() + }) + }) + }) diff --git a/test/window.shim.js b/test/window.shim.js new file mode 100644 index 00000000..52bfd35c --- /dev/null +++ b/test/window.shim.js @@ -0,0 +1,13 @@ +try { + module.exports = window || { + addEventListener: _ => { + return false + }, + } +} catch (e) { + module.exports = { + addEventListener: _ => { + return false + }, + } +}