diff --git a/config.js b/config.js index 5c658ea68..d71dd7624 100644 --- a/config.js +++ b/config.js @@ -7,6 +7,8 @@ var defaultConfig = { // DEFAULT unit: Bit unitName: 'bits', unitToSatoshi: 100, + alternativeName: 'US Dollar', + alternativeIsoCode: 'USD', // wallet limits limits: { @@ -54,6 +56,11 @@ var defaultConfig = { storageSalt: 'mjuBtGybi/4=', }, + rate: { + url: 'https://bitpay.com/api/rates', + updateFrequencySeconds: 60 * 60 + }, + disableVideo: true, verbose: 1, }; diff --git a/js/controllers/send.js b/js/controllers/send.js index 87208e276..3dc673183 100644 --- a/js/controllers/send.js +++ b/js/controllers/send.js @@ -2,14 +2,67 @@ var bitcore = require('bitcore'); angular.module('copayApp.controllers').controller('SendController', - function($scope, $rootScope, $window, $timeout, $anchorScroll, $modal, isMobile, notification, controllerUtils) { + function($scope, $rootScope, $window, $timeout, $anchorScroll, $modal, isMobile, notification, controllerUtils, rateService) { $scope.title = 'Send'; $scope.loading = false; var satToUnit = 1 / config.unitToSatoshi; $scope.defaultFee = bitcore.TransactionBuilder.FEE_PER_1000B_SAT * satToUnit; $scope.unitToBtc = config.unitToSatoshi / bitcore.util.COIN; + $scope.unitToSatoshi = config.unitToSatoshi; $scope.minAmount = config.limits.minAmountSatoshi * satToUnit; + $scope.alternativeName = config.alternativeName; + $scope.alternativeIsoCode = config.alternativeIsoCode; + + $scope.isRateAvailable = false; + $scope.rateService = rateService; + + rateService.whenAvailable(function() { + $scope.isRateAvailable = true; + $scope.$digest(); + }); + + /** + * Setting the two related amounts as properties prevents an infinite + * recursion for watches while preserving the original angular updates + */ + Object.defineProperty($scope, + "alternative", { + get: function () { + return this._alternative; + }, + set: function (newValue) { + this._alternative = newValue; + if (typeof(newValue) === 'number' && $scope.isRateAvailable) { + this._amount = Number.parseFloat( + (rateService.fromFiat(newValue, config.alternativeIsoCode) * satToUnit + ).toFixed(config.unitDecimals), 10); + } else { + this._amount = 0; + } + }, + enumerable: true, + configurable: true + }); + Object.defineProperty($scope, + "amount", { + get: function () { + return this._amount; + }, + set: function (newValue) { + this._amount = newValue; + if (typeof(newValue) === 'number' && $scope.isRateAvailable) { + this._alternative = Number.parseFloat( + (rateService.toFiat(newValue * config.unitToSatoshi, config.alternativeIsoCode) + ).toFixed(2), 10); + } else { + this._alternative = 0; + } + }, + enumerable: true, + configurable: true + }); + $scope.loadTxs = function() { var opts = { pending: true, diff --git a/js/controllers/settings.js b/js/controllers/settings.js index 49f716519..89b9206ef 100644 --- a/js/controllers/settings.js +++ b/js/controllers/settings.js @@ -1,6 +1,6 @@ 'use strict'; -angular.module('copayApp.controllers').controller('SettingsController', function($scope, $rootScope, $window, $location, controllerUtils) { +angular.module('copayApp.controllers').controller('SettingsController', function($scope, $rootScope, $window, $location, controllerUtils, rateService) { controllerUtils.redirIfLogged(); $scope.title = 'Settings'; @@ -14,21 +14,41 @@ angular.module('copayApp.controllers').controller('SettingsController', function $scope.unitOpts = [{ name: 'Satoshis (100,000,000 satoshis = 1BTC)', shortName: 'SAT', - value: 1 + value: 1, + decimals: 0 }, { name: 'bits (1,000,000 bits = 1BTC)', shortName: 'bits', - value: 100 + value: 100, + decimals: 2 }, { name: 'mBTC (1,000 mBTC = 1BTC)', shortName: 'mBTC', - value: 100000 + value: 100000, + decimals: 5 }, { name: 'BTC', shortName: 'BTC', - value: 100000000 + value: 100000000, + decimals: 8 }]; + $scope.selectedAlternative = { + name: config.alternativeName, + isoCode: config.alternativeIsoCode + }; + $scope.alternativeOpts = rateService.isAvailable ? + rateService.listAlternatives() : [$scope.selectedAlternative]; + + rateService.whenAvailable(function() { + $scope.alternativeOpts = rateService.listAlternatives(); + for (var ii in $scope.alternativeOpts) { + if (config.alternativeIsoCode === $scope.alternativeOpts[ii].isoCode) { + $scope.selectedAlternative = $scope.alternativeOpts[ii]; + } + } + }); + for (var ii in $scope.unitOpts) { if (config.unitName === $scope.unitOpts[ii].shortName) { $scope.selectedUnit = $scope.unitOpts[ii]; @@ -68,7 +88,11 @@ angular.module('copayApp.controllers').controller('SettingsController', function disableVideo: $scope.disableVideo, unitName: $scope.selectedUnit.shortName, unitToSatoshi: $scope.selectedUnit.value, - version: copay.version, + unitDecimals: $scope.selectedUnit.decimals, + alternativeName: $scope.selectedAlternative.name, + alternativeIsoCode: $scope.selectedAlternative.isoCode, + + version: copay.version })); // Go home reloading the application diff --git a/js/services/controllerUtils.js b/js/services/controllerUtils.js index 143536504..03b1eaff5 100644 --- a/js/services/controllerUtils.js +++ b/js/services/controllerUtils.js @@ -2,7 +2,7 @@ var bitcore = require('bitcore'); angular.module('copayApp.services') - .factory('controllerUtils', function($rootScope, $sce, $location, notification, $timeout, video, uriHandler) { + .factory('controllerUtils', function($rootScope, $sce, $location, notification, $timeout, video, uriHandler, rateService) { var root = {}; root.getVideoMutedStatus = function(copayer) { if (!$rootScope.videoInfo) return; @@ -217,7 +217,15 @@ angular.module('copayApp.services') $rootScope.balanceByAddr = balanceByAddr; root.updateAddressList(); $rootScope.updatingBalance = false; - return cb ? cb() : null; + + rateService.whenAvailable(function() { + $rootScope.totalBalanceAlternative = rateService.toFiat(balanceSat, config.alternativeIsoCode); + $rootScope.alternativeIsoCode = config.alternativeIsoCode; + $rootScope.lockedBalanceAlternative = rateService.toFiat(balanceSat - safeBalanceSat, config.alternativeIsoCode); + + + return cb ? cb() : null; + }); }); }; diff --git a/js/services/rate.js b/js/services/rate.js new file mode 100644 index 000000000..0c28ae046 --- /dev/null +++ b/js/services/rate.js @@ -0,0 +1,83 @@ +'use strict'; + +var RateService = function(request) { + this.isAvailable = false; + this.UNAVAILABLE_ERROR = 'Service is not available - check for service.isAvailable or use service.whenAvailable'; + this.SAT_TO_BTC = 1 / 1e8; + var MINS_IN_HOUR = 60; + var MILLIS_IN_SECOND = 1000; + var rateServiceConfig = config.rate; + var updateFrequencySeconds = rateServiceConfig.updateFrequencySeconds || 60 * MINS_IN_HOUR; + var rateServiceUrl = rateServiceConfig.url || 'https://bitpay.com/api/rates'; + this.queued = []; + this.alternatives = []; + var that = this; + var backoffSeconds = 5; + var retrieve = function() { + request.get({ + url: rateServiceUrl, + json: true + }, function(err, response, listOfCurrencies) { + if (err) { + backoffSeconds *= 1.5; + setTimeout(retrieve, backoffSeconds * MILLIS_IN_SECOND); + return; + } + var rates = {}; + listOfCurrencies.forEach(function(element) { + rates[element.code] = element.rate; + that.alternatives.push({ + name: element.name, + isoCode: element.code, + rate: element.rate + }); + }); + that.isAvailable = true; + that.rates = rates; + that.queued.forEach(function(callback) { + setTimeout(callback, 1); + }); + setTimeout(retrieve, updateFrequencySeconds * MILLIS_IN_SECOND); + }); + }; + retrieve(); +}; + +RateService.prototype.whenAvailable = function(callback) { + if (this.isAvailable) { + setTimeout(callback, 1); + } else { + this.queued.push(callback); + } +}; + +RateService.prototype.toFiat = function(satoshis, code) { + if (!this.isAvailable) { + throw new Error(this.UNAVAILABLE_ERROR); + } + return satoshis * this.SAT_TO_BTC * this.rates[code]; +}; + +RateService.prototype.fromFiat = function(amount, code) { + if (!this.isAvailable) { + throw new Error(this.UNAVAILABLE_ERROR); + } + return amount / this.rates[code] / this.SAT_TO_BTC; +}; + +RateService.prototype.listAlternatives = function() { + if (!this.isAvailable) { + throw new Error(this.UNAVAILABLE_ERROR); + } + + var alts = []; + this.alternatives.forEach(function(element) { + alts.push({ + name: element.name, + isoCode: element.isoCode + }); + }); + return alts; +}; + +angular.module('copayApp.services').service('rateService', RateService); diff --git a/js/services/request.js b/js/services/request.js new file mode 100644 index 000000000..5a92093c9 --- /dev/null +++ b/js/services/request.js @@ -0,0 +1,6 @@ +'use strict'; + +angular.module('copayApp.services').factory('request', function() { + return require('request'); +}); + diff --git a/package.json b/package.json index 383f137d1..258a25603 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,9 @@ "travis-cov": "0.2.5", "uglifyify": "1.2.3", "crypto-js": "3.1.2", - "shelljs": "0.3.0" + "shelljs":"0.3.0", + "browser-request": "0.3.2", + "request": "2.40.0" }, "main": "app.js", "homepage": "https://github.com/bitpay/copay", diff --git a/test/run.sh b/test/run.sh old mode 100644 new mode 100755 diff --git a/test/unit/controllers/controllersSpec.js b/test/unit/controllers/controllersSpec.js index 4b67894a7..b99da8e3c 100644 --- a/test/unit/controllers/controllersSpec.js +++ b/test/unit/controllers/controllersSpec.js @@ -29,7 +29,9 @@ describe("Unit: Controllers", function() { totalCopayers: 5, spendUnconfirmed: 1, reconnectDelay: 100, - networkName: 'testnet' + networkName: 'testnet', + alternativeName: 'lol currency', + alternativeIsoCode: 'LOL' }; it('Copay config should be binded', function() { @@ -124,11 +126,21 @@ describe("Unit: Controllers", function() { }); describe('Send Controller', function() { - var scope, form, sendForm; + var scope, form, sendForm, sendCtrl; beforeEach(angular.mock.module('copayApp')); - beforeEach(angular.mock.inject(function($compile, $rootScope, $controller) { + beforeEach(module(function($provide) { + $provide.value('request', { + 'get': function(_, cb) { + cb(null, null, [{name: 'lol currency', code: 'LOL', rate: 2}]); + } + }); + })); + beforeEach(angular.mock.inject(function($compile, $rootScope, $controller, rateService) { scope = $rootScope.$new(); + scope.rateService = rateService; $rootScope.wallet = new FakeWallet(walletConfig); + config.alternativeName = 'lol currency'; + config.alternativeIsoCode = 'LOL'; var element = angular.element( '
' ); $compile(element2)(scope); - $controller('SendController', { + sendCtrl = $controller('SendController', { $scope: scope, $modal: {}, }); @@ -241,8 +254,22 @@ describe("Unit: Controllers", function() { config.unitToSatoshi = old; }); - - + it('should convert bits amount to fiat', function(done) { + scope.rateService.whenAvailable(function() { + sendForm.amount.$setViewValue(1e6); + scope.$digest(); + expect(scope.alternative).to.equal(2); + done(); + }); + }); + it('should convert fiat to bits amount', function(done) { + scope.rateService.whenAvailable(function() { + sendForm.alternative.$setViewValue(2); + scope.$digest(); + expect(scope.amount).to.equal(1e6); + done(); + }); + }); it('should create and send a transaction proposal', function() { sendForm.address.$setViewValue('mkfTyEk7tfgV611Z4ESwDDSZwhsZdbMpVy'); diff --git a/test/unit/services/servicesSpec.js b/test/unit/services/servicesSpec.js index df7eed5bf..144e9488b 100644 --- a/test/unit/services/servicesSpec.js +++ b/test/unit/services/servicesSpec.js @@ -79,8 +79,6 @@ describe("Unit: controllerUtils", function() { expect($rootScope.addrInfos[0].address).to.be.equal(Waddr);; })); }); - - }); describe("Unit: Notification Service", function() { @@ -135,3 +133,36 @@ describe("Unit: uriHandler service", function() { }).should.not.throw(); })); }); + +describe('Unit: Rate Service', function() { + beforeEach(angular.mock.module('copayApp.services')); + it('should be injected correctly', inject(function(rateService) { + should.exist(rateService); + })); + it('should be possible to ask if it is available', + inject(function(rateService) { + should.exist(rateService.isAvailable); + }) + ); + beforeEach(module(function($provide) { + $provide.value('request', { + 'get': function(_, cb) { + cb(null, null, [{name: 'lol currency', code: 'LOL', rate: 2}]); + } + }); + })); + it('should be possible to ask for conversion from fiat', + inject(function(rateService) { + rateService.whenAvailable(function() { + (1).should.equal(rateService.fromFiat(2, 'LOL')); + }); + }) + ); + it('should be possible to ask for conversion to fiat', + inject(function(rateService) { + rateService.whenAvailable(function() { + (2).should.equal(rateService.toFiat(1e8, 'LOL')); + }); + }) + ); +}); diff --git a/views/includes/sidebar.html b/views/includes/sidebar.html index a22bb2475..c0b942533 100644 --- a/views/includes/sidebar.html +++ b/views/includes/sidebar.html @@ -24,10 +24,9 @@ class="has-tip" data-options="disable_for_touch:true" tooltip-popup-delay='500' - tooltip="{{totalBalanceBTC |noFractionNumber:8}} BTC" + tooltip="{{totalBalanceAlternative |noFractionNumber:2}} {{alternativeIsoCode}}" tooltip-trigger="mouseenter" - tooltip-placement="bottom">{{totalBalance || 0 - |noFractionNumber}} {{$root.unitName}} + tooltip-placement="bottom">{{totalBalance || 0 |noFractionNumber}} {{$root.unitName}}@@ -119,8 +135,11 @@
{{amount + defaultFee |noFractionNumber}} {{$root.unitName}}
+
+ {{ rateService.toFiat((amount + defaultFee) * unitToSatoshi, alternativeIsoCode) | noFractionNumber: 2 }} {{ alternativeIsoCode }}
+
+
- {{ ((amount + defaultFee) * unitToBtc)|noFractionNumber:8}} BTC
Including fee of {{defaultFee|noFractionNumber}} {{$root.unitName}}