UNPKG

ripplelib

Version:

A JavaScript API for interacting with Ripple in Node.js and the browser

1,345 lines (1,072 loc) 38.8 kB
// 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'; var _ = require('lodash'); var util = require('util'); var extend = require('extend'); var assert = require('assert'); var async = require('async'); var EventEmitter = require('events').EventEmitter; var Amount = require('./amount').Amount; var UInt160 = require('./uint160').UInt160; var Currency = require('./currency').Currency; var AutobridgeCalculator = require('./autobridgecalculator'); var OrderBookUtils = require('./orderbookutils'); var log = require('./log').internal.sub('orderbook'); /** * @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); var 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 synchronized if we have a current // copy of the offers, we are online and subscribed to updates this._synchronized = 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._offersModifiedAt = 0; this._transactionsLeft = 0; this._calculatorRunning = false; this.sortOffers = this._currencyGets.has_interest() ? _sortOffers.bind(this) : _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 = Amount.from_json(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) { var result = {}; var keys = Object.keys(offer); for (var i = 0, l = keys.length; i < l; i++) { var 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 */ OrderBook.prototype.subscribe = function () { var self = this; if (!this._shouldSubscribe || this._destroyed) { return; } if (this._remote.trace) { log.info('subscribing', this._key); } var 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 () { var 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 */ OrderBook.prototype.requestOffers = function () { var callback = arguments.length <= 0 || arguments[0] === undefined ? function () {} : arguments[0]; var internal = arguments.length <= 1 || arguments[1] === undefined ? false : arguments[1]; var self = this; if (!this._remote.isConnected()) { // do not make request if not online. // that requests will be queued and // eventually all of them will fire back return undefined; } if (!this._shouldSubscribe) { callback(new Error('Should not request offers')); return undefined; } if (this._remote.trace) { log.info('requesting offers', this._key); } this._synchronized = 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 Error('Invalid response')); self.emit('model', []); return; } if (self._remote.trace) { log.info('requested offers', self._key, 'offers: ' + res.offers.length); } self.setOffers(res.offers); self._synchronized = true; 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; var request = this._remote.requestBookOffers(this.toJSON()); 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'); var 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 = Amount.from_json(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) { var 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); } var 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._offers = []; this._synchronized = false; 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'); assert(this._issuerTransferRate.is_valid(), 'Transfer rate is invalid'); var adjustedBalance = OrderBookUtils.normalizeAmount(balance).divide(this._issuerTransferRate).multiply(OrderBook.DEFAULT_TRANSFER_RATE).to_json().value; 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) { var 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) { var 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) { var previousAmount = this.getOwnerOfferTotal(account); var 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) { var previousAmount = this.getOwnerOfferTotal(account); var 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) { var 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'); var takerGets = Amount.from_json(offer.TakerGets); var fundedAmount = this.getOwnerFunds(offer.Account); var previousOfferSum = this.getOwnerOfferTotal(offer.Account); var 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(); var quality = OrderBookUtils.getOfferQuality(offer, this._currencyGets); var 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) { var 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(UInt160.is_valid(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 () { return !this._calculatorRunning; }; OrderBook.prototype.onTransaction = function (transaction) { this.updateFundedAmounts(transaction); if (--this._transactionsLeft === 0 && !this._waitingForOffers) { var lastClosedLedger = this._remote.getLedgerSequence() - 1; if (this._isAutobridgeable) { if (this._canRunAutobridgeCalc()) { if (this._legOneBook._offersModifiedAt === lastClosedLedger || this._legTwoBook._offersModifiedAt === lastClosedLedger) { this.computeAutobridgedOffersWrapper(); } else if (this._offersModifiedAt === lastClosedLedger) { this.mergeDirectAndAutobridgedBooks(); } } } else if (this._offersModifiedAt === 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) { var self = this; if (!this._currencyGets.is_native() && !this._issuerTransferRate) { if (this._remote.trace) { log.info('waiting for transfer rate'); } this.requestTransferRate(function () { // Defer until transfer rate is requested self.updateFundedAmounts(transaction); }); return; } var affectedNodes = transaction.mmeta.getNodes({ nodeType: 'ModifiedNode', entryType: this._currencyGets.is_native() ? 'AccountRoot' : 'RippleState' }); _.each(affectedNodes, function (node) { if (self.isBalanceChangeNode(node)) { var 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) { // assert(UInt160.is_valid(account), 'Account is invalid'); var self = this; if (!this.hasOwnerFunds(account)) { // We are only updating owner funds that are already cached return; } if (this._remote.trace) { var 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 var previousOffer = extend({}, offer); var previousFundedGets = undefined; 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); var takerGetsFunded = OrderBookUtils.getOfferTakerGetsFunded(offer); var 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) { var self = this; if (!this._subscribed || this._destroyed) { return; } if (this._remote.trace) { log.info('notifying', this._key, transaction.transaction.hash); } var affectedNodes = transaction.mmeta.getNodes({ entryType: 'Offer', bookKey: this._key }); if (affectedNodes.length < 1) { return; } var takerGetsTotal = Amount.from_json('0' + (Currency.from_json(this._currencyGets).is_native() ? '' : '/' + this._currencyGets.to_human() + '/' + this._issuerGets)); var takerPaysTotal = Amount.from_json('0' + (Currency.from_json(this._currencyPays).is_native() ? '' : '/' + this._currencyPays.to_human() + '/' + this._issuerPays)); var isOfferCancel = transaction.transaction.TransactionType === 'OfferCancel'; var transactionOwnerFunds = transaction.transaction.owner_funds; function handleNode(node) { switch (node.nodeType) { case 'DeletedNode': if (self._validAccounts[node.fields.Account] === undefined) { assert(UInt160.is_valid(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(UInt160.is_valid(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(UInt160.is_valid(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 var fundedAmount = transactionOwnerFunds !== undefined ? transactionOwnerFunds : Infinity; self.setOwnerFunds(node.fields.Account, fundedAmount); self.insertOffer(node); break; } } _.each(affectedNodes, handleNode); this.emit('transaction', transaction); this._offersModifiedAt = this._remote.getLedgerSequence() - 1; 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); } var offer = OrderBook.offerRewrite(node.fields); var takerGets = this.normalizeAmount(this._currencyGets, offer.TakerGets); var 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(); var originalLength = this._offers.length; if (!this._currencyGets.has_interest()) { // use fast path for (var i = 0; i < originalLength; i++) { if (offer.qualityHex <= this._offers[i].qualityHex) { this._offers.splice(i, 0, offer); break; } } } else { for (var i = 0; i < originalLength; i++) { var quality = OrderBookUtils.getOfferQuality(offer, this._currencyGets); var existingOfferQuality = OrderBookUtils.getOfferQuality(this._offers[i], this._currencyGets); if (quality.compareTo(existingOfferQuality) <= 0) { 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) { var 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 (var i = 0; i < this._offers.length; i++) { var 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 (var i = 0; i < this._offers.length; i++) { var 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(); var i = -1, offer = undefined; var l = offers.length; while (++i < l) { offer = OrderBook.offerRewrite(offers[i]); if (this._validAccounts[offer.Account] === undefined) { assert(UInt160.is_valid(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; }; /** * 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._synchronized) { 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 () { var 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() || UInt160.is_valid(this._issuerPays)) && this._currencyGets && this._currencyGets.is_valid() && (this._currencyGets.is_native() || UInt160.is_valid(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 () { var _this = this; var callback = arguments.length <= 0 || arguments[0] === undefined ? function () {} : arguments[0]; assert(!this._currencyGets.is_native() && !this._currencyPays.is_native(), 'Autobridging is only for IOU:IOU orderbooks'); if (this._destroyed) { return; } var autobridgeCalculator = new AutobridgeCalculator(this._currencyGets, this._currencyPays, this._legOneBook.getOffersSync(), this._legTwoBook.getOffersSync(), this._issuerGets, this._issuerPays); var lastClosedLedger = this._remote.getLedgerSequence() - 1; autobridgeCalculator.calculate(function (autobridgedOffers) { _this._offersAutobridged = autobridgedOffers; callback(); }); }; OrderBook.prototype.computeAutobridgedOffersWrapper = function () { var _this2 = this; if (!this._gotOffersFromLegOne || !this._gotOffersFromLegTwo || !this._synchronized || this._destroyed || this._calculatorRunning) { return; } this._calculatorRunning = true; var startTime = Date.now(); this.computeAutobridgedOffers(function () { _this2.mergeDirectAndAutobridgedBooks(); _this2._calculatorRunning = false; }); }; function _sortOffers(a, b) { var aQuality = OrderBookUtils.getOfferQuality(a, this._currencyGets); var bQuality = OrderBookUtils.getOfferQuality(b, this._currencyGets); return aQuality._value.comparedTo(bQuality._value); } function _sortOffersQuick(a, b) { return a.qualityHex.localeCompare(b.qualityHex); } /** * 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._synchronized && 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;