diff --git a/lib/model/notification.js b/lib/model/notification.js index 72b5e69..0b51988 100644 --- a/lib/model/notification.js +++ b/lib/model/notification.js @@ -25,23 +25,22 @@ var Uuid = require('uuid'); * */ - - function Notification(opts) { opts = opts || {}; - this.createdOn = Math.floor(Date.now() / 1000); - this.id = ('000000000000' + this.createdOn).slice(-12) + Uuid.v4(); + var now = Date.now(); + this.createdOn = Math.floor(now / 1000); + this.id = ('00000000000000' + now).slice(-14) + ('0000' + opts.ticker||0).slice(-4) ; this.type = opts.type || 'general'; this.data = opts.data; }; -Notification.prototype.fromObj = function(obj) { +Notification.fromObj = function(obj) { var x= new Notification(); x.createdOn = obj.createdOn; x.type = obj.type, - x.data = opts.data; + x.data = obj.data; return x; }; diff --git a/lib/model/txproposal.js b/lib/model/txproposal.js index 171850e..292f7c6 100644 --- a/lib/model/txproposal.js +++ b/lib/model/txproposal.js @@ -13,8 +13,10 @@ function TxProposal(opts) { opts = opts || {}; this.version = VERSION; - this.createdOn = Math.floor(Date.now() / 1000); - this.id = ('000000000000' + this.createdOn).slice(-12) + Uuid.v4(); + + var now = Date.now(); + this.createdOn = Math.floor(now / 1000); + this.id = ('00000000000000' + now).slice(-14) + Uuid.v4(); this.creatorId = opts.creatorId; this.toAddress = opts.toAddress; this.amount = opts.amount; diff --git a/lib/server.js b/lib/server.js index 5d6c383..87770fc 100644 --- a/lib/server.js +++ b/lib/server.js @@ -36,6 +36,7 @@ var storage; function CopayServer() { if (!initialized) throw new Error('Server not initialized'); this.storage = storage; + this.notifyTicker = 0; }; nodeutil.inherits(CopayServer, events.EventEmitter); @@ -61,7 +62,8 @@ CopayServer.initialize = function(opts) { */ CopayServer.getInstanceWithAuth = function(opts, cb) { - if (!Utils.checkRequired(opts, ['copayerId', 'message', 'signature'])) return cb(new ClientError('Required argument missing')); + if (!Utils.checkRequired(opts, ['copayerId', 'message', 'signature'])) + return cb(new ClientError('Required argument missing')); var server = new CopayServer(); server.storage.fetchCopayerLookup(opts.copayerId, function(err, copayer) { @@ -163,6 +165,7 @@ CopayServer.prototype._notify = function(type, data) { var n = new Notification({ type: type, data: data, + ticker: this.notifyTicker++, }); this.storage.storeNotification(walletId, n, function() { self.emit(n); @@ -180,9 +183,11 @@ CopayServer.prototype._notify = function(type, data) { CopayServer.prototype.joinWallet = function(opts, cb) { var self = this; - if (!Utils.checkRequired(opts, ['walletId', 'name', 'xPubKey', 'xPubKeySignature'])) return cb(new ClientError('Required argument missing')); + if (!Utils.checkRequired(opts, ['walletId', 'name', 'xPubKey', 'xPubKeySignature'])) + return cb(new ClientError('Required argument missing')); - if (_.isEmpty(opts.name)) return cb(new ClientError('Invalid copayer name')); + if (_.isEmpty(opts.name)) + return cb(new ClientError('Invalid copayer name')); Utils.runLocked(opts.walletId, cb, function(cb) { self.storage.fetchWallet(opts.walletId, function(err, wallet) { @@ -196,7 +201,8 @@ CopayServer.prototype.joinWallet = function(opts, cb) { if (_.find(wallet.copayers, { xPubKey: opts.xPubKey })) return cb(new ClientError('CINWALLET', 'Copayer already in wallet')); - if (wallet.copayers.length == wallet.n) return cb(new ClientError('WFULL', 'Wallet full')); + if (wallet.copayers.length == wallet.n) + return cb(new ClientError('WFULL', 'Wallet full')); var copayer = new Copayer({ name: opts.name, @@ -209,6 +215,7 @@ CopayServer.prototype.joinWallet = function(opts, cb) { self.storage.storeWalletAndUpdateCopayersLookup(wallet, function(err) { self._notify('NewCopayer', { walletId: opts.walletId, + copayerId: copayer.id, }); return cb(err, copayer.id); }); @@ -227,7 +234,8 @@ CopayServer.prototype.createAddress = function(opts, cb) { Utils.runLocked(self.walletId, cb, function(cb) { self.getWallet({}, function(err, wallet) { if (err) return cb(err); - if (!wallet.isComplete()) return cb(new ClientError('Wallet is not complete')); + if (!wallet.isComplete()) + return cb(new ClientError('Wallet is not complete')); var address = wallet.createAddress(false); @@ -624,7 +632,7 @@ CopayServer.prototype.signTx = function(opts, cb) { self.storage.storeTx(self.walletId, txp, function(err) { if (err) return cb(err); - self._notify('newOutgoingTx', { + self._notify('NewOutgoingTx', { txProposalId: opts.txProposalId, txid: txid }); @@ -704,6 +712,8 @@ CopayServer.prototype.getPendingTxs = function(opts, cb) { /** * Retrieves pending transaction proposals in the range (maxTs-minTs) + * Times are in UNIX EPOCH + * * @param {Object} opts.minTs (defaults to 0) * @param {Object} opts.maxTs (defaults to now) * @param {Object} opts.limit @@ -720,10 +730,13 @@ CopayServer.prototype.getTxs = function(opts, cb) { /** * Retrieves notifications in the range (maxTs-minTs). + * Times are in UNIX EPOCH. Order is assured even for events with the same time + * * @param {Object} opts.minTs (defaults to 0) * @param {Object} opts.maxTs (defaults to now) * @param {Object} opts.limit - * @returns {Notification[]} Notifications, first newer + * @param {Object} opts.reverse (default false) + * @returns {Notification[]} Notifications */ CopayServer.prototype.getNotifications = function(opts, cb) { var self = this; diff --git a/lib/storage.js b/lib/storage.js index 8295c45..81b237a 100644 --- a/lib/storage.js +++ b/lib/storage.js @@ -31,7 +31,7 @@ var opKey = function(key) { var MAX_TS = '999999999999'; var opKeyTs = function(key) { - return key ? '!' + ('000000000000' + key).slice(-12) : ''; + return key ? '!' + ('00000000000000' + key).slice(-14) : ''; }; @@ -144,7 +144,7 @@ Storage.prototype.fetchPendingTxs = function(walletId, cb) { }; /** - * fetchTxs + * fetchTxs. Times are in UNIX EPOCH (seconds) * * @param walletId * @param opts.minTs @@ -155,8 +155,8 @@ Storage.prototype.fetchTxs = function(walletId, opts, cb) { var txs = []; opts = opts || {}; opts.limit = _.isNumber(opts.limit) ? parseInt(opts.limit) : -1; - opts.minTs = _.isNumber(opts.minTs) ? ('000000000000' + parseInt(opts.minTs)).slice(-12) : 0; - opts.maxTs = _.isNumber(opts.maxTs) ? ('000000000000' + parseInt(opts.maxTs)).slice(-12) : MAX_TS; + opts.minTs = _.isNumber(opts.minTs) ? ('00000000000' + parseInt(opts.minTs)).slice(-11) : 0; + opts.maxTs = _.isNumber(opts.maxTs) ? ('00000000000' + parseInt(opts.maxTs)).slice(-11) : MAX_TS; var key = KEY.TXP(walletId, opts.minTs); var endkey = KEY.TXP(walletId, opts.maxTs); @@ -192,8 +192,8 @@ Storage.prototype.fetchNotifications = function(walletId, opts, cb) { var txs = []; opts = opts || {}; opts.limit = _.isNumber(opts.limit) ? parseInt(opts.limit) : -1; - opts.minTs = _.isNumber(opts.minTs) ? ('000000000000' + parseInt(opts.minTs)).slice(-12) : 0; - opts.maxTs = _.isNumber(opts.maxTs) ? ('000000000000' + parseInt(opts.maxTs)).slice(-12) : MAX_TS; + opts.minTs = _.isNumber(opts.minTs) ? ('00000000000000' + parseInt(opts.minTs)).slice(-14) : 0; + opts.maxTs = _.isNumber(opts.maxTs) ? ('00000000000000' + parseInt(opts.maxTs)).slice(-14) : MAX_TS; var key = KEY.NOTIFICATION(walletId, opts.minTs); var endkey = KEY.NOTIFICATION(walletId, opts.maxTs); @@ -201,11 +201,11 @@ Storage.prototype.fetchNotifications = function(walletId, opts, cb) { this.db.createReadStream({ gt: key, lt: endkey + '~', - reverse: true, + reverse: opts.reverse, limit: opts.limit, }) .on('data', function(data) { - txs.push(TxProposal.fromObj(data.value)); + txs.push(Notification.fromObj(data.value)); }) .on('error', function(err) { if (err.notFound) return cb(); diff --git a/test/integration.js b/test/integration.js index c07897b..7138660 100644 --- a/test/integration.js +++ b/test/integration.js @@ -1168,6 +1168,126 @@ describe('Copay server', function() { }); + + describe('Notifications', function() { + var server, wallet, copayerPriv; + + beforeEach(function(done) { + if (server) return done(); + console.log('\tCreating TXS...'); + helpers.createAndJoinWallet(1, 1, function(s, w, c) { + server = s; + wallet = w; + copayerPriv = c; + server.createAddress({}, function(err, address) { + helpers.createUtxos(server, wallet, helpers.toSatoshi(_.range(4)), function(utxos) { + helpers.stubBlockExplorer(server, utxos); + var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.01, null, copayerPriv[0].privKey); + async.eachSeries(_.range(3), function(i, next) { + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + next(); + }); + }, function(err) { + return done(err); + }); + }); + }); + }); + }); + + it('should pull the last 5 notifications after 3 TXs', function(done) { + server.getNotifications({ + limit: 5, + reverse: true, + }, function(err, notifications) { + should.not.exist(err); + var types = _.pluck(notifications, 'type'); + types.should.deep.equal(['NewTxProposal', 'NewTxProposal', 'NewTxProposal', 'NewAddress', 'NewAddress']); + done(); + }); + }); + + + + it('should pull the first 5 notifications after wallet creation', function(done) { + server.getNotifications({ + minTs: 0, + limit: 5 + }, function(err, notifications) { + should.not.exist(err); + var types = _.pluck(notifications, 'type'); + types.should.deep.equal(['NewCopayer', 'NewAddress', 'NewAddress', 'NewAddress', 'NewAddress']); + done(); + }); + }); + + it('should notify sign and acceptance', function(done) { + server.getPendingTxs({}, function(err, txs) { + var tx = txs[0]; + var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey); + server.signTx({ + txProposalId: tx.id, + signatures: signatures, + }, function(err) { + server.getNotifications({ + limit: 3, + reverse: true, + }, function(err, notifications) { + should.not.exist(err); + var types = _.pluck(notifications, 'type'); + types.should.deep.equal(['TxProposalFinallyAccepted', 'TxProposalAcceptedBy', 'NewTxProposal']); + done(); + }); + }); + }); + }); + + it('should notify rejection', function(done) { + server.getPendingTxs({}, function(err, txs) { + var tx = txs[1]; + server.rejectTx({ + txProposalId: tx.id, + }, function(err) { + should.not.exist(err); + server.getNotifications({ + limit: 2, + reverse: true, + }, function(err, notifications) { + should.not.exist(err); + var types = _.pluck(notifications, 'type'); + types.should.deep.equal(['TxProposalFinallyRejected', 'TxProposalRejectedBy']); + done(); + }); + }); + }); + }); + + + it('should notify sign, acceptance, and broadcast', function(done) { + server.getPendingTxs({}, function(err, txs) { + var tx = txs[2]; + var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey); + helpers.stubBlockExplorer(server, [], '1122334455'); + server.signTx({ + txProposalId: tx.id, + signatures: signatures, + }, function(err) { + server.getNotifications({ + limit: 3, + reverse: true, + }, function(err, notifications) { + should.not.exist(err); + var types = _.pluck(notifications, 'type'); + types.should.deep.equal(['NewOutgoingTx','TxProposalFinallyAccepted', 'TxProposalAcceptedBy']); + done(); + }); + }); + }); + }); + + }); + describe('#removeWallet', function() { var server, wallet, clock;