UNPKG

divvy-lib-orderbook

Version:
1,358 lines (1,115 loc) 42.6 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'; // eslint-disable-line strict var _get = require('babel-runtime/helpers/get')['default']; var _inherits = require('babel-runtime/helpers/inherits')['default']; var _createClass = require('babel-runtime/helpers/create-class')['default']; var _classCallCheck = require('babel-runtime/helpers/class-call-check')['default']; var _Promise = require('babel-runtime/core-js/promise')['default']; var _Object$keys = require('babel-runtime/core-js/object/keys')['default']; var _ = require('lodash'); var assert = require('assert'); var _require = require('events'); var EventEmitter = _require.EventEmitter; var _require2 = require('./currencyutils'); var normalizeCurrency = _require2.normalizeCurrency; var isValidCurrency = _require2.isValidCurrency; var _require3 = require('./autobridgecalculator'); var AutobridgeCalculator = _require3.AutobridgeCalculator; var OrderBookUtils = require('./orderbookutils'); var _require4 = require('divvy-address-codec'); var isValidAddress = _require4.isValidAddress; var _require5 = require('divvy-lib-value'); var XDVValue = _require5.XDVValue; var IOUValue = _require5.IOUValue; var log = require('./log').internal.sub('orderbook'); var DEFAULT_TRANSFER_RATE = new IOUValue('1.000000000'); var ZERO_NATIVE_AMOUNT = new XDVValue('0'); var ZERO_NORMALIZED_AMOUNT = new IOUValue('0'); /** * Events emitted from OrderBook */ var EVENTS = ['transaction', 'model', 'trade', 'offer_added', 'offer_removed', 'offer_changed', 'offer_funds_changed']; function prepareTrade(currency, issuer_) { var issuer = issuer_ === undefined ? '' : issuer_; var suffix = normalizeCurrency(currency) === 'XDV' ? '' : '/' + issuer; return currency + suffix; } function parseDivvydAmount(amount) { return typeof amount === 'string' ? new XDVValue(amount) : new IOUValue(amount.value); } function _sortOffersQuick(a, b) { return a.qualityHex.localeCompare(b.qualityHex); } /** * account is to specify a "perspective", which affects which unfunded offers * are returned * * @constructor OrderBook * @param {DivvyAPI} api * @param {String} account * @param {String} ask currency * @param {String} ask issuer * @param {String} bid currency * @param {String} bid issuer */ var OrderBook = (function (_EventEmitter) { _inherits(OrderBook, _EventEmitter); function OrderBook(api, currencyGets, issuerGets, currencyPays, issuerPays, account, ledgerIndex) { var trace = arguments.length <= 7 || arguments[7] === undefined ? false : arguments[7]; _classCallCheck(this, OrderBook); _get(Object.getPrototypeOf(OrderBook.prototype), 'constructor', this).call(this); this._trace = trace; if (this._trace) { log.info('OrderBook:constructor', currencyGets, issuerGets, currencyPays, issuerPays, ledgerIndex); } this._api = api; this._account = account !== undefined ? account : ''; this._currencyGets = normalizeCurrency(currencyGets); this._issuerGets = issuerGets !== undefined ? issuerGets : ''; this._currencyPays = normalizeCurrency(currencyPays); this._issuerPays = issuerPays !== undefined ? issuerPays : ''; this._key = prepareTrade(currencyGets, issuerGets) + ':' + prepareTrade(currencyPays, issuerPays); this._ledgerIndex = ledgerIndex; // When orderbook is IOU/IOU, there will be IOU/XDV and XDV/IOU // books that we must keep track of to compute autobridged offers this._legOneBook = null; this._legTwoBook = null; this._listeners = 0; this._transactionsLeft = -1; this._waitingForOffers = false; this._subscribed = false; this._synced = false; this._isAutobridgeable = this._currencyGets !== 'XDV' && this._currencyPays !== 'XDV'; this._issuerTransferRate = null; this._transferRateIsDefault = false; this._offerCounts = {}; this._ownerFundsUnadjusted = {}; this._ownerFunds = {}; this._ownerOffersTotal = {}; this._validAccounts = {}; this._validAccountsCount = 0; this._offers = []; this._closedLedgerVersion = 0; this._lastUpdateLedgerSequence = 0; this._calculatorRunning = false; this._gotOffersFromLegOne = false; this._gotOffersFromLegTwo = false; this._onReconnectBound = this._onReconnect.bind(this); this._onTransactionBound = this._onTransaction.bind(this); if (this._isAutobridgeable) { this._legOneBook = new OrderBook(api, 'XDV', undefined, currencyPays, issuerPays, account, this._ledgerIndex, this._trace); this._legTwoBook = new OrderBook(api, currencyGets, issuerGets, 'XDV', undefined, account, this._ledgerIndex, this._trace); } this._initializeSubscriptionMonitoring(); } /** * Creates OrderBook instance using options object same as for * old Remote.createOrderBook method. * * @param {Object} api * @param {Object} api * */ _createClass(OrderBook, [{ key: 'isValid', /** * 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 */ value: function isValid() { // XXX Should check for same currency (non-native) && same issuer return Boolean(this._currencyPays) && isValidCurrency(this._currencyPays) && (this._currencyPays === 'XDV' || isValidAddress(this._issuerPays)) && Boolean(this._currencyGets) && isValidCurrency(this._currencyGets) && (this._currencyGets === 'XDV' || isValidAddress(this._issuerGets)) && !(this._currencyPays === 'XDV' && this._currencyGets === 'XDV'); } /** * 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 */ }, { key: 'getOffersSync', value: function getOffersSync() { return this._offers; } }, { key: 'requestOffers', value: function requestOffers() { var _this = this; if (this._waitingForOffers) { return new _Promise(function (resolve) { _this.once('model', resolve); }); } if (!this._api.isConnected()) { // do not make request if not online. // that requests will be queued and // eventually all of them will fire back return _Promise.reject(new this._api.errors.DivvyError('Server is offline')); } if (this._isAutobridgeable) { this._gotOffersFromLegOne = false; this._gotOffersFromLegTwo = false; if (this._legOneBook !== null && this._legOneBook !== undefined) { this._legOneBook.requestOffers(); } if (this._legTwoBook !== null && this._legTwoBook !== undefined) { this._legTwoBook.requestOffers(); } } this._waitingForOffers = true; this._resetCache(); return this._requestTransferRate().then(this._requestOffers.bind(this)); } }, { key: 'toJSON', value: function toJSON() { var json = { taker_gets: { currency: this._currencyGets }, taker_pays: { currency: this._currencyPays } }; if (this._currencyGets !== 'XDV') { json.taker_gets.issuer = this._issuerGets; } if (this._currencyPays !== 'XDV') { json.taker_pays.issuer = this._issuerPays; } return json; } }, { key: '_initializeSubscriptionMonitoring', value: function _initializeSubscriptionMonitoring() { var self = this; function computeAutobridgedOffersWrapperOne() { if (!self._gotOffersFromLegOne) { self._gotOffersFromLegOne = true; self._computeAutobridgedOffersWrapper(); } } function computeAutobridgedOffersWrapperTwo() { if (!self._gotOffersFromLegTwo) { self._gotOffersFromLegTwo = true; self._computeAutobridgedOffersWrapper(); } } function onLedgerClosedWrapper(message) { self._onLedgerClosed(message); self._pruneExpiredOffers(message); } function listenersModified(action, event) { // Automatically subscribe and unsubscribe to orderbook // on the basis of existing event listeners if (_.contains(EVENTS, event)) { switch (action) { case 'add': if (++self._listeners === 1) { if (self._isAutobridgeable) { if (self._legOneBook !== null && self._legOneBook !== undefined) { self._legOneBook.on('model', computeAutobridgedOffersWrapperOne); } if (self._legTwoBook !== null && self._legTwoBook !== undefined) { self._legTwoBook.on('model', computeAutobridgedOffersWrapperTwo); } } if (self._ledgerIndex) { self._getHistoricalOrderbook(); } else { self._api.on('ledger', onLedgerClosedWrapper); self._subscribe(true); } } break; case 'remove': if (--self._listeners === 0) { self._api.removeListener('ledger', onLedgerClosedWrapper); self._gotOffersFromLegOne = false; self._gotOffersFromLegTwo = false; if (self._isAutobridgeable) { if (self._legOneBook !== null && self._legOneBook !== undefined) { self._legOneBook.removeListener('model', computeAutobridgedOffersWrapperOne); } if (self._legTwoBook !== null && self._legTwoBook !== undefined) { self._legTwoBook.removeListener('model', computeAutobridgedOffersWrapperTwo); } } self._subscribe(false); self._resetCache(); } break; } } } this.on('newListener', function (event) { listenersModified('add', event); }); this.on('removeListener', function (event) { listenersModified('remove', event); }); } }, { key: '_onReconnect', value: function _onReconnect() { setTimeout(this._subscribe.bind(this, false), 1); setTimeout(this._subscribe.bind(this, true), 2); } }, { key: '_getHistoricalOrderbook', value: function _getHistoricalOrderbook() { this._requestTransferRate().then(this._requestOffers.bind(this)); } }, { key: '_subscribe', value: function _subscribe(subscribe) { var _this2 = this; var request = { command: subscribe ? 'subscribe' : 'unsubscribe', streams: ['transactions'] }; this._api.connection.request(request).then(function () { _this2._subscribed = subscribe; }); if (subscribe) { this._api.connection.on('connected', this._onReconnectBound); this._api.connection.on('transaction', this._onTransactionBound); this._waitingForOffers = true; this._requestTransferRate().then(this._requestOffers.bind(this)); } else { this._api.connection.removeListener('transaction', this._onTransactionBound); this._api.connection.removeListener('connected', this._onReconnectBound); this._resetCache(); } } }, { key: '_onLedgerClosed', value: function _onLedgerClosed(message) { this._transactionsLeft = -1; this._closedLedgerVersion = message.ledgerVersion; if (!message || message && !_.isNumber(message.transactionCount) || this._waitingForOffers) { return; } this._transactionsLeft = message.transactionCount; return; } }, { key: '_onTransaction', value: function _onTransaction(transaction) { if (this._subscribed && !this._waitingForOffers && this._transactionsLeft > 0) { this._processTransaction(transaction); if (--this._transactionsLeft === 0) { var lastClosedLedger = this._closedLedgerVersion; if (this._isAutobridgeable && this._legOneBook !== null && this._legTwoBook !== null) { if (!this._calculatorRunning) { if (this._legOneBook._lastUpdateLedgerSequence === lastClosedLedger || this._legTwoBook._lastUpdateLedgerSequence === lastClosedLedger) { this._computeAutobridgedOffersWrapper(); } else if (this._lastUpdateLedgerSequence === lastClosedLedger) { this._mergeDirectAndAutobridgedBooks(); } } } else if (this._lastUpdateLedgerSequence === lastClosedLedger) { this._emitAsync(['model', this._offers]); } } } } }, { key: '_processTransaction', value: function _processTransaction(transaction) { if (this._trace) { log.info('_processTransaction', this._key, transaction.transaction.hash); } var metadata = transaction.meta || transaction.metadata; if (!metadata) { return; } var affectedNodes = OrderBookUtils.getAffectedNodes(metadata, { entryType: 'Offer', bookKey: this._key }); if (this._trace) { log.info('_processTransaction:affectedNodes.length: ' + String(affectedNodes.length)); } if (affectedNodes.length > 0) { var state = { takerGetsTotal: this._currencyGets === 'XDV' ? new XDVValue('0') : new IOUValue('0'), takerPaysTotal: this._currencyPays === 'XDV' ? new XDVValue('0') : new IOUValue('0'), transactionOwnerFunds: transaction.transaction.owner_funds }; var isOfferCancel = transaction.transaction.TransactionType === 'OfferCancel'; affectedNodes.forEach(this._processTransactionNode.bind(this, isOfferCancel, state)); this.emit('transaction', transaction.transaction); this._lastUpdateLedgerSequence = this._closedLedgerVersion; if (!state.takerGetsTotal.isZero()) { this.emit('trade', state.takerPaysTotal, state.takerGetsTotal); } } this._updateFundedAmounts(transaction); } }, { key: '_processTransactionNode', value: function _processTransactionNode(isOfferCancel, state, node) { if (this._trace) { log.info('_processTransactionNode', isOfferCancel, node); } switch (node.nodeType) { case 'DeletedNode': { this._validateAccount(node.fields.Account); this._deleteOffer(node, isOfferCancel); // We don't want to count an OfferCancel as a trade if (!isOfferCancel) { state.takerGetsTotal = state.takerGetsTotal.add(parseDivvydAmount(node.fieldsFinal.TakerGets)); state.takerPaysTotal = state.takerPaysTotal.add(parseDivvydAmount(node.fieldsFinal.TakerPays)); } break; } case 'ModifiedNode': { this._validateAccount(node.fields.Account); this._modifyOffer(node); state.takerGetsTotal = state.takerGetsTotal.add(parseDivvydAmount(node.fieldsPrev.TakerGets)).subtract(parseDivvydAmount(node.fieldsFinal.TakerGets)); state.takerPaysTotal = state.takerPaysTotal.add(parseDivvydAmount(node.fieldsPrev.TakerPays)).subtract(parseDivvydAmount(node.fieldsFinal.TakerPays)); break; } case 'CreatedNode': { this._validateAccount(node.fields.Account); // divvyd does not set owner_funds if the order maker is the issuer // because the value would be infinite var fundedAmount = state.transactionOwnerFunds !== undefined ? state.transactionOwnerFunds : 'Infinity'; this._setOwnerFunds(node.fields.Account, fundedAmount); this._insertOffer(node); break; } } } /** * Updates funded amounts/balances using modified balance nodes * * Update owner funds using modified AccountRoot and DivvyState nodes * Update funded amounts for offers in the orderbook using owner funds * * @param {Object} transaction - transaction that holds meta nodes */ }, { key: '_updateFundedAmounts', value: function _updateFundedAmounts(transaction) { var _this3 = this; var metadata = transaction.meta || transaction.metadata; if (!metadata) { return; } if (this._currencyGets !== 'XDV' && !this._issuerTransferRate) { if (this._trace) { log.info('waiting for transfer rate'); } this._requestTransferRate().then(function () { // Defer until transfer rate is requested _this3._updateFundedAmounts(transaction); }, function (err) { log.error('Failed to request transfer rate, will not update funded amounts: ' + err.toString()); }); return; } var affectedNodes = OrderBookUtils.getAffectedNodes(metadata, { nodeType: 'ModifiedNode', entryType: this._currencyGets === 'XDV' ? 'AccountRoot' : 'DivvyState' }); if (this._trace) { log.info('_updateFundedAmounts:affectedNodes.length: ' + String(affectedNodes.length)); } affectedNodes.forEach(function (node) { if (_this3._isBalanceChangeNode(node)) { var result = _this3._parseAccountBalanceFromNode(node); if (_this3._hasOwnerFunds(result.account)) { // We are only updating owner funds that are already cached _this3._setOwnerFunds(result.account, result.balance); _this3._updateOwnerOffersFundedAmount(result.account); } } }); } /** * Get account and final balance of a meta node * * @param {Object} node - DivvyState or AccountRoot meta node * @return {Object} */ }, { key: '_parseAccountBalanceFromNode', value: function _parseAccountBalanceFromNode(node) { var result = { account: '', balance: '' }; switch (node.entryType) { case 'AccountRoot': result.account = node.fields.Account; result.balance = node.fieldsFinal.Balance; break; case 'DivvyState': 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 = parseDivvydAmount(node.fieldsFinal.Balance).negate().toFixed(); } break; } assert(!isNaN(String(result.balance)), 'node has an invalid balance'); this._validateAccount(result.account); return result; } /** * Check that affected meta node represents a balance change * * @param {Object} node - DivvyState or AccountRoot meta node * @return {Boolean} */ }, { key: '_isBalanceChangeNode', value: function _isBalanceChangeNode(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 === 'XDV') { return !isNaN(node.fields.Balance); } // Check if balance change is not for taker gets currency if (node.fields.Balance.currency !== this._currencyGets) { 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; } /** * Modify an existing offer in the orderbook * * @param {Object} node - Offer node */ }, { key: '_modifyOffer', value: function _modifyOffer(node) { if (this._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 */ }, { key: '_deleteOffer', value: function _deleteOffer(node, isOfferCancel) { if (this._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); } } /** * 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} */ }, { key: '_subtractOwnerOfferTotal', value: function _subtractOwnerOfferTotal(account, amount) { var previousAmount = this._getOwnerOfferTotal(account); var newAmount = previousAmount.subtract(parseDivvydAmount(amount)); this._ownerOffersTotal[account] = newAmount; assert(!newAmount.isNegative(), 'Offer total cannot be negative'); return newAmount; } /** * 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 */ }, { key: '_insertOffer', value: function _insertOffer(node) { if (this._trace) { log.info('inserting offer', this._key, node.fields); } var originalLength = this._offers.length; var offer = OrderBook._offerRewrite(node.fields); var takerGets = new IOUValue(offer.TakerGets.value || offer.TakerGets); var takerPays = new IOUValue(offer.TakerPays.value || offer.TakerPays); // We're safe to calculate quality for newly created offers offer.quality = takerPays.divide(takerGets).toFixed(); offer.LedgerEntryType = node.entryType; offer.index = node.ledgerIndex; for (var 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); } }, { key: '_pruneExpiredOffers', value: function _pruneExpiredOffers(ledger) { var _this4 = this; var offersLength = this._offers.length; this._offers = this._offers.filter(function (offer) { if (offer.Expiration <= ledger.ledger_time) { _this4._subtractOwnerOfferTotal(offer.Account, offer.TakerGets); _this4._decrementOwnerOfferCount(offer.Account); _this4._updateOwnerOffersFundedAmount(offer.Account); _this4.emit('offer_removed', offer); return false; } return true; }); if (this._offers.length < offersLength) { this.emit('model', this._offers); } } /** * 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} */ }, { key: '_decrementOwnerOfferCount', value: function _decrementOwnerOfferCount(account) { var result = (this._offerCounts[account] || 1) - 1; this._offerCounts[account] = result; if (result < 1) { this._deleteOwnerFunds(account); } return result; } /** * Remove cached owner's funds * * @param {String} account - owner's account address */ }, { key: '_deleteOwnerFunds', value: function _deleteOwnerFunds(account) { delete this._ownerFunds[account]; } /** * Update offers' funded amount with their owner's funds * * @param {String} account - owner's account address */ }, { key: '_updateOwnerOffersFundedAmount', value: function _updateOwnerOffersFundedAmount(account) { var _this5 = this; if (!this._hasOwnerFunds(account)) { // We are only updating owner funds that are already cached return; } if (this._trace) { var ownerFunds = this._getOwnerFunds(account); log.info('updating offer funds', this._key, account, ownerFunds ? ownerFunds.toString() : 'undefined'); } this._resetOwnerOfferTotal(account); this._offers.forEach(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 = null; 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); previousFundedGets = _this5._getOfferTakerGetsFunded(offer); } _this5._setOfferFundedAmount(offer); _this5._addOwnerOfferTotal(offer.Account, offer.TakerGets); var takerGetsFunded = _this5._getOfferTakerGetsFunded(offer); var areFundsChanged = previousFundedGets !== null && !takerGetsFunded.equals(previousFundedGets); if (areFundsChanged) { _this5.emit('offer_changed', previousOffer, offer); _this5.emit('offer_funds_changed', offer, previousOffer.taker_gets_funded, offer.taker_gets_funded); } }); } }, { key: '_getOfferTakerGetsFunded', value: function _getOfferTakerGetsFunded(offer) { return this._currencyGets === 'XDV' ? new XDVValue(offer.taker_gets_funded) : new IOUValue(offer.taker_gets_funded); } /** * Reset offers amount sum for owner to 0 * * @param {String} account - owner's account address * @return {Amount} */ }, { key: '_resetOwnerOfferTotal', value: function _resetOwnerOfferTotal(account) { if (this._currencyGets === 'XDV') { this._ownerOffersTotal[account] = ZERO_NATIVE_AMOUNT; } else { this._ownerOffersTotal[account] = ZERO_NORMALIZED_AMOUNT; } } }, { key: '_validateAccount', value: function _validateAccount(account) { if (this._validAccounts[account] === undefined) { assert(isValidAddress(account), 'node has an invalid account'); this._validAccounts[account] = true; this._validAccountsCount++; } } /** * Request transfer rate for this orderbook's issuer * * @param {Function} callback */ }, { key: '_requestTransferRate', value: function _requestTransferRate() { var _this6 = this; if (this._currencyGets === 'XDV') { // Transfer rate is default for the native currency this._issuerTransferRate = DEFAULT_TRANSFER_RATE; this._transferRateIsDefault = true; return _Promise.resolve(this._issuerTransferRate); } if (this._issuerTransferRate) { // Transfer rate has already been cached return _Promise.resolve(this._issuerTransferRate); } return this._api.getSettings(this._issuerGets, {}).then(function (settings) { // When transfer rate is not explicitly set on account, it implies the // default transfer rate _this6._transferRateIsDefault = !settings.transferRate; _this6._issuerTransferRate = settings.transferRate ? new IOUValue(settings.transferRate) : DEFAULT_TRANSFER_RATE; return _this6._issuerTransferRate; }); } /** * Request orderbook entries from server * * @param {Function} callback */ }, { key: '_requestOffers', value: function _requestOffers() { var _this7 = this; if (!this._api.isConnected()) { // do not make request if not online. // that requests will be queued and // eventually all of them will fire back return _Promise.reject(new this._api.errors.DivvyError('Server is offline')); } if (this._trace) { log.info('requesting offers', this._key); } var requestMessage = _.extend({ command: 'book_offers', taker: this._account ? this._account : 'rrrrrrrrrrrrrrrrrrrrBZbvji', ledger_index: this._ledgerIndex || 'validated' }, this.toJSON()); return this._api.connection.request(requestMessage).then(function (response) { _this7._lastUpdateLedgerSequence = response.ledger_index; if (!Array.isArray(response.offers)) { _this7._emitAsync(['model', []]); throw new _this7._api.errors.DivvyError('Invalid response'); } if (_this7._ledgerIndex) { assert(response.ledger_index === _this7._ledgerIndex); } if (_this7._trace) { log.info('requested offers', _this7._key, 'offers: ' + response.offers.length); } _this7._setOffers(response.offers); if (!_this7._isAutobridgeable) { _this7._waitingForOffers = false; _this7._emitAsync(['model', _this7._offers]); return _this7._offers; } _this7._computeAutobridgedOffersWrapper(); return new _Promise(function (resolve) { _this7.once('model', function (offers) { _this7._waitingForOffers = false; resolve(offers); }); }); }); } /** * Reset internal offers cache from book_offers request * * @param {Array} offers * @api private */ }, { key: '_setOffers', value: function _setOffers(offers) { assert(Array.isArray(offers), 'Offers is not an array'); this._resetCache(); var i = -1; var offer = undefined; var length = offers.length; while (++i < length) { offer = OrderBook._offerRewrite(offers[i]); this._validateAccount(offer.Account); 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; } /** * Check whether owner's funds have been cached * * @param {String} account - owner's account address */ }, { key: '_hasOwnerFunds', value: function _hasOwnerFunds(account) { if (account === undefined) { return false; } return this._ownerFunds[account] !== undefined; } /** * Set owner's, transfer rate adjusted, funds in cache * * @param {String} account - owner's account address * @param {String} fundedAmount */ }, { key: '_setOwnerFunds', value: function _setOwnerFunds(account, fundedAmount) { assert(!isNaN(Number(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} */ }, { key: '_applyTransferRate', value: function _applyTransferRate(balance) { assert(!isNaN(Number(balance)), 'Balance is invalid'); if (this._transferRateIsDefault) { return balance; } var adjustedBalance = new IOUValue(balance).divide(this._issuerTransferRate).toFixed(); return adjustedBalance; } /** * Increment offer count for owner * * @param {String} account - owner's account address * @return {Number} */ }, { key: '_incrementOwnerOfferCount', value: function _incrementOwnerOfferCount(account) { var result = (this._offerCounts[account] || 0) + 1; this._offerCounts[account] = result; return result; } /** * 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 */ }, { key: '_setOfferFundedAmount', value: function _setOfferFundedAmount(offer) { assert.strictEqual(typeof offer, 'object', 'Offer is invalid'); var takerGets = parseDivvydAmount(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); assert(fundedAmount.constructor === currentOfferSum.constructor); offer.is_fully_funded = fundedAmount.comparedTo(currentOfferSum) >= 0; if (offer.is_fully_funded) { offer.taker_gets_funded = takerGets.toString(); offer.taker_pays_funded = OrderBook._getValFromDivvydAmount(offer.TakerPays); } else if (previousOfferSum.comparedTo(fundedAmount) < 0) { offer.taker_gets_funded = fundedAmount.subtract(previousOfferSum).toString(); var quality = new IOUValue(offer.quality); var takerPaysFunded = quality.multiply(new IOUValue(offer.taker_gets_funded)); offer.taker_pays_funded = this._currencyPays === 'XDV' ? String(Math.floor(Number(takerPaysFunded.toString()))) : takerPaysFunded.toString(); } else { offer.taker_gets_funded = '0'; offer.taker_pays_funded = '0'; } return offer; } /** * 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} */ }, { key: '_addOwnerOfferTotal', value: function _addOwnerOfferTotal(account, amount) { var previousAmount = this._getOwnerOfferTotal(account); var currentAmount = previousAmount.add(this._makeGetsValue(amount)); this._ownerOffersTotal[account] = currentAmount; return currentAmount; } /** * Get offers amount sum for owner * * @param {String} account - owner's account address * @return {Value} */ }, { key: '_getOwnerOfferTotal', value: function _getOwnerOfferTotal(account) { var amount = this._ownerOffersTotal[account]; if (amount) { return amount; } return this._currencyGets === 'XDV' ? ZERO_NATIVE_AMOUNT : ZERO_NORMALIZED_AMOUNT; } }, { key: '_makeGetsValue', value: function _makeGetsValue(value_) { var value = OrderBook._getValFromDivvydAmount(value_); return this._currencyGets === 'XDV' ? new XDVValue(value) : new IOUValue(value); } /** * Get owner's cached unadjusted funds * * @param {String} account - owner's account address * @return {String} */ }, { key: '_getUnadjustedOwnerFunds', value: function _getUnadjustedOwnerFunds(account) { return this._ownerFundsUnadjusted[account]; } /** * Get owner's cached, transfer rate adjusted, funds * * @param {String} account - owner's account address * @return {Value} */ }, { key: '_getOwnerFunds', value: function _getOwnerFunds(account) { if (this._hasOwnerFunds(account)) { return this._makeGetsValue(this._ownerFunds[account]); } if (this._trace) { log.info('No owner funds for ' + account, this._key); } throw new this._api.errors.DivvyError('No owner funds'); } /** * Reset cached owner's funds, offer counts, and offer sums */ }, { key: '_resetCache', value: function _resetCache() { this._ownerFundsUnadjusted = {}; this._ownerFunds = {}; this._ownerOffersTotal = {}; this._offerCounts = {}; this._offers = []; this._synced = false; if (this._validAccountsCount > 3000) { this._validAccounts = {}; this._validAccountsCount = 0; } } }, { key: '_emitAsync', value: function _emitAsync(args) { var _this8 = this; setTimeout(function () { return _this8.emit.apply(_this8, args); }, 0); } /** * Compute autobridged offers for an IOU:IOU orderbook by merging offers from * IOU:XDV and XDV:IOU books */ }, { key: '_computeAutobridgedOffers', value: function _computeAutobridgedOffers() { var _this9 = this; assert(this._currencyGets !== 'XDV' && this._currencyPays !== 'XDV', 'Autobridging is only for IOU:IOU orderbooks'); if (this._trace) { log.info('_computeAutobridgedOffers autobridgeCalculator.calculate', this._key); } // this check is only for flow var legOneOffers = this._legOneBook !== null && this._legOneBook !== undefined ? this._legOneBook.getOffersSync() : []; var legTwoOffers = this._legTwoBook !== null && this._legTwoBook !== undefined ? this._legTwoBook.getOffersSync() : []; var autobridgeCalculator = new AutobridgeCalculator(this._currencyGets, this._currencyPays, legOneOffers, legTwoOffers, this._issuerGets, this._issuerPays); return autobridgeCalculator.calculate().then(function (autobridgedOffers) { _this9._offersAutobridged = autobridgedOffers; }); } }, { key: '_computeAutobridgedOffersWrapper', value: function _computeAutobridgedOffersWrapper() { var _this10 = this; if (this._trace) { log.info('_computeAutobridgedOffersWrapper', this._key, this._synced, this._calculatorRunning); } if (!this._gotOffersFromLegOne || !this._gotOffersFromLegTwo || !this._synced || this._calculatorRunning) { return; } this._calculatorRunning = true; this._computeAutobridgedOffers().then(function () { _this10._mergeDirectAndAutobridgedBooks(); _this10._calculatorRunning = false; }); } /** * Merge direct and autobridged offers into a combined orderbook * * @return */ }, { key: '_mergeDirectAndAutobridgedBooks', value: function _mergeDirectAndAutobridgedBooks() { 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._emitAsync(['model', []]); } return; } this._mergedOffers = this._offers.concat(this._offersAutobridged).sort(_sortOffersQuick); this._emitAsync(['model', this._mergedOffers]); } }], [{ key: 'createOrderBook', value: function createOrderBook(api, options) { var orderbook = new OrderBook(api, options.currency_gets, options.issuer_gets, options.currency_pays, options.issuer_pays, options.account, options.ledger_index, options.trace); return orderbook; } }, { key: '_getValFromDivvydAmount', value: function _getValFromDivvydAmount(value_) { return typeof value_ === 'string' ? value_ : value_.value; } /** * Normalize offers from book_offers and transaction stream * * @param {Object} offer * @return {Object} normalized */ }, { key: '_offerRewrite', value: function _offerRewrite(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; } }]); return OrderBook; })(EventEmitter); exports.OrderBook = OrderBook;