From 2b22b49f8340f44650523effaa71dc9ada7d86ee Mon Sep 17 00:00:00 2001 From: wltsmrz Date: Wed, 13 Aug 2014 12:26:00 -0700 Subject: [PATCH] Track unfunded orders in the orderbook. #132 --- src/js/ripple/amount.js | 389 +++++++------ src/js/ripple/meta.js | 185 ++++-- src/js/ripple/orderbook.js | 1134 +++++++++++++++++++++++++++++------- test/orderbook-test.js | 1057 +++++++++++++++++++++++++++++++++ 4 files changed, 2309 insertions(+), 456 deletions(-) create mode 100644 test/orderbook-test.js diff --git a/src/js/ripple/amount.js b/src/js/ripple/amount.js index ad1551d8..987c2dc5 100644 --- a/src/js/ripple/amount.js +++ b/src/js/ripple/amount.js @@ -160,157 +160,64 @@ Amount.prototype.add = function(v) { return result; }; -/** - * Turn this amount into its inverse. - * - * @private - */ -Amount.prototype._invert = function() { - this._value = consts.bi_1e32.divide(this._value); - this._offset = -32 - this._offset; - this.canonicalize(); - - return this; +// Result in terms of this currency and issuer. +Amount.prototype.subtract = function(v) { + // Correctness over speed, less code has less bugs, reuse add code. + return this.add(Amount.from_json(v).negate()); }; -/** - * Return the inverse of this amount. - * - * @return {Amount} New Amount object with same currency and issuer, but the - * inverse of the value. - */ -Amount.prototype.invert = function() { - return this.copy()._invert(); -}; - -Amount.prototype.canonicalize = function() { - if (!(this._value instanceof BigInteger)) { - // NaN. - // nothing - } else if (this._is_native) { - // Native. - if (this._value.equals(BigInteger.ZERO)) { - this._offset = 0; - this._is_negative = false; - } else { - // Normalize _offset to 0. - - while (this._offset < 0) { - this._value = this._value.divide(consts.bi_10); - this._offset += 1; - } - - while (this._offset > 0) { - this._value = this._value.multiply(consts.bi_10); - this._offset -= 1; - } - } - - // XXX Make sure not bigger than supported. Throw if so. - } else if (this.is_zero()) { - this._offset = -100; - this._is_negative = false; - } else { - // Normalize mantissa to valid range. - - while (this._value.compareTo(consts.bi_man_min_value) < 0) { - this._value = this._value.multiply(consts.bi_10); - this._offset -= 1; - } - - while (this._value.compareTo(consts.bi_man_max_value) > 0) { - this._value = this._value.divide(consts.bi_10); - this._offset += 1; - } - } - - return this; -}; - -Amount.prototype.clone = function(negate) { - return this.copyTo(new Amount(), negate); -}; - -Amount.prototype.compareTo = function(v) { +// Result in terms of this' currency and issuer. +// XXX Diverges from cpp. +Amount.prototype.multiply = function(v) { var result; - if (!this.is_comparable(v)) { - result = Amount.NaN(); - } else if (this._is_negative !== v._is_negative) { - // Different sign. - result = this._is_negative ? -1 : 1; - } else if (this._value.equals(BigInteger.ZERO)) { - // Same sign: positive. - result = v._value.equals(BigInteger.ZERO) ? 0 : -1; - } else if (v._value.equals(BigInteger.ZERO)) { - // Same sign: positive. - result = 1; - } else if (!this._is_native && this._offset > v._offset) { - result = this._is_negative ? -1 : 1; - } else if (!this._is_native && this._offset < v._offset) { - result = this._is_negative ? 1 : -1; + v = Amount.from_json(v); + + if (this.is_zero()) { + result = this; + } else if (v.is_zero()) { + result = this.clone(); + result._value = BigInteger.ZERO; } else { - result = this._value.compareTo(v._value); - if (result > 0) { - result = this._is_negative ? -1 : 1; - } else if (result < 0) { - result = this._is_negative ? 1 : -1; + var v1 = this._value; + var o1 = this._offset; + var v2 = v._value; + var o2 = v._offset; + + if (this.is_native()) { + while (v1.compareTo(consts.bi_man_min_value) < 0) { + v1 = v1.multiply(consts.bi_10); + o1 -= 1; + } } + + if (v.is_native()) { + while (v2.compareTo(consts.bi_man_min_value) < 0) { + v2 = v2.multiply(consts.bi_10); + o2 -= 1; + } + } + + result = new Amount(); + result._offset = o1 + o2 + 14; + result._value = v1.multiply(v2).divide(consts.bi_1e14).add(consts.bi_7); + result._is_native = this._is_native; + result._is_negative = this._is_negative !== v._is_negative; + result._currency = this._currency; + result._issuer = this._issuer; + + result.canonicalize(); } return result; }; -// Make d a copy of this. Returns d. -// Modification of objects internally refered to is not allowed. -Amount.prototype.copyTo = function(d, negate) { - if (typeof this._value === 'object') { - this._value.copyTo(d._value); - } else { - d._value = this._value; - } - - d._offset = this._offset; - d._is_native = this._is_native; - d._is_negative = negate - ? !this._is_negative // Negating. - : this._is_negative; // Just copying. - - d._currency = this._currency; - d._issuer = this._issuer; - - // Prevent negative zero - if (d.is_zero()) { - d._is_negative = false; - } - - return d; -}; - -Amount.prototype.currency = function() { - return this._currency; -}; - -Amount.prototype.equals = function(d, ignore_issuer) { - if (typeof d === 'string') { - return this.equals(Amount.from_json(d)); - } - - var result = true; - - result = !((!this.is_valid() || !d.is_valid()) - || (this._is_native !== d._is_native) - || (!this._value.equals(d._value) || this._offset !== d._offset) - || (this._is_negative !== d._is_negative) - || (!this._is_native && (!this._currency.equals(d._currency) || !ignore_issuer && !this._issuer.equals(d._issuer)))); - - return result; -}; - // Result in terms of this' currency and issuer. Amount.prototype.divide = function(d) { var result; + d = Amount.from_json(d); + if (d.is_zero()) { throw new Error('divide by zero'); } @@ -359,8 +266,6 @@ Amount.prototype.divide = function(d) { }; /** - * Calculate a ratio between two amounts. - * * This function calculates a ratio - such as a price - between two Amount * objects. * @@ -382,14 +287,15 @@ Amount.prototype.divide = function(d) { Amount.prototype.ratio_human = function(denominator, opts) { opts = extend({ }, opts); + var numerator = this; + if (typeof denominator === 'number' && parseInt(denominator, 10) === denominator) { // Special handling of integer arguments - denominator = Amount.from_json('' + denominator + '.0'); + denominator = Amount.from_json(String(denominator) + '.0'); } else { denominator = Amount.from_json(denominator); } - var numerator = this; denominator = Amount.from_json(denominator); // If either operand is NaN, the result is NaN. @@ -397,6 +303,10 @@ Amount.prototype.ratio_human = function(denominator, opts) { return Amount.NaN(); } + if (denominator.is_zero()) { + return Amount.NaN(); + } + // Apply interest/demurrage // // We only need to apply it to the second factor, because the currency unit of @@ -482,6 +392,155 @@ Amount.prototype.product_human = function(factor, opts) { return product; }; +/** + * Turn this amount into its inverse. + * + * @private + */ +Amount.prototype._invert = function() { + this._value = consts.bi_1e32.divide(this._value); + this._offset = -32 - this._offset; + this.canonicalize(); + + return this; +}; + +/** + * Return the inverse of this amount. + * + * @return {Amount} New Amount object with same currency and issuer, but the + * inverse of the value. + */ +Amount.prototype.invert = function() { + return this.copy()._invert(); +}; + +Amount.prototype.canonicalize = function() { + if (!(this._value instanceof BigInteger)) { + // NaN. + // nothing + } else if (this._is_native) { + // Native. + if (this._value.equals(BigInteger.ZERO)) { + this._offset = 0; + this._is_negative = false; + } else { + // Normalize _offset to 0. + + while (this._offset < 0) { + this._value = this._value.divide(consts.bi_10); + this._offset += 1; + } + + while (this._offset > 0) { + this._value = this._value.multiply(consts.bi_10); + this._offset -= 1; + } + } + + // XXX Make sure not bigger than supported. Throw if so. + } else if (this.is_zero()) { + this._offset = -100; + this._is_negative = false; + } else { + // Normalize mantissa to valid range. + + while (this._value.compareTo(consts.bi_man_min_value) < 0) { + this._value = this._value.multiply(consts.bi_10); + this._offset -= 1; + } + + while (this._value.compareTo(consts.bi_man_max_value) > 0) { + this._value = this._value.divide(consts.bi_10); + this._offset += 1; + } + } + + return this; +}; + +Amount.prototype.clone = function(negate) { + return this.copyTo(new Amount(), negate); +}; + +Amount.prototype.compareTo = function(v) { + var result; + + v = Amount.from_json(v); + + if (!this.is_comparable(v)) { + result = Amount.NaN(); + } else if (this._is_negative !== v._is_negative) { + // Different sign. + result = this._is_negative ? -1 : 1; + } else if (this._value.equals(BigInteger.ZERO)) { + // Same sign: positive. + result = v._value.equals(BigInteger.ZERO) ? 0 : -1; + } else if (v._value.equals(BigInteger.ZERO)) { + // Same sign: positive. + result = 1; + } else if (!this._is_native && this._offset > v._offset) { + result = this._is_negative ? -1 : 1; + } else if (!this._is_native && this._offset < v._offset) { + result = this._is_negative ? 1 : -1; + } else { + result = this._value.compareTo(v._value); + if (result > 0) { + result = this._is_negative ? -1 : 1; + } else if (result < 0) { + result = this._is_negative ? 1 : -1; + } + } + + return result; +}; + +// Make d a copy of this. Returns d. +// Modification of objects internally refered to is not allowed. +Amount.prototype.copyTo = function(d, negate) { + if (typeof this._value === 'object') { + this._value.copyTo(d._value); + } else { + d._value = this._value; + } + + d._offset = this._offset; + d._is_native = this._is_native; + d._is_negative = negate + ? !this._is_negative // Negating. + : this._is_negative; // Just copying. + + d._currency = this._currency; + d._issuer = this._issuer; + + // Prevent negative zero + if (d.is_zero()) { + d._is_negative = false; + } + + return d; +}; + +Amount.prototype.currency = function() { + return this._currency; +}; + +Amount.prototype.equals = function(d, ignore_issuer) { + if (typeof d === 'string') { + return this.equals(Amount.from_json(d)); + } + + var result = true; + + result = !((!this.is_valid() || !d.is_valid()) + || (this._is_native !== d._is_native) + || (!this._value.equals(d._value) || this._offset !== d._offset) + || (this._is_negative !== d._is_negative) + || (!this._is_native && (!this._currency.equals(d._currency) || !ignore_issuer && !this._issuer.equals(d._issuer)))); + + return result; +}; + // True if Amounts are valid and both native or non-native. Amount.prototype.is_comparable = function(v) { return this._value instanceof BigInteger @@ -520,50 +579,6 @@ Amount.prototype.issuer = function() { return this._issuer; }; -// Result in terms of this' currency and issuer. -// XXX Diverges from cpp. -Amount.prototype.multiply = function(v) { - var result; - - if (this.is_zero()) { - result = this; - } else if (v.is_zero()) { - result = this.clone(); - result._value = BigInteger.ZERO; - } else { - var v1 = this._value; - var o1 = this._offset; - var v2 = v._value; - var o2 = v._offset; - - if (this.is_native()) { - while (v1.compareTo(consts.bi_man_min_value) < 0) { - v1 = v1.multiply(consts.bi_10); - o1 -= 1; - } - } - - if (v.is_native()) { - while (v2.compareTo(consts.bi_man_min_value) < 0) { - v2 = v2.multiply(consts.bi_10); - o2 -= 1; - } - } - - result = new Amount(); - result._offset = o1 + o2 + 14; - result._value = v1.multiply(v2).divide(consts.bi_1e14).add(consts.bi_7); - result._is_native = this._is_native; - result._is_negative = this._is_negative !== v._is_negative; - result._currency = this._currency; - result._issuer = this._issuer; - - result.canonicalize(); - } - - return result; -}; - // Return a new value. Amount.prototype.negate = function() { return this.clone('NEGATE'); @@ -953,12 +968,6 @@ Amount.prototype.set_issuer = function(issuer) { return this; }; -// Result in terms of this' currency and issuer. -Amount.prototype.subtract = function(v) { - // Correctness over speed, less code has less bugs, reuse add code. - return this.add(Amount.from_json(v).negate()); -}; - Amount.prototype.to_number = function(allow_nan) { var s = this.to_text(allow_nan); return typeof s === 'string' ? Number(s) : s; diff --git a/src/js/ripple/meta.js b/src/js/ripple/meta.js index bbdb475c..6bca5575 100644 --- a/src/js/ripple/meta.js +++ b/src/js/ripple/meta.js @@ -5,47 +5,51 @@ var Amount = require('./amount').Amount; /** * Meta data processing facility + * + * @constructor + * @param {Object} transaction metadata */ -function Meta(raw_data) { +function Meta(data) { var self = this; this.nodes = [ ]; - raw_data.AffectedNodes.forEach(function(an) { - var result = { }; + if (typeof data !== 'object') { + throw new TypeError('Missing metadata'); + } - if ((result.diffType = self.diffType(an))) { - an = an[result.diffType]; + if (!Array.isArray(data.AffectedNodes)) { + throw new TypeError('Metadata missing AffectedNodes'); + } - result.entryType = an.LedgerEntryType; - result.ledgerIndex = an.LedgerIndex; - result.fields = extend({}, an.PreviousFields, an.NewFields, an.FinalFields); - result.fieldsPrev = an.PreviousFields || {}; - result.fieldsNew = an.NewFields || {}; - result.fieldsFinal = an.FinalFields || {}; - - // getAffectedBooks will set this - // result.bookKey = undefined; - - self.nodes.push(result); - } - }); + data.AffectedNodes.forEach(this.addNode, this); }; -Meta.node_types = [ +Meta.nodeTypes = [ 'CreatedNode', 'ModifiedNode', 'DeletedNode' ]; -Meta.prototype.diffType = function(an) { - var result = false; +Meta.amountFieldsAffectingIssuer = [ + 'LowLimit', + 'HighLimit', + 'TakerPays', + 'TakerGets' +]; - for (var i=0; i= 0; - case 'CreatedNode': - // XXX Should use Amount#from_quality - var price = Amount.from_json(an.fields.TakerPays).ratio_human(an.fields.TakerGets, {reference_date: new Date()}); + if (offer.is_fully_funded) { + offer.taker_gets_funded = takerGetsValue; + } else { + offer.taker_gets_funded = fundedAmount; + } - 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, {reference_date: new Date()}); + return offer; +}; - if (price.compareTo(priceItem) <= 0) { - var obj = an.fields; - obj.index = an.ledgerIndex; - self._offers.splice(i, 0, an.fields); - changed = true; - break; - } - } - break; - } +/** + * Determine what an account is funded to offer for orderbook's + * currency/issuer + * + * @param {String} account + * @param {Function} callback + */ + +OrderBook.prototype.requestFundedAmount = function(account, callback) { + assert(UInt160.is_valid(account), 'Account is invalid'); + assert.strictEqual(typeof callback, 'function', 'Callback is invalid'); + + var self = this; + + if (self._remote.trace) { + log.info('requesting funds', account); + } + + function requestNativeBalance(callback) { + self._remote.requestAccountInfo(account, function(err, info) { + if (err) { + callback(err); + } else { + callback(null, String(info.account_data.Balance)); + } + }); }; - message.mmeta.each(handleTransaction); + function requestLineBalance(callback) { + var request = self._remote.requestAccountLines( + account, // account + void(0), // account index + 'VALIDATED', // ledger + self._issuerGets //peer + ); - // 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); + request.request(function(err, res) { + if (err) { + return callback(err); + } + + var currency = self._currencyGets.to_json(); + var balance = '0'; + + for (var i=0, line; (line=res.lines[i]); i++) { + if (line.currency === currency) { + balance = line.balance; + break; + } + } + + callback(null, balance); + }); + }; + + function computeFundedAmount(err, results) { + if (err) { + if (self._remote.trace) { + log.info('failed to request funds', err); + } + //XXX What now? + return callback(err); } - if (!trade_gets.is_zero()) { - this.emit('trade', trade_pays, trade_gets); + + if (self._remote.trace) { + log.info('requested funds', account, results); + } + + var balance; + var fundedAmount; + + if (self._currencyGets.is_native()) { + balance = results[0]; + fundedAmount = balance; + } else { + balance = results[1]; + fundedAmount = self.applyTransferRate(balance, results[0]); + } + + callback(null, fundedAmount); + }; + + var steps = [ ]; + + if (this._currencyGets.is_native()) { + steps.push(requestNativeBalance); + } else { + steps.push(this.requestTransferRate.bind(this)); + steps.push(requestLineBalance); + } + + async.parallel(steps, computeFundedAmount); +}; + +/** + * Get changed balance of an affected node + * + * @param {Object} RippleState or AccountRoot node + * @return {Object} { account, balance } + */ + +OrderBook.prototype.getBalanceChange = function(node) { + var result = { + account: void(0), + balance: void(0) + }; + + switch (node.entryType) { + case 'AccountRoot': + result.account = node.fields.Account; + result.balance = node.fieldsFinal.Balance; + break; + + case 'RippleState': + if (node.fields.HighLimit.issuer === this._issuerGets) { + result.account = node.fields.LowLimit.issuer; + result.balance = node.fieldsFinal.Balance.value; + } else if (node.fields.LowLimit.issuer === this._issuerGets) { + // Negate balance + result.account = node.fields.HighLimit.issuer; + result.balance = Amount.from_json( + node.fieldsFinal.Balance + ).negate().to_json().value; + } + break; + } + + result.isValid = !isNaN(result.balance) + && UInt160.is_valid(result.account); + + return result; +}; + +/** + * Check that affected node represents a balance change + * + * @param {Object} RippleState or AccountRoot node + * @return {Boolean} + */ + +OrderBook.prototype.isBalanceChange = function(node) { + // Check balance change + if (!(node.fields && node.fields.Balance + && node.fieldsPrev && node.fieldsFinal + && node.fieldsPrev.Balance && node.fieldsFinal.Balance)) { + return false; + } + + // Check currency + if (this._currencyGets.is_native()) { + return !isNaN(node.fields.Balance); + } + + if (node.fields.Balance.currency !== this._currencyGets.to_json()) { + return false; + } + + // Check issuer + if (!(node.fields.HighLimit.issuer === this._issuerGets + || node.fields.LowLimit.issuer === this._issuerGets)) { + return false; + } + + return true; +}; + +/** + * Update funded amounts for offers in the orderbook as new transactions are + * streamed from server + * + * @param {Object} transaction + */ + +OrderBook.prototype.updateFundedAmounts = function(message) { + var self = this; + + var affectedAccounts = message.mmeta.getAffectedAccounts(); + + var isOwnerAffected = affectedAccounts.some(function(account) { + return self.hasCachedFunds(account); + }); + + if (!isOwnerAffected) { + return; + } + + if (!this._currencyGets.is_native() && !this._issuerTransferRate) { + // Defer until transfer rate is requested + if (self._remote.trace) { + log.info('waiting for transfer rate'); + } + + this.once('transfer_rate', function() { + self.updateFundedAmounts(message); + }); + return; + } + + var nodes = message.mmeta.getNodes({ + nodeType: 'ModifiedNode', + entryType: this._currencyGets.is_native() ? 'AccountRoot' : 'RippleState' + }); + + for (var i=0; i