From bceacaca29034686e4ee26adbbc836ca6361ceaf Mon Sep 17 00:00:00 2001 From: Overtorment Date: Thu, 16 Apr 2020 15:40:23 +0100 Subject: [PATCH] ADD: support for Electrum Seed format, legacy (closes #954) --- class/abstract-hd-electrum-wallet.js | 15 +++++ class/abstract-wallet.js | 4 ++ class/hd-legacy-electrum-seed-p2pkh-wallet.js | 65 +++++++++++++++++++ class/index.js | 1 + class/walletImport.js | 9 +++ package-lock.json | 10 +++ package.json | 3 + .../hd-segwit-bech32-wallet.test.js | 15 +++++ ...-legacy-electrum-seed-p2pkh-wallet.test.js | 30 +++++++++ 9 files changed, 152 insertions(+) create mode 100644 class/hd-legacy-electrum-seed-p2pkh-wallet.js create mode 100644 tests/unit/hd-legacy-electrum-seed-p2pkh-wallet.test.js diff --git a/class/abstract-hd-electrum-wallet.js b/class/abstract-hd-electrum-wallet.js index 567b81c9..b9717b8f 100644 --- a/class/abstract-hd-electrum-wallet.js +++ b/class/abstract-hd-electrum-wallet.js @@ -12,6 +12,9 @@ const reverse = require('buffer-reverse'); const { RNRandomBytes } = NativeModules; +/** + * Electrum - means that it utilizes Electrum protocol for blockchain data + */ export class AbstractHDElectrumWallet extends AbstractHDWallet { static type = 'abstract'; static typeReadable = 'abstract'; @@ -986,4 +989,16 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { if (broadcast.indexOf('successfully') !== -1) return true; return broadcast.length === 64; // this means return string is txid (precise length), so it was broadcasted ok } + + /** + * Probes zero address in external hierarchy for transactions, if there are any returns TRUE. + * Zero address is a pretty good indicator, since its a first one to fund the wallet. How can you use the wallet and + * not fund it first? + * + * @returns {Promise} + */ + async wasEverUsed() { + let txs = await BlueElectrum.getTransactionsByAddress(this._getExternalAddressByIndex(0)); + return txs.length > 0; + } } diff --git a/class/abstract-wallet.js b/class/abstract-wallet.js index f795a188..cf5e53e2 100644 --- a/class/abstract-wallet.js +++ b/class/abstract-wallet.js @@ -171,4 +171,8 @@ export class AbstractWallet { useWithHardwareWalletEnabled() { return false; } + + async wasEverUsed() { + throw new Error('Not implemented'); + } } diff --git a/class/hd-legacy-electrum-seed-p2pkh-wallet.js b/class/hd-legacy-electrum-seed-p2pkh-wallet.js new file mode 100644 index 00000000..789b15c7 --- /dev/null +++ b/class/hd-legacy-electrum-seed-p2pkh-wallet.js @@ -0,0 +1,65 @@ +import { HDLegacyP2PKHWallet } from './'; + +const bitcoin = require('bitcoinjs-lib'); +const mn = require('electrum-mnemonic'); + +/** + * ElectrumSeed means that instead of BIP39 seed format it works with the format invented by Electrum wallet. Otherwise + * its a regular HD wallet that has all the properties of parent class. + * + * @see https://electrum.readthedocs.io/en/latest/seedphrase.html + */ +export class HDLegacyElectrumSeedP2PKHWallet extends HDLegacyP2PKHWallet { + static type = 'HDlegacyElectrumSeedP2PKH'; + static typeReadable = 'HD Legacy Electrum (BIP32 P2PKH)'; + + validateMnemonic() { + try { + mn.mnemonicToSeedSync(this.secret); + return true; + } catch (_) { + return false; + } + } + + getXpub() { + if (this._xpub) { + return this._xpub; // cache hit + } + const root = bitcoin.bip32.fromSeed(mn.mnemonicToSeedSync(this.secret)); + this._xpub = root.toBase58(); + return this._xpub; + } + + _getInternalAddressByIndex(index) { + index = index * 1; // cast to int + if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit + + const node = bitcoin.bip32.fromBase58(this.getXpub()); + const address = bitcoin.payments.p2pkh({ + pubkey: node.derive(1).derive(index).publicKey, + }).address; + + return (this.internal_addresses_cache[index] = address); + } + + _getExternalAddressByIndex(index) { + index = index * 1; // cast to int + if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit + + const node = bitcoin.bip32.fromBase58(this.getXpub()); + const address = bitcoin.payments.p2pkh({ + pubkey: node.derive(0).derive(index).publicKey, + }).address; + + return (this.external_addresses_cache[index] = address); + } + + _getWIFByIndex(internal, index) { + const root = bitcoin.bip32.fromSeed(mn.mnemonicToSeedSync(this.secret)); + const path = `m/${internal ? 1 : 0}/${index}`; + const child = root.derivePath(path); + + return child.toWIF(); + } +} diff --git a/class/index.js b/class/index.js index 1ec5ece2..45793cd1 100644 --- a/class/index.js +++ b/class/index.js @@ -13,3 +13,4 @@ export * from './abstract-hd-wallet'; export * from './hd-segwit-bech32-wallet'; export * from './hd-segwit-bech32-transaction'; export * from './placeholder-wallet'; +export * from './hd-legacy-electrum-seed-p2pkh-wallet'; diff --git a/class/walletImport.js b/class/walletImport.js index f079b9ac..d2ddbea4 100644 --- a/class/walletImport.js +++ b/class/walletImport.js @@ -10,6 +10,7 @@ import { LightningCustodianWallet, PlaceholderWallet, SegwitBech32Wallet, + HDLegacyElectrumSeedP2PKHWallet, } from '../class'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; const EV = require('../events'); @@ -97,6 +98,7 @@ export default class WalletImport { // 1. check if its HDSegwitP2SHWallet (BIP49) // 2. check if its HDLegacyP2PKHWallet (BIP44) // 3. check if its HDLegacyBreadwalletWallet (no BIP, just "m/0") + // 3.1 check HD Electrum legacy // 4. check if its Segwit WIF (P2SH) // 5. check if its Legacy WIF // 6. check if its address (watch-only wallet) @@ -202,6 +204,13 @@ export default class WalletImport { } } + let hdElectrumSeedLegacy = new HDLegacyElectrumSeedP2PKHWallet(); + hdElectrumSeedLegacy.setSecret(importText); + if (await hdElectrumSeedLegacy.wasEverUsed()) { + // not fetching txs or balances, fuck it, yolo, life is too short + return WalletImport._saveWallet(hdElectrumSeedLegacy); + } + let hd2 = new HDSegwitP2SHWallet(); hd2.setSecret(importText); if (hd2.validateMnemonic()) { diff --git a/package-lock.json b/package-lock.json index 92e3150b..3ff871b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4460,6 +4460,16 @@ "version": "git+https://github.com/BlueWallet/rn-electrum-client.git#2a5bb11dd9a8d89f328049d9ed59bce49d88a15d", "from": "git+https://github.com/BlueWallet/rn-electrum-client.git#2a5bb11dd9a8d89f328049d9ed59bce49d88a15d" }, + "electrum-mnemonic": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/electrum-mnemonic/-/electrum-mnemonic-1.0.5.tgz", + "integrity": "sha512-Wq5vFTTZzHu6w6GSxSNdSgEouqK5x7nky7XGQOnIvS5gx2on7EHysAqB6sSGOhhD5MnoT8nn9LhZPSBrDWkllw==", + "requires": { + "create-hmac": "^1.1.7", + "pbkdf2": "^3.0.17", + "randombytes": "^2.1.0" + } + }, "elliptic": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", diff --git a/package.json b/package.json index 159a1a16..9c25f67c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "e2e:debug": "(test -f android/app/build/outputs/apk/debug/app-debug.apk && test -f android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk) || detox build -c android.emu.debug; detox test -c android.emu.debug", "lint": "./node_modules/.bin/eslint *.js screen/**/*.js screen/ class/ models/ loc/ tests/integration/ tests/e2e/ tests/unit/", "lint:fix": "./node_modules/.bin/eslint *.js screen/**/*.js screen/ class/ models/ loc/ tests/integration/ tests/e2e/ tests/unit/ --fix", + "lint:quickfix": "git status --porcelain | grep -v '\\.json' | grep '\\.js' --color=never | awk '{print $2}' | xargs ./node_modules/.bin/eslint --fix; exit 0", "unit": "node node_modules/jest/bin/jest.js tests/unit/*" }, "jest": { @@ -79,6 +80,7 @@ "dayjs": "1.8.23", "ecurve": "1.0.6", "electrum-client": "git+https://github.com/BlueWallet/rn-electrum-client.git#2a5bb11dd9a8d89f328049d9ed59bce49d88a15d", + "electrum-mnemonic": "1.0.5", "eslint-config-prettier": "6.10.0", "eslint-config-standard": "12.0.0", "eslint-config-standard-react": "7.0.2", @@ -90,6 +92,7 @@ "lottie-react-native": "3.1.1", "node-libs-react-native": "1.0.3", "path-browserify": "1.0.0", + "pbkdf2": "3.0.17", "prettier": "1.19.1", "process": "0.11.10", "prop-types": "15.7.2", diff --git a/tests/integration/hd-segwit-bech32-wallet.test.js b/tests/integration/hd-segwit-bech32-wallet.test.js index 4e5a5b74..b4d16414 100644 --- a/tests/integration/hd-segwit-bech32-wallet.test.js +++ b/tests/integration/hd-segwit-bech32-wallet.test.js @@ -235,4 +235,19 @@ describe('Bech32 Segwit HD (BIP84)', () => { assert.strictEqual(totalInput - totalOutput, fee); assert.strictEqual(outputs[outputs.length - 1].address, changeAddress); }); + + it('wasEverUsed() works', async () => { + if (!process.env.HD_MNEMONIC) { + console.error('process.env.HD_MNEMONIC not set, skipped'); + return; + } + + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(process.env.HD_MNEMONIC); + assert.ok(await hd.wasEverUsed()); + + hd = new HDSegwitBech32Wallet(); + await hd.generate(); + assert.ok(!(await hd.wasEverUsed()), hd.getSecret()); + }); }); diff --git a/tests/unit/hd-legacy-electrum-seed-p2pkh-wallet.test.js b/tests/unit/hd-legacy-electrum-seed-p2pkh-wallet.test.js new file mode 100644 index 00000000..a2eadda9 --- /dev/null +++ b/tests/unit/hd-legacy-electrum-seed-p2pkh-wallet.test.js @@ -0,0 +1,30 @@ +/* global describe, it */ +import { HDLegacyElectrumSeedP2PKHWallet } from '../../class'; +let assert = require('assert'); + +describe('HDLegacyElectrumSeedP2PKHWallet', () => { + it('can import mnemonics and generate addresses and WIFs', async function() { + if (!process.env.HD_ELECTRUM_SEED_LEGACY) { + console.error('process.env.HD_ELECTRUM_SEED_LEGACY not set, skipped'); + return; + } + let hd = new HDLegacyElectrumSeedP2PKHWallet(); + hd.setSecret(process.env.HD_ELECTRUM_SEED_LEGACY); + assert.ok(hd.validateMnemonic()); + + let address = hd._getExternalAddressByIndex(0); + assert.strictEqual(address, '1Ca9ZVshGdKiiMEMNTG1bYqbifYMZMwV8'); + + address = hd._getInternalAddressByIndex(0); + assert.strictEqual(address, '1JygAvTQS9haAYgRfPSdHgmXd3syjB8Fnp'); + + let wif = hd._getExternalWIFByIndex(0); + assert.strictEqual(wif, 'KxGPz9dyib26p6bL2vQPvBPHBMA8iHVqEetg3x5XA4Rk1trZ11Kz'); + + wif = hd._getInternalWIFByIndex(0); + assert.strictEqual(wif, 'L52d26QmYGW8ctHo1omM5fZeJMgaonSkEWCGpnEekNvkVUoqTsNF'); + + hd.setSecret('bs'); + assert.ok(!hd.validateMnemonic()); + }); +});