diff --git a/i18n/po/template.pot b/i18n/po/template.pot index 9c0e3bdc6..5483ce40b 100644 --- a/i18n/po/template.pot +++ b/i18n/po/template.pot @@ -3849,4 +3849,20 @@ msgstr "" #: src/js/services/incomingData.js:129 msgid "This invoice is no longer accepting payments" +msgstr "" + +#: src/js/controllers/tab-scan.js:120 +msgid "Scan Failed" +msgstr "" + +#: src/js/controllers/tab-scan.js:121 +msgid "Data not recognised." +msgstr "" + +#: src/js/controllers/tab-scan.js:121 +msgid "Unsupported" +msgstr "" + +#: src/js/controllers/tab-scan.js:121 +msgid "Testnet is not supported." msgstr "" \ No newline at end of file diff --git a/src/js/app.js b/src/js/app.js index 745ceef50..503da9f52 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -19,7 +19,8 @@ var modules = [ 'copayApp.controllers', 'copayApp.directives', 'copayApp.addons', - 'bitcoincom.directives' + 'bitcoincom.directives', + 'bitcoincom.services' ]; var copayApp = window.copayApp = angular.module('copayApp', modules); @@ -30,3 +31,4 @@ angular.module('copayApp.controllers', []); angular.module('copayApp.directives', []); angular.module('copayApp.addons', []); angular.module('bitcoincom.directives', []); +angular.module('bitcoincom.services', []); diff --git a/src/js/controllers/amount.spec.js b/src/js/controllers/amount.spec.js index ed64da836..20b403a4d 100644 --- a/src/js/controllers/amount.spec.js +++ b/src/js/controllers/amount.spec.js @@ -7,6 +7,8 @@ describe('amountController', function(){ platformInfo, profileService, rateService, + sendFlowService, + shapeshiftService, $stateParams; @@ -39,9 +41,11 @@ describe('amountController', function(){ isIos: true }; - profileService = jasmine.createSpyObj(['getWallets']); + profileService = jasmine.createSpyObj(['getWallet', 'getWallets']); rateService = jasmine.createSpyObj(['fromFiat', 'whenAvailable']); + sendFlowService = jasmine.createSpyObj(['getStateClone']); + shapeshiftService = jasmine.createSpyObj(['shiftIt']); $stateParams = {}; @@ -61,6 +65,11 @@ describe('amountController', function(){ stateName: 'ignoreme' }; $ionicHistory.backView.and.returnValue(backView); + + var wallet = { + + }; + profileService.getWallet.and.returnValue(wallet); profileService.getWallets.and.returnValue([{}]); rateService.fromFiat.and.returnValue(12); // satoshis or coins? @@ -80,22 +89,25 @@ describe('amountController', function(){ popupService: {}, rateService: rateService, $scope: $scope, + sendFlowService: sendFlowService, + shapeshiftService: shapeshiftService, $state: {}, $stateParams: $stateParams, txFormatService: {}, walletService: {} }); - var data = { - stateParams: { - fromWalletId: 'fd56c1e7-e3ac-4fd9-8afc-27b9c1b3718b', - toAddress: 'qrup46avn8t466xxwlzs4qelht7cnwvesv2e29wf7s' - } + var sendFlowState = { + fromWalletId: 'fd56c1e7-e3ac-4fd9-8afc-27b9c1b3718b', + toAddress: 'qrup46avn8t466xxwlzs4qelht7cnwvesv2e29wf7s' }; - $scope.$emit('$ionicView.beforeEnter', data); - expect($scope.fromWalletId).toBe('fd56c1e7-e3ac-4fd9-8afc-27b9c1b3718b'); - expect($scope.toAddress).toBe('qrup46avn8t466xxwlzs4qelht7cnwvesv2e29wf7s'); + sendFlowService.getStateClone.and.returnValue(sendFlowState); + + $scope.$emit('$ionicView.beforeEnter', {}); + + //expect($scope.fromWalletId).toBe('fd56c1e7-e3ac-4fd9-8afc-27b9c1b3718b'); + //expect($scope.toAddress).toBe('qrup46avn8t466xxwlzs4qelht7cnwvesv2e29wf7s'); }); }); \ No newline at end of file diff --git a/src/js/controllers/tab-scan.js b/src/js/controllers/tab-scan.js index 4a654d91d..e6f57d0d2 100644 --- a/src/js/controllers/tab-scan.js +++ b/src/js/controllers/tab-scan.js @@ -1,6 +1,6 @@ 'use strict'; -angular.module('copayApp.controllers').controller('tabScanController', function($scope, $log, $timeout, scannerService, incomingData, $state, $ionicHistory, $rootScope, $ionicNavBarDelegate) { +angular.module('copayApp.controllers').controller('tabScanController', function(bitcoinUriService, gettextCatalog, popupService, $scope, $log, $timeout, scannerService, incomingData, $state, $ionicHistory, $rootScope, $ionicNavBarDelegate) { var scannerStates = { unauthorized: 'unauthorized', @@ -111,7 +111,27 @@ angular.module('copayApp.controllers').controller('tabScanController', function( // Sometimes (testing in Chrome, when reading QR Code) data is an object // that has a string data.result. contents = contents.result || contents; - incomingData.redir(contents); + + var parsed = bitcoinUriService.parse(contents); + var title = ''; + var msg = ''; + if (parsed.isValid) { + if (parsed.testnet) { + title = gettextCatalog.getString('Unsupported'); + msg = gettextCatalog.getString('Testnet is not supported.'); + popupService.showAlert(title, msg, function onAlertShown() { + scannerService.resumePreview(); + }); + } else { + incomingData.redir(contents); + } + } else { + title = gettextCatalog.getString('Scan Failed'); + msg = gettextCatalog.getString('Data not recognised.'); + popupService.showAlert(title, msg, function onAlertShown() { + scannerService.resumePreview(); + }); + } } $rootScope.$on('incomingDataMenu.menuHidden', function() { diff --git a/src/js/controllers/tab-send.js b/src/js/controllers/tab-send.js index 9ac6c35cb..eba744560 100644 --- a/src/js/controllers/tab-send.js +++ b/src/js/controllers/tab-send.js @@ -1,6 +1,6 @@ 'use strict'; -angular.module('copayApp.controllers').controller('tabSendController', function($scope, $rootScope, $log, $timeout, $ionicScrollDelegate, $ionicLoading, addressbookService, profileService, lodash, $state, walletService, incomingData, popupService, platformInfo, sendFlowService, bwcError, gettextCatalog, scannerService, configService, bitcoinCashJsService, $ionicPopup, $ionicNavBarDelegate, clipboardService) { +angular.module('copayApp.controllers').controller('tabSendController', function(bitcoinUriService, $scope, $rootScope, $log, $timeout, $ionicScrollDelegate, $ionicLoading, addressbookService, profileService, lodash, $state, walletService, incomingData, popupService, platformInfo, sendFlowService, bwcError, gettextCatalog, scannerService, configService, bitcoinCashJsService, $ionicPopup, $ionicNavBarDelegate, clipboardService) { var clipboardHasAddress = false; var clipboardHasContent = false; var originalList; @@ -39,7 +39,9 @@ angular.module('copayApp.controllers').controller('tabSendController', function( $scope.clipboardHasAddress = false; $scope.clipboardHasContent = false; - if ((text.indexOf('bitcoincash:') === 0 || text[0] === 'C' || text[0] === 'H' || text[0] === 'p' || text[0] === 'q') && text.replace('bitcoincash:', '').length === 42) { // CashAddr + var parsed = bitcoinUriService.parse(text); + console.log('parsed', parsed); + if (parsed.isValid && parsed.publicAddress && parsed.coin === 'bch' && !parsed.testnet) { // CashAddr $scope.clipboardHasAddress = true; } else if ((text[0] === "1" || text[0] === "3" || text.substring(0, 3) === "bc1") && text.length >= 26 && text.length <= 35) { // Legacy Addresses $scope.clipboardHasAddress = true; diff --git a/src/js/controllers/walletSelectorController.js b/src/js/controllers/walletSelectorController.js index 777871e44..6a5b96cbf 100644 --- a/src/js/controllers/walletSelectorController.js +++ b/src/js/controllers/walletSelectorController.js @@ -99,6 +99,7 @@ angular.module('copayApp.controllers').controller('walletSelectorController', fu $scope.requestAmountSecondary = fiatAmount; $scope.requestCurrencySecondary = fiatCurrrency; } + $scope.$apply(); } }); } diff --git a/src/js/services/bitcoin-uri.service.js b/src/js/services/bitcoin-uri.service.js new file mode 100644 index 000000000..0dd57dbf2 --- /dev/null +++ b/src/js/services/bitcoin-uri.service.js @@ -0,0 +1,336 @@ +'use strict'; + +// https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki +// https://github.com/bitcoin/bips/blob/master/bip-0072.mediawiki + +(function(){ + + angular + .module('bitcoincom.services') + .factory('bitcoinUriService', bitcoinUriService); + + function bitcoinUriService(bitcoinCashJsService, bwcService, $log) { + var bch = bitcoinCashJsService.getBitcoinCashJs(); + var bitcore = bwcService.getBitcore(); + + var service = { + parse: parse + }; + + return service; + + function bitpayAddrOnMainnet(address) { + var Address = bch.Address; + var BitpayFormat = Address.BitpayFormat; + + var mainnet = bch.Networks.mainnet; + + var result = null; + if (address[0] == 'C') { + try { + result = Address.fromString(address, mainnet, 'pubkeyhash', BitpayFormat); + } catch (e) {}; + + } else if (address[0] == 'H') { + try { + result = Address.fromString(address, mainnet, 'scripthash', BitpayFormat); + } catch (e) {}; + + } + return result; + } + + function cashAddrOnMainnet(address) { + var Address = bch.Address; + var CashAddrFormat = Address.CashAddrFormat; + + var mainnet = bch.Networks.mainnet; + + var prefixed = 'bitcoincash:' + address; + var result = null; + if (address[0] == 'q') { + try { + result = Address.fromString(prefixed, mainnet, 'pubkeyhash', CashAddrFormat); + } catch (e) {}; + + } else if (address[0] == 'p') { + try { + result = Address.fromString(prefixed, mainnet, 'scripthash', CashAddrFormat); + } catch (e) {}; + + } + return result; + } + + function cashAddrOnTestnet(address) { + var Address = bch.Address; + var CashAddrFormat = Address.CashAddrFormat; + + var testnet = bch.Networks.testnet; + + var prefixed = 'bchtest:' + address; + var result = null; + if (address[0] == 'q') { + try { + result = Address.fromString(prefixed, testnet, 'pubkeyhash', CashAddrFormat); + } catch (e) {}; + + } else if (address[0] == 'p') { + try { + result = Address.fromString(prefixed, testnet, 'scripthash', CashAddrFormat); + } catch (e) {}; + + } + return result; + } + + + /* + For parsing: + BIP21 + BIP72 + + returns: + { + amount: '', + coin: '', + copayInvitation: '', + isValid: false, + label: '', + message: '', + other: { + somethingIDontUnderstand: 'Its value' + }, + privateKey: { + encrypted: '', + wif: '' + }'', + publicAddress: { + bitpay: '', + cashAddr: '', + legacy: '', + }, + req: { + "req-param0": '', + "req-param1": '' + }, + testnet: false, + url: '' // For BIP70 + } + + Only fields that are present in the data are defined in the returned object. Both privateKey and publicAddress only have 1 field defined, if they exist at all. + The exception to this is the coin property, which is determined from other data, such as the prefix or address type. + + */ + + function parse(data) { + var parsed = { + isValid: false + }; + + if (typeof data !== 'string') { + return parsed; + } + + // Identify prefix + var trimmed = data.trim(); + var colonSplit = /^([\w-]*):?(.*)$/.exec(trimmed); + if (!colonSplit) { + return parsed; + } + + var addressAndParams = ''; + var preColonLower = colonSplit[1].toLowerCase(); + if (preColonLower === 'bitcoin') { + parsed.coin = 'btc'; + addressAndParams = colonSplit[2]; + console.log('Is btc'); + + } else if (/^(?:bitcoincash)|(?:bitcoin-cash)$/.test(preColonLower)) { + parsed.coin = 'bch'; + parsed.test = false; + addressAndParams = colonSplit[2]; + console.log('Is bch'); + + } else if (/^(?:bchtest)$/.test(preColonLower)) { + parsed.coin = 'bch'; + parsed.testnet = true; + addressAndParams = colonSplit[2]; + console.log('Is bch'); + + } else if (colonSplit[2] === '') { + // No colon and no coin specifier. + addressAndParams = colonSplit[1]; + console.log('No prefix.'); + + } else { + // Something with a colon in the middle that we don't recognise + return parsed; + } + + // Remove erroneous leading slashes + var leadingSlashes = /^\/*([^\/]+(?:.*))$/.exec(addressAndParams); + if (!leadingSlashes) { + return parsed; + } + addressAndParams = leadingSlashes[1]; + + var questionMarkSplit = /^([^\?]*)\??([^\?]*)$/.exec(addressAndParams); + if (!questionMarkSplit) { + return parsed; + } + + var address = questionMarkSplit[1]; + var params = questionMarkSplit[2]; + + if (params.length > 0) { + var paramsSplit = params.split('&'); + var others; + var req; + var paramCount = paramsSplit.length; + for(var i = 0; i < paramCount; i++) { + var param = paramsSplit[i]; + var valueSplit = param.split('='); + if (valueSplit.length !== 2) { + return parsed; + } + + var key = valueSplit[0]; + var value = valueSplit[1]; + var decodedValue = decodeURIComponent(value); + switch(key) { + case 'amount': + var amount = parseFloat(decodedValue); + if (amount) { // Checking for NaN, or no numbers at all etc. + parsed.amount = decodedValue; + } else { + return parsed; + } + break; + + case 'label': + parsed.label = decodedValue; + break; + + case 'message': + parsed.message = decodedValue; + break; + + case 'r': + // Could use a more comprehesive regex to test URL validity, but then how would we know + // which part of the validation it failed? + if (decodedValue.startsWith('https://')) { + parsed.url = decodedValue; + } else { + return parsed; + } + break; + + default: + if (key.startsWith('req-')) { + req = req || {}; + req[key] = decodedValue; + } else { + others = others || {}; + others[key] = decodedValue; + } + } + + }; + } + + parsed.others = others; + parsed.req = req; + + + if (address) { + var addressLowerCase = address.toLowerCase(); + var copayInvitationRe = /^[0-9A-HJ-NP-Za-km-z]{70,80}$/; + //var legacyRe = /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/; + //var legacyTestnetRe = /^[mn][a-km-zA-HJ-NP-Z1-9]{25,34}$/; + var privateKeyEncryptedRe = /^6P[1-9A-HJ-NP-Za-km-z]{56}$/; + var privateKeyForUncompressedPublicKeyRe = /^5[1-9A-HJ-NP-Za-km-z]{50}$/; + var privateKeyForUncompressedPublicKeyTestnetRe = /^9[1-9A-HJ-NP-Za-km-z]{50}$/; + var privateKeyForCompressedPublicKeyRe = /^[KL][1-9A-HJ-NP-Za-km-z]{51}$/; + var privateKeyForCompressedPublicKeyTestnetRe = /^[c][1-9A-HJ-NP-Za-km-z]{51}$/; + + var bitpayAddrMainnet = bitpayAddrOnMainnet(address); + var cashAddrTestnet = cashAddrOnTestnet(addressLowerCase); + var cashAddrMainnet = cashAddrOnMainnet(addressLowerCase); + var privateKey = ''; + + if (parsed.testnet && cashAddrTestnet) { + parsed.address = addressLowerCase; + parsed.coin = 'bch'; + parsed.publicAddress = { + cashAddr: addressLowerCase + }; + parsed.isValid = true; + + } else if (cashAddrMainnet) { + parsed.coin = 'bch'; + parsed.publicAddress = { + cashAddr: addressLowerCase + }; + parsed.testnet = false; + parsed.isValid = true; + + } else if (bitcore.Address.isValid(address, 'livenet')) { + parsed.publicAddress = { + legacy: address + }; + parsed.testnet = false; + parsed.isValid = true; + + } else if (bitcore.Address.isValid(address, 'testnet')) { + parsed.publicAddress = { + legacy: address + }; + parsed.testnet = true; + parsed.isValid = true; + + } else if (bitpayAddrMainnet) { + parsed.coin = 'bch'; + parsed.publicAddress = { + bitpay: address + }; + parsed.testnet = false; + parsed.isValid = true; + + } else if (copayInvitationRe.test(address) ) { + parsed.copayInvitation = address; + parsed.isValid = true; + + } else if (privateKeyForUncompressedPublicKeyRe.test(address) || privateKeyForCompressedPublicKeyRe.test(address)) { + privateKey = address; + try { + new bitcore.PrivateKey(privateKey, 'livenet'); + parsed.privateKey = { wif: privateKey }; + parsed.testnet = false; + parsed.isValid = true; + } catch (e) {} + + } else if (privateKeyForUncompressedPublicKeyTestnetRe.test(address) || privateKeyForCompressedPublicKeyTestnetRe.test(address)) { + privateKey = address; + try { + new bitcore.PrivateKey(privateKey, 'testnet'); + parsed.privateKey = { wif: privateKey }; + parsed.testnet = true; + parsed.isValid = true; + } catch (e) {} + + } else if (privateKeyEncryptedRe.test(address)) { + parsed.privateKey = { encrypted: address }; + parsed.isValid = true; + } + + } else { + parsed.isValid = !!parsed.url; // BIP72 + } + + return parsed; + } + + } + +})(); \ No newline at end of file diff --git a/src/js/services/bitcoin-uri.service.spec.js b/src/js/services/bitcoin-uri.service.spec.js new file mode 100644 index 000000000..0e4a2ba31 --- /dev/null +++ b/src/js/services/bitcoin-uri.service.spec.js @@ -0,0 +1,353 @@ +fdescribe('bitcoinUriService', function() { + var bitcoinUriService; + + beforeEach(function() { + module('bitcoinCashJsModule'); + module('bitcoincom.services'); + module('bwcModule'); + + inject(function($injector){ + bitcoinUriService = $injector.get('bitcoinUriService'); + }); + }); + + + it('Bitcoin BIP72', function() { + + var parsed = bitcoinUriService.parse('bitcoin:?r=https://bitpay.com/i/CwzbKP3k3JNgXJBfuoerDr'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('btc'); + expect(parsed.testnet).toBeUndefined(); + expect(parsed.publicAddress).toBeUndefined(); + expect(parsed.url).toBe('https://bitpay.com/i/CwzbKP3k3JNgXJBfuoerDr'); + }); + + it('Bitcoin Cash BIP72', function() { + + var parsed = bitcoinUriService.parse('bitcoincash:?r=https://bitpay.com/i/SmHdie5dvBnG5kouZzEPzu'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.publicAddress).toBeUndefined(); + expect(parsed.testnet).toBeUndefined(); + expect(parsed.url).toBe('https://bitpay.com/i/SmHdie5dvBnG5kouZzEPzu'); + }); + + it('Bitcoin Cash prefix with legacy address', function() { + + var parsed = bitcoinUriService.parse('bitcoincash:1G9FA9fFnHfTYxvmXeAbBD9FwzPAVMbd3j'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.publicAddress.legacy).toBe('1G9FA9fFnHfTYxvmXeAbBD9FwzPAVMbd3j'); + expect(parsed.testnet).toBe(false); + }); + + it('Bitcoin Cash prefix with legacy address on testnet', function() { + + var parsed = bitcoinUriService.parse('bitcoincash:mkDQrKfSFD441JxrD1iPBsJFExgkvrPGQn'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.publicAddress.legacy).toBe('mkDQrKfSFD441JxrD1iPBsJFExgkvrPGQn'); + expect(parsed.testnet).toBe(true); + }); + + it('Bitcoin Cash uri with extended params', function() { + + var parsed = bitcoinUriService.parse('bitcoincash:qr8v2vqnzntykakht43rqmxq8cdjzjp795fc3vsjgc?unknown=something&mystery=Melton%20probang&req-one=ichi&req-beta=Ni%20san'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.others.mystery).toBe('Melton probang'); + expect(parsed.others.unknown).toBe('something'); + expect(parsed.publicAddress.cashAddr).toBe('qr8v2vqnzntykakht43rqmxq8cdjzjp795fc3vsjgc'); + expect(parsed.req['req-beta']).toBe('Ni san'); + expect(parsed.req['req-one']).toBe('ichi'); + expect(parsed.testnet).toBe(false); + }); + + it('Bitcoin Cash uri with invalid amount', function() { + + var parsed = bitcoinUriService.parse('bitcoincash:qq0knhwj4d5zy3kdph24w6etq58vwzua6sm7lhcmuk?amount=three'); + + expect(parsed.isValid).toBe(false); + }); + + + it('Bitcoin testnet address', function() { + + var parsed = bitcoinUriService.parse('mtWcoToWhbtPoCby5fvs8xdBujT5GGenD4'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBeUndefined(); + expect(parsed.publicAddress.legacy).toBe('mtWcoToWhbtPoCby5fvs8xdBujT5GGenD4'); + expect(parsed.testnet).toBe(true); + }); + + it('Bitcoin uri', function() { + + var parsed = bitcoinUriService.parse('bitcoin:15yCdKWVKRvfXMJpPYZBqMhiGKwjKzZdLN'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('btc'); + expect(parsed.publicAddress.legacy).toBe('15yCdKWVKRvfXMJpPYZBqMhiGKwjKzZdLN'); + expect(parsed.testnet).toBe(false); + }); + + it('Bitcoin uri with encoded label', function() { + + var parsed = bitcoinUriService.parse('bitcoin:1MxudKDEBWZ1yjizUSf6htacenNtb3DWbT?label=Mr.%20Smith'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('btc'); + expect(parsed.label).toBe('Mr. Smith'); + expect(parsed.publicAddress.legacy).toBe('1MxudKDEBWZ1yjizUSf6htacenNtb3DWbT'); + expect(parsed.testnet).toBe(false); + }); + + it('Bitcoin uri with params', function() { + + var parsed = bitcoinUriService.parse('bitcoin:12nCRhMDfxVnuF3uYMXv2fNxBohNmacfWu?amount=20.3&label=Luke-Jr&message=Donation%20for%20project%20xyz'); + + expect(parsed.isValid).toBe(true); + expect(parsed.amount).toBe('20.3'); + expect(parsed.coin).toBe('btc'); + expect(parsed.label).toBe('Luke-Jr'); + expect(parsed.publicAddress.legacy).toBe('12nCRhMDfxVnuF3uYMXv2fNxBohNmacfWu'); + expect(parsed.message).toBe('Donation for project xyz'); + expect(parsed.testnet).toBe(false); + }); + + it('Bitcoin uri with slash', function() { + + var parsed = bitcoinUriService.parse('bitcoin:/1GhpYmbRaf73AZRxDwAGr6653iZBGzdgeA'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('btc'); + expect(parsed.publicAddress.legacy).toBe('1GhpYmbRaf73AZRxDwAGr6653iZBGzdgeA'); + expect(parsed.testnet).toBe(false); + }); + + it('Bitcoin uri with slashes', function() { + + var parsed = bitcoinUriService.parse('bitcoin://18PCPhgZJjLxe9g3Q1BXLpL5aVut1fW3aX'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('btc'); + expect(parsed.publicAddress.legacy).toBe('18PCPhgZJjLxe9g3Q1BXLpL5aVut1fW3aX'); + expect(parsed.testnet).toBe(false); + }); + + it('Bitpay without prefix', function() { + + var parsed = bitcoinUriService.parse('CJoRov8TirekvajiimQpb5Hk95evA7H2Yz'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.publicAddress.bitpay).toBe('CJoRov8TirekvajiimQpb5Hk95evA7H2Yz'); + expect(parsed.testnet).toBe(false); + }); + + it('legacy address', function() { + + var parsed = bitcoinUriService.parse('1JXeGEu7bNEAYu6URT6dU6g1Ys6ffSAWYW'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBeUndefined(); + expect(parsed.publicAddress.legacy).toBe('1JXeGEu7bNEAYu6URT6dU6g1Ys6ffSAWYW'); + expect(parsed.testnet).toBe(false); + }); + + it('cashAddr testnet with prefix', function() { + + var parsed = bitcoinUriService.parse('bchtest:qpcz6pmurq9ctg5848trzz9zmuuygj4q5qam7ph3gt'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.publicAddress.cashAddr).toBe('qpcz6pmurq9ctg5848trzz9zmuuygj4q5qam7ph3gt'); + expect(parsed.testnet).toBe(true); + }); + + it('cashAddr uppercase', function() { + + var parsed = bitcoinUriService.parse('BITCOINCASH:QZZG9NMC5VX8GAP6XFATX3TWNSDN2YRMCSSULSMY44'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.publicAddress.cashAddr).toBe('qzzg9nmc5vx8gap6xfatx3twnsdn2yrmcssulsmy44'); + expect(parsed.testnet).toBe(false); + }); + + it('cashAddr with dash', function() { + + var parsed = bitcoinUriService.parse('bitcoin-cash:qpshfu3dk5s3e7zdcgdcun6xgxtra6uyxs7g580js0'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.publicAddress.cashAddr).toBe('qpshfu3dk5s3e7zdcgdcun6xgxtra6uyxs7g580js0'); + expect(parsed.testnet).toBe(false); + }); + + it('cashAddr with prefix', function() { + + var parsed = bitcoinUriService.parse('bitcoincash:qrq9p82a247lecv08ldk5p5h6ahtnjzpqcnh8yhq92'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.publicAddress.cashAddr).toBe('qrq9p82a247lecv08ldk5p5h6ahtnjzpqcnh8yhq92'); + expect(parsed.testnet).toBe(false); + }); + + it('cashAddr with slash', function() { + + var parsed = bitcoinUriService.parse('bitcoincash:/qzdectfmuw0xxztfx7mh045830dqcshj85hr44l35a'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.publicAddress.cashAddr).toBe('qzdectfmuw0xxztfx7mh045830dqcshj85hr44l35a'); + expect(parsed.testnet).toBe(false); + }); + + it('cashAddr with slashes', function() { + + var parsed = bitcoinUriService.parse('bitcoincash://qpj966w8utue75lqqq3rlgh20zkz3rmydqpq8syv9c'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.publicAddress.cashAddr).toBe('qpj966w8utue75lqqq3rlgh20zkz3rmydqpq8syv9c'); + expect(parsed.testnet).toBe(false); + }); + + it('cashAddr without prefix', function() { + + var parsed = bitcoinUriService.parse('qqen2y3l28dpk0dzsag8w027ds96u7z4pc0uxtl0nq'); + + expect(parsed.isValid).toBe(true); + expect(parsed.coin).toBe('bch'); + expect(parsed.publicAddress.cashAddr).toBe('qqen2y3l28dpk0dzsag8w027ds96u7z4pc0uxtl0nq'); + expect(parsed.testnet).toBe(false); + }); + + it('copay invitation', function() { + + var parsed = bitcoinUriService.parse('PD5B7rEEj72st9d5nFszyuKxJP6FAGS7idVC2SMqiMxUcWVd8JifZDJw1UgjUctxefUFE3Sz6qLbch'); + + expect(parsed.isValid).toBe(true); + expect(parsed.copayInvitation).toBe('PD5B7rEEj72st9d5nFszyuKxJP6FAGS7idVC2SMqiMxUcWVd8JifZDJw1UgjUctxefUFE3Sz6qLbch'); + }); + + // Invalid addresses from https://github.com/bitcoincashorg/bitcoincash.org/blob/master/spec/cashaddr.md + it('invalid cashAddr style 1', function() { + var parsed = bitcoinUriService.parse('prefix:x64nx6hz'); + expect(parsed.isValid).toBe(false); + }); + + it('invalid cashAddr style 2', function() { + var parsed = bitcoinUriService.parse('p:gpf8m4h7'); + expect(parsed.isValid).toBe(false); + }); + + it('invalid cashAddr style 3', function() { + var parsed = bitcoinUriService.parse('bitcoincash:qpzry9x8gf2tvdw0s3jn54khce6mua7lcw20ayyn'); + expect(parsed.isValid).toBe(false); + }); + + it('invalid cashAddr style 4', function() { + var parsed = bitcoinUriService.parse('bchtest:testnetaddress4d6njnut'); + expect(parsed.isValid).toBe(false); + }); + + it('invalid cashAddr style 5', function() { + var parsed = bitcoinUriService.parse('bchreg:555555555555555555555555555555555555555555555udxmlmrz'); + expect(parsed.isValid).toBe(false); + }); + + it('non-string', function() { + + var parsed = bitcoinUriService.parse([1, 2, 3, 4]); + + expect(parsed.isValid).toBe(false); + }); + + it('private key encrypted with BIP38', function() { + + var parsed = bitcoinUriService.parse('6PRN5nEDmX842gsBzJryPu8Tw5kcsaQq1GPLcjVQPcEStvbFAtz11JX9pX'); + + expect(parsed.isValid).toBe(true); + expect(parsed.privateKey.encrypted).toBe('6PRN5nEDmX842gsBzJryPu8Tw5kcsaQq1GPLcjVQPcEStvbFAtz11JX9pX'); + }); + + it('private key for compressed pubkey mainnet', function() { + + var parsed = bitcoinUriService.parse('5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ'); + + expect(parsed.isValid).toBe(true); + expect(parsed.privateKey.wif).toBe('5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ'); + expect(parsed.testnet).toBe(false); + }); + + it('private key for compressed pubkey mainnet with wrong checksum', function() { + + var parsed = bitcoinUriService.parse('5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTu'); + + expect(parsed.isValid).toBe(false); + }); + + it('private key for compressed pubkey testnet', function() { + + var parsed = bitcoinUriService.parse('cNJFgo1driFnPcBdBX8BrJrpxchBWXwXCvNH5SoSkdcF6JXXwHMm'); + + expect(parsed.isValid).toBe(true); + expect(parsed.privateKey.wif).toBe('cNJFgo1driFnPcBdBX8BrJrpxchBWXwXCvNH5SoSkdcF6JXXwHMm'); + expect(parsed.testnet).toBe(true); + }); + + it('private key for compressed pubkey testnet with wrong checksum', function() { + + var parsed = bitcoinUriService.parse('cNJFgo1driFnPcBdBX8BrJrpxchBWXwXCvNH5SoSkdcF6JXXwHMM'); + + expect(parsed.isValid).toBe(false); + }); + + it('private key for uncompressed pubkey mainnet', function() { + + var parsed = bitcoinUriService.parse('L18V3rAhCKEioPnJ4BHLCCsaYa8eSNFrMjNQ2EdwgeAdmBSnTMwx'); + + expect(parsed.isValid).toBe(true); + expect(parsed.privateKey.wif).toBe('L18V3rAhCKEioPnJ4BHLCCsaYa8eSNFrMjNQ2EdwgeAdmBSnTMwx'); + expect(parsed.testnet).toBe(false); + }); + + it('private key for uncompressed pubkey mainnet with wrong checksum', function() { + + var parsed = bitcoinUriService.parse('L18V3rAhCKEioPnJ4BHLCCsaYa8eSNFrMjNQ2EdwgeAdmBSnTTwx'); + + expect(parsed.isValid).toBe(false); + }); + + it('private key for uncompressed pubkey testnet', function() { + + var parsed = bitcoinUriService.parse('92Pg46rUhgTT7romnV7iGW6W1gbGdeezqdbJCzShkCsYNzyyNcc'); + + expect(parsed.isValid).toBe(true); + expect(parsed.privateKey.wif).toBe('92Pg46rUhgTT7romnV7iGW6W1gbGdeezqdbJCzShkCsYNzyyNcc'); + expect(parsed.testnet).toBe(true); + }); + + it('private key for uncompressed pubkey testnet with wrong checksum', function() { + + var parsed = bitcoinUriService.parse('92Pg46rUhgTT7romnV7iGW6W1gbGdeezqdbJCzShkCsYNzyyNcC'); + + expect(parsed.isValid).toBe(false); + }); + + it('URL only', function() { + + var parsed = bitcoinUriService.parse('https://www.google.com'); + + expect(parsed.isValid).toBe(false); + }); + +}); \ No newline at end of file diff --git a/src/js/services/incomingData.js b/src/js/services/incomingData.js index 0bf708d8a..b2e125e48 100644 --- a/src/js/services/incomingData.js +++ b/src/js/services/incomingData.js @@ -1,6 +1,6 @@ 'use strict'; -angular.module('copayApp.services').factory('incomingData', function($log, $state, $timeout, $ionicHistory, bitcore, bitcoreCash, $rootScope, payproService, scannerService, sendFlowService, appConfigService, popupService, gettextCatalog, bitcoinCashJsService) { +angular.module('copayApp.services').factory('incomingData', function(bitcoinUriService, $log, $state, $timeout, $ionicHistory, bitcore, bitcoreCash, $rootScope, payproService, scannerService, sendFlowService, appConfigService, popupService, gettextCatalog, bitcoinCashJsService) { var root = {}; @@ -11,6 +11,15 @@ angular.module('copayApp.services').factory('incomingData', function($log, $stat root.redir = function(data, serviceId, serviceData) { var originalAddress = null; var noPrefixInAddress = 0; + var allParsed = bitcoinUriService.parse(data); + + if (allParsed.isValid && allParsed.testnet) { + popupService.showAlert( + gettextCatalog.getString('Unsupported'), + gettextCatalog.getString('Testnet is not supported.') + ); + return false; + } if (data.toLowerCase().indexOf('bitcoin') < 0) { noPrefixInAddress = 1; @@ -114,10 +123,10 @@ angular.module('copayApp.services').factory('incomingData', function($log, $stat }, 100); } // data extensions for Payment Protocol with non-backwards-compatible request - if ((/^bitcoin(cash)?:\?r=[\w+]/).exec(data)) { - var coin = data.indexOf('bitcoincash') >= 0 ? 'bch' : 'btc'; - data = decodeURIComponent(data.replace(/bitcoin(cash)?:\?r=/, '')); - if (coin == 'bch') { + if (allParsed.isValid && allParsed.coin && allParsed.url && !allParsed.testnet) { + var coin = allParsed.coin; + data = allParsed.url; + if (allParsed.coin == 'bch') { payproService.getPayProDetailsViaHttp(data, function onGetPayProDetailsViaHttp(err, details) { if (err) { var message = err.toString(); @@ -127,15 +136,15 @@ angular.module('copayApp.services').factory('incomingData', function($log, $stat } popupService.showAlert(gettextCatalog.getString('Error'), message) } else { - handlePayPro(details, coin); + handlePayPro(details, allParsed.coin); } }); } else { - payproService.getPayProDetails(data, coin, function onGetPayProDetails(err, details) { + payproService.getPayProDetails(data, allParsed.coin, function onGetPayProDetails(err, details) { if (err) { popupService.showAlert(gettextCatalog.getString('Error'), err); } else { - handlePayPro(details, coin); + handlePayPro(details, allParsed.coin); } }); } @@ -144,6 +153,7 @@ angular.module('copayApp.services').factory('incomingData', function($log, $stat data = sanitizeUri(data); + var addr = ''; // Bitcoin URL if (bitcore.URI.isValid(data)) { var coin = 'btc'; @@ -166,28 +176,33 @@ angular.module('copayApp.services').factory('incomingData', function($log, $stat } return true; // Cash URI - } else if (bitcoreCash.URI.isValid(data)) { - var coin = 'bch'; - var parsed = new bitcoreCash.URI(data); + } else if (allParsed.isValid && allParsed.coin === 'bch' && allParsed.publicAddress && !allParsed.testnet) { + var prefix = allParsed.testnet ? 'bchtest:' : 'bitcoincash:'; + var addrIn = allParsed.publicAddress.legacy || allParsed.publicAddress.bitpay || prefix + allParsed.publicAddress.cashAddr; + originalAddress = allParsed.publicAddress.cashAddr || allParsed.publicAddress.legacy || allParsed.publicAddress.bitpay; - var addr = parsed.address ? parsed.address.toString() : ''; - var message = parsed.message; + var addresses = bitcoinCashJsService.readAddress(addrIn); + if (!addresses) { + return false; + } + addr = addresses.legacy; + var message = allParsed.message; - var amount = parsed.amount ? parsed.amount : ''; + var amount = allParsed.amount ? allParsed.amount : ''; // paypro not yet supported on cash - if (parsed.r) { - payproService.getPayProDetails(parsed.r, coin, function(err, details) { + if (allParsed.url) { + payproService.getPayProDetails(allParsed.url, allParsed.coin, function(err, details) { if (err) { if (addr && amount) - goSend(addr, amount, message, coin, serviceId, serviceData); + goSend(addr, amount, message, allParsed.coin, serviceId, serviceData); else popupService.showAlert(gettextCatalog.getString('Error'), err); } - handlePayPro(details, coin); + handlePayPro(details, allParsed.coin); }); } else { - goSend(addr, amount, message, coin, serviceId, serviceData); + goSend(addr, amount, message, allParsed.coin, serviceId, serviceData); } return true; @@ -401,7 +416,7 @@ angular.module('copayApp.services').factory('incomingData', function($log, $stat function handlePayPro(payProData, coin) { - console.log(payProData); + console.log('payProData', payProData); var toAddr = payProData.toAddress; var amount = payProData.amount; @@ -456,9 +471,6 @@ angular.module('copayApp.services').factory('incomingData', function($log, $stat stateParams.requiredFeeRate = thirdPartyData.requiredFeeRate * 1024; } - // This does not make sense, thirdPartyData gets added by stateParams below - //sendFlowService.pushState(thirdPartyData); - scannerService.pausePreview(); $state.go('tabs.send', {}, { 'reload': true, diff --git a/test/karma.conf.js b/test/karma.conf.js index b4f64af73..22efcd1c8 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -17,7 +17,7 @@ module.exports = function(config) { files: [ 'node_modules/angular/angular.js', - 'bitanalytics/bitanalytics-0.1.0.js', + 'bitanalytics/bitanalytics.js', // From Gruntfile.js 'bower_components/qrcode-generator/js/qrcode.js',