diff --git a/copay.js b/copay.js index f3d332743..bdb5c759b 100644 --- a/copay.js +++ b/copay.js @@ -12,6 +12,7 @@ module.exports.logger = require('./js/log'); // components var Async = module.exports.Async = require('./js/models/Async'); var Insight = module.exports.Insight = require('./js/models/Insight'); +var RateService = module.exports.RateService = require('./js/models/RateService'); module.exports.Identity = require('./js/models/Identity'); module.exports.Wallet = require('./js/models/Wallet'); diff --git a/js/controllers/history.js b/js/controllers/history.js index b7331b7ad..01868c399 100644 --- a/js/controllers/history.js +++ b/js/controllers/history.js @@ -59,7 +59,7 @@ angular.module('copayApp.controllers').controller('HistoryController', return; } - _.each(res, function (r) { + _.each(res, function(r) { r.ts = r.minedTs || r.sentTs; if (r.action === 'sent' && r.peerActions) { r.actionList = controllerUtils.getActionList(r.peerActions); @@ -82,14 +82,6 @@ angular.module('copayApp.controllers').controller('HistoryController', var w = $rootScope.wallet; return w.getNetworkName().substring(0, 4); }; - $scope.amountAlternative = function(amount, txIndex, cb) { - var w = $rootScope.wallet; - rateService.whenAvailable(function() { - var valueSat = amount * w.settings.unitToSatoshi; - $scope.alternativeCurrency[txIndex] = rateService.toFiat(valueSat, w.settings.alternativeIsoCode); - return cb ? cb() : null; - }); - }; // Autoload transactions $scope.getTransactions(); diff --git a/js/controllers/send.js b/js/controllers/send.js index cba594c7b..46eb1d7cb 100644 --- a/js/controllers/send.js +++ b/js/controllers/send.js @@ -25,7 +25,6 @@ angular.module('copayApp.controllers').controller('SendController', $scope.rateService = rateService; - rateService.whenAvailable(function() { $scope.isRateAvailable = true; $scope.$digest(); diff --git a/js/models/Identity.js b/js/models/Identity.js index 4e6effb71..bf1985258 100644 --- a/js/models/Identity.js +++ b/js/models/Identity.js @@ -86,7 +86,9 @@ Identity.create = function(opts, cb) { opts = _.extend({}, opts); var iden = new Identity(opts); - iden.store(_.extend(opts, {failIfExists: true}), function(err) { + iden.store(_.extend(opts, { + failIfExists: true + }), function(err) { if (err) return cb(err); return cb(null, iden); }); diff --git a/js/models/RateService.js b/js/models/RateService.js new file mode 100644 index 000000000..7c952f3b3 --- /dev/null +++ b/js/models/RateService.js @@ -0,0 +1,139 @@ +'use strict'; + +var util = require('util'); +var _ = require('lodash'); +var log = require('../log'); +var preconditions = require('preconditions').singleton(); +var request = require('request'); + +/* + This class lets interfaces with BitPay's exchange rate API. +*/ + +var RateService = function(opts) { + var self = this; + + opts = opts || {}; + self.request = opts.request || request; + + self.SAT_TO_BTC = 1 / 1e8; + self.BTC_TO_SAT = 1e8; + self.UNAVAILABLE_ERROR = 'Service is not available - check for service.isAvailable() or use service.whenAvailable()'; + self.UNSUPPORTED_CURRENCY_ERROR = 'Currency not supported'; + + self._isAvailable = false; + self._rates = {}; + self._alternatives = []; + self._queued = []; + + self._fetchCurrencies(); +}; + +var _instance; +RateService.singleton = function(opts) { + if (!_instance) { + _instance = new RateService(opts); + } + return _instance; +}; + + +RateService.prototype._fetchCurrencies = function() { + var self = this; + + log.info('Fetching exchange rates'); + + var backoffSeconds = 5; + var updateFrequencySeconds = 3600; + var rateServiceUrl = 'https://bitpay.com/api/rates'; + + self.request.get({ + url: rateServiceUrl, + json: true + }, function(err, res, body) { + if (err || !body) { + backoffSeconds *= 1.5; + setTimeout(retrieve, backoffSeconds * 1000); + return; + } + _.each(body, function(currency) { + self._rates[currency.code] = currency.rate; + self._alternatives.push({ + name: currency.name, + isoCode: currency.code, + rate: currency.rate + }); + }); + self._isAvailable = true; + _.each(self._queued, function(callback) { + setTimeout(callback, 1); + }); + setTimeout(function() { + self._fetchCurrencies() + }, updateFrequencySeconds * 1000); + }); +}; + +RateService.prototype._getRate = function(code) { + return this._rates[code]; +}; + +RateService.prototype._getHistoricRate = function(code, date, cb) { + // TODO (isocolsky): implement with a remote call + return cb(new Error('Not implemented')); +}; + +RateService.prototype._getAlternatives = function() { + return this._alternatives; +}; + +RateService.prototype.isAvailable = function() { + return this._isAvailable; +}; + +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._getRate(code); +}; + +RateService.prototype.toFiatHistoric = function(satoshis, code, date, cb) { + var self = this; + + self._getHistoricRate(code, date, function(err, rate) { + if (err) return cb(err); + return cb(null, satoshis * self.SAT_TO_BTC * rate); + }); +}; + +RateService.prototype.fromFiat = function(amount, code) { + if (!this.isAvailable()) { + throw new Error(this.UNAVAILABLE_ERROR); + } + return amount / this._getRate(code) * this.BTC_TO_SAT; +}; + +RateService.prototype.listAlternatives = function() { + if (!this.isAvailable()) { + throw new Error(this.UNAVAILABLE_ERROR); + } + + return _.map(this._getAlternatives(), function(item) { + return { + name: item.name, + isoCode: item.isoCode + } + }); +}; + + +module.exports = RateService; diff --git a/js/models/Wallet.js b/js/models/Wallet.js index c1d4b876e..6f66162f6 100644 --- a/js/models/Wallet.js +++ b/js/models/Wallet.js @@ -2866,19 +2866,23 @@ Wallet.prototype.getTransactionHistory = function(cb) { type: 'out' }); - var proposal = _.findWhere(proposals, { - sentTxid: tx.txid - }); - tx.comment = proposal ? proposal.comment : undefined; tx.labelTo = firstOut ? firstOut.label : undefined; tx.addressTo = firstOut ? firstOut.address : undefined; tx.amountSat = Math.abs(amount); tx.amount = tx.amountSat * satToUnit; - tx.sentTs = proposal ? proposal.sentTs : undefined; tx.minedTs = !_.isNaN(tx.time) ? tx.time * 1000 : undefined; - tx.merchant = proposal ? proposal.merchant : undefined; - tx.peerActions = proposal ? proposal.peerActions : undefined; - tx.finallyRejected = proposal ? proposal.finallyRejected : undefined; + + var proposal = _.findWhere(proposals, { + sentTxid: tx.txid + }); + + if (proposal) { + tx.comment = proposal.comment; + tx.sentTs = proposal.sentTs; + tx.merchant = proposal.merchant; + tx.peerActions = proposal.peerActions; + tx.finallyRejected = proposal.finallyRejected; + } }; if (addresses.length > 0) { diff --git a/js/services/controllerUtils.js b/js/services/controllerUtils.js index 52b37c4c9..ee5129272 100644 --- a/js/services/controllerUtils.js +++ b/js/services/controllerUtils.js @@ -324,6 +324,17 @@ angular.module('copayApp.services') }; root.updateTxs = function(opts) { + function computeAlternativeAmount(w, tx, cb) { + rateService.whenAvailable(function() { + _.each(tx.outs, function(out) { + var valueSat = out.value * w.settings.unitToSatoshi; + out.alternativeAmount = rateService.toFiat(valueSat, w.settings.alternativeIsoCode); + out.alternativeIsoCode = w.settings.alternativeIsoCode; + }); + if (cb) return cb(); + }); + }; + var w = opts.wallet || $rootScope.wallet; if (!w) return; opts = opts || $rootScope.txsOpts || {}; @@ -364,6 +375,9 @@ angular.module('copayApp.services') i.fee = i.builder.feeSat * satToUnit; i.missingSignatures = tx.countInputMissingSignatures(0); i.actionList = getActionList(i.peerActions); + if (i.isPending) { + computeAlternativeAmount(w, i); + } txs.push(i); } }); diff --git a/js/services/rate.js b/js/services/rate.js index 6599905c1..cbfeb2726 100644 --- a/js/services/rate.js +++ b/js/services/rate.js @@ -1,85 +1,7 @@ 'use strict'; -var MINS_IN_HOUR = 60; -var MILLIS_IN_SECOND = 1000; - -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; - this.BTC_TO_SAT = 1e8; - 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 self = 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; - self.alternatives.push({ - name: element.name, - isoCode: element.code, - rate: element.rate - }); - }); - self.isAvailable = true; - self.rates = rates; - self.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.BTC_TO_SAT; -}; - -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 - }); +angular.module('copayApp.services').factory('rateService', function(request) { + return copay.RateService.singleton({ + request: request }); - return alts; -}; - -angular.module('copayApp.services').service('rateService', RateService); +}); diff --git a/test/PayPro.js b/test/PayPro.js index 969c64dcf..c8a19bb7d 100644 --- a/test/PayPro.js +++ b/test/PayPro.js @@ -83,7 +83,6 @@ describe('PayPro (in Wallet) model', function() { c.network.getHexNonces = sinon.stub(); c.network.send = sinon.stub(); - return new Wallet(c); } diff --git a/test/RateService.js b/test/RateService.js new file mode 100644 index 000000000..3ebb9a5dc --- /dev/null +++ b/test/RateService.js @@ -0,0 +1,179 @@ +'use strict'; + +var RateService = copay.RateService; + +describe('RateService model', function() { + before(function() { + sinon.stub(RateService.prototype, '_fetchCurrencies').returns(); + }); + after(function() {}); + + it('should create an instance', function() { + var rs = new RateService(); + should.exist(rs); + }); + + describe('#toFiat', function() { + it('should throw error when unavailable', function() { + var rs = new RateService(); + rs.isAvailable = sinon.stub().returns(false); + (function() { + rs.toFiat(10000, 'USD'); + }).should.throw; + }); + it('should return current valuation', function() { + var rs = new RateService(); + rs.isAvailable = sinon.stub().returns(true); + var getRateStub = sinon.stub(rs, '_getRate') + getRateStub.withArgs('USD').returns(300.00); + getRateStub.withArgs('EUR').returns(250.00); + var params = [{ + satoshis: 0, + code: 'USD', + expected: '0.00' + }, { + satoshis: 1e8, + code: 'USD', + expected: '300.00' + }, { + satoshis: 10000, + code: 'USD', + expected: '0.03' + }, { + satoshis: 20000, + code: 'EUR', + expected: '0.05' + }, ]; + + _.each(params, function(p) { + rs.toFiat(p.satoshis, p.code).toFixed(2).should.equal(p.expected); + }); + }); + }); + + describe('#toFiatHistoric', function() { + it('should return historic valuation', function() { + var rs = new RateService(); + rs.isAvailable = sinon.stub().returns(true); + var today = Date.now(); + var yesterday = today - 24 * 3600; + var getHistoricalRateStub = sinon.stub(rs, '_getHistoricRate'); + getHistoricalRateStub.withArgs('USD', today).yields(null, 300.00); + getHistoricalRateStub.withArgs('USD', yesterday).yields(null, 250.00); + getHistoricalRateStub.withArgs('EUR', today).yields(null, 250.00); + getHistoricalRateStub.withArgs('EUR', yesterday).yields(null, 200.00); + var params = [{ + satoshis: 0, + code: 'USD', + date: today, + expected: '0.00' + }, { + satoshis: 1e8, + code: 'USD', + date: today, + expected: '300.00' + }, { + satoshis: 10000, + code: 'USD', + date: today, + expected: '0.03' + }, { + satoshis: 0, + code: 'USD', + date: today, + expected: '0.00' + }, { + satoshis: 1e8, + code: 'USD', + date: today, + expected: '300.00' + }, { + satoshis: 10000, + code: 'USD', + date: today, + expected: '0.03' + }, { + satoshis: 20000, + code: 'EUR', + date: today, + expected: '0.05' + }, { + satoshis: 20000, + code: 'EUR', + date: yesterday, + expected: '0.04' + }, ]; + + _.each(params, function(p) { + rs.toFiatHistoric(p.satoshis, p.code, p.date, function(err, rate) { + rate.toFixed(2).should.equal(p.expected); + }); + }); + }); + }); + + describe('#fromFiat', function() { + it('should throw error when unavailable', function() { + var rs = new RateService(); + rs.isAvailable = sinon.stub().returns(false); + (function() { + rs.fromFiat(300, 'USD'); + }).should.throw; + }); + it('should return current valuation', function() { + var rs = new RateService(); + rs.isAvailable = sinon.stub().returns(true); + var getRateStub = sinon.stub(rs, '_getRate') + getRateStub.withArgs('USD').returns(300.00); + getRateStub.withArgs('EUR').returns(250.00); + var params = [{ + amount: 0, + code: 'USD', + expected: 0 + }, { + amount: 300.00, + code: 'USD', + expected: 1e8 + }, { + amount: 600.00, + code: 'USD', + expected: 2e8 + }, { + amount: 250.00, + code: 'EUR', + expected: 1e8 + }, ]; + + _.each(params, function(p) { + rs.fromFiat(p.amount, p.code).should.equal(p.expected); + }); + }); + }); + describe('#listAlternatives', function() { + it('should throw error when unavailable', function() { + var rs = new RateService(); + rs.isAvailable = sinon.stub().returns(false); + (function() { + rs.listAlternatives(); + }).should.throw; + }); + + it('should return list of available currencies', function() { + var rs = new RateService(); + rs.isAvailable = sinon.stub().returns(true); + sinon.stub(rs, '_getAlternatives').returns([{ + name: 'United States Dollar', + isoCode: 'USD', + rate: 300.00, + }, { + name: 'European Union Euro', + isoCode: 'EUR', + rate: 250.00, + }, ]) + + var list = rs.listAlternatives(); + list.should.exist; + list.length.should.equal(2); + }); + }); +}); diff --git a/test/Wallet.js b/test/Wallet.js index 4c760977e..0a4f25890 100644 --- a/test/Wallet.js +++ b/test/Wallet.js @@ -56,8 +56,8 @@ var addCopayers = function(w) { } }; -describe('Wallet model', function() { +describe('Wallet model', function() { it('should fail to create an instance', function() { (function() { new Wallet(walletConfig) @@ -102,8 +102,6 @@ describe('Wallet model', function() { c.network.peerFromCopayer = sinon.stub().returns('xxxx'); c.network.send = sinon.stub(); - - c.addressBook = { '2NFR2kzH9NUdp8vsXTB4wWQtTtzhpKxsyoJ': { label: 'John', diff --git a/test/unit/controllers/controllersSpec.js b/test/unit/controllers/controllersSpec.js index dfc539e55..13a990411 100644 --- a/test/unit/controllers/controllersSpec.js +++ b/test/unit/controllers/controllersSpec.js @@ -26,11 +26,11 @@ describe("Unit: Controllers", function() { var server; beforeEach(module('copayApp')); - beforeEach(module('copayApp.controllers')); - beforeEach(module(function($provide) { - $provide.value('request', { - 'get': function(_, cb) { - cb(null, null, [{ + beforeEach(module('copayApp.controllers')); + beforeEach(module(function($provide) { + $provide.value('request', { + 'get': function(_, cb) { + cb(null, null, [{ name: 'USD Dollars', code: 'USD', rate: 2 @@ -71,7 +71,7 @@ describe("Unit: Controllers", function() { w.createTx = sinon.stub().yields(null); w.sendTx = sinon.stub().yields(null); w.requiresMultipleSignatures = sinon.stub().returns(true); - w.getTxProposals = sinon.stub().returns([1,2,3]); + w.getTxProposals = sinon.stub().returns([1, 2, 3]); $rootScope.wallet = w; @@ -172,17 +172,6 @@ describe("Unit: Controllers", function() { scope.getTransactions(); expect(scope.blockchain_txs).to.be.empty; }); - - it('should call amountAlternative and return a value', function() { - var cb = sinon.spy(); - var s1 = sinon.stub(scope, 'amountAlternative'); - s1.onCall(0).returns(1000); - s1.onCall(1).returns(2000); - expect(s1(100, 0, cb)).equal(1000); - expect(s1(200, 1, cb)).equal(2000); - sinon.assert.callCount(scope.amountAlternative, 2); - s1.restore(); - }); }); describe('Send Controller', function() { diff --git a/views/includes/transaction.html b/views/includes/transaction.html index aa52d45d1..f4847c54b 100644 --- a/views/includes/transaction.html +++ b/views/includes/transaction.html @@ -10,6 +10,7 @@
{{out.value |noFractionNumber}} {{$root.wallet.settings.unitName}} + {{out.alternativeAmount|noFractionNumber}} {{out.alternativeIsoCode}}