diff --git a/src/js/account.js b/src/js/account.js index 2e1121e5..dcc14c67 100644 --- a/src/js/account.js +++ b/src/js/account.js @@ -128,6 +128,22 @@ Account.prototype.entry = function (callback) return this; }; +/** + * Notify object of a relevant transaction. + * + * This is only meant to be called by the Remote class. You should never have to + * call this yourself. + */ +Account.prototype.notifyTx = function (message) +{ + // Only trigger the event if the account object is actually + // subscribed - this prevents some weird phantom events from + // occurring. + if (this._subs) { + this.emit('transaction', message); + } +}; + exports.Account = Account; // vim:sw=2:sts=2:ts=8:et diff --git a/src/js/currency.js b/src/js/currency.js index ab960418..22bd112f 100644 --- a/src/js/currency.js +++ b/src/js/currency.js @@ -22,9 +22,9 @@ Currency.json_rewrite = function (j) { }; Currency.from_json = function (j) { - return 'string' === typeof j - ? (new Currency()).parse_json(j) - : j.clone(); + if (j instanceof Currency) return j.clone(); + else if ('string' === typeof j) return (new Currency()).parse_json(j); + else return new Currency(); // NaN }; Currency.is_valid = function (j) { diff --git a/src/js/index.js b/src/js/index.js index b603d6a5..05ec1f84 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -1,5 +1,6 @@ exports.Remote = require('./remote').Remote; exports.Amount = require('./amount').Amount; +exports.Currency = require('./currency').Currency; exports.UInt160 = require('./amount').UInt160; exports.Seed = require('./amount').Seed; diff --git a/src/js/meta.js b/src/js/meta.js index 46c0e46c..23ba8152 100644 --- a/src/js/meta.js +++ b/src/js/meta.js @@ -1,4 +1,5 @@ var extend = require('extend'); +var utils = require('./utils'); var UInt160 = require('./uint160').UInt160; var Amount = require('./amount').Amount; @@ -102,7 +103,35 @@ Meta.prototype.getAffectedAccounts = function () } }); + accounts = utils.arrayUnique(accounts); + return accounts; }; +Meta.prototype.getAffectedBooks = function () +{ + var books = []; + + this.each(function (an) { + if (an.entryType !== 'Offer') return; + + var gets = Amount.from_json(an.fields.TakerGets); + var pays = Amount.from_json(an.fields.TakerPays); + + var getsKey = gets.currency().to_json(); + if (getsKey !== 'XRP') getsKey += '/' + gets.issuer().to_json(); + + var paysKey = pays.currency().to_json(); + if (paysKey !== 'XRP') paysKey += '/' + pays.issuer().to_json(); + + var key = getsKey + ":" + paysKey; + + books.push(key); + }); + + books = utils.arrayUnique(books); + + return books; +}; + exports.Meta = Meta; diff --git a/src/js/orderbook.js b/src/js/orderbook.js index 00ac29b2..535919ec 100644 --- a/src/js/orderbook.js +++ b/src/js/orderbook.js @@ -1,6 +1,10 @@ // Routines for working with an orderbook. // +// One OrderBook object represents one half of an order book. (i.e. bids OR +// asks) Which one depends on the ordering of the parameters. +// // Events: +// - transaction A transaction that affects the order book. // var network = require("./network.js"); @@ -12,28 +16,29 @@ var Currency = require('./currency').Currency; var extend = require('extend'); var OrderBook = function (remote, - currency_out, issuer_out, - currency_in, issuer_in) { + currency_gets, issuer_gets, + currency_pays, issuer_pays) { var self = this; this._remote = remote; - this._currency_out = currency_out; - this._issuer_out = issuer_out; - this._currency_in = currency_in; - this._issuer_in = issuer_in; + this._currency_gets = currency_gets; + this._issuer_gets = issuer_gets; + this._currency_pays = currency_pays; + this._issuer_pays = issuer_pays; this._subs = 0; - // Ledger entry object - // Important: This must never be overwritten, only extend()-ed - this._entry = {}; + // We consider ourselves synchronized if we have a current copy of the offers, + // we are online and subscribed to updates. + this._sync = false; + + // Offers + this._offers = []; this.on('newListener', function (type, listener) { if (OrderBook.subscribe_events.indexOf(type) !== -1) { if (!self._subs && 'open' === self._remote._online_state) { - self._remote.request_subscribe() - .books([self.to_json()], true) - .request(); + self._subscribe(); } self._subs += 1; } @@ -44,6 +49,7 @@ var OrderBook = function (remote, self._subs -= 1; if (!self._subs && 'open' === self._remote._online_state) { + self._sync = false; self._remote.request_unsubscribe() .books([self.to_json()]) .request(); @@ -59,6 +65,10 @@ var OrderBook = function (remote, } }); + this._remote.on('disconnect', function () { + self._sync = false; + }); + return this; }; @@ -67,17 +77,42 @@ OrderBook.prototype = new EventEmitter; /** * List of events that require a remote subscription to the orderbook. */ -OrderBook.subscribe_events = ['transaction']; +OrderBook.subscribe_events = ['transaction', 'model']; + +/** + * Subscribes to orderbook. + * + * @private + */ +OrderBook.prototype._subscribe = function () +{ + var self = this; + self._remote.request_subscribe() + .books([self.to_json()], true) + .on('error', function () { + // XXX What now? + }) + .on('success', function (res) { + self._sync = true; + self._offers = res.offers; + self.emit('model', self._offers); + }) + .request(); +}; OrderBook.prototype.to_json = function () { var json = { - "CurrencyOut": this._currency_out, - "CurrencyIn": this._currency_in + "taker_gets": { + "currency": this._currency_gets + }, + "taker_pays": { + "currency": this._currency_pays + } }; - if (json["CurrencyOut"] !== "XRP") json["IssuerOut"] = this._issuer_out; - if (json["CurrencyIn"] !== "XRP") json["IssuerIn"] = this._issuer_in; + if (this._currency_gets !== "XRP") json["taker_gets"]["issuer"] = this._issuer_gets; + if (this._currency_pays !== "XRP") json["taker_pays"]["issuer"] = this._issuer_pays; return json; }; @@ -90,15 +125,108 @@ OrderBook.prototype.to_json = function () */ OrderBook.prototype.is_valid = function () { + // XXX Should check for same currency (non-native) && same issuer return ( - Currency.is_valid(this._currency_in) && - (this._currency_in !== "XRP" && UInt160.is_valid(this._issuer_in)) && - Currency.is_valid(this._currency_out) && - (this._currency_out !== "XRP" && UInt160.is_valid(this._issuer_out)) && - !(this._currency_in === "XRP" && this._currency_out === "XRP") + Currency.is_valid(this._currency_pays) && + (this._currency_pays === "XRP" || UInt160.is_valid(this._issuer_pays)) && + Currency.is_valid(this._currency_gets) && + (this._currency_gets === "XRP" || UInt160.is_valid(this._issuer_gets)) && + !(this._currency_pays === "XRP" && this._currency_gets === "XRP") ); }; +/** + * Notify object of a relevant transaction. + * + * This is only meant to be called by the Remote class. You should never have to + * call this yourself. + */ +OrderBook.prototype.notifyTx = function (message) +{ + var self = this; + + var changed = false; + + message.mmeta.each(function (an) { + if (an.entryType !== 'Offer') return; + + var i, l, offer; + if (an.diffType === 'DeletedNode' || + an.diffType === 'ModifiedNode') { + for (i = 0, l = self._offers.length; i < l; i++) { + offer = self._offers[i]; + console.log(offer.index, an.ledgerIndex); + if (offer.index === an.ledgerIndex) { + if (an.diffType === 'DeletedNode') { + self._offers.splice(i, 1); + console.log('node removed'); + } + else extend(offer, an.fieldsFinal); + changed = true; + break; + } + } + } else if (an.diffType === 'CreatedNode') { + var price = Amount.from_json(an.fields.TakerPays).ratio_human(an.fields.TakerGets); + for (i = 0, l = self._offers.length; i < l; i++) { + offer = self._offers[i]; + var priceItem = Amount.from_json(offer.TakerPays).ratio_human(offer.TakerGets); + console.log(price.to_text_full(), priceItem.to_text_full()); + if (price.compareTo(priceItem) <= 0) { + var obj = an.fields; + obj.index = an.ledgerIndex; + self._offers.splice(i, 0, an.fields); + changed = true; + break; + } + } + } + }); + + // Only trigger the event if the account object is actually + // subscribed - this prevents some weird phantom events from + // occurring. + if (this._subs) { + this.emit('transaction', message); + if (changed) this.emit('model', this._offers); + } +}; + +/** + * Get offers model asynchronously. + * + * This function takes a callback and calls it with an array containing the + * current set of offers in this order book. + * + * If the data is available immediately, the callback may be called synchronously. + */ +OrderBook.prototype.offers = function (callback) +{ + var self = this; + + if ("function" === typeof callback) { + if (this._sync) { + callback(this._offers); + } else { + this.once('model', function (offers) { + callback(offers); + }); + } + } + return this; +}; + +/** + * Return latest known offers. + * + * Usually, this will just be an empty array if the order book hasn't been + * loaded yet. But this accessor may be convenient in some circumstances. + */ +OrderBook.prototype.offersSync = function () +{ + return this._offers; +}; + exports.OrderBook = OrderBook; // vim:sw=2:sts=2:ts=8:et diff --git a/src/js/remote.js b/src/js/remote.js index fed02525..25c6d7a2 100644 --- a/src/js/remote.js +++ b/src/js/remote.js @@ -208,20 +208,25 @@ Request.prototype.books = function (books, state) { for (var i = 0, l = books.length; i < l; i++) { var book = books[i]; + var json = {}; - var json = { - "CurrencyOut": Currency.json_rewrite(book["CurrencyOut"]), - "CurrencyIn": Currency.json_rewrite(book["CurrencyIn"]) - }; + function process(side) { + if (!book[side]) throw new Error("Missing "+side); - if (json["CurrencyOut"] !== "XRP") { - json["IssuerOut"] = UInt160.json_rewrite(book["IssuerOut"]); - } - if (json["CurrencyIn"] !== "XRP") { - json["IssuerIn"] = UInt160.json_rewrite(book["IssuerIn"]); + var obj = {}; + obj["currency"] = Currency.json_rewrite(book[side]["currency"]); + if (obj["currency"] !== "XRP") { + obj.issuer = UInt160.json_rewrite(book[side]["issuer"]); + } + + json[side] = obj; } - if (state || book["StateNow"]) json["StateNow"] = true; + process("taker_gets"); + process("taker_pays"); + + if (state || book["state_now"]) json["state_now"] = true; + if (book["both_sides"]) json["both_sides"] = true; procBooks.push(json); } @@ -280,6 +285,7 @@ var Remote = function (opts, trace) { this._reserve_base = undefined; this._reserve_inc = undefined; this._server_status = undefined; + this._last_tx = null; // Local signing implies local fees and sequences if (this.local_signing) { @@ -300,6 +306,9 @@ var Remote = function (opts, trace) { // Hash map of Account objects by AccountId. this._accounts = {}; + // Hash map of OrderBook objects + this._books = {}; + // List of secrets that we know about. this.secrets = { // Secrets can be set by calling set_secret(account, secret). @@ -629,7 +638,10 @@ Remote.prototype._connect_message = function (ws, json) { // unsubscribes will be added as needed. // XXX If not trusted, need proof. - // XXX Should de-duplicate transaction events + // De-duplicate transactions that are immediately following each other + // XXX Should have a cache of n txs so we can dedup out of order txs + if (this._last_tx === message.transaction.hash) break; + this._last_tx = message.transaction.hash; if (this.trace) utils.logObject("remote: tx: %s", message); @@ -641,12 +653,15 @@ Remote.prototype._connect_message = function (ws, json) { for (var i = 0, l = affected.length; i < l; i++) { var account = self._accounts[affected[i]]; - // Only trigger the event if the account object is actually - // subscribed - this prevents some weird phantom events from - // occurring. - if (account && account._subs) { - account.emit('transaction', message); - } + if (account) account.notifyTx(message); + } + + // Pass the event on to any related OrderBooks + affected = message.mmeta.getAffectedBooks(); + for (i = 0, l = affected.length; i < l; i++) { + var book = self._books[affected[i]]; + + if (book) book.notifyTx(message); } this.emit('transaction', message); @@ -1143,13 +1158,26 @@ Remote.prototype.account = function (accountId) { return this._accounts[accountId]; }; -Remote.prototype.book = function (currency_out, issuer_out, - currency_in, issuer_in) { - var book = new OrderBook(this, - currency_out, issuer_out, - currency_in, issuer_in); +Remote.prototype.book = function (currency_gets, issuer_gets, + currency_pays, issuer_pays) { + var gets = currency_gets; + if (gets !== 'XRP') gets += '/' + issuer_gets; + var pays = currency_pays; + if (pays !== 'XRP') pays += '/' + issuer_pays; - return book; + var key = gets + ":" + pays; + + if (!this._books[key]) { + var book = new OrderBook(this, + currency_gets, issuer_gets, + currency_pays, issuer_pays); + + if (!book.is_valid()) return book; + + this._books[key] = book; + } + + return this._books[key]; } // Return the next account sequence if possible. diff --git a/src/js/utils.js b/src/js/utils.js index 462f3f28..eafa8bea 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -97,6 +97,21 @@ var assert = function (assertion, msg) { } }; +/** + * Return unique values in array. + */ +var arrayUnique = function (arr) { + var u = {}, a = []; + for (var i = 0, l = arr.length; i < l; ++i){ + if (u.hasOwnProperty(arr[i])) { + continue; + } + a.push(arr[i]); + u[arr[i]] = 1; + } + return a; +}; + /** * Convert a ripple epoch to a JavaScript timestamp. * @@ -115,6 +130,7 @@ exports.stringToHex = stringToHex; exports.chunkString = chunkString; exports.logObject = logObject; exports.assert = assert; +exports.arrayUnique = arrayUnique; exports.toTimestamp = toTimestamp; // vim:sw=2:sts=2:ts=8:et