mirror of
https://github.com/Xahau/xahau.js.git
synced 2025-11-27 15:45:48 +00:00
1416 lines
36 KiB
JavaScript
1416 lines
36 KiB
JavaScript
// 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:
|
|
// - model
|
|
// - trade
|
|
// - transaction
|
|
|
|
'use strict';
|
|
|
|
const _ = require('lodash');
|
|
const util = require('util');
|
|
const extend = require('extend');
|
|
const assert = require('assert');
|
|
const async = require('async');
|
|
const EventEmitter = require('events').EventEmitter;
|
|
const {isValidAddress} = require('ripple-address-codec');
|
|
const Amount = require('./amount').Amount;
|
|
const Currency = require('./currency').Currency;
|
|
const AutobridgeCalculator = require('./autobridgecalculator');
|
|
const OrderBookUtils = require('./orderbookutils');
|
|
const log = require('./log').internal.sub('orderbook');
|
|
const {IOUValue} = require('ripple-lib-value');
|
|
const RippleError = require('./rippleerror').RippleError;
|
|
|
|
function _sortOffersQuick(a, b) {
|
|
return a.qualityHex.localeCompare(b.qualityHex);
|
|
}
|
|
|
|
/**
|
|
* @constructor OrderBook
|
|
* @param {Remote} remote
|
|
* @param {String} ask currency
|
|
* @param {String} ask issuer
|
|
* @param {String} bid currency
|
|
* @param {String} bid issuer
|
|
* @param {String} orderbook key
|
|
* @param {Boolean} fire 'model' event after receiving transaction
|
|
only once in 10 seconds
|
|
*/
|
|
|
|
function OrderBook(remote,
|
|
currencyGets, issuerGets, currencyPays, issuerPays, key
|
|
) {
|
|
EventEmitter.call(this);
|
|
|
|
const self = this;
|
|
|
|
this._remote = remote;
|
|
this._currencyGets = Currency.from_json(currencyGets);
|
|
this._issuerGets = issuerGets;
|
|
this._currencyPays = Currency.from_json(currencyPays);
|
|
this._issuerPays = issuerPays;
|
|
this._key = key;
|
|
this._subscribed = false;
|
|
this._shouldSubscribe = true;
|
|
this._listeners = 0;
|
|
this._offers = [];
|
|
this._offersAutobridged = [];
|
|
this._mergedOffers = [];
|
|
this._offerCounts = {};
|
|
this._ownerFundsUnadjusted = {};
|
|
this._ownerFunds = {};
|
|
this._ownerOffersTotal = {};
|
|
this._validAccounts = {};
|
|
this._validAccountsCount = 0;
|
|
|
|
// We consider ourselves synced if we have a current
|
|
// copy of the offers, we are online and subscribed to updates
|
|
this._synced = false;
|
|
|
|
// Transfer rate of the taker gets currency issuer
|
|
this._issuerTransferRate = null;
|
|
|
|
// When orderbook is IOU/IOU, there will be IOU/XRP and XRP/IOU
|
|
// books that we must keep track of to compute autobridged offers
|
|
this._legOneBook = null;
|
|
this._legTwoBook = null;
|
|
this._gotOffersFromLegOne = false;
|
|
this._gotOffersFromLegTwo = false;
|
|
|
|
this._waitingForOffers = false;
|
|
this._lastUpdateLedgerSequence = 0;
|
|
this._transactionsLeft = 0;
|
|
this._calculatorRunning = false;
|
|
|
|
|
|
this.sortOffers = _sortOffersQuick;
|
|
|
|
this._isAutobridgeable = !this._currencyGets.is_native()
|
|
&& !this._currencyPays.is_native();
|
|
|
|
function computeAutobridgedOffersWrapperOne() {
|
|
if (!self._gotOffersFromLegOne) {
|
|
self._gotOffersFromLegOne = true;
|
|
self.computeAutobridgedOffersWrapper();
|
|
}
|
|
}
|
|
|
|
function computeAutobridgedOffersWrapperTwo() {
|
|
if (!self._gotOffersFromLegTwo) {
|
|
self._gotOffersFromLegTwo = true;
|
|
self.computeAutobridgedOffersWrapper();
|
|
}
|
|
}
|
|
|
|
function onDisconnect() {
|
|
self.resetCache();
|
|
self._gotOffersFromLegOne = false;
|
|
self._gotOffersFromLegTwo = false;
|
|
if (!self._destroyed) {
|
|
self._remote.once('disconnect', onDisconnect);
|
|
self._remote.once('connect', function() {
|
|
self.subscribe();
|
|
});
|
|
}
|
|
}
|
|
|
|
if (this._isAutobridgeable) {
|
|
this._legOneBook = remote.createOrderBook({
|
|
currency_gets: 'XRP',
|
|
currency_pays: currencyPays,
|
|
issuer_pays: issuerPays
|
|
});
|
|
|
|
this._legTwoBook = remote.createOrderBook({
|
|
currency_gets: currencyGets,
|
|
issuer_gets: issuerGets,
|
|
currency_pays: 'XRP'
|
|
});
|
|
}
|
|
|
|
function onTransactionWrapper(transaction) {
|
|
self.onTransaction(transaction);
|
|
}
|
|
|
|
function onLedgerClosedWrapper(message) {
|
|
self.onLedgerClosed(message);
|
|
}
|
|
|
|
function listenersModified(action, event) {
|
|
// Automatically subscribe and unsubscribe to orderbook
|
|
// on the basis of existing event listeners
|
|
if (_.contains(OrderBook.EVENTS, event)) {
|
|
switch (action) {
|
|
case 'add':
|
|
if (++self._listeners === 1) {
|
|
self._shouldSubscribe = true;
|
|
self.subscribe();
|
|
|
|
self._remote.on('transaction', onTransactionWrapper);
|
|
self._remote.on('ledger_closed', onLedgerClosedWrapper);
|
|
self._remote.once('disconnect', onDisconnect);
|
|
|
|
if (self._isAutobridgeable) {
|
|
self._legOneBook.on('model', computeAutobridgedOffersWrapperOne);
|
|
self._legTwoBook.on('model', computeAutobridgedOffersWrapperTwo);
|
|
}
|
|
}
|
|
break;
|
|
case 'remove':
|
|
if (--self._listeners === 0) {
|
|
self.unsubscribe();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.on('newListener', function(event) {
|
|
listenersModified('add', event);
|
|
});
|
|
|
|
this.on('removeListener', function(event) {
|
|
listenersModified('remove', event);
|
|
});
|
|
|
|
this.on('unsubscribe', function() {
|
|
self.resetCache();
|
|
|
|
self._remote.removeListener('transaction', onTransactionWrapper);
|
|
self._remote.removeListener('ledger_closed', onLedgerClosedWrapper);
|
|
self._remote.removeListener('disconnect', onDisconnect);
|
|
|
|
self._gotOffersFromLegOne = false;
|
|
self._gotOffersFromLegTwo = false;
|
|
|
|
if (self._isAutobridgeable) {
|
|
self._legOneBook.removeListener('model',
|
|
computeAutobridgedOffersWrapperOne);
|
|
self._legTwoBook.removeListener('model',
|
|
computeAutobridgedOffersWrapperTwo);
|
|
}
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
util.inherits(OrderBook, EventEmitter);
|
|
|
|
/**
|
|
* Events emitted from OrderBook
|
|
*/
|
|
|
|
OrderBook.EVENTS = [
|
|
'transaction', 'model', 'trade',
|
|
'offer_added', 'offer_removed',
|
|
'offer_changed', 'offer_funds_changed'
|
|
];
|
|
|
|
OrderBook.DEFAULT_TRANSFER_RATE = new IOUValue(1000000000);
|
|
|
|
OrderBook.ZERO_NATIVE_AMOUNT = Amount.from_json('0');
|
|
|
|
OrderBook.ZERO_NORMALIZED_AMOUNT = OrderBookUtils.normalizeAmount('0');
|
|
|
|
/**
|
|
* Normalize offers from book_offers and transaction stream
|
|
*
|
|
* @param {Object} offer
|
|
* @return {Object} normalized
|
|
*/
|
|
|
|
OrderBook.offerRewrite = function(offer) {
|
|
const result = {};
|
|
const keys = Object.keys(offer);
|
|
|
|
for (let i = 0, l = keys.length; i < l; i++) {
|
|
const key = keys[i];
|
|
switch (key) {
|
|
case 'PreviousTxnID':
|
|
case 'PreviousTxnLgrSeq':
|
|
break;
|
|
default:
|
|
result[key] = offer[key];
|
|
}
|
|
}
|
|
|
|
result.Flags = result.Flags || 0;
|
|
result.OwnerNode = result.OwnerNode || new Array(16 + 1).join('0');
|
|
result.BookNode = result.BookNode || new Array(16 + 1).join('0');
|
|
result.qualityHex = result.BookDirectory.slice(-16);
|
|
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Initialize orderbook. Get orderbook offers and subscribe to transactions
|
|
* @api private
|
|
* NOTE: this method is not meant to be publicly used
|
|
* and it does not work for autobridged books since
|
|
* it does not add listeners for them
|
|
*/
|
|
|
|
OrderBook.prototype.subscribe = function() {
|
|
const self = this;
|
|
|
|
if (!this._shouldSubscribe || this._destroyed) {
|
|
return;
|
|
}
|
|
|
|
if (this._remote.trace) {
|
|
log.info('subscribing', this._key);
|
|
}
|
|
|
|
const steps = [
|
|
function(callback) {
|
|
self.requestTransferRate(callback);
|
|
},
|
|
function(callback) {
|
|
self.requestOffers(callback, true);
|
|
},
|
|
function(callback) {
|
|
self.subscribeTransactions(callback);
|
|
}
|
|
];
|
|
|
|
async.series(steps);
|
|
};
|
|
|
|
/**
|
|
* Unhook event listeners and prevent ripple-lib from further work on this
|
|
* orderbook. There is no more orderbook stream, so "unsubscribe" is nominal
|
|
* @api private
|
|
*/
|
|
|
|
OrderBook.prototype.unsubscribe = function() {
|
|
const self = this;
|
|
|
|
if (this._remote.trace) {
|
|
log.info('unsubscribing', this._key);
|
|
}
|
|
|
|
this._subscribed = false;
|
|
this._shouldSubscribe = false;
|
|
|
|
OrderBook.EVENTS.forEach(function(event) {
|
|
if (self.listeners(event).length > 0) {
|
|
self.removeAllListeners(event);
|
|
}
|
|
});
|
|
|
|
this.emit('unsubscribe');
|
|
};
|
|
|
|
/**
|
|
* After that you can't use this object.
|
|
*/
|
|
|
|
OrderBook.prototype.destroy = function() {
|
|
this._destroyed = true;
|
|
if (this._subscribed) {
|
|
this.unsubscribe();
|
|
}
|
|
|
|
if (this._remote._books.hasOwnProperty(this._key)) {
|
|
delete this._remote._books[this._key];
|
|
}
|
|
|
|
if (this._isAutobridgeable) {
|
|
this._legOneBook.destroy();
|
|
this._legTwoBook.destroy();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Request orderbook entries from server
|
|
*
|
|
* @param {Function} callback
|
|
* @param {boolean} internal - internal request made on 'subscribe'
|
|
*/
|
|
|
|
OrderBook.prototype.requestOffers = function(callback = function() {},
|
|
internal = false) {
|
|
const self = this;
|
|
|
|
if (!this._remote.isConnected() && !internal) {
|
|
// do not make request if not online.
|
|
// that requests will be queued and
|
|
// eventually all of them will fire back
|
|
callback(new RippleError('remote is offline'));
|
|
return undefined;
|
|
}
|
|
|
|
if (!this._shouldSubscribe) {
|
|
callback(new RippleError('Should not request offers'));
|
|
return undefined;
|
|
}
|
|
|
|
if (this._remote.trace) {
|
|
log.info('requesting offers', this._key);
|
|
}
|
|
|
|
this._synced = false;
|
|
|
|
if (this._isAutobridgeable && !internal) {
|
|
this._gotOffersFromLegOne = false;
|
|
this._gotOffersFromLegTwo = false;
|
|
|
|
this._legOneBook.requestOffers();
|
|
this._legTwoBook.requestOffers();
|
|
}
|
|
|
|
|
|
function handleOffers(res) {
|
|
if (self._destroyed) {
|
|
return;
|
|
}
|
|
|
|
self._waitingForOffers = false;
|
|
|
|
if (!Array.isArray(res.offers)) {
|
|
// XXX What now?
|
|
callback(new RippleError('Invalid response'));
|
|
self.emit('model', []);
|
|
return;
|
|
}
|
|
|
|
if (self._remote.trace) {
|
|
log.info('requested offers', self._key, 'offers: ' + res.offers.length);
|
|
}
|
|
self.setOffers(res.offers);
|
|
|
|
if (self._isAutobridgeable) {
|
|
self.computeAutobridgedOffersWrapper();
|
|
} else {
|
|
self.emit('model', self._offers);
|
|
}
|
|
|
|
callback(null, self._offers);
|
|
}
|
|
|
|
function handleError(err) {
|
|
// XXX What now?
|
|
if (self._remote.trace) {
|
|
log.info('failed to request offers', self._key, err);
|
|
}
|
|
|
|
self._waitingForOffers = false;
|
|
callback(err);
|
|
}
|
|
|
|
this._waitingForOffers = true;
|
|
|
|
const requestOptions = _.merge({}, this.toJSON(), {ledger: 'validated'});
|
|
const request = this._remote.requestBookOffers(requestOptions);
|
|
request.once('success', handleOffers);
|
|
request.once('error', handleError);
|
|
request.request();
|
|
|
|
return request;
|
|
};
|
|
|
|
/**
|
|
* Request transfer rate for this orderbook's issuer
|
|
*
|
|
* @param {Function} callback
|
|
*/
|
|
|
|
OrderBook.prototype.requestTransferRate = function(callback) {
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
const self = this;
|
|
|
|
if (this._currencyGets.is_native()) {
|
|
// Transfer rate is default for the native currency
|
|
this._issuerTransferRate = OrderBook.DEFAULT_TRANSFER_RATE;
|
|
|
|
return callback(null, OrderBook.DEFAULT_TRANSFER_RATE);
|
|
}
|
|
|
|
if (this._issuerTransferRate) {
|
|
// Transfer rate has already been cached
|
|
return callback(null, this._issuerTransferRate);
|
|
}
|
|
|
|
function handleAccountInfo(err, info) {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
// When transfer rate is not explicitly set on account, it implies the
|
|
// default transfer rate
|
|
self._issuerTransferRate =
|
|
info.account_data.TransferRate ?
|
|
new IOUValue(info.account_data.TransferRate) :
|
|
OrderBook.DEFAULT_TRANSFER_RATE;
|
|
|
|
callback(null, self._issuerTransferRate);
|
|
}
|
|
|
|
this._remote.requestAccountInfo(
|
|
{account: this._issuerGets},
|
|
handleAccountInfo
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Subscribe to transactions stream
|
|
*
|
|
* @param {Function} callback
|
|
*/
|
|
|
|
OrderBook.prototype.subscribeTransactions = function(callback) {
|
|
const self = this;
|
|
|
|
if (!this._shouldSubscribe) {
|
|
return callback('Should not subscribe');
|
|
}
|
|
|
|
if (this._remote.trace) {
|
|
log.info('subscribing to transactions');
|
|
}
|
|
|
|
function handleSubscribed(res) {
|
|
if (self._remote.trace) {
|
|
log.info('subscribed to transactions');
|
|
}
|
|
|
|
self._subscribed = true;
|
|
|
|
callback(null, res);
|
|
}
|
|
|
|
function handleError(err) {
|
|
if (self._remote.trace) {
|
|
log.info('failed to subscribe to transactions', self._key, err);
|
|
}
|
|
|
|
callback(err);
|
|
}
|
|
|
|
const request = this._remote.requestSubscribe();
|
|
request.addStream('transactions');
|
|
request.once('success', handleSubscribed);
|
|
request.once('error', handleError);
|
|
request.request();
|
|
|
|
return request;
|
|
};
|
|
|
|
|
|
/**
|
|
* Reset cached owner's funds, offer counts, and offer sums
|
|
*/
|
|
|
|
OrderBook.prototype.resetCache = function() {
|
|
this._ownerFunds = {};
|
|
this._ownerOffersTotal = {};
|
|
this._offerCounts = {};
|
|
this._synced = false;
|
|
this._offers = [];
|
|
|
|
if (this._validAccountsCount > 3000) {
|
|
this._validAccounts = {};
|
|
this._validAccountsCount = 0;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check whether owner's funds have been cached
|
|
*
|
|
* @param {String} account - owner's account address
|
|
*/
|
|
|
|
OrderBook.prototype.hasOwnerFunds = function(account) {
|
|
return this._ownerFunds[account] !== undefined;
|
|
};
|
|
|
|
/**
|
|
* Set owner's, transfer rate adjusted, funds in cache
|
|
*
|
|
* @param {String} account - owner's account address
|
|
* @param {String} fundedAmount
|
|
*/
|
|
|
|
OrderBook.prototype.setOwnerFunds = function(account, fundedAmount) {
|
|
assert(!isNaN(fundedAmount), 'Funded amount is invalid');
|
|
|
|
this._ownerFundsUnadjusted[account] = fundedAmount;
|
|
this._ownerFunds[account] = this.applyTransferRate(fundedAmount);
|
|
};
|
|
|
|
/**
|
|
* Compute adjusted balance that would be left after issuer's transfer fee is
|
|
* deducted
|
|
*
|
|
* @param {String} balance
|
|
* @return {String}
|
|
*/
|
|
|
|
OrderBook.prototype.applyTransferRate = function(balance) {
|
|
assert(!isNaN(balance), 'Balance is invalid');
|
|
|
|
const adjustedBalance = (new IOUValue(balance))
|
|
.divide(this._issuerTransferRate)
|
|
.multiply(OrderBook.DEFAULT_TRANSFER_RATE).toString();
|
|
|
|
return adjustedBalance;
|
|
};
|
|
|
|
/**
|
|
* Get owner's cached, transfer rate adjusted, funds
|
|
*
|
|
* @param {String} account - owner's account address
|
|
* @return {Amount}
|
|
*/
|
|
|
|
OrderBook.prototype.getOwnerFunds = function(account) {
|
|
if (this.hasOwnerFunds(account)) {
|
|
if (this._currencyGets.is_native()) {
|
|
return Amount.from_json(this._ownerFunds[account]);
|
|
}
|
|
return OrderBookUtils.normalizeAmount(this._ownerFunds[account]);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get owner's cached unadjusted funds
|
|
*
|
|
* @param {String} account - owner's account address
|
|
* @return {String}
|
|
*/
|
|
|
|
OrderBook.prototype.getUnadjustedOwnerFunds = function(account) {
|
|
return this._ownerFundsUnadjusted[account];
|
|
};
|
|
|
|
/**
|
|
* Remove cached owner's funds
|
|
*
|
|
* @param {String} account - owner's account address
|
|
*/
|
|
|
|
OrderBook.prototype.deleteOwnerFunds = function(account) {
|
|
this._ownerFunds[account] = undefined;
|
|
};
|
|
|
|
/**
|
|
* Get offer count for owner
|
|
*
|
|
* @param {String} account - owner's account address
|
|
* @return {Number}
|
|
*/
|
|
|
|
OrderBook.prototype.getOwnerOfferCount = function(account) {
|
|
return this._offerCounts[account] || 0;
|
|
};
|
|
|
|
/**
|
|
* Increment offer count for owner
|
|
*
|
|
* @param {String} account - owner's account address
|
|
* @return {Number}
|
|
*/
|
|
|
|
OrderBook.prototype.incrementOwnerOfferCount = function(account) {
|
|
const result = (this._offerCounts[account] || 0) + 1;
|
|
this._offerCounts[account] = result;
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Decrement offer count for owner
|
|
* When an account has no more orders, we also stop tracking their account funds
|
|
*
|
|
* @param {String} account - owner's account address
|
|
* @return {Number}
|
|
*/
|
|
|
|
OrderBook.prototype.decrementOwnerOfferCount = function(account) {
|
|
const result = (this._offerCounts[account] || 1) - 1;
|
|
this._offerCounts[account] = result;
|
|
|
|
if (result < 1) {
|
|
this.deleteOwnerFunds(account);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Add amount sum being offered for owner
|
|
*
|
|
* @param {String} account - owner's account address
|
|
* @param {Object|String} amount - offer amount as native string or IOU
|
|
* currency format
|
|
* @return {Amount}
|
|
*/
|
|
|
|
OrderBook.prototype.addOwnerOfferTotal = function(account, amount) {
|
|
const previousAmount = this.getOwnerOfferTotal(account);
|
|
const currentAmount = previousAmount.add(Amount.from_json(amount));
|
|
|
|
this._ownerOffersTotal[account] = currentAmount;
|
|
|
|
return currentAmount;
|
|
};
|
|
|
|
/**
|
|
* Subtract amount sum being offered for owner
|
|
*
|
|
* @param {String} account - owner's account address
|
|
* @param {Object|String} amount - offer amount as native string or IOU
|
|
* currency format
|
|
* @return {Amount}
|
|
*/
|
|
|
|
OrderBook.prototype.subtractOwnerOfferTotal = function(account, amount) {
|
|
const previousAmount = this.getOwnerOfferTotal(account);
|
|
const newAmount = previousAmount.subtract(Amount.from_json(amount));
|
|
|
|
this._ownerOffersTotal[account] = newAmount;
|
|
|
|
assert(!newAmount.is_negative(), 'Offer total cannot be negative');
|
|
return newAmount;
|
|
};
|
|
|
|
/**
|
|
* Get offers amount sum for owner
|
|
*
|
|
* @param {String} account - owner's account address
|
|
* @return {Amount}
|
|
*/
|
|
|
|
OrderBook.prototype.getOwnerOfferTotal = function(account) {
|
|
const amount = this._ownerOffersTotal[account];
|
|
if (amount) {
|
|
return amount;
|
|
}
|
|
if (this._currencyGets.is_native()) {
|
|
return OrderBook.ZERO_NATIVE_AMOUNT.clone();
|
|
}
|
|
return OrderBook.ZERO_NORMALIZED_AMOUNT.clone();
|
|
};
|
|
|
|
/**
|
|
* Reset offers amount sum for owner to 0
|
|
*
|
|
* @param {String} account - owner's account address
|
|
* @return {Amount}
|
|
*/
|
|
|
|
OrderBook.prototype.resetOwnerOfferTotal = function(account) {
|
|
if (this._currencyGets.is_native()) {
|
|
this._ownerOffersTotal[account] = OrderBook.ZERO_NATIVE_AMOUNT.clone();
|
|
} else {
|
|
this._ownerOffersTotal[account] = OrderBook.ZERO_NORMALIZED_AMOUNT.clone();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Set funded amount on offer with its owner's cached funds
|
|
*
|
|
* is_fully_funded indicates if these funds are sufficient for the offer placed.
|
|
* taker_gets_funded indicates the amount this account can afford to offer.
|
|
* taker_pays_funded indicates adjusted TakerPays for partially funded offer.
|
|
*
|
|
* @param {Object} offer
|
|
* @return offer
|
|
*/
|
|
|
|
OrderBook.prototype.setOfferFundedAmount = function(offer) {
|
|
assert.strictEqual(typeof offer, 'object', 'Offer is invalid');
|
|
|
|
const takerGets = Amount.from_json(offer.TakerGets);
|
|
const fundedAmount = this.getOwnerFunds(offer.Account);
|
|
const previousOfferSum = this.getOwnerOfferTotal(offer.Account);
|
|
const currentOfferSum = previousOfferSum.add(takerGets);
|
|
|
|
offer.owner_funds = this.getUnadjustedOwnerFunds(offer.Account);
|
|
|
|
offer.is_fully_funded = fundedAmount.is_comparable(currentOfferSum) &&
|
|
fundedAmount.compareTo(currentOfferSum) >= 0;
|
|
|
|
if (offer.is_fully_funded) {
|
|
offer.taker_gets_funded = takerGets.to_text();
|
|
offer.taker_pays_funded = Amount.from_json(offer.TakerPays).to_text();
|
|
} else if (previousOfferSum.compareTo(fundedAmount) < 0) {
|
|
offer.taker_gets_funded = fundedAmount.subtract(previousOfferSum).to_text();
|
|
|
|
const quality = OrderBookUtils.getOfferQuality(offer);
|
|
const takerPaysFunded = quality.multiply(
|
|
OrderBookUtils.getOfferTakerGetsFunded(offer)
|
|
);
|
|
|
|
offer.taker_pays_funded = this._currencyPays.is_native()
|
|
? String(Math.floor(takerPaysFunded.to_number()))
|
|
: takerPaysFunded.to_json().value;
|
|
} else {
|
|
offer.taker_gets_funded = '0';
|
|
offer.taker_pays_funded = '0';
|
|
}
|
|
|
|
return offer;
|
|
};
|
|
|
|
/**
|
|
* Get account and final balance of a meta node
|
|
*
|
|
* @param {Object} node - RippleState or AccountRoot meta node
|
|
* @return {Object}
|
|
*/
|
|
|
|
OrderBook.prototype.parseAccountBalanceFromNode = function(node) {
|
|
const result = {
|
|
account: undefined,
|
|
balance: undefined
|
|
};
|
|
|
|
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) {
|
|
result.account = node.fields.HighLimit.issuer;
|
|
|
|
// Negate balance on the trust line
|
|
result.balance = Amount.from_json(
|
|
node.fieldsFinal.Balance
|
|
).negate().to_json().value;
|
|
}
|
|
break;
|
|
}
|
|
|
|
assert(!isNaN(result.balance), 'node has an invalid balance');
|
|
if (this._validAccounts[result.Account] === undefined) {
|
|
assert(isValidAddress(result.account), 'node has an invalid account');
|
|
this._validAccounts[result.Account] = true;
|
|
this._validAccountsCount++;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Check that affected meta node represents a balance change
|
|
*
|
|
* @param {Object} node - RippleState or AccountRoot meta node
|
|
* @return {Boolean}
|
|
*/
|
|
|
|
OrderBook.prototype.isBalanceChangeNode = function(node) {
|
|
// Check meta node has balance, previous balance, and final balance
|
|
if (!(node.fields && node.fields.Balance
|
|
&& node.fieldsPrev && node.fieldsFinal
|
|
&& node.fieldsPrev.Balance && node.fieldsFinal.Balance)) {
|
|
return false;
|
|
}
|
|
|
|
// Check if taker gets currency is native and balance is not a number
|
|
if (this._currencyGets.is_native()) {
|
|
return !isNaN(node.fields.Balance);
|
|
}
|
|
|
|
// Check if balance change is not for taker gets currency
|
|
if (node.fields.Balance.currency !== this._currencyGets.to_json()) {
|
|
return false;
|
|
}
|
|
|
|
// Check if trustline does not refer to the taker gets currency issuer
|
|
if (!(node.fields.HighLimit.issuer === this._issuerGets
|
|
|| node.fields.LowLimit.issuer === this._issuerGets)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
OrderBook.prototype._canRunAutobridgeCalc = function(): boolean {
|
|
return !this._calculatorRunning;
|
|
};
|
|
|
|
OrderBook.prototype.onTransaction = function(transaction) {
|
|
this.updateFundedAmounts(transaction);
|
|
|
|
|
|
if (--this._transactionsLeft === 0 && !this._waitingForOffers) {
|
|
const lastClosedLedger = this._remote.getLedgerSequenceSync();
|
|
if (this._isAutobridgeable) {
|
|
if (this._canRunAutobridgeCalc()) {
|
|
if (this._legOneBook._lastUpdateLedgerSequence === lastClosedLedger ||
|
|
this._legTwoBook._lastUpdateLedgerSequence === lastClosedLedger
|
|
) {
|
|
this.computeAutobridgedOffersWrapper();
|
|
} else if (this._lastUpdateLedgerSequence === lastClosedLedger) {
|
|
this.mergeDirectAndAutobridgedBooks();
|
|
}
|
|
}
|
|
} else if (this._lastUpdateLedgerSequence === lastClosedLedger) {
|
|
this.emit('model', this._offers);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Updates funded amounts/balances using modified balance nodes
|
|
*
|
|
* Update owner funds using modified AccountRoot and RippleState nodes
|
|
* Update funded amounts for offers in the orderbook using owner funds
|
|
*
|
|
* @param {Object} transaction - transaction that holds meta nodes
|
|
*/
|
|
|
|
OrderBook.prototype.updateFundedAmounts = function(transaction) {
|
|
const self = this;
|
|
|
|
if (!this._currencyGets.is_native() && !this._issuerTransferRate) {
|
|
if (this._remote.trace) {
|
|
log.info('waiting for transfer rate');
|
|
}
|
|
|
|
this.requestTransferRate(function(err) {
|
|
if (err) {
|
|
log.error(
|
|
'Failed to request transfer rate, will not update funded amounts: '
|
|
+ err.toString());
|
|
} else {
|
|
// Defer until transfer rate is requested
|
|
self.updateFundedAmounts(transaction);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
const affectedNodes = transaction.mmeta.getNodes({
|
|
nodeType: 'ModifiedNode',
|
|
entryType: this._currencyGets.is_native() ? 'AccountRoot' : 'RippleState'
|
|
});
|
|
|
|
_.each(affectedNodes, function(node) {
|
|
if (self.isBalanceChangeNode(node)) {
|
|
const result = self.parseAccountBalanceFromNode(node);
|
|
|
|
if (self.hasOwnerFunds(result.account)) {
|
|
// We are only updating owner funds that are already cached
|
|
self.setOwnerFunds(result.account, result.balance);
|
|
|
|
self.updateOwnerOffersFundedAmount(result.account);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
|
|
/**
|
|
* Update offers' funded amount with their owner's funds
|
|
*
|
|
* @param {String} account - owner's account address
|
|
*/
|
|
|
|
OrderBook.prototype.updateOwnerOffersFundedAmount = function(account) {
|
|
const self = this;
|
|
|
|
if (!this.hasOwnerFunds(account)) {
|
|
// We are only updating owner funds that are already cached
|
|
return;
|
|
}
|
|
|
|
if (this._remote.trace) {
|
|
const ownerFunds = this.getOwnerFunds(account);
|
|
log.info('updating offer funds', this._key, account,
|
|
ownerFunds ? ownerFunds.to_text() : 'undefined');
|
|
}
|
|
|
|
this.resetOwnerOfferTotal(account);
|
|
|
|
_.each(this._offers, function(offer) {
|
|
if (offer.Account !== account) {
|
|
return;
|
|
}
|
|
|
|
// Save a copy of the old offer so we can show how the offer has changed
|
|
const previousOffer = extend({}, offer);
|
|
let previousFundedGets;
|
|
|
|
if (_.isString(offer.taker_gets_funded)) {
|
|
// Offer is not new, so we should consider it for offer_changed and
|
|
// offer_funds_changed events
|
|
previousFundedGets = OrderBookUtils.getOfferTakerGetsFunded(offer);
|
|
}
|
|
|
|
self.setOfferFundedAmount(offer);
|
|
self.addOwnerOfferTotal(offer.Account, offer.TakerGets);
|
|
|
|
const takerGetsFunded = OrderBookUtils.getOfferTakerGetsFunded(offer);
|
|
const areFundsChanged = previousFundedGets
|
|
&& !takerGetsFunded.equals(previousFundedGets);
|
|
|
|
if (areFundsChanged) {
|
|
self.emit('offer_changed', previousOffer, offer);
|
|
self.emit('offer_funds_changed',
|
|
offer,
|
|
previousOffer.taker_gets_funded,
|
|
offer.taker_gets_funded
|
|
);
|
|
}
|
|
});
|
|
};
|
|
|
|
|
|
OrderBook.prototype.onLedgerClosed = function(message) {
|
|
if (!message || (message && !_.isNumber(message.txn_count)) ||
|
|
!this._subscribed || this._destroyed || this._waitingForOffers
|
|
) {
|
|
return;
|
|
}
|
|
this._transactionsLeft = message.txn_count;
|
|
};
|
|
|
|
/**
|
|
* Notify orderbook of a relevant transaction
|
|
*
|
|
* @param {Object} transaction
|
|
* @api private
|
|
*/
|
|
|
|
OrderBook.prototype.notify = function(transaction) {
|
|
const self = this;
|
|
|
|
if (!(this._subscribed && this._synced) || this._destroyed) {
|
|
return;
|
|
}
|
|
|
|
if (this._remote.trace) {
|
|
log.info('notifying', this._key, transaction.transaction.hash);
|
|
}
|
|
|
|
const affectedNodes = transaction.mmeta.getNodes({
|
|
entryType: 'Offer',
|
|
bookKey: this._key
|
|
});
|
|
|
|
if (affectedNodes.length < 1) {
|
|
return;
|
|
}
|
|
|
|
let takerGetsTotal = Amount.from_json(
|
|
'0' + ((Currency.from_json(this._currencyGets).is_native())
|
|
? ''
|
|
: ('/' + this._currencyGets.to_json() + '/' + this._issuerGets))
|
|
);
|
|
|
|
let takerPaysTotal = Amount.from_json(
|
|
'0' + ((Currency.from_json(this._currencyPays).is_native())
|
|
? ''
|
|
: ('/' + this._currencyPays.to_json() + '/' + this._issuerPays))
|
|
);
|
|
|
|
const isOfferCancel =
|
|
transaction.transaction.TransactionType === 'OfferCancel';
|
|
const transactionOwnerFunds = transaction.transaction.owner_funds;
|
|
|
|
function handleNode(node) {
|
|
switch (node.nodeType) {
|
|
case 'DeletedNode':
|
|
if (self._validAccounts[node.fields.Account] === undefined) {
|
|
assert(isValidAddress(node.fields.Account),
|
|
'node has an invalid account');
|
|
self._validAccounts[node.fields.Account] = true;
|
|
self._validAccountsCount++;
|
|
}
|
|
self.deleteOffer(node, isOfferCancel);
|
|
|
|
// We don't want to count an OfferCancel as a trade
|
|
if (!isOfferCancel) {
|
|
takerGetsTotal = takerGetsTotal.add(node.fieldsFinal.TakerGets);
|
|
takerPaysTotal = takerPaysTotal.add(node.fieldsFinal.TakerPays);
|
|
}
|
|
break;
|
|
|
|
case 'ModifiedNode':
|
|
if (self._validAccounts[node.fields.Account] === undefined) {
|
|
assert(isValidAddress(node.fields.Account),
|
|
'node has an invalid account');
|
|
self._validAccounts[node.fields.Account] = true;
|
|
self._validAccountsCount++;
|
|
}
|
|
self.modifyOffer(node);
|
|
|
|
takerGetsTotal = takerGetsTotal
|
|
.add(node.fieldsPrev.TakerGets)
|
|
.subtract(node.fieldsFinal.TakerGets);
|
|
|
|
takerPaysTotal = takerPaysTotal
|
|
.add(node.fieldsPrev.TakerPays)
|
|
.subtract(node.fieldsFinal.TakerPays);
|
|
break;
|
|
|
|
case 'CreatedNode':
|
|
if (self._validAccounts[node.fields.Account] === undefined) {
|
|
assert(isValidAddress(node.fields.Account),
|
|
'node has an invalid account');
|
|
self._validAccounts[node.fields.Account] = true;
|
|
self._validAccountsCount++;
|
|
}
|
|
// rippled does not set owner_funds if the order maker is the issuer
|
|
// because the value would be infinite
|
|
const fundedAmount = transactionOwnerFunds !== undefined ?
|
|
transactionOwnerFunds : Infinity;
|
|
self.setOwnerFunds(node.fields.Account, fundedAmount);
|
|
self.insertOffer(node);
|
|
break;
|
|
}
|
|
}
|
|
|
|
_.each(affectedNodes, handleNode);
|
|
|
|
this.emit('transaction', transaction);
|
|
|
|
this._lastUpdateLedgerSequence = this._remote.getLedgerSequenceSync();
|
|
|
|
if (!takerGetsTotal.is_zero()) {
|
|
this.emit('trade', takerPaysTotal, takerGetsTotal);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Insert an offer into the orderbook
|
|
*
|
|
* NOTE: We *MUST* update offers' funded amounts when a new offer is placed
|
|
* because funds go to the highest quality offers first.
|
|
*
|
|
* @param {Object} node - Offer node
|
|
*/
|
|
|
|
OrderBook.prototype.insertOffer = function(node) {
|
|
if (this._remote.trace) {
|
|
log.info('inserting offer', this._key, node.fields);
|
|
}
|
|
|
|
const offer = OrderBook.offerRewrite(node.fields);
|
|
const takerGets = this.normalizeAmount(this._currencyGets, offer.TakerGets);
|
|
const takerPays = this.normalizeAmount(this._currencyPays, offer.TakerPays);
|
|
|
|
offer.LedgerEntryType = node.entryType;
|
|
offer.index = node.ledgerIndex;
|
|
|
|
// We're safe to calculate quality for newly created offers
|
|
offer.quality = takerPays.divide(takerGets).to_text();
|
|
|
|
const originalLength = this._offers.length;
|
|
|
|
for (let i = 0; i < originalLength; i++) {
|
|
if (offer.qualityHex <= this._offers[i].qualityHex) {
|
|
this._offers.splice(i, 0, offer);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (this._offers.length === originalLength) {
|
|
this._offers.push(offer);
|
|
}
|
|
|
|
this.incrementOwnerOfferCount(offer.Account);
|
|
|
|
this.updateOwnerOffersFundedAmount(offer.Account);
|
|
|
|
this.emit('offer_added', offer);
|
|
};
|
|
|
|
/**
|
|
* Convert any amount into default IOU
|
|
*
|
|
* NOTE: This is necessary in some places because Amount.js arithmetic
|
|
* does not deal with native and non-native amounts well.
|
|
*
|
|
* @param {Currency} currency
|
|
* @param {Object} amountObj
|
|
*/
|
|
|
|
OrderBook.prototype.normalizeAmount = function(currency, amountObj) {
|
|
const value = currency.is_native()
|
|
? amountObj
|
|
: amountObj.value;
|
|
|
|
return OrderBookUtils.normalizeAmount(value);
|
|
};
|
|
|
|
/**
|
|
* Modify an existing offer in the orderbook
|
|
*
|
|
* @param {Object} node - Offer node
|
|
*/
|
|
|
|
OrderBook.prototype.modifyOffer = function(node) {
|
|
if (this._remote.trace) {
|
|
log.info('modifying offer', this._key, node.fields);
|
|
}
|
|
|
|
for (let i = 0; i < this._offers.length; i++) {
|
|
const offer = this._offers[i];
|
|
|
|
if (offer.index === node.ledgerIndex) {
|
|
// TODO: This assumes no fields are deleted, which is
|
|
// probably a safe assumption, but should be checked.
|
|
extend(offer, node.fieldsFinal);
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.updateOwnerOffersFundedAmount(node.fields.Account);
|
|
};
|
|
|
|
/**
|
|
* Delete an existing offer in the orderbook
|
|
*
|
|
* NOTE: We only update funded amounts when the node comes from an OfferCancel
|
|
* transaction because when offers are deleted, it frees up funds to fund
|
|
* other existing offers in the book
|
|
*
|
|
* @param {Object} node - Offer node
|
|
* @param {Boolean} isOfferCancel - whether node came from an OfferCancel
|
|
*/
|
|
|
|
OrderBook.prototype.deleteOffer = function(node, isOfferCancel) {
|
|
if (this._remote.trace) {
|
|
log.info('deleting offer', this._key, node.fields);
|
|
}
|
|
|
|
for (let i = 0; i < this._offers.length; i++) {
|
|
const offer = this._offers[i];
|
|
|
|
if (offer.index === node.ledgerIndex) {
|
|
// Remove offer amount from sum for account
|
|
this.subtractOwnerOfferTotal(offer.Account, offer.TakerGets);
|
|
|
|
this._offers.splice(i, 1);
|
|
this.decrementOwnerOfferCount(offer.Account);
|
|
|
|
this.emit('offer_removed', offer);
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (isOfferCancel) {
|
|
this.updateOwnerOffersFundedAmount(node.fields.Account);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Reset internal offers cache from book_offers request
|
|
*
|
|
* @param {Array} offers
|
|
* @api private
|
|
*/
|
|
|
|
OrderBook.prototype.setOffers = function(offers) {
|
|
assert(Array.isArray(offers), 'Offers is not an array');
|
|
|
|
this.resetCache();
|
|
|
|
let i = -1;
|
|
let offer;
|
|
const l = offers.length;
|
|
|
|
while (++i < l) {
|
|
offer = OrderBook.offerRewrite(offers[i]);
|
|
|
|
if (this._validAccounts[offer.Account] === undefined) {
|
|
assert(isValidAddress(offer.Account), 'Account is invalid');
|
|
this._validAccounts[offer.Account] = true;
|
|
this._validAccountsCount++;
|
|
}
|
|
if (offer.owner_funds !== undefined) {
|
|
// The first offer of each owner from book_offers contains owner balance
|
|
// of offer's output
|
|
this.setOwnerFunds(offer.Account, offer.owner_funds);
|
|
}
|
|
|
|
this.incrementOwnerOfferCount(offer.Account);
|
|
|
|
this.setOfferFundedAmount(offer);
|
|
this.addOwnerOfferTotal(offer.Account, offer.TakerGets);
|
|
offers[i] = offer;
|
|
}
|
|
|
|
this._offers = offers;
|
|
this._synced = true;
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
*
|
|
* @param {Function} callback
|
|
*/
|
|
|
|
OrderBook.prototype.offers =
|
|
OrderBook.prototype.getOffers = function(callback) {
|
|
assert.strictEqual(typeof callback, 'function', 'Callback missing');
|
|
|
|
if (this._synced) {
|
|
callback(null, this._offers);
|
|
} else {
|
|
this.once('model', function(m) {
|
|
callback(null, m);
|
|
});
|
|
}
|
|
|
|
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.
|
|
*
|
|
* @return {Array} offers
|
|
*/
|
|
|
|
OrderBook.prototype.offersSync =
|
|
OrderBook.prototype.getOffersSync = function() {
|
|
return this._offers;
|
|
};
|
|
|
|
/**
|
|
* Get request-representation of orderbook
|
|
*
|
|
* @return {Object} json
|
|
*/
|
|
|
|
OrderBook.prototype.toJSON =
|
|
OrderBook.prototype.to_json = function() {
|
|
const json = {
|
|
taker_gets: {
|
|
currency: this._currencyGets.to_hex()
|
|
},
|
|
taker_pays: {
|
|
currency: this._currencyPays.to_hex()
|
|
}
|
|
};
|
|
|
|
if (!this._currencyGets.is_native()) {
|
|
json.taker_gets.issuer = this._issuerGets;
|
|
}
|
|
|
|
if (!this._currencyPays.is_native()) {
|
|
json.taker_pays.issuer = this._issuerPays;
|
|
}
|
|
|
|
return json;
|
|
};
|
|
|
|
/**
|
|
* Whether the OrderBook is valid
|
|
*
|
|
* Note: This only checks whether the parameters (currencies and issuer) are
|
|
* syntactically valid. It does not check anything against the ledger.
|
|
*
|
|
* @return {Boolean} is valid
|
|
*/
|
|
|
|
OrderBook.prototype.isValid =
|
|
OrderBook.prototype.is_valid = function() {
|
|
// XXX Should check for same currency (non-native) && same issuer
|
|
return (
|
|
this._currencyPays && this._currencyPays.is_valid() &&
|
|
(this._currencyPays.is_native() || isValidAddress(this._issuerPays)) &&
|
|
this._currencyGets && this._currencyGets.is_valid() &&
|
|
(this._currencyGets.is_native() || isValidAddress(this._issuerGets)) &&
|
|
!(this._currencyPays.is_native() && this._currencyGets.is_native())
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Compute autobridged offers for an IOU:IOU orderbook by merging offers from
|
|
* IOU:XRP and XRP:IOU books
|
|
*/
|
|
|
|
OrderBook.prototype.computeAutobridgedOffers = function(callback = function() {}
|
|
) {
|
|
assert(!this._currencyGets.is_native() && !this._currencyPays.is_native(),
|
|
'Autobridging is only for IOU:IOU orderbooks');
|
|
|
|
|
|
if (this._destroyed) {
|
|
return;
|
|
}
|
|
|
|
const autobridgeCalculator = new AutobridgeCalculator(
|
|
this._currencyGets,
|
|
this._currencyPays,
|
|
this._legOneBook.getOffersSync(),
|
|
this._legTwoBook.getOffersSync(),
|
|
this._issuerGets,
|
|
this._issuerPays
|
|
);
|
|
|
|
autobridgeCalculator.calculate((autobridgedOffers) => {
|
|
this._offersAutobridged = autobridgedOffers;
|
|
callback();
|
|
});
|
|
};
|
|
|
|
OrderBook.prototype.computeAutobridgedOffersWrapper = function() {
|
|
if (!this._gotOffersFromLegOne || !this._gotOffersFromLegTwo ||
|
|
!this._synced || this._destroyed || this._calculatorRunning
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this._calculatorRunning = true;
|
|
this.computeAutobridgedOffers(() => {
|
|
this.mergeDirectAndAutobridgedBooks();
|
|
this._calculatorRunning = false;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Merge direct and autobridged offers into a combined orderbook
|
|
*
|
|
* @return [Array]
|
|
*/
|
|
|
|
OrderBook.prototype.mergeDirectAndAutobridgedBooks = function() {
|
|
|
|
if (this._destroyed) {
|
|
return;
|
|
}
|
|
|
|
if (_.isEmpty(this._offers) && _.isEmpty(this._offersAutobridged)) {
|
|
if (this._synced && this._gotOffersFromLegOne &&
|
|
this._gotOffersFromLegTwo) {
|
|
// emit empty model to indicate to listeners that we've got offers,
|
|
// just there was no one
|
|
this.emit('model', []);
|
|
}
|
|
return;
|
|
}
|
|
|
|
this._mergedOffers = this._offers
|
|
.concat(this._offersAutobridged)
|
|
.sort(this.sortOffers);
|
|
|
|
this.emit('model', this._mergedOffers);
|
|
};
|
|
|
|
exports.OrderBook = OrderBook;
|