diff --git a/.flowconfig b/.flowconfig index 9bded78be..ebf6585f6 100644 --- a/.flowconfig +++ b/.flowconfig @@ -67,4 +67,4 @@ suppress_comment=\\(.\\|\n\\)*\\$FlowFixedInNextDeploy suppress_comment=\\(.\\|\n\\)*\\$FlowExpectedError [version] -^0.86.0 +^0.97.0 diff --git a/.gitignore b/.gitignore index 04555cfac..4e6fc7f1e 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,6 @@ buck-out/ #BlueWallet release-notes.json -release-notes.txt \ No newline at end of file +release-notes.txt + +ios/Pods/ diff --git a/App.js b/App.js index 4fd5ae01f..923673381 100644 --- a/App.js +++ b/App.js @@ -1,11 +1,14 @@ import React from 'react'; import { Linking, AppState, Clipboard, StyleSheet, KeyboardAvoidingView, Platform, View } from 'react-native'; +import AsyncStorage from '@react-native-community/async-storage'; import Modal from 'react-native-modal'; import { NavigationActions } from 'react-navigation'; import MainBottomTabs from './MainBottomTabs'; import NavigationService from './NavigationService'; import { BlueTextCentered, BlueButton } from './BlueComponents'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; +import url from 'url'; +import { AppStorage, LightningCustodianWallet } from './class'; const bitcoin = require('bitcoinjs-lib'); const bitcoinModalString = 'Bitcoin address'; const lightningModalString = 'Lightning Invoice'; @@ -56,7 +59,13 @@ export default class App extends React.Component { hasSchema(schemaString) { if (typeof schemaString !== 'string' || schemaString.length <= 0) return false; const lowercaseString = schemaString.trim().toLowerCase(); - return lowercaseString.startsWith('bitcoin:') || lowercaseString.startsWith('lightning:'); + return ( + lowercaseString.startsWith('bitcoin:') || + lowercaseString.startsWith('lightning:') || + lowercaseString.startsWith('blue:') || + lowercaseString.startsWith('bluewallet:') || + lowercaseString.startsWith('lapp:') + ); } isBitcoinAddress(address) { @@ -86,6 +95,12 @@ export default class App extends React.Component { return isValidLightningInvoice; } + isSafelloRedirect(event) { + let urlObject = url.parse(event.url, true) // eslint-disable-line + + return !!urlObject.query['safello-state-token']; + } + handleOpenURL = event => { if (event.url === null) { return; @@ -113,13 +128,95 @@ export default class App extends React.Component { }, }), ); + } else if (this.isSafelloRedirect(event)) { + let urlObject = url.parse(event.url, true) // eslint-disable-line + + const safelloStateToken = urlObject.query['safello-state-token']; + + this.navigator && + this.navigator.dispatch( + NavigationActions.navigate({ + routeName: 'BuyBitcoin', + params: { + uri: event.url, + safelloStateToken, + }, + }), + ); + } else { + let urlObject = url.parse(event.url, true); // eslint-disable-line + console.log('parsed', urlObject); + (async () => { + if (urlObject.protocol === 'bluewallet:' || urlObject.protocol === 'lapp:' || urlObject.protocol === 'blue:') { + switch (urlObject.host) { + case 'openlappbrowser': + console.log('opening LAPP', urlObject.query.url); + // searching for LN wallet: + let haveLnWallet = false; + for (let w of BlueApp.getWallets()) { + if (w.type === LightningCustodianWallet.type) { + haveLnWallet = true; + } + } + + if (!haveLnWallet) { + // need to create one + let w = new LightningCustodianWallet(); + w.setLabel(this.state.label || w.typeReadable); + + try { + let lndhub = await AsyncStorage.getItem(AppStorage.LNDHUB); + if (lndhub) { + w.setBaseURI(lndhub); + w.init(); + } + await w.createAccount(); + await w.authorize(); + } catch (Err) { + // giving up, not doing anything + return; + } + BlueApp.wallets.push(w); + await BlueApp.saveToDisk(); + } + + // now, opening lapp browser and navigating it to URL. + // looking for a LN wallet: + let lnWallet; + for (let w of BlueApp.getWallets()) { + if (w.type === LightningCustodianWallet.type) { + lnWallet = w; + break; + } + } + + if (!lnWallet) { + // something went wrong + return; + } + + this.navigator && + this.navigator.dispatch( + NavigationActions.navigate({ + routeName: 'LappBrowser', + params: { + fromSecret: lnWallet.getSecret(), + fromWallet: lnWallet, + url: urlObject.query.url, + }, + }), + ); + break; + } + } + })(); } }; renderClipboardContentModal = () => { return ( ReactNativeHapticFeedback.trigger('impactLight', false)} + onModalShow={() => ReactNativeHapticFeedback.trigger('impactLight', { ignoreAndroidSystemSettings: false })} isVisible={this.state.isClipboardContentModalVisible} style={styles.bottomModal} onBackdropPress={() => { diff --git a/App.test.js b/App.test.js index 971c583f2..ee68c0a57 100644 --- a/App.test.js +++ b/App.test.js @@ -5,13 +5,11 @@ import TestRenderer from 'react-test-renderer'; import Settings from './screen/settings/settings'; import Selftest from './screen/selftest'; import { BlueHeader } from './BlueComponents'; -import MockStorage from './MockStorage'; import { FiatUnit } from './models/fiatUnit'; +import AsyncStorage from '@react-native-community/async-storage'; global.crypto = require('crypto'); // shall be used by tests under nodejs CLI, but not in RN environment let assert = require('assert'); jest.mock('react-native-qrcode-svg', () => 'Video'); -const AsyncStorage = new MockStorage(); -jest.setMock('AsyncStorage', AsyncStorage); jest.useFakeTimers(); jest.mock('Picker', () => { // eslint-disable-next-line import/no-unresolved @@ -105,7 +103,6 @@ it('Selftest work', () => { }); it('Appstorage - loadFromDisk works', async () => { - AsyncStorage.storageCache = {}; // cleanup from other tests /** @type {AppStorage} */ let Storage = new AppStorage(); let w = new SegwitP2SHWallet(); @@ -125,16 +122,14 @@ it('Appstorage - loadFromDisk works', async () => { // emulating encrypted storage (and testing flag) - AsyncStorage.storageCache.data = false; - AsyncStorage.storageCache.data_encrypted = '1'; // flag + await AsyncStorage.setItem('data', false); + await AsyncStorage.setItem(AppStorage.FLAG_ENCRYPTED, '1'); let Storage3 = new AppStorage(); isEncrypted = await Storage3.storageIsEncrypted(); assert.ok(isEncrypted); }); it('Appstorage - encryptStorage & load encrypted storage works', async () => { - AsyncStorage.storageCache = {}; // cleanup from other tests - /** @type {AppStorage} */ let Storage = new AppStorage(); let w = new SegwitP2SHWallet(); @@ -228,6 +223,12 @@ it('Wallet can fetch UTXO', async () => { assert.ok(w.utxo.length > 0, 'unexpected empty UTXO'); }); +it('SegwitP2SHWallet can generate segwit P2SH address from WIF', () => { + let l = new SegwitP2SHWallet(); + l.setSecret('Kxr9tQED9H44gCmp6HAdmemAzU3n84H3dGkuWTKvE23JgHMW8gct'); + assert.ok(l.getAddress() === '34AgLJhwXrvmkZS1o5TrcdeevMt22Nar53', 'expected ' + l.getAddress()); +}); + it('Wallet can fetch balance', async () => { jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; let w = new LegacyWallet(); @@ -236,7 +237,7 @@ it('Wallet can fetch balance', async () => { assert.ok(w.getUnconfirmedBalance() === 0); assert.ok(w._lastBalanceFetch === 0); await w.fetchBalance(); - assert.ok(w.getBalance() === 0.18262); + assert.ok(w.getBalance() === 18262000); assert.ok(w.getUnconfirmedBalance() === 0); assert.ok(w._lastBalanceFetch > 0); }); @@ -302,19 +303,18 @@ it('Wallet can fetch TXs', async () => { describe('currency', () => { it('fetches exchange rate and saves to AsyncStorage', async () => { jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; - AsyncStorage.storageCache = {}; // cleanup from other tests let currency = require('./currency'); await currency.startUpdater(); - let cur = AsyncStorage.storageCache[AppStorage.EXCHANGE_RATES]; + let cur = await AsyncStorage.getItem(AppStorage.EXCHANGE_RATES); cur = JSON.parse(cur); assert.ok(Number.isInteger(cur[currency.STRUCT.LAST_UPDATED])); assert.ok(cur[currency.STRUCT.LAST_UPDATED] > 0); assert.ok(cur['BTC_USD'] > 0); // now, setting other currency as default - AsyncStorage.storageCache[AppStorage.PREFERRED_CURRENCY] = JSON.stringify(FiatUnit.JPY); + await AsyncStorage.setItem(AppStorage.PREFERRED_CURRENCY, JSON.stringify(FiatUnit.JPY)); await currency.startUpdater(); - cur = JSON.parse(AsyncStorage.storageCache[AppStorage.EXCHANGE_RATES]); + cur = JSON.parse(await AsyncStorage.getItem(AppStorage.EXCHANGE_RATES)); assert.ok(cur['BTC_JPY'] > 0); // now setting with a proper setter @@ -322,7 +322,7 @@ describe('currency', () => { await currency.startUpdater(); let preferred = await currency.getPreferredCurrency(); assert.strictEqual(preferred.endPointKey, 'EUR'); - cur = JSON.parse(AsyncStorage.storageCache[AppStorage.EXCHANGE_RATES]); + cur = JSON.parse(await AsyncStorage.getItem(AppStorage.EXCHANGE_RATES)); assert.ok(cur['BTC_EUR'] > 0); }); }); diff --git a/App2.test.js b/App2.test.js index 1a8a11ab0..6d3795594 100644 --- a/App2.test.js +++ b/App2.test.js @@ -1,5 +1,4 @@ -/* global it, describe, jasmine */ -import { WatchOnlyWallet } from './class'; +/* global it, jasmine */ let assert = require('assert'); it('bip38 decodes', async () => { @@ -37,50 +36,3 @@ it('bip38 decodes slow', async () => { 'KxqRtpd9vFju297ACPKHrGkgXuberTveZPXbRDiQ3MXZycSQYtjc', ); }); - -describe('Watch only wallet', () => { - it('can fetch balance', async () => { - let w = new WatchOnlyWallet(); - w.setSecret('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'); - await w.fetchBalance(); - assert.ok(w.getBalance() > 16); - }); - - it('can fetch tx', async () => { - jasmine.DEFAULT_TIMEOUT_INTERVAL = 150 * 1000; - let w = new WatchOnlyWallet(); - - w.setSecret('167zK5iZrs1U6piDqubD3FjRqUTM2CZnb8'); - await w.fetchTransactions(); - assert.strictEqual(w.getTransactions().length, 233); - - w = new WatchOnlyWallet(); - w.setSecret('1BiJW1jyUaxcJp2JWwbPLPzB1toPNWTFJV'); - await w.fetchTransactions(); - assert.strictEqual(w.getTransactions().length, 2); - - // fetch again and make sure no duplicates - await w.fetchTransactions(); - assert.strictEqual(w.getTransactions().length, 2); - }); - - it('can fetch complex TXs', async () => { - jasmine.DEFAULT_TIMEOUT_INTERVAL = 120 * 1000; - let w = new WatchOnlyWallet(); - w.setSecret('3NLnALo49CFEF4tCRhCvz45ySSfz3UktZC'); - await w.fetchTransactions(); - for (let tx of w.getTransactions()) { - assert.ok(tx.value, 'incorrect tx.value'); - } - }); - - it('can validate address', async () => { - let w = new WatchOnlyWallet(); - w.setSecret('12eQ9m4sgAwTSQoNXkRABKhCXCsjm2jdVG'); - assert.ok(w.valid()); - w.setSecret('3BDsBDxDimYgNZzsqszNZobqQq3yeUoJf2'); - assert.ok(w.valid()); - w.setSecret('not valid'); - assert.ok(!w.valid()); - }); -}); diff --git a/BlueApp.js b/BlueApp.js index 99feb0958..ae8afb738 100644 --- a/BlueApp.js +++ b/BlueApp.js @@ -10,7 +10,7 @@ let A = require('./analytics'); let BlueElectrum = require('./BlueElectrum'); // eslint-disable-line /** @type {AppStorage} */ -let BlueApp = new AppStorage(); +const BlueApp = new AppStorage(); async function startAndDecrypt(retry) { console.log('startAndDecrypt'); diff --git a/BlueComponents.js b/BlueComponents.js index 9b9604384..b29756b3b 100644 --- a/BlueComponents.js +++ b/BlueComponents.js @@ -25,7 +25,6 @@ import { import LinearGradient from 'react-native-linear-gradient'; import { LightningCustodianWallet } from './class'; import Carousel from 'react-native-snap-carousel'; -import DeviceInfo from 'react-native-device-info'; import { BitcoinUnit } from './models/bitcoinUnits'; import NavigationService from './NavigationService'; import ImagePicker from 'react-native-image-picker'; @@ -36,6 +35,7 @@ let loc = require('./loc/'); let BlueApp = require('./BlueApp'); const { height, width } = Dimensions.get('window'); const aspectRatio = height / width; +const BigNumber = require('bignumber.js'); let isIpad; if (aspectRatio > 1.6) { isIpad = false; @@ -93,32 +93,26 @@ export class BitcoinButton extends Component { - - - - {loc.wallets.add.bitcoin} - + + {loc.wallets.add.bitcoin} + ); @@ -137,32 +131,26 @@ export class LightningButton extends Component { - - - - {loc.wallets.add.lightning} - + + {loc.wallets.add.lightning} + ); @@ -241,6 +229,14 @@ export class BlueCopyTextToClipboard extends Component { this.state = { hasTappedText: false, address: props.text }; } + static getDerivedStateFromProps(props, state) { + if (state.hasTappedText) { + return { hasTappedText: state.hasTappedText, address: state.address }; + } else { + return { hasTappedText: state.hasTappedText, address: props.text }; + } + } + copyToClipboard = () => { this.setState({ hasTappedText: true }, () => { Clipboard.setString(this.props.text); @@ -404,29 +400,6 @@ export class BlueFormMultiInput extends Component { } } -export class BlueFormInputAddress extends Component { - render() { - return ( - - ); - } -} - export class BlueHeader extends Component { render() { return ( @@ -560,13 +533,6 @@ export class is { static ipad() { return isIpad; } - - static iphone8() { - if (Platform.OS !== 'ios') { - return false; - } - return DeviceInfo.getDeviceId() === 'iPhone10,4'; - } } export class BlueSpacing20 extends Component { @@ -575,6 +541,12 @@ export class BlueSpacing20 extends Component { } } +export class BlueSpacing10 extends Component { + render() { + return ; + } +} + export class BlueList extends Component { render() { return ( @@ -1733,7 +1705,7 @@ export class BlueAddressInput extends Component { export class BlueBitcoinAmount extends Component { static propTypes = { isLoading: PropTypes.bool, - amount: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + amount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), onChangeText: PropTypes.func, disabled: PropTypes.bool, unit: PropTypes.string, @@ -1744,8 +1716,15 @@ export class BlueBitcoinAmount extends Component { }; render() { - const amount = typeof this.props.amount === 'number' ? this.props.amount.toString() : this.props.amount; - + const amount = this.props.amount || 0; + let localCurrency = loc.formatBalanceWithoutSuffix(amount, BitcoinUnit.LOCAL_CURRENCY, false); + if (this.props.unit === BitcoinUnit.BTC) { + let sat = new BigNumber(amount); + sat = sat.multipliedBy(100000000).toString(); + localCurrency = loc.formatBalanceWithoutSuffix(sat, BitcoinUnit.LOCAL_CURRENCY, false); + } else { + localCurrency = loc.formatBalanceWithoutSuffix(amount.toString(), BitcoinUnit.LOCAL_CURRENCY, false); + } return ( this.textInput.focus()}> @@ -1788,13 +1767,7 @@ export class BlueBitcoinAmount extends Component { - - {loc.formatBalance( - this.props.unit === BitcoinUnit.BTC ? amount || 0 : loc.formatBalanceWithoutSuffix(amount || 0, BitcoinUnit.BTC, false), - BitcoinUnit.LOCAL_CURRENCY, - false, - )} - + {localCurrency} diff --git a/BlueElectrum.js b/BlueElectrum.js index fc3995819..ea6737950 100644 --- a/BlueElectrum.js +++ b/BlueElectrum.js @@ -1,20 +1,25 @@ -import { AsyncStorage } from 'react-native'; +import AsyncStorage from '@react-native-community/async-storage'; +import { SegwitBech32Wallet } from './class'; const ElectrumClient = require('electrum-client'); let bitcoin = require('bitcoinjs-lib'); let reverse = require('buffer-reverse'); const storageKey = 'ELECTRUM_PEERS'; -const defaultPeer = { host: 'electrum.coinucopia.io', tcp: 50001 }; +const defaultPeer = { host: 'electrum1.bluewallet.io', tcp: '50001' }; const hardcodedPeers = [ - { host: 'noveltybobble.coinjoined.com', tcp: '50001' }, - { host: 'electrum.be', tcp: '50001' }, + // { host: 'noveltybobble.coinjoined.com', tcp: '50001' }, // down + // { host: 'electrum.be', tcp: '50001' }, // { host: 'node.ispol.sk', tcp: '50001' }, // down - { host: '139.162.14.142', tcp: '50001' }, + // { host: '139.162.14.142', tcp: '50001' }, // { host: 'electrum.coinucopia.io', tcp: '50001' }, // SLOW - { host: 'Bitkoins.nl', tcp: '50001' }, - { host: 'fullnode.coinkite.com', tcp: '50001' }, + // { host: 'Bitkoins.nl', tcp: '50001' }, // down + // { host: 'fullnode.coinkite.com', tcp: '50001' }, // { host: 'preperfect.eleCTruMioUS.com', tcp: '50001' }, // down { host: 'electrum1.bluewallet.io', tcp: '50001' }, + { host: 'electrum1.bluewallet.io', tcp: '50001' }, // 2x weight + { host: 'electrum2.bluewallet.io', tcp: '50001' }, + { host: 'electrum3.bluewallet.io', tcp: '50001' }, + { host: 'electrum3.bluewallet.io', tcp: '50001' }, // 2x weight ]; let mainClient = false; @@ -26,7 +31,7 @@ async function connectMain() { console.log('begin connection:', JSON.stringify(usingPeer)); mainClient = new ElectrumClient(usingPeer.tcp, usingPeer.host, 'tcp'); await mainClient.connect(); - const ver = await mainClient.server_version('2.7.11', '1.2'); + const ver = await mainClient.server_version('2.7.11', '1.4'); let peers = await mainClient.serverPeers_subscribe(); if (peers && peers.length > 0) { console.log('connected to ', ver); @@ -35,7 +40,7 @@ async function connectMain() { } } catch (e) { mainConnected = false; - console.log('bad connection:', JSON.stringify(usingPeer)); + console.log('bad connection:', JSON.stringify(usingPeer), e); } if (!mainConnected) { @@ -43,7 +48,7 @@ async function connectMain() { mainClient.keepAlive = () => {}; // dirty hack to make it stop reconnecting mainClient.reconnect = () => {}; // dirty hack to make it stop reconnecting mainClient.close(); - setTimeout(connectMain, 5000); + setTimeout(connectMain, 500); } } @@ -118,23 +123,106 @@ async function getTransactionsByAddress(address) { return history; } +async function getTransactionsFullByAddress(address) { + let txs = await this.getTransactionsByAddress(address); + let ret = []; + for (let tx of txs) { + let full = await mainClient.blockchainTransaction_get(tx.tx_hash, true); + full.address = address; + for (let input of full.vin) { + input.address = SegwitBech32Wallet.witnessToAddress(input.txinwitness[1]); + input.addresses = [input.address]; + // now we need to fetch previous TX where this VIN became an output, so we can see its amount + let prevTxForVin = await mainClient.blockchainTransaction_get(input.txid, true); + if (prevTxForVin && prevTxForVin.vout && prevTxForVin.vout[input.vout]) { + input.value = prevTxForVin.vout[input.vout].value; + } + } + + for (let output of full.vout) { + if (output.scriptPubKey && output.scriptPubKey.addresses) output.addresses = output.scriptPubKey.addresses; + } + full.inputs = full.vin; + full.outputs = full.vout; + delete full.vin; + delete full.vout; + delete full.hex; // compact + delete full.hash; // compact + ret.push(full); + } + + return ret; +} + /** * * @param addresses {Array} - * @returns {Promise<{balance: number, unconfirmed_balance: number}>} + * @param batchsize {Number} + * @returns {Promise<{balance: number, unconfirmed_balance: number, addresses: object}>} */ -async function multiGetBalanceByAddress(addresses) { +async function multiGetBalanceByAddress(addresses, batchsize) { + batchsize = batchsize || 100; if (!mainClient) throw new Error('Electrum client is not connected'); - let balance = 0; - let unconfirmedBalance = 0; - for (let addr of addresses) { - let b = await getBalanceByAddress(addr); + let ret = { balance: 0, unconfirmed_balance: 0, addresses: {} }; - balance += b.confirmed; - unconfirmedBalance += b.unconfirmed_balance; + let chunks = splitIntoChunks(addresses, batchsize); + for (let chunk of chunks) { + let scripthashes = []; + let scripthash2addr = {}; + for (let addr of chunk) { + let script = bitcoin.address.toOutputScript(addr); + let hash = bitcoin.crypto.sha256(script); + let reversedHash = Buffer.from(reverse(hash)); + reversedHash = reversedHash.toString('hex'); + scripthashes.push(reversedHash); + scripthash2addr[reversedHash] = addr; + } + + let balances = await mainClient.blockchainScripthash_getBalanceBatch(scripthashes); + + for (let bal of balances) { + ret.balance += +bal.result.confirmed; + ret.unconfirmed_balance += +bal.result.unconfirmed; + ret.addresses[scripthash2addr[bal.param]] = bal.result; + } } - return { balance, unconfirmed_balance: unconfirmedBalance }; + return ret; +} + +async function multiGetUtxoByAddress(addresses, batchsize) { + batchsize = batchsize || 100; + if (!mainClient) throw new Error('Electrum client is not connected'); + let ret = {}; + + let chunks = splitIntoChunks(addresses, batchsize); + for (let chunk of chunks) { + let scripthashes = []; + let scripthash2addr = {}; + for (let addr of chunk) { + let script = bitcoin.address.toOutputScript(addr); + let hash = bitcoin.crypto.sha256(script); + let reversedHash = Buffer.from(reverse(hash)); + reversedHash = reversedHash.toString('hex'); + scripthashes.push(reversedHash); + scripthash2addr[reversedHash] = addr; + } + + let results = await mainClient.blockchainScripthash_listunspentBatch(scripthashes); + + for (let utxos of results) { + ret[scripthash2addr[utxos.param]] = utxos.result; + for (let utxo of ret[scripthash2addr[utxos.param]]) { + utxo.address = scripthash2addr[utxos.param]; + utxo.txId = utxo.tx_hash; + utxo.vout = utxo.tx_pos; + delete utxo.tx_pos; + delete utxo.tx_hash; + } + } + } + + return ret; } /** @@ -164,8 +252,8 @@ async function waitTillConnected() { async function estimateFees() { if (!mainClient) throw new Error('Electrum client is not connected'); const fast = await mainClient.blockchainEstimatefee(1); - const medium = await mainClient.blockchainEstimatefee(6); - const slow = await mainClient.blockchainEstimatefee(12); + const medium = await mainClient.blockchainEstimatefee(5); + const slow = await mainClient.blockchainEstimatefee(10); return { fast, medium, slow }; } @@ -182,9 +270,11 @@ async function broadcast(hex) { module.exports.getBalanceByAddress = getBalanceByAddress; module.exports.getTransactionsByAddress = getTransactionsByAddress; module.exports.multiGetBalanceByAddress = multiGetBalanceByAddress; +module.exports.getTransactionsFullByAddress = getTransactionsFullByAddress; module.exports.waitTillConnected = waitTillConnected; module.exports.estimateFees = estimateFees; module.exports.broadcast = broadcast; +module.exports.multiGetUtxoByAddress = multiGetUtxoByAddress; module.exports.forceDisconnect = () => { mainClient.keepAlive = () => {}; // dirty hack to make it stop reconnecting @@ -194,6 +284,15 @@ module.exports.forceDisconnect = () => { module.exports.hardcodedPeers = hardcodedPeers; +let splitIntoChunks = function(arr, chunkSize) { + let groups = []; + let i; + for (i = 0; i < arr.length; i += chunkSize) { + groups.push(arr.slice(i, i + chunkSize)); + } + return groups; +}; + /* diff --git a/Electrum.test.js b/Electrum.test.js index e489826da..c819a9506 100644 --- a/Electrum.test.js +++ b/Electrum.test.js @@ -2,11 +2,13 @@ global.net = require('net'); let BlueElectrum = require('./BlueElectrum'); let assert = require('assert'); +let bitcoin = require('bitcoinjs-lib'); jasmine.DEFAULT_TIMEOUT_INTERVAL = 150 * 1000; afterAll(() => { // after all tests we close socket so the test suite can actually terminate - return BlueElectrum.forceDisconnect(); + BlueElectrum.forceDisconnect(); + return new Promise(resolve => setTimeout(resolve, 10000)); // simple sleep to wait for all timeouts termination }); beforeAll(async () => { @@ -14,8 +16,8 @@ beforeAll(async () => { // while app starts up, but for tests we need to wait for it try { await BlueElectrum.waitTillConnected(); - } catch (Err) { - console.log('failed to connect to Electrum:', Err); + } catch (err) { + console.log('failed to connect to Electrum:', err); process.exit(1); } }); @@ -23,18 +25,17 @@ beforeAll(async () => { describe('Electrum', () => { it('ElectrumClient can connect and query', async () => { const ElectrumClient = require('electrum-client'); - let bitcoin = require('bitcoinjs-lib'); for (let peer of BlueElectrum.hardcodedPeers) { let mainClient = new ElectrumClient(peer.tcp, peer.host, 'tcp'); try { await mainClient.connect(); - await mainClient.server_version('2.7.11', '1.2'); + await mainClient.server_version('2.7.11', '1.4'); } catch (e) { mainClient.reconnect = mainClient.keepAlive = () => {}; // dirty hack to make it stop reconnecting mainClient.close(); - throw new Error('bad connection: ' + JSON.stringify(peer)); + throw new Error('bad connection: ' + JSON.stringify(peer) + ' ' + e.message); } let addr4elect = 'bc1qwqdg6squsna38e46795at95yu9atm8azzmyvckulcc7kytlcckxswvvzej'; @@ -52,7 +53,6 @@ describe('Electrum', () => { hash = bitcoin.crypto.sha256(script); reversedHash = Buffer.from(hash.reverse()); balance = await mainClient.blockchainScripthash_getBalance(reversedHash.toString('hex')); - assert.ok(balance.confirmed === 51432); // let peers = await mainClient.serverPeers_subscribe(); // console.log(peers); @@ -61,18 +61,77 @@ describe('Electrum', () => { } }); - it('BlueElectrum works', async function() { + it('BlueElectrum can do getBalanceByAddress()', async function() { let address = '3GCvDBAktgQQtsbN6x5DYiQCMmgZ9Yk8BK'; let balance = await BlueElectrum.getBalanceByAddress(address); assert.strictEqual(balance.confirmed, 51432); assert.strictEqual(balance.unconfirmed, 0); assert.strictEqual(balance.addr, address); + }); - let txs = await BlueElectrum.getTransactionsByAddress(address); + it('BlueElectrum can do getTransactionsByAddress()', async function() { + let txs = await BlueElectrum.getTransactionsByAddress('bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh'); assert.strictEqual(txs.length, 1); + assert.strictEqual(txs[0].tx_hash, 'ad00a92409d8982a1d7f877056dbed0c4337d2ebab70b30463e2802279fb936d'); + assert.strictEqual(txs[0].height, 563077); + }); + + it('BlueElectrum can do getTransactionsFullByAddress()', async function() { + let txs = await BlueElectrum.getTransactionsFullByAddress('bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh'); for (let tx of txs) { - assert.ok(tx.tx_hash); - assert.ok(tx.height); + assert.ok(tx.address === 'bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh'); + assert.ok(tx.txid); + assert.ok(tx.confirmations); + assert.ok(!tx.vin); + assert.ok(!tx.vout); + assert.ok(tx.inputs); + assert.ok(tx.inputs[0].addresses.length > 0); + assert.ok(tx.inputs[0].value > 0); + assert.ok(tx.outputs); + assert.ok(tx.outputs[0].value > 0); + assert.ok(tx.outputs[0].scriptPubKey); + assert.ok(tx.outputs[0].addresses.length > 0); } }); + + it('BlueElectrum can do multiGetBalanceByAddress()', async function() { + let balances = await BlueElectrum.multiGetBalanceByAddress([ + 'bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh', + 'bc1qvd6w54sydc08z3802svkxr7297ez7cusd6266p', + 'bc1qwp58x4c9e5cplsnw5096qzdkae036ug7a34x3r', + 'bc1qcg6e26vtzja0h8up5w2m7utex0fsu4v0e0e7uy', + ]); + + assert.strictEqual(balances.balance, 200000); + assert.strictEqual(balances.unconfirmed_balance, 0); + assert.strictEqual(balances.addresses['bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh'].confirmed, 50000); + assert.strictEqual(balances.addresses['bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh'].unconfirmed, 0); + assert.strictEqual(balances.addresses['bc1qvd6w54sydc08z3802svkxr7297ez7cusd6266p'].confirmed, 50000); + assert.strictEqual(balances.addresses['bc1qvd6w54sydc08z3802svkxr7297ez7cusd6266p'].unconfirmed, 0); + assert.strictEqual(balances.addresses['bc1qwp58x4c9e5cplsnw5096qzdkae036ug7a34x3r'].confirmed, 50000); + assert.strictEqual(balances.addresses['bc1qwp58x4c9e5cplsnw5096qzdkae036ug7a34x3r'].unconfirmed, 0); + assert.strictEqual(balances.addresses['bc1qcg6e26vtzja0h8up5w2m7utex0fsu4v0e0e7uy'].confirmed, 50000); + assert.strictEqual(balances.addresses['bc1qcg6e26vtzja0h8up5w2m7utex0fsu4v0e0e7uy'].unconfirmed, 0); + }); + + it('BlueElectrum can do multiGetUtxoByAddress()', async () => { + let utxos = await BlueElectrum.multiGetUtxoByAddress( + [ + 'bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh', + 'bc1qvd6w54sydc08z3802svkxr7297ez7cusd6266p', + 'bc1qwp58x4c9e5cplsnw5096qzdkae036ug7a34x3r', + 'bc1qcg6e26vtzja0h8up5w2m7utex0fsu4v0e0e7uy', + ], + 3, + ); + + assert.strictEqual(Object.keys(utxos).length, 4); + assert.strictEqual( + utxos['bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh'][0].txId, + 'ad00a92409d8982a1d7f877056dbed0c4337d2ebab70b30463e2802279fb936d', + ); + assert.strictEqual(utxos['bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh'][0].vout, 1); + assert.strictEqual(utxos['bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh'][0].value, 50000); + assert.strictEqual(utxos['bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh'][0].address, 'bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh'); + }); }); diff --git a/HDBech32Wallet.test.js b/HDBech32Wallet.test.js new file mode 100644 index 000000000..c918ca5dc --- /dev/null +++ b/HDBech32Wallet.test.js @@ -0,0 +1,265 @@ +/* global it, describe, jasmine, afterAll, beforeAll */ +import { HDSegwitBech32Wallet } from './class'; +global.crypto = require('crypto'); // shall be used by tests under nodejs CLI, but not in RN environment +let assert = require('assert'); +global.net = require('net'); // needed by Electrum client. For RN it is proviced in shim.js +let BlueElectrum = require('./BlueElectrum'); // so it connects ASAP + +afterAll(async () => { + // after all tests we close socket so the test suite can actually terminate + BlueElectrum.forceDisconnect(); + return new Promise(resolve => setTimeout(resolve, 10000)); // simple sleep to wait for all timeouts termination +}); + +beforeAll(async () => { + // awaiting for Electrum to be connected. For RN Electrum would naturally connect + // while app starts up, but for tests we need to wait for it + await BlueElectrum.waitTillConnected(); +}); + +describe('Bech32 Segwit HD (BIP84)', () => { + it('can create', async function() { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30 * 1000; + let mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(mnemonic); + + assert.strictEqual(true, hd.validateMnemonic()); + assert.strictEqual( + 'zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs', + hd.getXpub(), + ); + + assert.strictEqual(hd._getExternalWIFByIndex(0), 'KyZpNDKnfs94vbrwhJneDi77V6jF64PWPF8x5cdJb8ifgg2DUc9d'); + assert.strictEqual(hd._getExternalWIFByIndex(1), 'Kxpf5b8p3qX56DKEe5NqWbNUP9MnqoRFzZwHRtsFqhzuvUJsYZCy'); + assert.strictEqual(hd._getInternalWIFByIndex(0), 'KxuoxufJL5csa1Wieb2kp29VNdn92Us8CoaUG3aGtPtcF3AzeXvF'); + assert.ok(hd._getInternalWIFByIndex(0) !== hd._getInternalWIFByIndex(1)); + + assert.strictEqual(hd._getExternalAddressByIndex(0), 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu'); + assert.strictEqual(hd._getExternalAddressByIndex(1), 'bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g'); + assert.strictEqual(hd._getInternalAddressByIndex(0), 'bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el'); + assert.ok(hd._getInternalAddressByIndex(0) !== hd._getInternalAddressByIndex(1)); + + assert.ok(hd._lastBalanceFetch === 0); + await hd.fetchBalance(); + assert.strictEqual(hd.getBalance(), 0); + assert.ok(hd._lastBalanceFetch > 0); + + // checking that internal pointer and async address getter return the same address + let freeAddress = await hd.getAddressAsync(); + assert.strictEqual(hd.next_free_address_index, 0); + assert.strictEqual(hd._getExternalAddressByIndex(hd.next_free_address_index), freeAddress); + let freeChangeAddress = await hd.getChangeAddressAsync(); + assert.strictEqual(hd.next_free_change_address_index, 0); + assert.strictEqual(hd._getInternalAddressByIndex(hd.next_free_change_address_index), freeChangeAddress); + }); + + it('can fetch balance', async function() { + if (!process.env.HD_MNEMONIC) { + console.error('process.env.HD_MNEMONIC not set, skipped'); + return; + } + jasmine.DEFAULT_TIMEOUT_INTERVAL = 90 * 1000; + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(process.env.HD_MNEMONIC); + assert.ok(hd.validateMnemonic()); + + assert.strictEqual( + 'zpub6r7jhKKm7BAVx3b3nSnuadY1WnshZYkhK8gKFoRLwK9rF3Mzv28BrGcCGA3ugGtawi1WLb2vyjQAX9ZTDGU5gNk2bLdTc3iEXr6tzR1ipNP', + hd.getXpub(), + ); + + assert.strictEqual(hd._getExternalAddressByIndex(0), 'bc1qvd6w54sydc08z3802svkxr7297ez7cusd6266p'); + assert.strictEqual(hd._getExternalAddressByIndex(1), 'bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh'); + assert.strictEqual(hd._getInternalAddressByIndex(0), 'bc1qcg6e26vtzja0h8up5w2m7utex0fsu4v0e0e7uy'); + assert.strictEqual(hd._getInternalAddressByIndex(1), 'bc1qwp58x4c9e5cplsnw5096qzdkae036ug7a34x3r'); + + await hd.fetchBalance(); + assert.strictEqual(hd.getBalance(), 200000); + assert.strictEqual(await hd.getAddressAsync(), hd._getExternalAddressByIndex(2)); + assert.strictEqual(await hd.getChangeAddressAsync(), hd._getInternalAddressByIndex(2)); + assert.strictEqual(hd.next_free_address_index, 2); + assert.strictEqual(hd.next_free_change_address_index, 2); + + // now, reset HD wallet, and find free addresses from scratch: + hd = new HDSegwitBech32Wallet(); + hd.setSecret(process.env.HD_MNEMONIC); + + assert.strictEqual(await hd.getAddressAsync(), hd._getExternalAddressByIndex(2)); + assert.strictEqual(await hd.getChangeAddressAsync(), hd._getInternalAddressByIndex(2)); + assert.strictEqual(hd.next_free_address_index, 2); + assert.strictEqual(hd.next_free_change_address_index, 2); + }); + + it('can fetch transactions', async function() { + if (!process.env.HD_MNEMONIC) { + console.error('process.env.HD_MNEMONIC not set, skipped'); + return; + } + jasmine.DEFAULT_TIMEOUT_INTERVAL = 90 * 1000; + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(process.env.HD_MNEMONIC); + assert.ok(hd.validateMnemonic()); + + assert.strictEqual(hd.timeToRefreshBalance(), true); + assert.ok(hd._lastTxFetch === 0); + assert.ok(hd._lastBalanceFetch === 0); + await hd.fetchBalance(); + await hd.fetchTransactions(); + assert.ok(hd._lastTxFetch > 0); + assert.ok(hd._lastBalanceFetch > 0); + assert.strictEqual(hd.timeToRefreshBalance(), false); + assert.strictEqual(hd.getTransactions().length, 4); + + for (let tx of hd.getTransactions()) { + assert.ok(tx.hash); + assert.strictEqual(tx.value, 50000); + assert.ok(tx.received); + assert.ok(tx.confirmations > 1); + } + }); + + it('can fetch UTXO', async () => { + if (!process.env.HD_MNEMONIC) { + console.error('process.env.HD_MNEMONIC not set, skipped'); + return; + } + jasmine.DEFAULT_TIMEOUT_INTERVAL = 90 * 1000; + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(process.env.HD_MNEMONIC); + assert.ok(hd.validateMnemonic()); + + await hd.fetchBalance(); + await hd.fetchUtxo(); + let utxo = hd.getUtxo(); + assert.strictEqual(utxo.length, 4); + assert.ok(utxo[0].txId); + assert.ok(utxo[0].vout === 0 || utxo[0].vout === 1); + assert.ok(utxo[0].value); + assert.ok(utxo[0].address); + }); + + it('can generate addresses only via zpub', function() { + let zpub = 'zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs'; + let hd = new HDSegwitBech32Wallet(); + hd._xpub = zpub; + assert.strictEqual(hd._getExternalAddressByIndex(0), 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu'); + assert.strictEqual(hd._getExternalAddressByIndex(1), 'bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g'); + assert.strictEqual(hd._getInternalAddressByIndex(0), 'bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el'); + assert.ok(hd._getInternalAddressByIndex(0) !== hd._getInternalAddressByIndex(1)); + }); + + it('can generate', async () => { + let hd = new HDSegwitBech32Wallet(); + let hashmap = {}; + for (let c = 0; c < 1000; c++) { + await hd.generate(); + let secret = hd.getSecret(); + if (hashmap[secret]) { + throw new Error('Duplicate secret generated!'); + } + hashmap[secret] = 1; + assert.ok(secret.split(' ').length === 12 || secret.split(' ').length === 24); + } + + let hd2 = new HDSegwitBech32Wallet(); + hd2.setSecret(hd.getSecret()); + assert.ok(hd2.validateMnemonic()); + }); + + it('can catch up with externally modified wallet', async () => { + if (!process.env.HD_MNEMONIC_BIP84) { + console.error('process.env.HD_MNEMONIC_BIP84 not set, skipped'); + return; + } + jasmine.DEFAULT_TIMEOUT_INTERVAL = 90 * 1000; + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(process.env.HD_MNEMONIC_BIP84); + assert.ok(hd.validateMnemonic()); + + await hd.fetchBalance(); + let oldBalance = hd.getBalance(); + + await hd.fetchTransactions(); + let oldTransactions = hd.getTransactions(); + + // now, mess with internal state, make it 'obsolete' + + hd._txs_by_external_index['2'].pop(); + hd._txs_by_internal_index['16'].pop(); + hd._txs_by_internal_index['17'] = []; + + for (let c = 17; c < 100; c++) hd._balances_by_internal_index[c] = { c: 0, u: 0 }; + hd._balances_by_external_index['2'].c = 1000000; + + assert.ok(hd.getBalance() !== oldBalance); + assert.ok(hd.getTransactions().length !== oldTransactions.length); + + // now, refetch! should get back to normal + + await hd.fetchBalance(); + assert.strictEqual(hd.getBalance(), oldBalance); + await hd.fetchTransactions(); + assert.strictEqual(hd.getTransactions().length, oldTransactions.length); + }); + + it('can create transactions', async () => { + if (!process.env.HD_MNEMONIC_BIP84) { + console.error('process.env.HD_MNEMONIC_BIP84 not set, skipped'); + return; + } + jasmine.DEFAULT_TIMEOUT_INTERVAL = 90 * 1000; + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(process.env.HD_MNEMONIC_BIP84); + assert.ok(hd.validateMnemonic()); + + let start = +new Date(); + await hd.fetchBalance(); + let end = +new Date(); + end - start > 5000 && console.warn('fetchBalance took', (end - start) / 1000, 'sec'); + + start = +new Date(); + await hd.fetchTransactions(); + end = +new Date(); + end - start > 15000 && console.warn('fetchTransactions took', (end - start) / 1000, 'sec'); + + let txFound = 0; + for (let tx of hd.getTransactions()) { + if (tx.hash === 'e9ef58baf4cff3ad55913a360c2fa1fd124309c59dcd720cdb172ce46582097b') { + assert.strictEqual(tx.value, -129545); + txFound++; + } + if (tx.hash === 'e112771fd43962abfe4e4623bf788d6d95ff1bd0f9b56a6a41fb9ed4dacc75f1') { + assert.strictEqual(tx.value, 1000000); + txFound++; + } + } + assert.ok(txFound === 2); + + await hd.fetchUtxo(); + let changeAddress = await hd.getChangeAddressAsync(); + assert.ok(changeAddress && changeAddress.startsWith('bc1')); + + let { tx, inputs, outputs, fee } = hd.createTransaction( + hd.getUtxo(), + [{ address: 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu', value: 101000 }], + 13, + changeAddress, + ); + + assert.strictEqual(Math.round(fee / tx.byteLength()), 13); + + let totalInput = 0; + for (let inp of inputs) { + totalInput += inp.value; + } + + let totalOutput = 0; + for (let outp of outputs) { + totalOutput += outp.value; + } + + assert.strictEqual(totalInput - totalOutput, fee); + assert.strictEqual(outputs[outputs.length - 1].address, changeAddress); + }); +}); diff --git a/HDWallet.test.js b/HDWallet.test.js index 105149586..829541581 100644 --- a/HDWallet.test.js +++ b/HDWallet.test.js @@ -9,7 +9,8 @@ jasmine.DEFAULT_TIMEOUT_INTERVAL = 300 * 1000; afterAll(() => { // after all tests we close socket so the test suite can actually terminate - return BlueElectrum.forceDisconnect(); + BlueElectrum.forceDisconnect(); + return new Promise(resolve => setTimeout(resolve, 10000)); // simple sleep to wait for all timeouts termination }); beforeAll(async () => { @@ -79,18 +80,27 @@ it('HD (BIP49) can work with a gap', async function() { // console.log('external', c, hd._getExternalAddressByIndex(c)); // } await hd.fetchTransactions(); - console.log('hd.transactions.length=', hd.transactions.length); assert.ok(hd.transactions.length >= 3); }); it('Segwit HD (BIP49) can batch fetch many txs', async function() { - jasmine.DEFAULT_TIMEOUT_INTERVAL = 240 * 1000; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 300 * 1000; let hd = new HDSegwitP2SHWallet(); - hd._xpub = 'ypub6WZ2c7YJ1SQ1rBYftwMqwV9bBmybXzETFxWmkzMz25bCf6FkDdXjNgR7zRW8JGSnoddNdUH7ZQS7JeQAddxdGpwgPskcsXFcvSn1JdGVcPQ'; // cant fetch txs + hd._xpub = 'ypub6WZ2c7YJ1SQ1rBYftwMqwV9bBmybXzETFxWmkzMz25bCf6FkDdXjNgR7zRW8JGSnoddNdUH7ZQS7JeQAddxdGpwgPskcsXFcvSn1JdGVcPQ'; await hd.fetchBalance(); await hd.fetchTransactions(); - assert.ok(hd.transactions.length > 0); - console.log('hd.transactions.length=', hd.transactions.length); + assert.ok(hd.getTransactions().length === 153); +}); + +it('Segwit HD (BIP49) can fetch more data if pointers to last_used_addr are lagging behind', async function() { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 300 * 1000; + let hd = new HDSegwitP2SHWallet(); + hd._xpub = 'ypub6WZ2c7YJ1SQ1rBYftwMqwV9bBmybXzETFxWmkzMz25bCf6FkDdXjNgR7zRW8JGSnoddNdUH7ZQS7JeQAddxdGpwgPskcsXFcvSn1JdGVcPQ'; + hd.next_free_change_address_index = 40; + hd.next_free_address_index = 50; + await hd.fetchBalance(); + await hd.fetchTransactions(); + assert.strictEqual(hd.getTransactions().length, 153); }); it('Segwit HD (BIP49) can generate addressess only via ypub', function() { @@ -207,10 +217,13 @@ it('Segwit HD (BIP49) can fetch balance with many used addresses in hierarchy', let end = +new Date(); const took = (end - start) / 1000; took > 15 && console.warn('took', took, "sec to fetch huge HD wallet's balance"); - assert.strictEqual(hd.getBalance(), 0.00051432); + assert.strictEqual(hd.getBalance(), 51432); await hd.fetchUtxo(); assert.ok(hd.utxo.length > 0); + assert.ok(hd.utxo[0].txid); + assert.ok(hd.utxo[0].vout === 0); + assert.ok(hd.utxo[0].amount); await hd.fetchTransactions(); assert.strictEqual(hd.getTransactions().length, 107); diff --git a/LightningCustodianWallet.test.js b/LightningCustodianWallet.test.js index d81d14bd0..f5a32bff1 100644 --- a/LightningCustodianWallet.test.js +++ b/LightningCustodianWallet.test.js @@ -153,6 +153,9 @@ describe('LightningCustodianWallet', () => { await l2.fetchTransactions(); assert.strictEqual(l2.transactions_raw.length, txLen + 1); + let lastTx = l2.transactions_raw[l2.transactions_raw.length - 1]; + assert.strictEqual(typeof lastTx.payment_preimage, 'string', 'preimage is present and is a string'); + assert.strictEqual(lastTx.payment_preimage.length, 64, 'preimage is present and is a string of 32 hex-encoded bytes'); // transactions became more after paying an invoice // now, trying to pay duplicate invoice @@ -374,6 +377,14 @@ describe('LightningCustodianWallet', () => { err = true; } assert.ok(err); + + err = false; + try { + await l1.addInvoice(NaN, 'zero amt inv'); + } catch (_) { + err = true; + } + assert.ok(err); }); it('cant pay negative free amount', async () => { diff --git a/MainBottomTabs.js b/MainBottomTabs.js index a4d3a3372..e2fd1d370 100644 --- a/MainBottomTabs.js +++ b/MainBottomTabs.js @@ -12,6 +12,7 @@ import LightningSettings from './screen/settings/lightningSettings'; import WalletsList from './screen/wallets/list'; import WalletTransactions from './screen/wallets/transactions'; import AddWallet from './screen/wallets/add'; +import PleaseBackup from './screen/wallets/pleaseBackup'; import ImportWallet from './screen/wallets/import'; import WalletDetails from './screen/wallets/details'; import WalletExport from './screen/wallets/export'; @@ -183,6 +184,9 @@ const CreateWalletStackNavigator = createStackNavigator({ ImportWallet: { screen: ImportWallet, }, + PleaseBackup: { + screen: PleaseBackup, + }, }); const LightningScanInvoiceStackNavigator = createStackNavigator({ diff --git a/README.md b/README.md index 150dc41f3..82ff5d801 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,6 @@ Community: [telegram group](https://t.me/bluewallet) * Encryption. Plausible deniability * And many more [features...](https://bluewallet.io/features.html) -Beta version, do not use to store large amounts! - diff --git a/WatchConnectivity.js b/WatchConnectivity.js new file mode 100644 index 000000000..2efe80ddf --- /dev/null +++ b/WatchConnectivity.js @@ -0,0 +1,138 @@ +import * as watch from 'react-native-watch-connectivity'; +import { InteractionManager } from 'react-native'; +const loc = require('./loc'); +export default class WatchConnectivity { + isAppInstalled = false; + BlueApp = require('./BlueApp'); + + constructor() { + this.getIsWatchAppInstalled(); + } + + getIsWatchAppInstalled() { + watch.getIsWatchAppInstalled((err, isAppInstalled) => { + if (!err) { + this.isAppInstalled = isAppInstalled; + this.sendWalletsToWatch(); + } + }); + watch.subscribeToMessages(async (err, message, reply) => { + if (!err) { + if (message.request === 'createInvoice') { + const createInvoiceRequest = await this.handleLightningInvoiceCreateRequest( + message.walletIndex, + message.amount, + message.description, + ); + reply({ invoicePaymentRequest: createInvoiceRequest }); + } + } else { + reply(err); + } + }); + } + + async handleLightningInvoiceCreateRequest(walletIndex, amount, description) { + const wallet = this.BlueApp.getWallets()[walletIndex]; + if (wallet.allowReceive() && amount > 0 && description.trim().length > 0) { + try { + const invoiceRequest = await wallet.addInvoice(amount, description); + return invoiceRequest; + } catch (error) { + return error; + } + } + } + + async sendWalletsToWatch() { + InteractionManager.runAfterInteractions(async () => { + if (this.isAppInstalled) { + const allWallets = this.BlueApp.getWallets(); + let wallets = []; + for (const wallet of allWallets) { + let receiveAddress = ''; + if (wallet.allowReceive()) { + if (wallet.getAddressAsync) { + receiveAddress = await wallet.getAddressAsync(); + } else { + receiveAddress = wallet.getAddress(); + } + } + let transactions = wallet.getTransactions(10); + let watchTransactions = []; + for (const transaction of transactions) { + let type = 'pendingConfirmation'; + let memo = ''; + let amount = 0; + + if (transaction.hasOwnProperty('confirmations') && !transaction.confirmations > 0) { + type = 'pendingConfirmation'; + } else if (transaction.type === 'user_invoice' || transaction.type === 'payment_request') { + const currentDate = new Date(); + const now = (currentDate.getTime() / 1000) | 0; + const invoiceExpiration = transaction.timestamp + transaction.expire_time; + + if (invoiceExpiration > now) { + type = 'pendingConfirmation'; + } else if (invoiceExpiration < now) { + if (transaction.ispaid) { + type = 'received'; + } else { + type = 'sent'; + } + } + } else if (transaction.value / 100000000 < 0) { + type = 'sent'; + } else { + type = 'received'; + } + if (transaction.type === 'user_invoice' || transaction.type === 'payment_request') { + if (isNaN(transaction.value)) { + amount = '0'; + } + const currentDate = new Date(); + const now = (currentDate.getTime() / 1000) | 0; + const invoiceExpiration = transaction.timestamp + transaction.expire_time; + + if (invoiceExpiration > now) { + amount = loc.formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); + } else if (invoiceExpiration < now) { + if (transaction.ispaid) { + amount = loc.formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); + } else { + amount = loc.lnd.expired; + } + } else { + amount = loc.formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); + } + } else { + amount = loc.formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); + } + if (this.BlueApp.tx_metadata[transaction.hash] && this.BlueApp.tx_metadata[transaction.hash]['memo']) { + memo = this.BlueApp.tx_metadata[transaction.hash]['memo']; + } else if (transaction.memo) { + memo = transaction.memo; + } + const watchTX = { type, amount, memo, time: loc.transactionTimeToReadable(transaction.received) }; + watchTransactions.push(watchTX); + } + wallets.push({ + label: wallet.getLabel(), + balance: loc.formatBalance(Number(wallet.getBalance()), wallet.getPreferredBalanceUnit(), true), + type: wallet.type, + preferredBalanceUnit: wallet.getPreferredBalanceUnit(), + receiveAddress: receiveAddress, + transactions: watchTransactions, + }); + } + + watch.updateApplicationContext({ wallets }); + } + }); + } +} + +WatchConnectivity.init = function() { + if (WatchConnectivity.shared) return; + WatchConnectivity.shared = new WatchConnectivity(); +}; diff --git a/WatchOnlyWallet.test.js b/WatchOnlyWallet.test.js new file mode 100644 index 000000000..b1315dcbb --- /dev/null +++ b/WatchOnlyWallet.test.js @@ -0,0 +1,114 @@ +/* global it, describe, jasmine, afterAll, beforeAll */ +import { WatchOnlyWallet } from './class'; +let assert = require('assert'); +global.net = require('net'); // needed by Electrum client. For RN it is proviced in shim.js +let BlueElectrum = require('./BlueElectrum'); // so it connects ASAP + +afterAll(async () => { + // after all tests we close socket so the test suite can actually terminate + BlueElectrum.forceDisconnect(); + return new Promise(resolve => setTimeout(resolve, 10000)); // simple sleep to wait for all timeouts termination +}); + +beforeAll(async () => { + // awaiting for Electrum to be connected. For RN Electrum would naturally connect + // while app starts up, but for tests we need to wait for it + await BlueElectrum.waitTillConnected(); +}); + +describe('Watch only wallet', () => { + it('can fetch balance', async () => { + let w = new WatchOnlyWallet(); + w.setSecret('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'); + await w.fetchBalance(); + assert.ok(w.getBalance() > 16); + }); + + it('can fetch tx', async () => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 150 * 1000; + let w = new WatchOnlyWallet(); + + w.setSecret('167zK5iZrs1U6piDqubD3FjRqUTM2CZnb8'); + await w.fetchTransactions(); + assert.strictEqual(w.getTransactions().length, 233); + + w = new WatchOnlyWallet(); + w.setSecret('1BiJW1jyUaxcJp2JWwbPLPzB1toPNWTFJV'); + await w.fetchTransactions(); + assert.strictEqual(w.getTransactions().length, 2); + + // fetch again and make sure no duplicates + await w.fetchTransactions(); + assert.strictEqual(w.getTransactions().length, 2); + }); + + it('can fetch complex TXs', async () => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 120 * 1000; + let w = new WatchOnlyWallet(); + w.setSecret('3NLnALo49CFEF4tCRhCvz45ySSfz3UktZC'); + await w.fetchTransactions(); + for (let tx of w.getTransactions()) { + assert.ok(tx.value, 'incorrect tx.value'); + } + }); + + it('can validate address', async () => { + let w = new WatchOnlyWallet(); + w.setSecret('12eQ9m4sgAwTSQoNXkRABKhCXCsjm2jdVG'); + assert.ok(w.valid()); + w.setSecret('3BDsBDxDimYgNZzsqszNZobqQq3yeUoJf2'); + assert.ok(w.valid()); + w.setSecret('not valid'); + assert.ok(!w.valid()); + + w.setSecret('xpub6CQdfC3v9gU86eaSn7AhUFcBVxiGhdtYxdC5Cw2vLmFkfth2KXCMmYcPpvZviA89X6DXDs4PJDk5QVL2G2xaVjv7SM4roWHr1gR4xB3Z7Ps'); + assert.ok(w.valid()); + w.setSecret('ypub6XRzrn3HB1tjhhvrHbk1vnXCecZEdXohGzCk3GXwwbDoJ3VBzZ34jNGWbC6WrS7idXrYjjXEzcPDX5VqnHEnuNf5VAXgLfSaytMkJ2rwVqy'); + assert.ok(w.valid()); + w.setSecret('zpub6r7jhKKm7BAVx3b3nSnuadY1WnshZYkhK8gKFoRLwK9rF3Mzv28BrGcCGA3ugGtawi1WLb2vyjQAX9ZTDGU5gNk2bLdTc3iEXr6tzR1ipNP'); + assert.ok(w.valid()); + }); + + it('can fetch balance & transactions from zpub HD', async () => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 100 * 1000; + let w = new WatchOnlyWallet(); + w.setSecret('zpub6r7jhKKm7BAVx3b3nSnuadY1WnshZYkhK8gKFoRLwK9rF3Mzv28BrGcCGA3ugGtawi1WLb2vyjQAX9ZTDGU5gNk2bLdTc3iEXr6tzR1ipNP'); + await w.fetchBalance(); + assert.strictEqual(w.getBalance(), 200000); + await w.fetchTransactions(); + assert.strictEqual(w.getTransactions().length, 4); + assert.ok((await w.getAddressAsync()).startsWith('bc1')); + }); + + it('can fetch balance & transactions from ypub HD', async () => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 100 * 1000; + let w = new WatchOnlyWallet(); + w.setSecret('ypub6XRzrn3HB1tjhhvrHbk1vnXCecZEdXohGzCk3GXwwbDoJ3VBzZ34jNGWbC6WrS7idXrYjjXEzcPDX5VqnHEnuNf5VAXgLfSaytMkJ2rwVqy'); + await w.fetchBalance(); + assert.strictEqual(w.getBalance(), 52774); + await w.fetchTransactions(); + assert.strictEqual(w.getTransactions().length, 3); + assert.ok((await w.getAddressAsync()).startsWith('3')); + }); + + it('can fetch balance & transactions from xpub HD', async () => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 100 * 1000; + let w = new WatchOnlyWallet(); + w.setSecret('xpub6CQdfC3v9gU86eaSn7AhUFcBVxiGhdtYxdC5Cw2vLmFkfth2KXCMmYcPpvZviA89X6DXDs4PJDk5QVL2G2xaVjv7SM4roWHr1gR4xB3Z7Ps'); + await w.fetchBalance(); + assert.strictEqual(w.getBalance(), 0); + await w.fetchTransactions(); + assert.strictEqual(w.getTransactions().length, 4); + assert.ok((await w.getAddressAsync()).startsWith('1')); + }); + + it('can fetch large HD', async () => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 500 * 1000; + let w = new WatchOnlyWallet(); + w.setSecret('ypub6WnnYxkQCGeowv4BXq9Y9PHaXgHMJg9TkFaDJkunhcTAfbDw8z3LvV9kFNHGjeVaEoGdsSJgaMWpUBvYvpYGMJd43gTK5opecVVkvLwKttx'); + await w.fetchBalance(); + + await w.fetchTransactions(); + assert.ok(w.getTransactions().length >= 167); + }); +}); diff --git a/__mocks__/@react-native-community/async-storage.js b/__mocks__/@react-native-community/async-storage.js new file mode 100644 index 000000000..272ea598b --- /dev/null +++ b/__mocks__/@react-native-community/async-storage.js @@ -0,0 +1 @@ +export default from '@react-native-community/async-storage/jest/async-storage-mock' diff --git a/android/app/app.iml b/android/app/app.iml index dd3e70e39..2afd4f723 100644 --- a/android/app/app.iml +++ b/android/app/app.iml @@ -17,7 +17,7 @@ diff --git a/screen/lnd/lndViewInvoice.js b/screen/lnd/lndViewInvoice.js index ed733c08c..585d912f5 100644 --- a/screen/lnd/lndViewInvoice.js +++ b/screen/lnd/lndViewInvoice.js @@ -64,7 +64,7 @@ export default class LNDViewInvoice extends Component { if (updatedUserInvoice.ispaid) { // we fetched the invoice, and it is paid :-) this.setState({ isFetchingInvoices: false }); - ReactNativeHapticFeedback.trigger('notificationSuccess', false); + ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); clearInterval(this.fetchInvoiceInterval); this.fetchInvoiceInterval = undefined; EV(EV.enum.REMOTE_TRANSACTIONS_COUNT_CHANGED); // remote because we want to refetch from server tx list and balance @@ -75,7 +75,7 @@ export default class LNDViewInvoice extends Component { if (invoiceExpiration < now && !updatedUserInvoice.ispaid) { // invoice expired :-( this.setState({ isFetchingInvoices: false }); - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); clearInterval(this.fetchInvoiceInterval); this.fetchInvoiceInterval = undefined; EV(EV.enum.TRANSACTIONS_COUNT_CHANGED); @@ -116,6 +116,27 @@ export default class LNDViewInvoice extends Component { const now = (currentDate.getTime() / 1000) | 0; const invoiceExpiration = invoice.timestamp + invoice.expire_time; + if (this.state.showPreimageQr) { + return ( + + + Preimage: + + + + {invoice.payment_preimage} + + + ); + } + if (invoice.ispaid || invoice.type === 'paid_invoice') { return ( @@ -134,7 +155,21 @@ export default class LNDViewInvoice extends Component { > - This invoice has been paid for + {loc.lndViewInvoice.has_been_paid} + {invoice.payment_preimage && typeof invoice.payment_preimage === 'string' && ( + + this.setState({ showPreimageQr: true })} + title=" " + /> + + )} ); @@ -157,7 +192,7 @@ export default class LNDViewInvoice extends Component { > - This invoice was not paid for and has expired + {loc.lndViewInvoice.wasnt_paid_and_expired} ); @@ -166,7 +201,7 @@ export default class LNDViewInvoice extends Component { return ( - 'This invoice has been paid for.' + {loc.lndViewInvoice.has_been_paid} ); @@ -198,9 +233,15 @@ export default class LNDViewInvoice extends Component { - {invoice && invoice.amt && Please pay {invoice.amt} sats} + {invoice && invoice.amt && ( + + {loc.lndViewInvoice.please_pay} {invoice.amt} {loc.lndViewInvoice.sats} + + )} {invoice && invoice.hasOwnProperty('description') && invoice.description.length > 0 && ( - For: {invoice.description} + + {loc.lndViewInvoice.for} {invoice.description} + )} @@ -208,6 +249,7 @@ export default class LNDViewInvoice extends Component { icon={{ name: 'share-alternative', type: 'entypo', + size: 10, color: BlueApp.settings.buttonTextColor, }} onPress={async () => { @@ -219,14 +261,11 @@ export default class LNDViewInvoice extends Component { /> this.props.navigation.navigate('LNDViewAdditionalInvoiceInformation', { fromWallet: this.state.fromWallet })} - title="Additional Information" + title={loc.lndViewInvoice.additional_info} /> diff --git a/screen/lnd/scanLndInvoice.js b/screen/lnd/scanLndInvoice.js index 5b0d1fdb3..24c3c96b5 100644 --- a/screen/lnd/scanLndInvoice.js +++ b/screen/lnd/scanLndInvoice.js @@ -19,7 +19,7 @@ import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; /** @type {AppStorage} */ let BlueApp = require('../../BlueApp'); let EV = require('../../events'); -let loc = require('../../loc'); +const loc = require('../../loc'); export default class ScanLndInvoice extends React.Component { static navigationOptions = ({ navigation }) => ({ @@ -38,7 +38,7 @@ export default class ScanLndInvoice extends React.Component { super(props); if (!BlueApp.getWallets().some(item => item.type === LightningCustodianWallet.type)) { - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); alert('Before paying a Lightning invoice, you must first add a Lightning wallet.'); props.navigation.dismiss(); } else { @@ -93,7 +93,7 @@ export default class ScanLndInvoice extends React.Component { processInvoice = data => { this.setState({ isLoading: true }, async () => { if (!this.state.fromWallet) { - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); alert('Before paying a Lightning invoice, you must first add a Lightning wallet.'); return this.props.navigation.goBack(); } @@ -133,7 +133,7 @@ export default class ScanLndInvoice extends React.Component { } catch (Err) { Keyboard.dismiss(); this.setState({ isLoading: false }); - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); alert(Err.message); } }); @@ -157,14 +157,14 @@ export default class ScanLndInvoice extends React.Component { let expiresIn = (decoded.timestamp * 1 + decoded.expiry * 1) * 1000; // ms if (+new Date() > expiresIn) { this.setState({ isLoading: false }); - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); return alert('Invoice expired'); } const currentUserInvoices = fromWallet.user_invoices_raw; // not fetching invoices, as we assume they were loaded previously if (currentUserInvoices.some(invoice => invoice.payment_hash === decoded.payment_hash)) { this.setState({ isLoading: false }); - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); return alert(loc.lnd.sameWalletAsInvoiceError); } @@ -173,7 +173,7 @@ export default class ScanLndInvoice extends React.Component { } catch (Err) { console.log(Err.message); this.setState({ isLoading: false }); - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); return alert(Err.message); } @@ -226,7 +226,7 @@ export default class ScanLndInvoice extends React.Component { {this.state.fromWallet.getLabel()} - {this.state.fromWallet.getBalance()} + {loc.formatBalanceWithoutSuffix(this.state.fromWallet.getBalance(), BitcoinUnit.BTC, false)} {BitcoinUnit.BTC} diff --git a/screen/receive/details.js b/screen/receive/details.js index f591841b0..ae69d4de9 100644 --- a/screen/receive/details.js +++ b/screen/receive/details.js @@ -1,5 +1,6 @@ +/* global alert */ import React, { Component } from 'react'; -import { View, Share } from 'react-native'; +import { View, Share, InteractionManager } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; import bip21 from 'bip21'; import { @@ -31,22 +32,13 @@ export default class ReceiveDetails extends Component { let secret = props.navigation.state.params.secret; this.state = { - isLoading: true, address: address, secret: secret, addressText: '', + bip21encoded: undefined, }; - - // EV(EV.enum.RECEIVE_ADDRESS_CHANGED, this.redrawScreen.bind(this)); } - /* redrawScreen(newAddress) { - console.log('newAddress =', newAddress); - this.setState({ - address: newAddress, - }); - } */ - async componentDidMount() { Privacy.enableBlur(); console.log('receive/details - componentDidMount'); @@ -60,24 +52,28 @@ export default class ReceiveDetails extends Component { wallet = w; } } - - if (wallet && wallet.getAddressAsync) { - setTimeout(async () => { + if (wallet) { + if (wallet.getAddressAsync) { address = await wallet.getAddressAsync(); - BlueApp.saveToDisk(); // caching whatever getAddressAsync() generated internally - this.setState({ - address: address, - addressText: address, - isLoading: false, - }); - }, 1); - } else { + } + BlueApp.saveToDisk(); // caching whatever getAddressAsync() generated internally + this.setState({ + address: address, + addressText: address, + }); + } else { + alert('There was a problem obtaining your receive address. Please, try again.'); + this.props.navigation.goBack(); this.setState({ - isLoading: false, address, addressText: address, }); } + + InteractionManager.runAfterInteractions(async () => { + const bip21encoded = bip21.encode(this.state.address); + this.setState({ bip21encoded }); + }); } componentWillUnmount() { @@ -85,25 +81,26 @@ export default class ReceiveDetails extends Component { } render() { - if (this.state.isLoading) { - return ; - } - return ( - - - - + + + {this.state.bip21encoded === undefined ? ( + + ) : ( + + )} - + + { @@ -112,19 +109,21 @@ export default class ReceiveDetails extends Component { }); }} /> - { - Share.share({ - message: this.state.address, - }); - }} - title={loc.receive.details.share} - /> + + { + Share.share({ + message: this.state.address, + }); + }} + title={loc.receive.details.share} + /> + diff --git a/screen/selftest.js b/screen/selftest.js index 0bc0a7316..3e6ca5809 100644 --- a/screen/selftest.js +++ b/screen/selftest.js @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import { ScrollView, View } from 'react-native'; import { BlueLoading, BlueSpacing20, SafeBlueArea, BlueCard, BlueText, BlueNavigationStyle } from '../BlueComponents'; import PropTypes from 'prop-types'; -import { SegwitP2SHWallet, LegacyWallet, HDSegwitP2SHWallet } from '../class'; +import { SegwitP2SHWallet, LegacyWallet, HDSegwitP2SHWallet, HDSegwitBech32Wallet } from '../class'; let BigNumber = require('bignumber.js'); let encryption = require('../encryption'); let bitcoin = require('bitcoinjs-lib'); @@ -45,6 +45,7 @@ export default class Selftest extends Component { // if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') { + await BlueElectrum.waitTillConnected(); let addr4elect = '3GCvDBAktgQQtsbN6x5DYiQCMmgZ9Yk8BK'; let electrumBalance = await BlueElectrum.getBalanceByAddress(addr4elect); if (electrumBalance.confirmed !== 51432) @@ -258,9 +259,18 @@ export default class Selftest extends Component { let hd3 = new HDSegwitP2SHWallet(); hd3._xpub = 'ypub6XRSuTABFst6pd8BuTmjSwkDya7HrCRqmtsNmtrh1gyrEZwe24GcjJf6jk6nhhenZpLsm6sDHx2BXwnCQQtjF63FbpNyVEkmngKFQF11aph'; await hd3.fetchBalance(); - if (hd3.getBalance() !== 0.00026) throw new Error('Could not fetch HD balance'); + if (hd3.getBalance() !== 26000) throw new Error('Could not fetch HD balance'); await hd3.fetchTransactions(); if (hd3.transactions.length !== 7) throw new Error('Could not fetch HD transactions'); + + // + + let hd4 = new HDSegwitBech32Wallet(); + hd4._xpub = 'zpub6r7jhKKm7BAVx3b3nSnuadY1WnshZYkhK8gKFoRLwK9rF3Mzv28BrGcCGA3ugGtawi1WLb2vyjQAX9ZTDGU5gNk2bLdTc3iEXr6tzR1ipNP'; + await hd4.fetchBalance(); + if (hd4.getBalance() !== 200000) throw new Error('Could not fetch HD Bech32 balance'); + await hd4.fetchTransactions(); + if (hd4.getTransactions().length !== 4) throw new Error('Could not fetch HD Bech32 transactions'); } else { console.warn('skipping RN-specific test'); } diff --git a/screen/send/confirm.js b/screen/send/confirm.js index 0dfec38a2..c06a15c6c 100644 --- a/screen/send/confirm.js +++ b/screen/send/confirm.js @@ -59,7 +59,7 @@ export default class Confirm extends Component { }); } } catch (error) { - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); this.setState({ isLoading: false }); alert(error.message); } @@ -103,7 +103,7 @@ export default class Confirm extends Component { alignSelf: 'center', }} > - {loc.send.create.fee}: {loc.formatBalance(this.state.fee, BitcoinUnit.BTC)} ( + {loc.send.create.fee}: {loc.formatBalance(this.state.feeSatoshi, BitcoinUnit.BTC)} ( {currency.satoshiToLocalCurrency(this.state.feeSatoshi)}) diff --git a/screen/send/details.js b/screen/send/details.js index aa1759197..59ede3cb6 100644 --- a/screen/send/details.js +++ b/screen/send/details.js @@ -11,10 +11,10 @@ import { TouchableWithoutFeedback, StyleSheet, Platform, - AsyncStorage, Text, } from 'react-native'; import { Icon } from 'react-native-elements'; +import AsyncStorage from '@react-native-community/async-storage'; import { BlueNavigationStyle, BlueButton, @@ -29,7 +29,7 @@ import Modal from 'react-native-modal'; import NetworkTransactionFees, { NetworkTransactionFee } from '../../models/networkTransactionFees'; import BitcoinBIP70TransactionDecode from '../../bip70/bip70'; import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; -import { HDLegacyP2PKHWallet, HDSegwitP2SHWallet, LightningCustodianWallet } from '../../class'; +import { HDLegacyP2PKHWallet, HDSegwitBech32Wallet, HDSegwitP2SHWallet, LightningCustodianWallet } from '../../class'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; const bip21 = require('bip21'); let BigNumber = require('bignumber.js'); @@ -50,7 +50,6 @@ export default class SendDetails extends Component { constructor(props) { super(props); - console.log('props.navigation.state.params=', props.navigation.state.params); let address; let memo; if (props.navigation.state.params) address = props.navigation.state.params.address; @@ -223,6 +222,7 @@ export default class SendDetails extends Component { let availableBalance; try { availableBalance = new BigNumber(balance); + availableBalance = availableBalance.div(100000000); // sat2btc availableBalance = availableBalance.minus(amount); availableBalance = availableBalance.minus(fee); availableBalance = availableBalance.toString(10); @@ -307,8 +307,6 @@ export default class SendDetails extends Component { let error = false; let requestedSatPerByte = this.state.fee.toString().replace(/\D/g, ''); - console.log({ requestedSatPerByte }); - if (!this.state.amount || this.state.amount === '0' || parseFloat(this.state.amount) === 0) { error = loc.send.details.amount_field_is_not_valid; console.log('validation error'); @@ -347,10 +345,24 @@ export default class SendDetails extends Component { if (error) { this.setState({ isLoading: false }); alert(error); - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); return; } + if (this.state.fromWallet.type === HDSegwitBech32Wallet.type) { + try { + await this.createHDBech32Transaction(); + } catch (Err) { + this.setState({ isLoading: false }, () => { + alert(Err.message); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); + }); + } + return; + } + + // legacy send below + this.setState({ isLoading: true }, async () => { let utxo; let actualSatoshiPerByte; @@ -411,7 +423,7 @@ export default class SendDetails extends Component { await BlueApp.saveToDisk(); } catch (err) { console.log(err); - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); alert(err); this.setState({ isLoading: false }); return; @@ -436,6 +448,40 @@ export default class SendDetails extends Component { }); } + async createHDBech32Transaction() { + /** @type {HDSegwitBech32Wallet} */ + const wallet = this.state.fromWallet; + await wallet.fetchUtxo(); + const changeAddress = await wallet.getChangeAddressAsync(); + let satoshis = new BigNumber(this.state.amount).multipliedBy(100000000).toNumber(); + const requestedSatPerByte = +this.state.fee.toString().replace(/\D/g, ''); + console.log({ satoshis, requestedSatPerByte, utxo: wallet.getUtxo() }); + + let targets = []; + targets.push({ address: this.state.address, value: satoshis }); + + let { tx, fee } = wallet.createTransaction(wallet.getUtxo(), targets, requestedSatPerByte, changeAddress); + + BlueApp.tx_metadata = BlueApp.tx_metadata || {}; + BlueApp.tx_metadata[tx.getId()] = { + txhex: tx.toHex(), + memo: this.state.memo, + }; + await BlueApp.saveToDisk(); + + this.setState({ isLoading: false }, () => + this.props.navigation.navigate('Confirm', { + amount: this.state.amount, + fee: new BigNumber(fee).dividedBy(100000000).toNumber(), + address: this.state.address, + memo: this.state.memo, + fromWallet: wallet, + tx: tx.toHex(), + satoshiPerByte: requestedSatPerByte, + }), + ); + } + onWalletSelect = wallet => { this.setState({ fromAddress: wallet.getAddress(), fromSecret: wallet.getSecret(), fromWallet: wallet }, () => { this.props.navigation.pop(); @@ -549,7 +595,7 @@ export default class SendDetails extends Component { {this.state.fromWallet.getLabel()} - {this.state.fromWallet.getBalance()} + {loc.formatBalanceWithoutSuffix(this.state.fromWallet.getBalance(), BitcoinUnit.BTC, false)} {BitcoinUnit.BTC} diff --git a/screen/send/scanQrAddress.js b/screen/send/scanQrAddress.js index 11d2e7b68..f1a7cf12e 100644 --- a/screen/send/scanQrAddress.js +++ b/screen/send/scanQrAddress.js @@ -1,8 +1,7 @@ import React from 'react'; -import { ActivityIndicator, Image, View, TouchableOpacity } from 'react-native'; +import { Image, TouchableOpacity } from 'react-native'; import PropTypes from 'prop-types'; -import Camera from 'react-native-camera'; -import Permissions from 'react-native-permissions'; +import { RNCamera } from 'react-native-camera'; import { SafeBlueArea } from '../../BlueComponents'; export default class CameraExample extends React.Component { @@ -12,53 +11,49 @@ export default class CameraExample extends React.Component { state = { isLoading: false, - hasCameraPermission: null, }; - onBarCodeScanned(ret) { + onBarCodeScanned = ret => { if (this.state.isLoading) return; this.setState({ isLoading: true }, () => { - const onBarScanned = this.props.navigation.getParam('onBarScanned'); + const onBarScannedProp = this.props.navigation.getParam('onBarScanned'); this.props.navigation.goBack(); - onBarScanned(ret.data); + onBarScannedProp(ret.data); }); - } // end - - componentDidMount() { - Permissions.request('camera').then(response => { - // Response is one of: 'authorized', 'denied', 'restricted', or 'undetermined' - this.setState({ hasCameraPermission: response === 'authorized' }); - }); - } + }; // end render() { - if (this.state.isLoading) { - return ( - - - - ); - } - - const { hasCameraPermission } = this.state; - if (hasCameraPermission === null) { - return ; - } else if (hasCameraPermission === false) { - return ; - } else { - return ( - - this.onBarCodeScanned(ret)}> - this.props.navigation.goBack(null)} - > - - - - - ); - } + return ( + + + this.props.navigation.goBack(null)} + > + + + + ); } } diff --git a/screen/send/success.js b/screen/send/success.js index 5bca848d1..3e4417954 100644 --- a/screen/send/success.js +++ b/screen/send/success.js @@ -26,7 +26,7 @@ export default class Success extends Component { async componentDidMount() { console.log('send/create - componentDidMount'); - ReactNativeHapticFeedback.trigger('notificationSuccess', false); + ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); } render() { diff --git a/screen/settings/language.js b/screen/settings/language.js index e4b103d1d..78e6a6183 100644 --- a/screen/settings/language.js +++ b/screen/settings/language.js @@ -54,7 +54,6 @@ export default class Language extends Component { { console.log('setLanguage', item.value); - loc.setLanguage(item.value); loc.saveLanguage(item.value); return this.setState({ language: item.value }); }} diff --git a/screen/settings/lightningSettings.js b/screen/settings/lightningSettings.js index 77ce8d3ba..70f159287 100644 --- a/screen/settings/lightningSettings.js +++ b/screen/settings/lightningSettings.js @@ -1,7 +1,8 @@ /* global alert */ import React, { Component } from 'react'; -import { AsyncStorage, View, TextInput, Linking } from 'react-native'; +import { View, TextInput, Linking } from 'react-native'; import { AppStorage } from '../../class'; +import AsyncStorage from '@react-native-community/async-storage'; import { BlueLoading, BlueSpacing20, BlueButton, SafeBlueArea, BlueCard, BlueNavigationStyle, BlueText } from '../../BlueComponents'; import PropTypes from 'prop-types'; import { Button } from 'react-native-elements'; diff --git a/screen/settings/settings.js b/screen/settings/settings.js index 7fef96444..ec73c17e0 100644 --- a/screen/settings/settings.js +++ b/screen/settings/settings.js @@ -1,9 +1,17 @@ import React, { Component } from 'react'; -import { ScrollView, TouchableOpacity } from 'react-native'; -import { BlueLoading, SafeBlueArea, BlueNavigationStyle, BlueHeaderDefaultSub, BlueListItem } from '../../BlueComponents'; +import { ScrollView, Switch, TouchableOpacity } from 'react-native'; +import { + BlueText, + BlueCard, + BlueLoading, + SafeBlueArea, + BlueNavigationStyle, + BlueHeaderDefaultSub, + BlueListItem, +} from '../../BlueComponents'; +import AsyncStorage from '@react-native-community/async-storage'; import PropTypes from 'prop-types'; -/** @type {AppStorage} */ -let BlueApp = require('../../BlueApp'); +import { AppStorage } from '../../class'; let loc = require('../../loc'); export default class Settings extends Component { @@ -20,12 +28,22 @@ export default class Settings extends Component { } async componentDidMount() { + let advancedModeEnabled = !!(await AsyncStorage.getItem(AppStorage.ADVANCED_MODE_ENABLED)); this.setState({ isLoading: false, - storageIsEncrypted: await BlueApp.storageIsEncrypted(), + advancedModeEnabled, }); } + async onAdvancedModeSwitch(value) { + if (value) { + await AsyncStorage.setItem(AppStorage.ADVANCED_MODE_ENABLED, '1'); + } else { + await AsyncStorage.removeItem(AppStorage.ADVANCED_MODE_ENABLED); + } + this.setState({ advancedModeEnabled: value }); + } + render() { if (this.state.isLoading) { return ; @@ -47,6 +65,16 @@ export default class Settings extends Component { this.props.navigation.navigate('Currency')}> + this.setState({ showAdvancedOptions: !this.state.showAdvancedOptions })}> + + + {this.state.showAdvancedOptions && ( + + {loc.settings.enable_advanced_mode} + this.onAdvancedModeSwitch(value)} /> + + )} + this.props.navigation.navigate('About')}> diff --git a/screen/transactions/RBF-create.js b/screen/transactions/RBF-create.js index af18884c0..a70644299 100644 --- a/screen/transactions/RBF-create.js +++ b/screen/transactions/RBF-create.js @@ -117,7 +117,7 @@ export default class SendCreate extends Component { BlueApp.tx_metadata[this.state.txid]['last_sequence'] = lastSequence; // in case new TX get confirmed, we must save metadata under new txid - let bitcoin = require('bitcoinjs-lib'); + let bitcoin = bitcoinjs; let txDecoded = bitcoin.Transaction.fromHex(tx); let txid = txDecoded.getId(); BlueApp.tx_metadata[txid] = BlueApp.tx_metadata[this.state.txid]; @@ -153,7 +153,11 @@ export default class SendCreate extends Component { let result = await this.state.fromWallet.broadcastTx(this.state.tx); console.log('broadcast result = ', result); if (typeof result === 'string') { - result = JSON.parse(result); + try { + result = JSON.parse(result); + } catch (_) { + result = { result }; + } } if (result && result.error) { alert(JSON.stringify(result.error)); diff --git a/screen/wallets/add.js b/screen/wallets/add.js index 76f9a25cd..20e1f457b 100644 --- a/screen/wallets/add.js +++ b/screen/wallets/add.js @@ -1,31 +1,32 @@ +/* global alert */ import React, { Component } from 'react'; -import { Alert, AsyncStorage, ActivityIndicator, Keyboard, Dimensions, View, TextInput, TouchableWithoutFeedback } from 'react-native'; +import { Alert, Text, LayoutAnimation, ActivityIndicator, Keyboard, KeyboardAvoidingView, Platform, View, TextInput } from 'react-native'; +import AsyncStorage from '@react-native-community/async-storage'; import { BlueTextCentered, BlueText, LightningButton, BitcoinButton, - BlueButtonLink, BlueFormLabel, BlueButton, SafeBlueArea, - BlueCard, + BlueFormInput, BlueNavigationStyle, + BlueButtonLink, BlueSpacing20, + BlueSpacing10, } from '../../BlueComponents'; import { RadioGroup, RadioButton } from 'react-native-flexi-radio-button'; import PropTypes from 'prop-types'; import { HDSegwitP2SHWallet } from '../../class/hd-segwit-p2sh-wallet'; import { LightningCustodianWallet } from '../../class/lightning-custodian-wallet'; -import { AppStorage, SegwitP2SHWallet } from '../../class'; +import { AppStorage, HDSegwitBech32Wallet, SegwitP2SHWallet } from '../../class'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; let EV = require('../../events'); let A = require('../../analytics'); /** @type {AppStorage} */ let BlueApp = require('../../BlueApp'); let loc = require('../../loc'); -const { width } = Dimensions.get('window'); - export default class WalletsAdd extends Component { static navigationOptions = ({ navigation }) => ({ ...BlueNavigationStyle(navigation, true), @@ -37,14 +38,21 @@ export default class WalletsAdd extends Component { super(props); this.state = { isLoading: true, + walletBaseURI: '', }; } async componentDidMount() { + let walletBaseURI = await AsyncStorage.getItem(AppStorage.LNDHUB); + let isAdvancedOptionsEnabled = !!(await AsyncStorage.getItem(AppStorage.ADVANCED_MODE_ENABLED)); + walletBaseURI = walletBaseURI || ''; + this.setState({ isLoading: false, - activeBitcoin: true, + activeBitcoin: undefined, label: '', + isAdvancedOptionsEnabled, + walletBaseURI, }); } @@ -61,6 +69,12 @@ export default class WalletsAdd extends Component { }); } + showAdvancedOptions = () => { + Keyboard.dismiss(); + LayoutAnimation.configureNext(LayoutAnimation.Presets.spring); + this.setState({ isAdvancedOptionsEnabled: true }); + }; + render() { if (this.state.isLoading) { return ( @@ -72,116 +86,139 @@ export default class WalletsAdd extends Component { return ( - - - {loc.wallets.add.wallet_name} - + {loc.wallets.add.wallet_name} + + { + this.setLabel(text); }} - > - { - this.setLabel(text); - }} - style={{ flex: 1, marginHorizontal: 8, color: '#81868e' }} - editable={!this.state.isLoading} - underlineColorAndroid="transparent" - /> - - {loc.wallets.add.wallet_type} + style={{ flex: 1, marginHorizontal: 8, color: '#81868e' }} + editable={!this.state.isLoading} + underlineColorAndroid="transparent" + /> + + {loc.wallets.add.wallet_type} - - - { - this.setState({ - activeBitcoin: true, - activeLightning: false, - }); - }} - style={{ - width: (width - 60) / 3, - height: (width - 60) / 3, - }} - title={loc.wallets.add.create} - /> - - - {loc.wallets.add.or} - - - { - this.setState({ - activeBitcoin: false, - activeLightning: true, - }); - }} - style={{ - width: (width - 60) / 3, - height: (width - 60) / 3, - }} - title={loc.wallets.add.create} - /> - + + { + Keyboard.dismiss(); + this.setState({ + activeBitcoin: true, + activeLightning: false, + }); + }} + style={{ + width: 141, + height: 88, + }} + /> + + {loc.wallets.add.or} + { + Keyboard.dismiss(); + this.setState({ + activeBitcoin: false, + activeLightning: true, + }); + }} + style={{ + width: 141, + height: 88, + }} + /> + - - {(() => { - if (this.state.activeBitcoin) { - return ( - + {(() => { + if (this.state.activeBitcoin && this.state.isAdvancedOptionsEnabled) { + return ( + + + {loc.settings.advanced_options} + this.onSelect(index, value)} selectedIndex={0}> + + {HDSegwitP2SHWallet.typeReadable} - Multiple addresses + + + {SegwitP2SHWallet.typeReadable} - Single address + + + {HDSegwitBech32Wallet.typeReadable} - Multiple addresses + + + + ); + } else if (this.state.activeLightning && this.state.isAdvancedOptionsEnabled) { + return ( + + + {loc.settings.advanced_options} + + Connect to your LNDHub + { + this.setState({ walletBaseURI: text }); }} - > - this.onSelect(index, value)} selectedIndex={0}> - - {HDSegwitP2SHWallet.typeReadable} - - - {SegwitP2SHWallet.typeReadable} - - - - ); - } else { - return ( - - - - ); - } - })()} - - + onSubmitEditing={Keyboard.dismiss} + placeholder="your node address" + clearButtonMode="while-editing" + autoCapitalize="none" + /> + + ); + } else if (this.state.activeBitcoin === undefined && this.state.isAdvancedOptionsEnabled) { + return ; + } + })()} {!this.state.isLoading ? ( { this.setState( { isLoading: true }, @@ -193,19 +230,28 @@ export default class WalletsAdd extends Component { this.createLightningWallet = async () => { w = new LightningCustodianWallet(); - w.setLabel(this.state.label || w.typeReadable); + w.setLabel(this.state.label || loc.wallets.details.title); try { - let lndhub = await AsyncStorage.getItem(AppStorage.LNDHUB); + let lndhub = + this.state.walletBaseURI.trim().length > 0 + ? this.state.walletBaseURI + : LightningCustodianWallet.defaultBaseUri; if (lndhub) { - w.setBaseURI(lndhub); - w.init(); + const isValidNodeAddress = await LightningCustodianWallet.isValidNodeAddress(lndhub); + if (isValidNodeAddress) { + w.setBaseURI(lndhub); + w.init(); + } else { + throw new Error('The provided node address is not valid LNDHub node.'); + } } await w.createAccount(); await w.authorize(); } catch (Err) { this.setState({ isLoading: false }); console.warn('lnd create failure', Err); + return alert(Err); // giving app, not adding anything } A(A.ENUM.CREATED_LIGHTNING_WALLET); @@ -214,7 +260,7 @@ export default class WalletsAdd extends Component { await BlueApp.saveToDisk(); EV(EV.enum.WALLETS_COUNT_CHANGED); A(A.ENUM.CREATED_WALLET); - ReactNativeHapticFeedback.trigger('notificationSuccess', false); + ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); this.props.navigation.dismiss(); }; @@ -243,15 +289,20 @@ export default class WalletsAdd extends Component { } else { this.createLightningWallet(); } + } else if (this.state.selectedIndex === 2) { + // btc was selected + // index 2 radio - hd bip84 + w = new HDSegwitBech32Wallet(); + w.setLabel(this.state.label || loc.wallets.details.title); } else if (this.state.selectedIndex === 1) { // btc was selected // index 1 radio - segwit single address w = new SegwitP2SHWallet(); - w.setLabel(this.state.label || loc.wallets.add.label_new_segwit); + w.setLabel(this.state.label || loc.wallets.details.title); } else { // zero index radio - HD segwit w = new HDSegwitP2SHWallet(); - w.setLabel((this.state.label || loc.wallets.add.label_new_segwit) + ' HD'); + w.setLabel(this.state.label || loc.wallets.details.title); } if (this.state.activeBitcoin) { await w.generate(); @@ -259,8 +310,14 @@ export default class WalletsAdd extends Component { await BlueApp.saveToDisk(); EV(EV.enum.WALLETS_COUNT_CHANGED); A(A.ENUM.CREATED_WALLET); - ReactNativeHapticFeedback.trigger('notificationSuccess', false); - this.props.navigation.dismiss(); + ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); + if (w.type === HDSegwitP2SHWallet.type || w.type === HDSegwitBech32Wallet.type) { + this.props.navigation.navigate('PleaseBackup', { + secret: w.getSecret(), + }); + } else { + this.props.navigation.dismiss(); + } } }, 1, @@ -270,16 +327,16 @@ export default class WalletsAdd extends Component { ) : ( )} - - { - this.props.navigation.navigate('ImportWallet'); - }} - /> - - + + { + this.props.navigation.navigate('ImportWallet'); + }} + /> + + ); } diff --git a/screen/wallets/buyBitcoin.js b/screen/wallets/buyBitcoin.js index fc60ef115..25223d2ed 100644 --- a/screen/wallets/buyBitcoin.js +++ b/screen/wallets/buyBitcoin.js @@ -1,15 +1,7 @@ import React, { Component } from 'react'; -import { Linking, View } from 'react-native'; -import { - BlueNavigationStyle, - BlueCopyTextToClipboard, - BlueLoading, - SafeBlueArea, - BlueButton, - BlueText, - BlueSpacing40, -} from '../../BlueComponents'; +import { BlueNavigationStyle, BlueLoading } from '../../BlueComponents'; import PropTypes from 'prop-types'; +import { WebView } from 'react-native-webview'; /** @type {AppStorage} */ let BlueApp = require('../../BlueApp'); let loc = require('../../loc'); @@ -71,35 +63,20 @@ export default class BuyBitcoin extends Component { return ; } + const { safelloStateToken } = this.props.navigation.state.params; + + let uri = 'https://bluewallet.io/buy-bitcoin-redirect.html?address=' + this.state.address; + + if (safelloStateToken) { + uri += '&safelloStateToken=' + safelloStateToken; + } + return ( - - - - {loc.buyBitcoin.tap_your_address} - - - - { - Linking.openURL('https://bluewallet.io/buy-bitcoin-redirect.html'); - }} - title="Buy Bitcoin" - /> - - - - - - - - - - + ); } } @@ -111,6 +88,7 @@ BuyBitcoin.propTypes = { params: PropTypes.shape({ address: PropTypes.string, secret: PropTypes.string, + safelloStateToken: PropTypes.string, }), }), }), diff --git a/screen/wallets/details.js b/screen/wallets/details.js index 8a6ef265e..024c1307f 100644 --- a/screen/wallets/details.js +++ b/screen/wallets/details.js @@ -180,7 +180,7 @@ export default class WalletDetails extends Component { { - ReactNativeHapticFeedback.trigger('notificationWarning', false); + ReactNativeHapticFeedback.trigger('notificationWarning', { ignoreAndroidSystemSettings: false }); Alert.alert( loc.wallets.details.delete + ' ' + loc.wallets.details.title, loc.wallets.details.are_you_sure, @@ -191,7 +191,7 @@ export default class WalletDetails extends Component { this.props.navigation.setParams({ isLoading: true }); this.setState({ isLoading: true }, async () => { BlueApp.deleteWallet(this.state.wallet); - ReactNativeHapticFeedback.trigger('notificationSuccess', false); + ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); await BlueApp.saveToDisk(); EV(EV.enum.TRANSACTIONS_COUNT_CHANGED); EV(EV.enum.WALLETS_COUNT_CHANGED); diff --git a/screen/wallets/import.js b/screen/wallets/import.js index be8cd80ca..9f248498c 100644 --- a/screen/wallets/import.js +++ b/screen/wallets/import.js @@ -6,6 +6,7 @@ import { HDLegacyBreadwalletWallet, HDSegwitP2SHWallet, HDLegacyP2PKHWallet, + HDSegwitBech32Wallet, } from '../../class'; import React, { Component } from 'react'; import { KeyboardAvoidingView, Dimensions, View, TouchableWithoutFeedback, Keyboard } from 'react-native'; @@ -60,7 +61,7 @@ export default class WalletsImport extends Component { alert('This wallet has been previously imported.'); } else { alert(loc.wallets.import.success); - ReactNativeHapticFeedback.trigger('notificationSuccess', false); + ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); w.setLabel(loc.wallets.import.imported + ' ' + w.typeReadable); BlueApp.wallets.push(w); await BlueApp.saveToDisk(); @@ -83,8 +84,11 @@ export default class WalletsImport extends Component { lnd.setBaseURI(LightningCustodianWallet.defaultBaseUri); lnd.setSecret(text); } + lnd.init(); await lnd.authorize(); await lnd.fetchTransactions(); + await lnd.fetchUserInvoices(); + await lnd.fetchPendingTransactions(); await lnd.fetchBalance(); return this._saveWallet(lnd); } @@ -114,6 +118,16 @@ export default class WalletsImport extends Component { // if we're here - nope, its not a valid WIF + let hd4 = new HDSegwitBech32Wallet(); + hd4.setSecret(text); + if (hd4.validateMnemonic()) { + await hd4.fetchBalance(); + if (hd4.getBalance() > 0) { + await hd4.fetchTransactions(); + return this._saveWallet(hd4); + } + } + let hd1 = new HDLegacyBreadwalletWallet(); hd1.setSecret(text); if (hd1.validateMnemonic()) { @@ -164,10 +178,16 @@ export default class WalletsImport extends Component { return this._saveWallet(hd3); } } + if (hd4.validateMnemonic()) { + await hd4.fetchTransactions(); + if (hd4.getTransactions().length !== 0) { + return this._saveWallet(hd4); + } + } // is it even valid? if yes we will import as: - if (hd2.validateMnemonic()) { - return this._saveWallet(hd2); + if (hd4.validateMnemonic()) { + return this._saveWallet(hd4); } // not valid? maybe its a watch-only address? @@ -188,8 +208,9 @@ export default class WalletsImport extends Component { } alert(loc.wallets.import.error); - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); // Plan: + // 0. check if its HDSegwitBech32Wallet (BIP84) // 1. check if its HDSegwitP2SHWallet (BIP49) // 2. check if its HDLegacyP2PKHWallet (BIP44) // 3. check if its HDLegacyBreadwalletWallet (no BIP, just "m/0") diff --git a/screen/wallets/list.js b/screen/wallets/list.js index ce451c4d4..697f0bab7 100644 --- a/screen/wallets/list.js +++ b/screen/wallets/list.js @@ -191,6 +191,7 @@ export default class WalletsList extends Component { } if (wallets[index].fetchUserInvoices) { await wallets[index].fetchUserInvoices(); + await wallets[index].fetchBalance(); // chances are, paid ln invoice was processed during `fetchUserInvoices()` call and altered user's balance, so its worth fetching balance again } this.redrawScreen(); didRefresh = true; @@ -232,7 +233,7 @@ export default class WalletsList extends Component { if (BlueApp.getWallets().length > 1) { this.props.navigation.navigate('ReorderWallets'); } else { - ReactNativeHapticFeedback.trigger('notificationError', false); + ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); } }; diff --git a/screen/wallets/pleaseBackup.js b/screen/wallets/pleaseBackup.js new file mode 100644 index 000000000..d7788b10a --- /dev/null +++ b/screen/wallets/pleaseBackup.js @@ -0,0 +1,233 @@ +import React, { Component } from 'react'; +import { ActivityIndicator, View, BackHandler, Text } from 'react-native'; +import { BlueSpacing20, SafeBlueArea, BlueNavigationStyle, BlueText, BlueButton } from '../../BlueComponents'; +import PropTypes from 'prop-types'; +import Privacy from '../../Privacy'; +let loc = require('../../loc'); + +export default class PleaseBackup extends Component { + static navigationOptions = ({ navigation }) => ({ + ...BlueNavigationStyle(navigation, true), + title: loc.pleasebackup.title, + headerLeft: null, + headerRight: null, + }); + + constructor(props) { + super(props); + + this.state = { + isLoading: true, + words: props.navigation.state.params.secret.split(' '), + }; + BackHandler.addEventListener('hardwareBackPress', this.handleBackButton.bind(this)); + } + + handleBackButton() { + this.props.navigation.dismiss(); + return true; + } + + componentDidMount() { + Privacy.enableBlur(); + this.setState({ + isLoading: false, + }); + } + + componentWillUnmount() { + Privacy.disableBlur(); + BackHandler.removeEventListener('hardwareBackPress', this.handleBackButton.bind(this)); + } + + render() { + if (this.state.isLoading) { + return ( + + + + ); + } + + return ( + + {loc.pleasebackup.text} + + + + + 1. + {this.state.words[0]} + + + + + 2. + {this.state.words[1]} + + + + + 3. + {this.state.words[2]} + + + + + 4. + {this.state.words[3]} + + + + + + + 5. + {this.state.words[4]} + + + + + 6. + {this.state.words[5]} + + + + + 7. + {this.state.words[6]} + + + + + 8. + {this.state.words[7]} + + + + + + + 9. + {this.state.words[8]} + + + + + 10. + {this.state.words[9]} + + + + + 11. + {this.state.words[10]} + + + + + 12. + {this.state.words[11]} + + + + + + + 13. + {this.state.words[12]} + + + + + 14. + {this.state.words[13]} + + + + + 15. + {this.state.words[14]} + + + + + 16. + {this.state.words[15]} + + + + + + + 17. + {this.state.words[16]} + + + + + 18. + {this.state.words[17]} + + + + + 19. + {this.state.words[18]} + + + + + 20. + {this.state.words[19]} + + + + + + + 21. + {this.state.words[20]} + + + + + 22. + {this.state.words[21]} + + + + + 23. + {this.state.words[22]} + + + + + 24. + {this.state.words[23]} + + + + + + + this.props.navigation.dismiss()} title={loc.pleasebackup.ok} /> + + + + + ); + } +} + +PleaseBackup.propTypes = { + navigation: PropTypes.shape({ + state: PropTypes.shape({ + params: PropTypes.shape({ + secret: PropTypes.string, + }), + }), + dismiss: PropTypes.func, + }), +}; diff --git a/screen/wallets/reorderWallets.js b/screen/wallets/reorderWallets.js index 278258a9f..e3bcd184c 100644 --- a/screen/wallets/reorderWallets.js +++ b/screen/wallets/reorderWallets.js @@ -161,14 +161,14 @@ export default class ReorderWallets extends Component { data={this.state.data} renderRow={this._renderItem} onChangeOrder={() => { - ReactNativeHapticFeedback.trigger('impactMedium', false); + ReactNativeHapticFeedback.trigger('impactMedium', { ignoreAndroidSystemSettings: false }); this.setState({ hasMovedARow: true }); }} onActivateRow={() => { - ReactNativeHapticFeedback.trigger('selection', false); + ReactNativeHapticFeedback.trigger('selection', { ignoreAndroidSystemSettings: false }); }} onReleaseRow={() => { - ReactNativeHapticFeedback.trigger('impactLight', false); + ReactNativeHapticFeedback.trigger('impactLight', { ignoreAndroidSystemSettings: false }); }} /> diff --git a/screen/wallets/scanQrWif.js b/screen/wallets/scanQrWif.js index 880f61c37..e4c0e7747 100644 --- a/screen/wallets/scanQrWif.js +++ b/screen/wallets/scanQrWif.js @@ -2,9 +2,8 @@ import React from 'react'; import { ActivityIndicator, Image, View, TouchableOpacity } from 'react-native'; import { BlueText, SafeBlueArea, BlueButton } from '../../BlueComponents'; -import Camera from 'react-native-camera'; -import Permissions from 'react-native-permissions'; -import { SegwitP2SHWallet, LegacyWallet, WatchOnlyWallet, HDLegacyP2PKHWallet } from '../../class'; +import { RNCamera } from 'react-native-camera'; +import { SegwitP2SHWallet, LegacyWallet, WatchOnlyWallet, HDLegacyP2PKHWallet, HDSegwitBech32Wallet } from '../../class'; import PropTypes from 'prop-types'; import { HDSegwitP2SHWallet } from '../../class/hd-segwit-p2sh-wallet'; import { LightningCustodianWallet } from '../../class/lightning-custodian-wallet'; @@ -24,10 +23,9 @@ export default class ScanQrWif extends React.Component { state = { isLoading: false, - hasCameraPermission: null, }; - async onBarCodeScanned(ret) { + onBarCodeScanned = async ret => { if (+new Date() - this.lastTimeIveBeenHere < 6000) { this.lastTimeIveBeenHere = +new Date(); return; @@ -73,8 +71,35 @@ export default class ScanQrWif extends React.Component { } } + // is it HD BIP84 mnemonic? + let hd = new HDSegwitBech32Wallet(); + hd.setSecret(ret.data); + if (hd.validateMnemonic()) { + for (let w of BlueApp.wallets) { + if (w.getSecret() === hd.getSecret()) { + // lookig for duplicates + this.setState({ isLoading: false }); + return alert(loc.wallets.scanQrWif.wallet_already_exists); // duplicate, not adding + } + } + this.setState({ isLoading: true }); + hd.setLabel(loc.wallets.import.imported + ' ' + hd.typeReadable); + await hd.fetchBalance(); + if (hd.getBalance() !== 0) { + await hd.fetchTransactions(); + BlueApp.wallets.push(hd); + await BlueApp.saveToDisk(); + alert(loc.wallets.import.success); + this.props.navigation.popToTop(); + setTimeout(() => EV(EV.enum.WALLETS_COUNT_CHANGED), 500); + this.setState({ isLoading: false }); + return; + } + } + // nope + // is it HD legacy (BIP44) mnemonic? - let hd = new HDLegacyP2PKHWallet(); + hd = new HDLegacyP2PKHWallet(); hd.setSecret(ret.data); if (hd.validateMnemonic()) { for (let w of BlueApp.wallets) { @@ -138,10 +163,13 @@ export default class ScanQrWif extends React.Component { try { await lnd.authorize(); await lnd.fetchTransactions(); + await lnd.fetchUserInvoices(); + await lnd.fetchPendingTransactions(); await lnd.fetchBalance(); } catch (Err) { console.log(Err); this.setState({ isLoading: false }); + alert(Err.message); return; } @@ -149,6 +177,7 @@ export default class ScanQrWif extends React.Component { lnd.setLabel(loc.wallets.import.imported + ' ' + lnd.typeReadable); this.props.navigation.popToTop(); alert(loc.wallets.import.success); + await BlueApp.saveToDisk(); setTimeout(() => EV(EV.enum.WALLETS_COUNT_CHANGED), 500); this.setState({ isLoading: false }); return; @@ -168,15 +197,14 @@ export default class ScanQrWif extends React.Component { } } - if (watchOnly.isAddressValid(watchAddr)) { - watchOnly.setSecret(watchAddr); + if (watchOnly.setSecret(watchAddr) && watchOnly.valid()) { watchOnly.setLabel(loc.wallets.scanQrWif.imported_watchonly); BlueApp.wallets.push(watchOnly); alert(loc.wallets.scanQrWif.imported_watchonly + loc.wallets.scanQrWif.with_address + watchOnly.getAddress()); - this.props.navigation.popToTop(); await watchOnly.fetchBalance(); await watchOnly.fetchTransactions(); await BlueApp.saveToDisk(); + this.props.navigation.popToTop(); setTimeout(() => EV(EV.enum.WALLETS_COUNT_CHANGED), 500); this.setState({ isLoading: false }); return; @@ -213,14 +241,7 @@ export default class ScanQrWif extends React.Component { await BlueApp.saveToDisk(); this.props.navigation.popToTop(); setTimeout(() => EV(EV.enum.WALLETS_COUNT_CHANGED), 500); - } // end - - async componentWillMount() { - Permissions.request('camera').then(response => { - // Response is one of: 'authorized', 'denied', 'restricted', or 'undetermined' - this.setState({ hasCameraPermission: response === 'authorized' }); - }); - } + }; // end render() { if (this.state.isLoading) { @@ -230,59 +251,68 @@ export default class ScanQrWif extends React.Component { ); } - - const { hasCameraPermission } = this.state; - if (hasCameraPermission === null) { - return ; - } else if (hasCameraPermission === false) { - alert('BlueWallet does not have permission to use your camera.'); - this.props.navigation.goBack(null); - return ; - } else { - return ( - - {(() => { - if (this.state.message) { - return ( - - - {this.state.message} - { - this.setState({ message: false }); + return ( + + {(() => { + if (this.state.message) { + return ( + + + {this.state.message} + { + this.setState({ message: false }); shold_stop_bip38 = true; // eslint-disable-line - }} - title={loc.wallets.scanQrWif.cancel} - /> - - - ); - } else { - return ( - - this.onBarCodeScanned(ret)}> - this.props.navigation.goBack(null)} - > - - - - - ); - } - })()} - - ); - } + }} + title={loc.wallets.scanQrWif.cancel} + /> + + + ); + } else { + return ( + + + this.props.navigation.goBack(null)} + > + + + + ); + } + })()} + + ); } } diff --git a/screen/wallets/selectWallet.js b/screen/wallets/selectWallet.js index 9d0a6e72f..4f83fd03b 100644 --- a/screen/wallets/selectWallet.js +++ b/screen/wallets/selectWallet.js @@ -36,7 +36,7 @@ export default class SelectWallet extends Component { return ( { - ReactNativeHapticFeedback.trigger('selection', false); + ReactNativeHapticFeedback.trigger('selection', { ignoreAndroidSystemSettings: false }); this.props.navigation.getParam('onWalletSelect')(item); }} > diff --git a/screen/wallets/walletMigrate.js b/screen/wallets/walletMigrate.js index 46271477f..b7be6f35d 100644 --- a/screen/wallets/walletMigrate.js +++ b/screen/wallets/walletMigrate.js @@ -1,5 +1,6 @@ import React, { Component } from 'react'; -import { View, ActivityIndicator, AsyncStorage } from 'react-native'; +import { View, ActivityIndicator } from 'react-native'; +import AsyncStorage from '@react-native-community/async-storage'; import PropTypes from 'prop-types'; import RNFS from 'react-native-fs'; diff --git a/screen/wallets/xpub.js b/screen/wallets/xpub.js index 9800e8d22..daeca6a87 100644 --- a/screen/wallets/xpub.js +++ b/screen/wallets/xpub.js @@ -42,12 +42,7 @@ export default class WalletXpub extends Component { Privacy.enableBlur(); this.setState({ isLoading: false, - showQr: false, }); - - setTimeout(() => { - this.setState({ showQr: true }); - }, 1000); } componentWillUnmount() { @@ -76,27 +71,16 @@ export default class WalletXpub extends Component { - {(() => { - if (this.state.showQr) { - return ( - - ); - } else { - return ( - - - - ); - } - })()} + + diff --git a/tests/unit/signer.js b/tests/unit/signer.js index 38db40649..775fe6af2 100644 --- a/tests/unit/signer.js +++ b/tests/unit/signer.js @@ -1,5 +1,5 @@ /* global describe, it */ - +let bitcoinjs = require('bitcoinjs-lib') let assert = require('assert') describe('unit - signer', function () { @@ -18,7 +18,6 @@ describe('unit - signer', function () { let txhex = signer.createSegwitTransaction(utxos, '1Pb81K1xJnMjUfFgKUbva6gr1HCHXxHVnr', 0.001, 0.0001, 'KyWpryAKPiXXbipxWhtprZjSLVjp22sxbVnJssq2TCNQxs1SuMeD', '3Bsssbs4ANCGNETvGLJ3Fvri6SiVnH1fbi', 0) assert.equal(txhex, '0100000000010115b7e9d1f6b8164a0e95544a94f5b0fbfaadc35f8415acd0ec0e58d5ce8c1a1e0100000017160014f90e5bca5635b84bd828064586bd7eb117fee9a90000000002905f0100000000001976a914f7c6c1f9f6142107ed293c8fbf85fbc49eb5f1b988ace00f97000000000017a9146fbf1cee74734503297e46a0db3e3fbb06f2e9d38702483045022100bd687693e57161282a80affb82f18386cbf319bca72ca2c16320b0f3b087bee802205e22a9a16b86628ea08eab83aebec1348c476e9d0c90cd41aa73c47f50d86aab0121039425479ea581ebc7f55959da8c2e1a1063491768860386335dd4630b5eeacfc500000000') // now, testing change addess, destination address, amounts & fees... - let bitcoinjs = require('bitcoinjs-lib') let tx = bitcoinjs.Transaction.fromHex(txhex) assert.equal(bitcoinjs.address.fromOutputScript(tx.outs[0].script), '1Pb81K1xJnMjUfFgKUbva6gr1HCHXxHVnr') assert.equal(bitcoinjs.address.fromOutputScript(tx.outs[1].script), '3Bsssbs4ANCGNETvGLJ3Fvri6SiVnH1fbi') @@ -30,7 +29,6 @@ describe('unit - signer', function () { it('should create Replace-By-Fee tx, given txhex', () => { let txhex = '0100000000010115b7e9d1f6b8164a0e95544a94f5b0fbfaadc35f8415acd0ec0e58d5ce8c1a1e0100000017160014f90e5bca5635b84bd828064586bd7eb117fee9a90000000002905f0100000000001976a914f7c6c1f9f6142107ed293c8fbf85fbc49eb5f1b988ace00f97000000000017a9146fbf1cee74734503297e46a0db3e3fbb06f2e9d38702483045022100bd687693e57161282a80affb82f18386cbf319bca72ca2c16320b0f3b087bee802205e22a9a16b86628ea08eab83aebec1348c476e9d0c90cd41aa73c47f50d86aab0121039425479ea581ebc7f55959da8c2e1a1063491768860386335dd4630b5eeacfc500000000' let signer = require('../../models/signer') - let bitcoinjs = require('bitcoinjs-lib') let dummyUtxodata = { '15b7e9d1f6b8164a0e95544a94f5b0fbfaadc35f8415acd0ec0e58d5ce8c1a1e': { // txid we use output from 1: 666 // output index and it's value in satoshi @@ -75,7 +73,7 @@ describe('unit - signer', function () { let signer = require('../../models/signer') let utxos = [ { 'txid': '160559030484800a77f9b38717bb0217e87bfeb47b92e2e5bad6316ad9d8d360', 'vout': 1, 'address': '3NBtBset4qPD8DZeLw4QbFi6SNjNL8hg7x', 'account': '3NBtBset4qPD8DZeLw4QbFi6SNjNL8hg7x', 'scriptPubKey': 'a914e0d81f03546ab8f29392b488ec62ab355ee7c57387', 'amount': 0.00400000, 'confirmations': 271, 'spendable': false, 'solvable': false, 'safe': true } ] let txhex = signer.createSegwitTransaction(utxos, '1Pb81K1xJnMjUfFgKUbva6gr1HCHXxHVnr', 0.003998, 0.000001, 'L4iRvejJG9gRhKVc3rZm5haoyd4EuCi77G91DnXRrvNDqiXktkXh') - let bitcoin = require('bitcoinjs-lib') + let bitcoin = bitcoinjs let tx = bitcoin.Transaction.fromHex(txhex); assert.equal(tx.ins.length, 1); assert.equal(tx.outs.length, 1); // only 1 output, which means change is neglected