diff --git a/bin/lamassu-server b/bin/lamassu-server new file mode 100755 index 00000000..86bc13d0 --- /dev/null +++ b/bin/lamassu-server @@ -0,0 +1,27 @@ +#!/usr/bin/env node +var fs = require('fs'); +var createServer = require('../lib/app.js'); +var argv = require('optimist').argv; + +var options = { + postgres: process.env.DATABASE_URL +}; + +var port = process.env.PORT || 3000; + +if (!argv.http) { + if (!argv.key || !argv.cert) { + console.error('--key and --cert are required'); + process.exit(1); + } + + options.https = { + key: fs.readFileSync(argv.key), + cert: fs.readFileSync(argv.cert) + }; +} + +var server = createServer(options); +server.listen(port, function () { + console.log('lamassu-server listening on port ' + port + ' ' + (argv.http ? '(http)' : '(https)')); +}); diff --git a/lib/app.js b/lib/app.js old mode 100755 new mode 100644 index 36dbdecb..b57a9c40 --- a/lib/app.js +++ b/lib/app.js @@ -1,4 +1,3 @@ -#!/usr/bin/env node /*jshint globalstrict: true, white: false, unused:false */ /*globals require, exports, console, module, process */ /* @@ -21,35 +20,73 @@ var http = require('http'); var https = require('https'); var path = require('path'); var express = require('express'); -var argv = require('optimist').argv; -var app = express(); var fs = require('fs'); var LamassuConfig = require('lamassu-config'); -var atm = require('lamassu-atm-protocol'); +var routes = require('./routes'); +var Trader = require('./trader'); +var PostgresqlInterface = require('./postgresql_interface'); -var conString, dbConfig, config; +module.exports = function (options) { + var app = express(); + var connectionString; + var server; + var config; + var trader; + var db; -conString = process.env.DATABASE_URL || 'postgres://lamassu:lamassu@localhost/lamassu'; + connectionString = options.postgres || + 'postgres://lamassu:lamassu@localhost/lamassu'; -config = new LamassuConfig(conString); + config = new LamassuConfig(connectionString); + db = new PostgresqlInterface(connectionString); + trader = new Trader(db); -var port = process.env.PORT || 3000; -app.use(express.logger()); -app.use(express.favicon()); -app.use(express.bodyParser()); -app.use(express.methodOverride()); + config.load(function (err, config) { + if (err) { + console.error('Loading config failed'); + throw err; + } -config.load(function(err, conf) { - if (err) { console.log(err); process.exit(1); } + trader.configure(config); + trader.startPolling(); + }); - var authMiddleware = function (req, res, next) { return next(); }; + config.on('configUpdate', function () { + config.load(function (err, config) { + if (err) { + return console.error('Error while reloading config'); + } - if (argv.http) { - http.createServer(app).listen(port, function () { - console.log('Express server listening on port ' + port + ' (http)'); + trader.configure(config); + console.log('Config reloaded'); }); + }); + + app.use(express.logger()); + app.use(express.bodyParser()); + + if (!options.https) { + server = http.createServer(app); } else { + var serverOptions = { + key: options.https.key, + cert: options.https.cert, + requestCert: true, + secureProtocol: 'TLSv1_method', + ciphers: 'AES128-GCM-SHA256:RC4:HIGH:!MD5:!aNULL:!EDH', + honorCipherOrder: true + }; + + server = https.createServer(serverOptions, app); + } + + var authMiddleware = function (req, res, next) { + req.device = {}; + return next(); + }; + + if (options.https) { authMiddleware = function(req, res, next) { var fingerprint = req.connection.getPeerCertificate().fingerprint; var e = new Error('Unauthorized'); @@ -62,21 +99,9 @@ config.load(function(err, conf) { next(); }); }; - - var options = { - key: fs.readFileSync(argv.key), - cert: fs.readFileSync(argv.cert), - requestCert: true, - secureProtocol: 'TLSv1_method', - ciphers: 'AES128-GCM-SHA256:RC4:HIGH:!MD5:!aNULL:!EDH', - honorCipherOrder: true - }; - - https.createServer(options, app).listen(port, function () { - console.log('Express server listening on port ' + port + ' (https)'); - }); } - atm.init(app, conf, config, authMiddleware); + routes.init(app, trader, authMiddleware); -}); + return server; +}; diff --git a/lib/postgresql_interface.js b/lib/postgresql_interface.js new file mode 100644 index 00000000..b04cd217 --- /dev/null +++ b/lib/postgresql_interface.js @@ -0,0 +1,69 @@ +'use strict'; + +var pg = require('pg'); +var PG_ERRORS = { + 23505: 'uniqueViolation' +}; + +var PostgresqlInterface = function (conString) { + if (!conString) { + throw new Error('Postgres connection string is required'); + } + + this.client = new pg.Client(conString); + + // TODO better logging + this.client.on('error', function (err) { console.log(err); }); + + this.client.connect(); +}; +PostgresqlInterface.factory = function factory(conString) { return new PostgresqlInterface(conString); }; +module.exports = PostgresqlInterface; + +PostgresqlInterface.prototype.summonTransaction = + function summonTransaction(deviceFingerprint, tx, cb) { + // First do an INSERT + // If it worked, go ahead with transaction + // If duplicate, fetch status and return + var self = this; + this.client.query('INSERT INTO transactions (id, status, "deviceFingerprint", ' + + '"toAddress", satoshis, "currencyCode", fiat) ' + + 'VALUES ($1, $2, $3, $4, $5, $6, $7)', [tx.txId, 'pending', deviceFingerprint, + tx.toAddress, tx.satoshis, tx.currencyCode, tx.fiat], + function (err) { + if (err && PG_ERRORS[err.code] === 'uniqueViolation') + return self._fetchTransaction(tx.txId, cb); + if (err) return cb(err); + cb(null, true); + }); +}; + +PostgresqlInterface.prototype.reportTransactionError = + function reportTransactionError(tx, err) { + this.client.query('UPDATE transactions SET status=$1, error=$2 WHERE id=$3', + ['failed', err.message, tx.txId]); +}; + +PostgresqlInterface.prototype.completeTransaction = + function completeTransaction(tx, txHash) { + if (txHash) + this.client.query('UPDATE transactions SET "txHash"=$1, status=$2, completed=now() WHERE id=$3', + [txHash, 'completed', tx.txId]); + else + this.client.query('UPDATE transactions SET status=$1, error=$2 WHERE id=$3', + ['failed', 'No txHash received', tx.txId]); +}; + +PostgresqlInterface.prototype._fetchTransaction = + function _fetchTransaction(txId, cb) { + this.client.query('SELECT status, "txHash" FROM transactions WHERE id=$1', + [txId], function (err, results) { + if (err) return cb(err); + + // This should never happen, since we already checked for existence + if (results.rows.length === 0) return cb(new Error('Couldn\'t find transaction.')); + + var result = results.rows[0]; + cb(null, false, result.txHash); + }); +}; diff --git a/lib/protocol/api/trade.js b/lib/protocol/api/trade.js new file mode 100644 index 00000000..afd8b784 --- /dev/null +++ b/lib/protocol/api/trade.js @@ -0,0 +1,100 @@ +'use strict'; + +require('date-utils'); +var winston = require('winston'); +var _ = require('underscore'); +var logger = new (winston.Logger)({transports:[new (winston.transports.Console)()]}); + +var _tradeExchange; +var _ticker; +var _tradeQueue = []; +var _api; +var _config; + +var SATOSHI_FACTOR = Math.pow(10, 8); + +var _consolidateTrades = function() { + var queue = _tradeQueue; + var tradeRec = { + fiat: 0, + satoshis: 0, + currency: 'USD' + }; + + while (true) { + var lastRec = queue.shift(); + if (!lastRec) { + break; + } + tradeRec.fiat += lastRec.fiat; + tradeRec.satoshis += lastRec.satoshis; + tradeRec.currency = lastRec.currency; + } + return tradeRec; +}; + + + +/** + * TODO: add error reporting + */ +var _purchase = function(trade) { + _ticker.rate(trade.currency, function(err, rate) { + _tradeExchange.purchase(trade.satoshis, rate, function(err) { + _api.triggerBalance(); + }); + }); +}; + +exports.init = function(config, api, tradeExchange, ticker) { + _config = config; + _api = api; + _tradeExchange = tradeExchange; + _ticker = ticker; + + var interval = setInterval(function() { + exports.executeTrades(); + }, _config.settings.tradeInterval); + interval.unref(); +}; + +exports.trade = function(fiat, satoshis, currency, cb) { + _tradeQueue.push({fiat: fiat, satoshis: satoshis, currency: currency}); + cb(null); +}; + +exports.queueFiatBalance = function(exchangeRate) { + var satoshis = _.reduce(_tradeQueue, function(memo, rec) { + return memo + rec.satoshis; + }, 0); + return (satoshis / SATOSHI_FACTOR) * exchangeRate; +}; + +exports.executeTrades = function() { + if (!_tradeExchange) return; + + logger.info('checking for trades'); + + if (!_config.plugins.current.trade) { + logger.info('NO ENGINE'); + return; + } + + var trade = _consolidateTrades(); + logger.info('consolidated: ' + JSON.stringify(trade)); + + if (trade.fiat === 0) { + logger.info('reject fiat 0'); + return; + } + + if (trade.fiat < _config.settings.minimumTradeFiat) { + // throw it back in the water + logger.info('reject fiat too small'); + _tradeQueue.unshift(trade); + return; + } + + logger.info('making a trade: %d', trade.satoshis / Math.pow(10,8)); + _purchase(trade); +}; diff --git a/lib/protocol/exchanges/custom_ticker.js b/lib/protocol/exchanges/custom_ticker.js new file mode 100644 index 00000000..e8a61228 --- /dev/null +++ b/lib/protocol/exchanges/custom_ticker.js @@ -0,0 +1,45 @@ +'use strict'; + +// TODO: refactor this with bitpay_ticker.js + +var https = require('https'); +var _ = require('underscore'); + +var CustomTicker = function(config) { + this.config = config; +}; + +CustomTicker.factory = function factory(config) { + return new CustomTicker(config); +}; + +CustomTicker.prototype.ticker = function ticker(currency, cb) { + var self = this; + https.get(this.config.uri, function(res) { + var buf = ''; + res.setEncoding('utf8'); + res.on('data', function(chunk) { + buf += chunk; + }) + .on('end', function() { + var json = null; + try { + json = JSON.parse(buf); + } catch(e) { + cb(new Error('Couldn\'t parse JSON response')); + return; + } + var rec = _.findWhere(json, {code: currency}); + + if (!rec) { + cb(new Error('Currency not listed: ' + currency)); + return; + } + cb(null, rec.rate); + }); + }).on('error', function(e) { + cb(e); + }); +}; + +module.exports = CustomTicker; diff --git a/lib/routes.js b/lib/routes.js new file mode 100644 index 00000000..2d3e1b56 --- /dev/null +++ b/lib/routes.js @@ -0,0 +1,95 @@ +'use strict'; + +var _trader; +var _lamassuConfig; + +// Make sure these are higher than polling interval +// or there will be a lot of errors +var STALE_TICKER = 180000; +var STALE_BALANCE = 180000; + +Error.prototype.toJSON = function () { + var self = this; + var ret = {}; + Object.getOwnPropertyNames(self).forEach(function (key) { + ret[key] = self[key]; + }); + return ret; +}; + +var poll = function(req, res) { + if (req.device.unpair) { + return res.json({ + unpair: true + }); + } + + var rateRec = _trader.rate(req.params.currency); + var satoshiBalanceRec = _trader.balance; + + // `rateRec` and `satoshiBalanceRec` are both objects, so there's no danger + // of misinterpreting rate or balance === 0 as 'Server initializing'. + if (!rateRec || !satoshiBalanceRec) { + return res.json({err: 'Server initializing'}); + } + + if (Date.now() - rateRec.timestamp > STALE_TICKER) { + return res.json({err: 'Stale ticker'}); + } + + if (Date.now() - rateRec.timestamp > STALE_BALANCE) { + return res.json({err: 'Stale balance'}); + } + + var rate = rateRec.rate; + + res.json({ + err: null, + rate: rate * _trader.config.exchanges.settings.commission, + fiat: _trader.fiatBalance(0, 0), + currency: req.params.currency, + txLimit: parseInt(_trader.config.exchanges.settings.compliance.maximum.limit, 10) + }); +}; + +var trade = function (req, res) { + _trader.trade(req.body.fiat, req.body.satoshis, req.body.currency, function(err) { + res.json({err: err}); + }); +}; + +var send = function(req, res) { + var fingerprint = req.connection.getPeerCertificate().fingerprint; + _trader.sendBitcoins(fingerprint, req.body, function(err, txHash) { + res.json({err: err, txHash: txHash}); + }); +}; + +var pair = function(req, res) { + var token = req.body.token; + var name = req.body.name; + + _lamassuConfig.pair( + token, + req.connection.getPeerCertificate().fingerprint, + name, + function(err) { + if (err) { + return res.json(500, { err: err.message }); + } + + res.json(200); + } + ); +}; + +exports.init = function(app, trader, authMiddleware) { + _trader = trader; + + app.get('/poll/:currency', authMiddleware, poll); + app.post('/send', authMiddleware, send); + app.post('/trade', authMiddleware, trade); + app.post('/pair', pair); + + return app; +}; diff --git a/lib/trader.js b/lib/trader.js new file mode 100644 index 00000000..a07b99be --- /dev/null +++ b/lib/trader.js @@ -0,0 +1,245 @@ +'use strict'; + +var path = require('path'); +var async = require('async'); +var winston = require('winston'); + +var SATOSHI_FACTOR = Math.pow(10, 8); + +var Trader = module.exports = function (db) { + if (!db) { + throw new Error('`db` is required'); + } + + this.db = db; + this.rates = {}; + this.logger = new (winston.Logger)({ + transports: [new (winston.transports.Console)()] + }); + + this._tradeQueue = []; +}; + +Trader.prototype._findExchange = function (name) { + var exchange; + + try { + exchange = require('lamassu-' + name); + } catch (err) { + if (!err.message.match(/Cannot find module/)) { + throw err; + } + + exchange = require(path.join(path.dirname(__dirname), 'exchanges', name)); + } + + return exchange; +}; + +Trader.prototype._findTicker = function (name) { + var exchange = Trader.prototype._findExchange(name); + return exchange.ticker || exchange; +}; + +Trader.prototype._findTrader = function (name) { + var exchange = Trader.prototype._findExchange(name); + return exchange.trader || exchange; +}; + +Trader.prototype._findWallet = function (name) { + var exchange = Trader.prototype._findExchange(name); + return exchange.wallet || exchange; +}; + +Trader.prototype.configure = function (config) { + if (config.exchanges.settings.lowBalanceMargin < 1) { + throw new Error('`settings.lowBalanceMargin` has to be >= 1'); + } + + var tickerExchangeCode = config.exchanges.plugins.current.ticker; + var tickerExchangeConfig = config.exchanges.plugins.settings[tickerExchangeCode] || {}; + tickerExchangeConfig.currency = config.exchanges.settings.currency; + this.tickerExchange = this._findTicker(tickerExchangeCode).factory(tickerExchangeConfig); + + var tradeExchangeCode = config.exchanges.plugins.current.trade; + if (tradeExchangeCode) { + var tradeExchangeConfig = config.exchanges.plugins.settings[tradeExchangeCode]; + this.tradeExchange = this._findTrader(tradeExchangeCode).factory(tradeExchangeConfig); + } + + var transferExchangeCode = config.exchanges.plugins.current.transfer; + var transferExchangeConfig = config.exchanges.plugins.settings[transferExchangeCode]; + this.transferExchange = this._findWallet(transferExchangeCode).factory(transferExchangeConfig); + + this.config = config; +}; + +/** + * return fiat balance + * + * in input to this function, balance has the following parameters... + * + * balance.transferBalance - in satoshis + * balance.tradeBalance - in USD + * + * Have added conversion here, but this really needs to be thought through, lamassu-bitstamp should perhaps + * return balance in satoshis + */ +Trader.prototype.fiatBalance = function (transferSatoshis, tradeFiat) { + var rate = this.rate(this.config.exchanges.settings.currency).rate; + var balance = this.balance; + var commission = this.config.exchanges.settings.commission; + + if (!rate || !balance) { + return 0; + } + + // The rate is actually our commission times real rate. + rate = commission * rate; + + // `lowBalanceMargin` is our safety net. It's a number > 1, and we divide + // all our balances by it to provide a safety margin. + var lowBalanceMargin = this.config.exchanges.settings.lowBalanceMargin; + + // `balance.transferBalance` is the balance of our transfer account (the one + // we use to send Bitcoins to clients). `transferSatoshis` is the number + // of satoshis we're expected to send for this transaction. By subtracting + // them, we get `adjustedTransferBalance`, amount of satoshis we'll have + // after the transaction. + var adjustedTransferBalance = balance.transferBalance - transferSatoshis; + + // Since `adjustedTransferBalance` is in Satoshis, we need to turn it into + // Bitcoins and then fiat to learn how much fiat currency we can exchange. + // + // Unit validity proof: [ $ ] = [ (B * 10^8) / 10^8 * $/B ] + // [ $ ] = [ B * $/B ] + // [ $ ] = [ $ ] + var fiatTransferBalance = ((adjustedTransferBalance / SATOSHI_FACTOR) * rate) / lowBalanceMargin; + + return fiatTransferBalance; +}; + +Trader.prototype.sendBitcoins = function (deviceFingerprint, tx, cb) { + var self = this; + + self.db.summonTransaction(deviceFingerprint, tx, function (err, isNew, txHash) { + if (err) { + return cb(err); + } + + if (isNew) { + return self.transferExchange.sendBitcoins( + tx.toAddress, + tx.satoshis, + self.config.exchanges.settings.transactionFee, + function(err, txHash) { + if (err) { + self.db.reportTransactionError(tx, err); + return cb(err); + } + + cb(null, txHash); + self.db.completeTransaction(tx, txHash); + self.pollRate(); + } + ); + } + + // transaction exists, but txHash might be null, + // in which case ATM should continue polling + cb(null, txHash); + }); +}; + +Trader.prototype.trade = function (fiat, satoshis, currency, callback) { + this._tradeQueue.push({fiat: fiat, satoshis: satoshis, currency: currency}); + callback(null); +}; + +Trader.prototype.tradeQueueFiatBalance = function (exchangeRate) { + var satoshis = this._tradeQueue.reduce(function (memo, rec) { + return memo + rec.satoshis; + }, 0); + return (satoshis / SATOSHI_FACTOR) * exchangeRate; +}; + +Trader.prototype._consolidateTrades = function () { + var queue = this._tradeQueue; + + var tradeRec = { + fiat: 0, + satoshis: 0, + currency: 'USD' + }; + + while (true) { + var lastRec = queue.shift(); + if (!lastRec) { + break; + } + tradeRec.fiat += lastRec.fiat; + tradeRec.satoshis += lastRec.satoshis; + tradeRec.currency = lastRec.currency; + } + return tradeRec; +}; + +Trader.prototype.startPolling = function () { + this.pollBalance(); + this.pollRate(); + + this.balanceInterval = setInterval(this.pollBalance.bind(this), 60 * 1000); + this.rateInterval = setInterval(this.pollRate.bind(this), 60 * 1000); +}; + +Trader.prototype.stopPolling = function () { + clearInterval(this.balanceInterval); + clearInterval(this.rateInterval); +}; + +Trader.prototype.pollBalance = function (callback) { + var self = this; + + self.logger.info('collecting balance'); + + async.parallel({ + transferBalance: self.transferExchange.balance.bind(self.transferExchange), + tradeBalance: function (next) { + if (!self.tradeExchange) { + return next(null, null); + } + + self.tradeExchange.balance(next); + } + }, function (err, balance) { + if (err) { + return callback && callback(err); + } + + balance.timestamp = Date.now(); + self.logger.info('Balance update:', balance); + self.balance = balance; + + return callback && callback(null, balance); + }); +}; + +Trader.prototype.pollRate = function (callback) { + var self = this; + + var currency = self.config.exchanges.settings.currency; + self.logger.info('polling for rate...'); + self.tickerExchange.ticker(currency, function(err, rate) { + if (err) { + return callback && callback(err); + } + + self.logger.info('Rate update:', rate); + self.rates[currency] = {rate: rate, timestamp: new Date()}; + return callback && callback(null, self.rates[currency]); + }); +}; + +Trader.prototype.rate = function (currency) { + return this.rates[currency]; +}; diff --git a/package.json b/package.json index 3a8b947e..5f5213b0 100644 --- a/package.json +++ b/package.json @@ -15,23 +15,36 @@ "express": "~3.4.7", "optimist": "~0.6.0", "lamassu-config": "~0.2.0", - "lamassu-atm-protocol": "~0.2.0" + "lodash": "~2.4.1", + "async": "~0.2.9", + "deepmerge": "~0.2.7", + "underscore": "~1.5.2", + "error-create": "0.0.0", + "date-utils": "~1.2.15", + "bitstamp": "~0.1.3", + "winston": "~0.7.2", + "pg": "~2.11.1", + "lamassu-bitpay": "~0.0.1", + "lamassu-bitstamp": "~0.0.1", + "lamassu-mtgox": "~0.0.1", + "lamassu-blockchain": "0.0.4" }, "repository": { "type": "git", "url": "https://github.com/lamassu/lamassu-server.git" }, "devDependencies": { - "grunt": "~0.4.1", - "grunt-contrib-jshint": "~0.6.0", - "load-grunt-tasks": "~0.1.0", "chai": "~1.8.1", "matchdep": "~0.3.0", "mocha": "~1.13.0", - "grunt-mocha-test": "~0.7.0", - "grunt-mocha-cov": "0.0.7" + "hock": "git+https://github.com/mmalecki/hock.git#no-http-server", + "jsonquest": "^0.2.2", + "node-uuid": "^1.4.1" }, "bin": { "lamassu-server": "./lib/app.js" + }, + "scripts": { + "test": "mocha --recursive test" } } diff --git a/test/api/sendTest.js b/test/api/sendTest.js new file mode 100644 index 00000000..182d7eb2 --- /dev/null +++ b/test/api/sendTest.js @@ -0,0 +1,125 @@ +/* + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +'use strict'; + +var async = require('async'); +var hock = require('hock'); +var createServer = require('../helpers/create-https-server.js'); +var assert = require('chai').assert; + +var LamassuConfig = require('lamassu-config'); +var con = 'psql://lamassu:lamassu@localhost/lamassu'; +var config = new LamassuConfig(con); + +var fnTable = {}; + +var app = { + get: function(route, fn) { + fnTable[route] = fn; + }, + post: function(route, fn) { + fnTable[route] = fn; + } +}; + +var cfg; +var port; + +var blockchainMock = hock.createHock(); + +// blockchain info +var guid = '3acf1633-db4d-44a9-9013-b13e85405404'; +var pwd = 'baz'; +var bitAddr = '1LhkU2R8nJaU8Zj6jB8VjWrMpvVKGqCZ64'; + + +describe('send test', function() { + + beforeEach(function(done) { + + async.parallel({ + blockchain: async.apply(createServer, blockchainMock.handler), + config: function(cb) { + config.load(cb); + } + }, function(err, results) { + assert.isNull(err); + + cfg = results.config; + port = results.blockchain.address().port; + + cfg.exchanges.plugins.current.transfer = 'blockchain'; + cfg.exchanges.plugins.settings.blockchain = { + host: 'localhost', + port: results.blockchain.address().port, + rejectUnauthorized: false, + password: pwd, + fromAddress: bitAddr, + guid: guid + }; + + done(); + }); + }); + + it('should send to blockchain', function(done) { + this.timeout(1000000); + + var amount= 100000000; + + var address_reponse = { + 'hash160':'660d4ef3a743e3e696ad990364e555c271ad504b', + 'address': bitAddr, + 'n_tx': 1, + 'n_unredeemed': 1, + 'total_received': 0, + 'total_sent': 0, + 'final_balance': 0, + 'txs': [] + }; + + var payment_response = { + 'message': 'Sent 0.1 BTC to 1LhkU2R8nJaU8Zj6jB8VjWrMpvVKGqCZ64', + 'tx_hash': 'f322d01ad784e5deeb25464a5781c3b20971c1863679ca506e702e3e33c18e9c', + 'notice': 'Some funds are pending confirmation and cannot be spent yet (Value 0.001 BTC)' + }; + + blockchainMock + .get('/address/1LhkU2R8nJaU8Zj6jB8VjWrMpvVKGqCZ64?format=json&limit=10&password=baz') + .reply(200, address_reponse) + .post('/merchant/3acf1633-db4d-44a9-9013-b13e85405404/payment?to=1LhkU2R8nJaU8Zj6jB8VjWrMpvVKGqCZ64&amount=100000000&from=1LhkU2R8nJaU8Zj6jB8VjWrMpvVKGqCZ64&password=baz') + .reply(200, payment_response); + + + var api = require('../../lib/protocol/atm-api'); + api.init(app, cfg); + + var params = { + body: { + address: bitAddr, + satoshis: amount + } + }; + + setTimeout(function() { + fnTable['/send'](params, {json: function(result) { + assert.isNull(result.err); + assert.equal(payment_response.tx_hash, result.results); + done(); + } + }); + }, 2000); + }); +}); diff --git a/test/api/tickerTest.js b/test/api/tickerTest.js new file mode 100644 index 00000000..93265480 --- /dev/null +++ b/test/api/tickerTest.js @@ -0,0 +1,119 @@ +/* + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +'use strict'; + +var hock = require('hock'); +var async = require('async'); +var createServer = require('../helpers/create-https-server.js'); +var assert = require('chai').assert; + +var LamassuConfig = require('lamassu-config'); +var con = 'psql://lamassu:lamassu@localhost/lamassu'; +var config = new LamassuConfig(con); + +var cfg; + +var blockchainMock = hock.createHock(); +var bitpayMock = hock.createHock(); + +var jsonquest = require('jsonquest'); +var express = require('express'); +var app = express(); +var testPort = 4000; + + + +describe('ticker test', function(){ + + beforeEach(function(done) { + + app.listen(testPort); + + async.parallel({ + blockchain: async.apply(createServer, blockchainMock.handler), + bitpay: async.apply(createServer, bitpayMock.handler), + config: config.load.bind(config) + }, function(err, results) { + assert.isNull(err); + + cfg = results.config; + + cfg.exchanges.settings.commission = 1; + + cfg.exchanges.plugins.current.ticker = 'bitpay'; + cfg.exchanges.plugins.current.trade = null; + cfg.exchanges.plugins.settings.bitpay = { + host: 'localhost', + port: results.bitpay.address().port, + rejectUnauthorized: false + }; + + cfg.exchanges.plugins.current.transfer = 'blockchain'; + cfg.exchanges.plugins.settings.blockchain = { + host: 'localhost', + port: results.blockchain.address().port, + rejectUnauthorized: false, + password: 'baz', + fromAddress: '1LhkU2R8nJaU8Zj6jB8VjWrMpvVKGqCZ64', + guid: '3acf1633-db4d-44a9-9013-b13e85405404' + }; + + done(); + }); + }); + + + it('should read ticker data from bitpay', function(done) { + this.timeout(1000000); + + bitpayMock + .get('/api/rates') + .reply(200, [ + { code: 'EUR', rate: 1337 }, + { code: 'USD', rate: 100 } + ]); + + blockchainMock + .get('/merchant/3acf1633-db4d-44a9-9013-b13e85405404/address_balance?address=1LhkU2R8nJaU8Zj6jB8VjWrMpvVKGqCZ64&confirmations=0&password=baz') + .reply(200, { balance: 100000000, total_received: 100000000 }) + .get('/merchant/3acf1633-db4d-44a9-9013-b13e85405404/address_balance?address=1LhkU2R8nJaU8Zj6jB8VjWrMpvVKGqCZ64&confirmations=1&password=baz') + .reply(200, { balance: 100000000, total_received: 100000000 }); + // That's 1 BTC. + + var api = require('../../lib/protocol/atm-api'); + api.init(app, cfg); + + // let ticker rate fetch finish... + setTimeout(function() { + jsonquest({ + host: 'localhost', + port: testPort, + path: '/poll/USD',//:currency + method: 'GET', + protocol: 'http' + }, function (err, res, body) { + assert.isNull(err); + assert.equal(res.statusCode, 200); + + assert.isNull(body.err); + assert.equal(Number(body.rate) > 0, true); + console.log(100 / cfg.exchanges.settings.lowBalanceMargin, body.fiat); + assert.equal(body.fiat, 100 / cfg.exchanges.settings.lowBalanceMargin); + + done(); + }); + }, 2000); + }); +}); diff --git a/test/api/tradeTest.js b/test/api/tradeTest.js new file mode 100644 index 00000000..c307be53 --- /dev/null +++ b/test/api/tradeTest.js @@ -0,0 +1,70 @@ +/* + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +'use strict'; + +var assert = require('chai').assert; +var hock = require('hock'); + +var LamassuConfig = require('lamassu-config'); +var con = 'psql://lamassu:lamassu@localhost/lamassu'; +var config = new LamassuConfig(con); + +var fnTable = {}; +var app = { get: function(route, fn) { + fnTable[route] = fn; + }, + post: function(route, fn) { + fnTable[route] = fn; + } + }; +var cfg; + +var bitstampMock = hock.createHock(); + +/** + * the tests + */ +describe('trade test', function(){ + + beforeEach(function(done) { + config.load(function(err, result) { + assert.isNull(err); + cfg = result; + done(); + }); + }); + + + + it('should execute a trade against bitstamp', function(done) { + this.timeout(1000000); + + cfg.exchanges.plugins.trade = 'bitstamp'; + var api = require('../../lib/protocol/atm-api'); + api.init(app, cfg); + + // schedule two trades this should result in a single consolidated trade hitting the trading system + fnTable['/trade']({body: {fiat: 100, satoshis: 10, currency: 'USD'}}, {json: function(result) { + console.log(result); + }}); + + fnTable['/trade']({body: {fiat: 100, satoshis: 10, currency: 'USD'}}, {json: function(result) { + console.log(result); + }}); + + setTimeout(function() { done(); }, 1000000); + // check results and execute done() + }); +}); diff --git a/testkeys/certificate.pem b/test/fixtures/certificate.pem similarity index 100% rename from testkeys/certificate.pem rename to test/fixtures/certificate.pem diff --git a/testkeys/certrequest.csr b/test/fixtures/certrequest.csr similarity index 100% rename from testkeys/certrequest.csr rename to test/fixtures/certrequest.csr diff --git a/testkeys/privatekey.pem b/test/fixtures/privatekey.pem similarity index 100% rename from testkeys/privatekey.pem rename to test/fixtures/privatekey.pem diff --git a/test/helpers/create-https-server.js b/test/helpers/create-https-server.js new file mode 100644 index 00000000..a7c9cf9f --- /dev/null +++ b/test/helpers/create-https-server.js @@ -0,0 +1,14 @@ +var fs = require('fs'); +var path = require('path'); +var https = require('https'); +var fixtures = path.join(__dirname, '..', 'fixtures'); + +module.exports = function(handler, callback) { + var server = https.createServer({ + key: fs.readFileSync(path.join(fixtures, 'privatekey.pem')), + cert: fs.readFileSync(path.join(fixtures, 'certificate.pem')) + }, handler); + server.listen(0, function() { + callback(null, server); + }); +}; diff --git a/test/unit/traderApiTest.js b/test/unit/traderApiTest.js new file mode 100644 index 00000000..ae8ed901 --- /dev/null +++ b/test/unit/traderApiTest.js @@ -0,0 +1,54 @@ +'use strict'; + +var assert = require('chai').assert; +var Trader = require('../../lib/trader.js'); +var PostgresqlInterface = require('../../lib/postgresql_interface.js'); + +var db = 'psql://lamassu:lamassu@localhost/lamassu-test'; +var psqlInterface = new PostgresqlInterface(db); + +describe('trader/api', function () { + it('should throw when trying to create a trader with no DB', function () { + assert.throws(function () { + new Trader(); + }); + }); + + it('should throw when trying to configure a trader with `lowBalanceMargin` < 1', function () { + var trader = new Trader(psqlInterface); + assert.throws(function () { + trader.configure({ + exchanges: { + settings: { + lowBalanceMargin: 0.8 + } + } + }); + }); + }); + + it('should find and instantiate ticker and trade exchanges', function () { + var trader = new Trader(psqlInterface); + trader.configure({ + exchanges: { + plugins: { + current: { + ticker: 'bitpay', + transfer: 'blockchain' + }, + settings: { + bitpay: {}, + blockchain: {} + } + }, + settings: { + currency: 'USD', + lowBalanceMargin: 2 + } + } + }); + + assert.ok(trader.tickerExchange); + assert.ok(trader.transferExchange); + }); +}); diff --git a/test/unit/traderFiatBalanceTest.js b/test/unit/traderFiatBalanceTest.js new file mode 100644 index 00000000..b913e6e3 --- /dev/null +++ b/test/unit/traderFiatBalanceTest.js @@ -0,0 +1,48 @@ +'use strict'; + +var assert = require('chai').assert; +var Trader = require('../../lib/trader.js'); +var PostgresqlInterface = require('../../lib/postgresql_interface.js'); + +var db = 'psql://lamassu:lamassu@localhost/lamassu-test'; +var psqlInterface = new PostgresqlInterface(db); + +var RATE = 101; +var CURRENCY = 'USD'; +var SATOSHI_FACTOR = Math.pow(10, 8); +var LOW_BALANCE_MARGIN = 1.2; +var COMMISSION = 1.1; + +var settings = { + currency: CURRENCY, + lowBalanceMargin: LOW_BALANCE_MARGIN, + commission: COMMISSION +}; + +describe('trader/fiatBalance', function() { + it('should calculate balance correctly with transfer exchange only', function() { + var trader = new Trader(db); + trader.configure({ + exchanges: { + plugins: { + current: { + transfer: 'blockchain', + ticker: 'bitpay' + }, + settings: { blockchain: {}, bitpay: {} } + }, + settings: settings + } + }); + + // We have 3 bitcoins, want to trade 1 bitcoin for 100 fiat + trader.balance = { + transferBalance: 3 * SATOSHI_FACTOR, + tradeBalance: null + }; + trader.rates[CURRENCY] = { rate: RATE }; + + var balance = trader.fiatBalance(1 * SATOSHI_FACTOR, 100); + assert.equal(balance, (202 / LOW_BALANCE_MARGIN) * COMMISSION); + }); +}); diff --git a/test/unit/traderSendTest.js b/test/unit/traderSendTest.js new file mode 100644 index 00000000..fcac1dde --- /dev/null +++ b/test/unit/traderSendTest.js @@ -0,0 +1,75 @@ +'use strict'; + +var assert = require('chai').assert; +var hock = require('hock'); +var uuid = require('node-uuid').v4; +var Trader = require('../../lib/trader.js'); +var PostgresqlInterface = require('../../lib/postgresql_interface.js'); + +var db = 'psql://lamassu:lamassu@localhost/lamassu-test'; +var psqlInterface = new PostgresqlInterface(db); + +var TRANSACTION_FEE = 1; +var FINGERPRINT = 'CB:3D:78:49:03:39:BA:47:0A:33:29:3E:31:25:F7:C6:4F:74:71:D7'; +var TXID = '216dabdb692670bae940deb71e59486038a575f637903d3c9af601ddd48057fc'; +var ADDRESS = '1LhkU2R8nJaU8Zj6jB8VjWrMpvVKGqCZ64'; +var SATOSHIS = 1337; +var CURRENCY = 'USD'; + +var OUR_TXID = uuid(); + +describe('trader/send', function () { + var trader = new Trader(psqlInterface); + trader.config = { + exchanges: { + settings: { + transactionFee: TRANSACTION_FEE + } + } + }; + + trader.pollRate = function () {}; + + it('should call `sendBitcoins` on the transfer exchange', function (done) { + trader.transferExchange = { + sendBitcoins: function (address, satoshis, transactionFee, callback) { + assert.equal(ADDRESS, address); + assert.equal(SATOSHIS, satoshis); + assert.equal(transactionFee, TRANSACTION_FEE); + callback(null, TXID); + } + }; + + trader.sendBitcoins(FINGERPRINT, { + fiat: 100, + txId: OUR_TXID, + currencyCode: CURRENCY, + toAddress: ADDRESS, + satoshis: SATOSHIS + }, function (err, txId) { + assert.notOk(err); + assert.equal(txId, TXID); + done(); + }); + }); + + it('should not call `sendBitcoins` on the transfer exchange with same send', function (done) { + trader.transferExchange = { + sendBitcoins: function () { + throw new Error('This should not have been called'); + } + }; + + trader.sendBitcoins(FINGERPRINT, { + fiat: 100, + txId: OUR_TXID, + currencyCode: CURRENCY, + toAddress: ADDRESS, + satoshis: SATOSHIS + }, function (err, txId) { + assert.notOk(err); + assert.equal(txId, TXID); + done(); + }); + }); +}); diff --git a/test/unit/traderTickerTest.js b/test/unit/traderTickerTest.js new file mode 100644 index 00000000..dca1cf1a --- /dev/null +++ b/test/unit/traderTickerTest.js @@ -0,0 +1,55 @@ +'use strict'; + +var assert = require('chai').assert; +var hock = require('hock'); +var uuid = require('node-uuid').v4; +var Trader = require('../../lib/trader.js'); +var PostgresqlInterface = require('../../lib/postgresql_interface.js'); + +var db = 'psql://lamassu:lamassu@localhost/lamassu-test'; +var psqlInterface = new PostgresqlInterface(db); + +var CURRENCY = 'USD'; + +describe('trader/send', function () { + var trader = new Trader(psqlInterface); + trader.config = { + exchanges: { + settings: { currency: CURRENCY } + } + }; + + it('should call `balance` on the transfer exchange', function (done) { + trader.transferExchange = { + balance: function (callback) { + callback(null, 100); + } + }; + + trader.pollBalance(function (err, balance) { + assert.notOk(err); + assert.equal(trader.balance.transferBalance, 100); + assert.ok(trader.balance.timestamp); + done(); + }); + }); + + it('should call `ticker` on the ticker exchange', function (done) { + trader.tickerExchange = { + ticker: function (currency, callback) { + assert.equal(currency, CURRENCY); + callback(null, 100); + } + }; + + trader.pollRate(function (err, rate) { + var rate; + + assert.notOk(err); + rate = trader.rate(CURRENCY); + assert.equal(rate.rate, 100); + assert.ok(rate.timestamp); + done(); + }); + }); +});