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/services/bitcoin-uri.service.js b/src/js/services/bitcoin-uri.service.js new file mode 100644 index 000000000..40dc48b9f --- /dev/null +++ b/src/js/services/bitcoin-uri.service.js @@ -0,0 +1,250 @@ +'use strict'; + +(function(){ + + angular + .module('bitcoincom.services') + .factory('bitcoinUriService', bitcoinUriService); + + function bitcoinUriService(bitcoinCashJsService, bwcService, $log) { + var bch = bitcoinCashJsService.getBitcoinCashJs(); + var bitcore = bwcService.getBitcore(); + var cashAddrRe = /^((?:q|p)[a-z0-9]{41})|((?:Q|P)[A-Z0-9]{41})$/; + + var service = { + parse: parse + }; + + return service; + + + + function isValidCashAddr(address, network) { + var privateKey = new bch.PrivateKey('testnet'); + var address1 = privateKey.toAddress(); + console.log('legacy pub:', address1.toString()); + //var addrss = bitcoinCashJsService.readAddress(address1); + //console.log('generated:', addrss.cashaddr); + //bch.Address.fromString(address1, 'testnet'); + console.log('generated:', address1.toString('cashaddr')); + + var isValid = false; + + var prefix = network === 'testnet' ? 'bchtest:' : 'bitcoincash:'; + + try { + if (cashAddrRe.test(address)) { + // bitcoinCashJs.Address.isValid() assumes legacy address for string data, so does not work with cashaddr. + var bchAddresses = bitcoinCashJsService.readAddress(address.toLowerCase()); + if (bchAddresses) { + var legacyAddress = bchAddresses.legacy; + if (bch.Address.isValid(legacyAddress, network)) { + isValid = true; + } + } + } + } catch (e) { + // Nop - Must not be a valid cashAddr. + $log.error('Error validating address.', e); + } + console.log(address,'isValidCashAddr:', isValid); + return isValid; + } + + + /* + For parsing: + BIP21 + BIP72 + + returns: + { + address: '', + amount: '', + coin: '', + isValid: false, + label: '', + legacyAddress: '', + message: '', + other: { + somethingIDontUnderstand: 'Its value' + }, + req: { + "req-param0": "", + "req-param1": "" + }, + testnet: false, + url: '' + + } + + // Need to do testnet, and copay too + + */ + // bitcoincash:?r=https://bitpay.com/i/GLRoZMZxaWBqLqpoXexzoD + function parse(uri) { + var parsed = { + isValid: false + }; + + // Identify prefix + var trimmed = uri.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]; + + var paramsSplit = params.split('&'); + var others; + var req; + paramsSplit.forEach(function onParam(param){ + var valueSplit = param.split('='); + if (valueSplit.length !== 2) { + return parsed; + } + + var key = valueSplit[0]; + var value = valueSplit[1]; + switch(key) { + case 'amount': + if (parseFloat(value)) { + parsed.amount = value; + } else { + return parsed; + } + break; + + case 'label': + parsed.label = value; + break; + + case 'message': + parsed.message = value; + 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 (value.startsWith('https://')) { + parsed.url = value; + } else { + return parsed; + } + break; + + default: + if (key.startsWith('req-')) { + req = req || {}; + req[key] = value; + } else { + others = others || {}; + others[key] = value; + } + } + + }); + + parsed.others = others; + parsed.req = req; + + + // Need to do bitpay format as well? Probably + if (address) { + var addressLowerCase = address.toLowerCase(); + var bch = bitcoinCashJsService.getBitcoinCashJs(); + // Just a rough validation to exclude half-pasted addresses, or things obviously not bitcoin addresses + var cashAddrRe = /^((?:q|p)[a-z0-9]{41})|((?:Q|P)[A-Z0-9]{41})$/; + + //var legacyRe = /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/; + //var legacyTestnetRe = /^[mn][a-km-zA-HJ-NP-Z1-9]{25,34}$/; + + if (bitcore.Address.isValid(address, 'livenet')) { + parsed.address = address; + parsed.legacyAddress = address; + parsed.testnet = false; + + } else if (bitcore.Address.isValid(address, 'testnet')) { + parsed.address = address; + parsed.legacyAddress = address; + parsed.testnet = true; + + // bitcoinCaashJs.Address.isValid() assumes legacy address for string data, so does not work with cashaddr. + // } else if (isValidCashAddr(addressLowerCase, 'livenet')) { + } else if (cashAddrRe.test(address) && parsed.testnet) { + var cashAddr = 'bchtest:' + addressLowerCase; + parsed.address = cashAddr; + parsed.coin = 'bch'; + // TODO: Get legacy address + + } else if (cashAddrRe.test(address)) { + var cashAddr = 'bitcoincash:' + addressLowerCase; + parsed.address = cashAddr; + parsed.coin = 'bch'; + + var bchAddresses = bitcoinCashJsService.readAddress(cashAddr); + parsed.legacyAddress = bchAddresses['legacy']; + + parsed.testnet = false; + + } + + } + + + + // TODO: Check for a private key here too + + + // If has no address, must have Url. + parsed.isValid = !!(parsed.address || parsed.url); + + 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..5cbfdb215 --- /dev/null +++ b/src/js/services/bitcoin-uri.service.spec.js @@ -0,0 +1,95 @@ +fdescribe('bitcoinUriService', function() { + var bitcoinUriService; + + beforeEach(function() { + module('bitcoinCashJsModule'); + module('bitcoincom.services'); + module('bwcModule'); + + inject(function($injector){ + bitcoinUriService = $injector.get('bitcoinUriService'); + }); + }); + + + it('Bitcoin testnet address', function() { + + var parsed = bitcoinUriService.parse('mtWcoToWhbtPoCby5fvs8xdBujT5GGenD4'); + + expect(parsed.isValid).toBe(true); + expect(parsed.address).toBe('mtWcoToWhbtPoCby5fvs8xdBujT5GGenD4'); + expect(parsed.coin).toBeUndefined(); + expect(parsed.legacyAddress).toBe('mtWcoToWhbtPoCby5fvs8xdBujT5GGenD4'); + expect(parsed.testnet).toBe(true); + }); + + it('legacy address', function() { + + var parsed = bitcoinUriService.parse('1JXeGEu7bNEAYu6URT6dU6g1Ys6ffSAWYW'); + + expect(parsed.isValid).toBe(true); + expect(parsed.address).toBe('1JXeGEu7bNEAYu6URT6dU6g1Ys6ffSAWYW'); + expect(parsed.coin).toBeUndefined(); + expect(parsed.legacyAddress).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.address).toBe('bchtest:qpcz6pmurq9ctg5848trzz9zmuuygj4q5qam7ph3gt'); + expect(parsed.coin).toBe('bch'); + expect(parsed.legacyAddress).toBe('mqk5vE278ytt6LUZqd97wi8c3FHsSYREX4'); + expect(parsed.testnet).toBe(true); + }); + + it('cashAddr with prefix', function() { + + var parsed = bitcoinUriService.parse('bitcoincash:qrq9p82a247lecv08ldk5p5h6ahtnjzpqcnh8yhq92'); + + expect(parsed.isValid).toBe(true); + expect(parsed.address).toBe('bitcoincash:qrq9p82a247lecv08ldk5p5h6ahtnjzpqcnh8yhq92'); + expect(parsed.coin).toBe('bch'); + expect(parsed.legacyAddress).toBe('1JXsK3HSFqoMnwh4Mevf5bTgqPcgNWX7ic'); + expect(parsed.testnet).toBe(false); + }); + + it('cashAddr without prefix', function() { + + var parsed = bitcoinUriService.parse('qqen2y3l28dpk0dzsag8w027ds96u7z4pc0uxtl0nq'); + + expect(parsed.isValid).toBe(true); + expect(parsed.address).toBe('bitcoincash:qqen2y3l28dpk0dzsag8w027ds96u7z4pc0uxtl0nq'); + expect(parsed.coin).toBe('bch'); + expect(parsed.legacyAddress).toBe('15fm3EwqgBYcxkndALBfforueps5yWKReJ'); + expect(parsed.testnet).toBe(false); + }); + + // 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); + }); +}); \ No newline at end of file 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',