mirror of
https://github.com/Xahau/xahau.js.git
synced 2025-12-06 17:27:59 +00:00
1081 lines
26 KiB
JavaScript
1081 lines
26 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
|
|
|
|
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 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
|
|
*/
|
|
|
|
function OrderBook(remote, getsC, getsI, paysC, paysI, key) {
|
|
EventEmitter.call(this);
|
|
|
|
var self = this;
|
|
|
|
this._remote = remote;
|
|
this._currencyGets = Currency.from_json(getsC);
|
|
this._issuerGets = getsI;
|
|
this._currencyPays = Currency.from_json(paysC);
|
|
this._issuerPays = paysI;
|
|
this._key = key;
|
|
this._subscribed = false;
|
|
this._shouldSubscribe = true;
|
|
this._listeners = 0;
|
|
this._offers = [ ];
|
|
this._ownerFunds = { };
|
|
this._offerCounts = { };
|
|
|
|
// We consider ourselves synchronized if we have a current
|
|
// copy of the offers, we are online and subscribed to updates.
|
|
this._synchronized = false;
|
|
|
|
function listenersModified(action, event) {
|
|
// Automatically subscribe and unsubscribe to orderbook
|
|
// on the basis of existing event listeners
|
|
if (~OrderBook.EVENTS.indexOf(event)) {
|
|
switch (action) {
|
|
case 'add':
|
|
if (++self._listeners === 1) {
|
|
self.subscribe();
|
|
}
|
|
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._ownerFunds = { };
|
|
self._remote.removeListener('transaction', updateFundedAmounts);
|
|
self._remote.removeListener('transaction', updateTransferRate);
|
|
});
|
|
|
|
this._remote.on('prepare_subscribe', function() {
|
|
self.subscribe();
|
|
});
|
|
|
|
this._remote.on('disconnect', function() {
|
|
self._ownerFunds = { };
|
|
self._offerCounts = { };
|
|
self._synchronized = false;
|
|
});
|
|
|
|
function updateFundedAmounts(message) {
|
|
self.updateFundedAmounts(message);
|
|
};
|
|
|
|
this._remote.on('transaction', updateFundedAmounts);
|
|
|
|
function updateTransferRate(message) {
|
|
self.updateTransferRate(message);
|
|
};
|
|
|
|
this._remote.on('transaction', updateTransferRate);
|
|
|
|
return this;
|
|
};
|
|
|
|
util.inherits(OrderBook, EventEmitter);
|
|
|
|
/**
|
|
* Events emitted from OrderBook
|
|
*/
|
|
|
|
OrderBook.EVENTS = [ 'transaction', 'model', 'trade', 'offer' ];
|
|
|
|
OrderBook.DEFAULT_TRANSFER_RATE = 1000000000;
|
|
|
|
/**
|
|
* 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())
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Initialize orderbook. Get orderbook offers and subscribe to transactions
|
|
*/
|
|
|
|
OrderBook.prototype.subscribe = function() {
|
|
var self = this;
|
|
|
|
if (!this._shouldSubscribe) {
|
|
return;
|
|
}
|
|
|
|
if (this._remote.trace) {
|
|
log.info('subscribing', this._key);
|
|
}
|
|
|
|
var steps = [
|
|
function(callback) {
|
|
self.requestTransferRate(callback);
|
|
},
|
|
function(callback) {
|
|
self.requestOffers(callback);
|
|
},
|
|
function(callback) {
|
|
self.subscribeTransactions(callback);
|
|
}
|
|
];
|
|
|
|
async.series(steps, function(err) {
|
|
//XXX What now?
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Unhook event listeners and prevent ripple-lib from further work on this
|
|
* orderbook. There is no more orderbook stream, so "unsubscribe" is nominal
|
|
*/
|
|
|
|
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');
|
|
};
|
|
|
|
/**
|
|
* Check that the funds for offer owner have been cached
|
|
*
|
|
* @param {String} account address
|
|
*/
|
|
|
|
OrderBook.prototype.hasCachedFunds = function(account) {
|
|
assert(UInt160.is_valid(account), 'Account is invalid');
|
|
return this._ownerFunds[account] !== void(0);
|
|
};
|
|
|
|
/**
|
|
* Add cached offer owner funds
|
|
*
|
|
* @param {String} account address
|
|
* @param {String} funded amount
|
|
*/
|
|
|
|
OrderBook.prototype.addCachedFunds = function(account, fundedAmount) {
|
|
assert(UInt160.is_valid(account), 'Account is invalid');
|
|
assert(!isNaN(fundedAmount), 'Funded amount is invalid');
|
|
this._ownerFunds[account] = fundedAmount;
|
|
};
|
|
|
|
/**
|
|
* Get cached offer owner funds
|
|
*
|
|
* @param {String} account address
|
|
*/
|
|
|
|
OrderBook.prototype.getCachedFunds = function(account) {
|
|
assert(UInt160.is_valid(account), 'Account is invalid');
|
|
return this._ownerFunds[account];
|
|
};
|
|
|
|
/**
|
|
* Remove cached offer owner funds
|
|
*
|
|
* @param {String} account address
|
|
*/
|
|
|
|
OrderBook.prototype.removeCachedFunds = function(account) {
|
|
assert(UInt160.is_valid(account), 'Account is invalid');
|
|
this._ownerFunds[account] = void(0);
|
|
};
|
|
|
|
/**
|
|
* Get offer count for offer owner
|
|
*/
|
|
|
|
OrderBook.prototype.getOfferCount = function(account) {
|
|
assert(UInt160.is_valid(account), 'Account is invalid');
|
|
return this._offerCounts[account] || 0;
|
|
};
|
|
|
|
/**
|
|
* Increment offer count for offer owner
|
|
*/
|
|
|
|
OrderBook.prototype.incrementOfferCount = function(account) {
|
|
assert(UInt160.is_valid(account), 'Account is invalid');
|
|
var result = (this._offerCounts[account] || 0) + 1;
|
|
this._offerCounts[account] = result;
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Decrement offer count for offer owner
|
|
*/
|
|
|
|
OrderBook.prototype.decrementOfferCount = function(account) {
|
|
assert(UInt160.is_valid(account), 'Account is invalid');
|
|
var result = (this._offerCounts[account] || 1) - 1;
|
|
this._offerCounts[account] = result;
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Compute funded amount for a balance/transferRate
|
|
*
|
|
* @param {String} balance
|
|
* @param [String] transferRate
|
|
* @return {Amount} funded amount
|
|
*/
|
|
|
|
OrderBook.prototype.applyTransferRate = function(balance, transferRate) {
|
|
assert(!isNaN(balance), 'Balance is invalid');
|
|
|
|
if (this._currencyGets.is_native()) {
|
|
return balance;
|
|
}
|
|
|
|
if (transferRate === void(0)) {
|
|
transferRate = this._issuerTransferRate;
|
|
}
|
|
|
|
assert(!isNaN(transferRate), 'Transfer rate is invalid');
|
|
|
|
if (transferRate === OrderBook.DEFAULT_TRANSFER_RATE) {
|
|
return balance;
|
|
}
|
|
|
|
var iouSuffix = '/USD/rrrrrrrrrrrrrrrrrrrrBZbvji';
|
|
var adjustedBalance = Amount.from_json(balance + iouSuffix)
|
|
.divide(transferRate)
|
|
.multiply(Amount.from_json(OrderBook.DEFAULT_TRANSFER_RATE))
|
|
.to_json()
|
|
.value;
|
|
|
|
return adjustedBalance;
|
|
};
|
|
|
|
/**
|
|
* Request transfer rate for this orderbook's issuer
|
|
*
|
|
* @param [Function] calback
|
|
*/
|
|
|
|
OrderBook.prototype.requestTransferRate = function(callback) {
|
|
var self = this;
|
|
var issuer = this._issuerGets;
|
|
|
|
this.once('transfer_rate', function(rate) {
|
|
if (typeof callback === 'function') {
|
|
callback(null, rate);
|
|
}
|
|
});
|
|
|
|
if (this._currencyGets.is_native()) {
|
|
// Transfer rate is default
|
|
return this.emit('transfer_rate', OrderBook.DEFAULT_TRANSFER_RATE);
|
|
}
|
|
|
|
if (this._issuerTransferRate) {
|
|
// Transfer rate has been cached
|
|
return this.emit('transfer_rate', this._issuerTransferRate);
|
|
}
|
|
|
|
this._remote.requestAccountInfo(issuer, function(err, info) {
|
|
if (err) {
|
|
// XXX What now?
|
|
return callback(err);
|
|
}
|
|
|
|
var transferRate = info.account_data.TransferRate
|
|
|| OrderBook.DEFAULT_TRANSFER_RATE;
|
|
|
|
self._issuerTransferRate = transferRate;
|
|
self.emit('transfer_rate', transferRate);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Set funded amount on offer. All offers have taker_gets_funded property,
|
|
* which reflects the amount this account can afford to offer. All offers have
|
|
* is_fully_funded property, indicating whether these funds are sufficient for
|
|
* the offer placed.
|
|
*
|
|
* @param {Object} offer
|
|
* @param {String} funds
|
|
* @return offer
|
|
*/
|
|
|
|
OrderBook.prototype.setFundedAmount = function(offer, fundedAmount) {
|
|
assert.strictEqual(typeof offer, 'object', 'Offer is invalid');
|
|
assert(!isNaN(fundedAmount), 'Funds is invalid');
|
|
|
|
if (fundedAmount === '0') {
|
|
offer.taker_gets_funded = '0';
|
|
offer.taker_pays_funded = '0';
|
|
offer.is_fully_funded = false;
|
|
return offer;
|
|
}
|
|
|
|
var iouSuffix = '/' + this._currencyGets.to_json()
|
|
+ '/' + this._issuerGets;
|
|
|
|
offer.is_fully_funded = Amount.from_json(
|
|
this._currencyGets.is_native() ? fundedAmount : fundedAmount + iouSuffix
|
|
).compareTo(Amount.from_json(offer.TakerGets)) >= 0;
|
|
|
|
if (offer.is_fully_funded) {
|
|
offer.taker_gets_funded = Amount.from_json(offer.TakerGets).to_text();
|
|
offer.taker_pays_funded = Amount.from_json(offer.TakerPays).to_text();
|
|
return offer;
|
|
}
|
|
|
|
offer.taker_gets_funded = fundedAmount;
|
|
|
|
var rate = Amount.from_json(offer.TakerPays)
|
|
.divide(Amount.from_json(offer.TakerGets));
|
|
|
|
var takerPays = Amount.from_json(offer.TakerPays);
|
|
|
|
takerPays.set_currency('XXX');
|
|
takerPays.set_issuer('rrrrrrrrrrrrrrrrrrrrBZbvji');
|
|
|
|
var fundedPays = Amount.from_json(
|
|
fundedAmount + '/XXX/rrrrrrrrrrrrrrrrrrrrBZbvji'
|
|
);
|
|
|
|
fundedPays = fundedPays.multiply(rate);
|
|
|
|
if (fundedPays.compareTo(takerPays) < 0) {
|
|
offer.taker_pays_funded = fundedPays.to_text();
|
|
} else {
|
|
offer.taker_pays_funded = Amount.from_json(offer.TakerPays).to_text();
|
|
}
|
|
|
|
return offer;
|
|
};
|
|
|
|
/**
|
|
* Determine what an account is funded to offer for orderbook's
|
|
* currency/issuer
|
|
*
|
|
* @param {String} account
|
|
* @param {Function} callback
|
|
*/
|
|
|
|
OrderBook.prototype.requestFundedAmount = function(account, callback) {
|
|
assert(UInt160.is_valid(account), 'Account is invalid');
|
|
assert.strictEqual(typeof callback, 'function', 'Callback is invalid');
|
|
|
|
var self = this;
|
|
|
|
if (self._remote.trace) {
|
|
log.info('requesting funds', account);
|
|
}
|
|
|
|
function requestNativeBalance(callback) {
|
|
self._remote.requestAccountInfo(account, function(err, info) {
|
|
if (err) {
|
|
callback(err);
|
|
} else {
|
|
callback(null, String(info.account_data.Balance));
|
|
}
|
|
});
|
|
};
|
|
|
|
function requestLineBalance(callback) {
|
|
var request = self._remote.requestAccountLines(
|
|
account, // account
|
|
void(0), // account index
|
|
'VALIDATED', // ledger
|
|
self._issuerGets //peer
|
|
);
|
|
|
|
request.request(function(err, res) {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
var currency = self._currencyGets.to_json();
|
|
var balance = '0';
|
|
|
|
for (var i=0, line; (line=res.lines[i]); i++) {
|
|
if (line.currency === currency) {
|
|
balance = line.balance;
|
|
break;
|
|
}
|
|
}
|
|
|
|
callback(null, balance);
|
|
});
|
|
};
|
|
|
|
function computeFundedAmount(err, results) {
|
|
if (err) {
|
|
if (self._remote.trace) {
|
|
log.info('failed to request funds', err);
|
|
}
|
|
//XXX What now?
|
|
return callback(err);
|
|
}
|
|
|
|
if (self._remote.trace) {
|
|
log.info('requested funds', account, results);
|
|
}
|
|
|
|
var balance;
|
|
var fundedAmount;
|
|
|
|
if (self._currencyGets.is_native()) {
|
|
balance = results[0];
|
|
fundedAmount = balance;
|
|
} else {
|
|
balance = results[1];
|
|
fundedAmount = self.applyTransferRate(balance, results[0]);
|
|
}
|
|
|
|
callback(null, fundedAmount);
|
|
};
|
|
|
|
var steps = [ ];
|
|
|
|
if (this._currencyGets.is_native()) {
|
|
steps.push(requestNativeBalance);
|
|
} else {
|
|
steps.push(this.requestTransferRate.bind(this));
|
|
steps.push(requestLineBalance);
|
|
}
|
|
|
|
async.parallel(steps, computeFundedAmount);
|
|
};
|
|
|
|
/**
|
|
* Get changed balance of an affected node
|
|
*
|
|
* @param {Object} RippleState or AccountRoot node
|
|
* @return {Object} { account, balance }
|
|
*/
|
|
|
|
OrderBook.prototype.getBalanceChange = function(node) {
|
|
var result = {
|
|
account: void(0),
|
|
balance: void(0)
|
|
};
|
|
|
|
switch (node.entryType) {
|
|
case 'AccountRoot':
|
|
result.account = node.fields.Account;
|
|
result.balance = node.fieldsFinal.Balance;
|
|
break;
|
|
|
|
case 'RippleState':
|
|
if (node.fields.HighLimit.issuer === this._issuerGets) {
|
|
result.account = node.fields.LowLimit.issuer;
|
|
result.balance = node.fieldsFinal.Balance.value;
|
|
} else if (node.fields.LowLimit.issuer === this._issuerGets) {
|
|
// Negate balance
|
|
result.account = node.fields.HighLimit.issuer;
|
|
result.balance = Amount.from_json(
|
|
node.fieldsFinal.Balance
|
|
).negate().to_json().value;
|
|
}
|
|
break;
|
|
}
|
|
|
|
result.isValid = !isNaN(result.balance)
|
|
&& UInt160.is_valid(result.account);
|
|
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Check that affected node represents a balance change
|
|
*
|
|
* @param {Object} RippleState or AccountRoot node
|
|
* @return {Boolean}
|
|
*/
|
|
|
|
OrderBook.prototype.isBalanceChange = function(node) {
|
|
// Check balance change
|
|
if (!(node.fields && node.fields.Balance
|
|
&& node.fieldsPrev && node.fieldsFinal
|
|
&& node.fieldsPrev.Balance && node.fieldsFinal.Balance)) {
|
|
return false;
|
|
}
|
|
|
|
// Check currency
|
|
if (this._currencyGets.is_native()) {
|
|
return !isNaN(node.fields.Balance);
|
|
}
|
|
|
|
if (node.fields.Balance.currency !== this._currencyGets.to_json()) {
|
|
return false;
|
|
}
|
|
|
|
// Check issuer
|
|
if (!(node.fields.HighLimit.issuer === this._issuerGets
|
|
|| node.fields.LowLimit.issuer === this._issuerGets)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Update funded amounts for offers in the orderbook as new transactions are
|
|
* streamed from server
|
|
*
|
|
* @param {Object} transaction
|
|
*/
|
|
|
|
OrderBook.prototype.updateFundedAmounts = function(message) {
|
|
var self = this;
|
|
|
|
var affectedAccounts = message.mmeta.getAffectedAccounts();
|
|
|
|
var isOwnerAffected = affectedAccounts.some(function(account) {
|
|
return self.hasCachedFunds(account);
|
|
});
|
|
|
|
if (!isOwnerAffected) {
|
|
return;
|
|
}
|
|
|
|
if (!this._currencyGets.is_native() && !this._issuerTransferRate) {
|
|
// Defer until transfer rate is requested
|
|
if (self._remote.trace) {
|
|
log.info('waiting for transfer rate');
|
|
}
|
|
|
|
this.once('transfer_rate', function() {
|
|
self.updateFundedAmounts(message);
|
|
});
|
|
|
|
this.requestTransferRate();
|
|
return;
|
|
}
|
|
|
|
var nodes = message.mmeta.getNodes({
|
|
nodeType: 'ModifiedNode',
|
|
entryType: this._currencyGets.is_native() ? 'AccountRoot' : 'RippleState'
|
|
});
|
|
|
|
for (var i=0; i<nodes.length; i++) {
|
|
var node = nodes[i];
|
|
|
|
if (!this.isBalanceChange(node)) {
|
|
continue;
|
|
}
|
|
|
|
var result = this.getBalanceChange(node);
|
|
|
|
if (result.isValid) {
|
|
var account = result.account;
|
|
var balance = result.balance;
|
|
|
|
if (this.hasCachedFunds(account)) {
|
|
var fundedAmount = this.applyTransferRate(balance);
|
|
this.updateOfferFunds(account, fundedAmount);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Update issuer's TransferRate as it changes
|
|
*
|
|
* @param {Object} transaction
|
|
*/
|
|
|
|
OrderBook.prototype.updateTransferRate = function(message) {
|
|
var self = this;
|
|
|
|
var affectedAccounts = message.mmeta.getAffectedAccounts();
|
|
|
|
var isIssuerAffected = affectedAccounts.some(function(account) {
|
|
return account === self._issuerGets;
|
|
});
|
|
|
|
if (!isIssuerAffected) {
|
|
return;
|
|
}
|
|
|
|
// XXX Update transfer rate
|
|
//
|
|
// var nodes = message.mmeta.getNodes({
|
|
// nodeType: 'ModifiedNode',
|
|
// entryType: 'AccountRoot'
|
|
// });
|
|
};
|
|
|
|
/**
|
|
* Request orderbook entries from server
|
|
*/
|
|
|
|
OrderBook.prototype.requestOffers = function(callback) {
|
|
var self = this;
|
|
|
|
if (typeof callback !== 'function') {
|
|
callback = function(){};
|
|
}
|
|
|
|
if (!this._shouldSubscribe) {
|
|
return callback(new Error('Should not request offers'));
|
|
}
|
|
|
|
if (this._remote.trace) {
|
|
log.info('requesting offers', this._key);
|
|
}
|
|
|
|
function handleOffers(res) {
|
|
if (!Array.isArray(res.offers)) {
|
|
// XXX What now?
|
|
return callback(new Error('Invalid response'));
|
|
}
|
|
|
|
if (self._remote.trace) {
|
|
log.info('requested offers', self._key, 'offers: ' + res.offers.length);
|
|
}
|
|
|
|
for (var i=0, l=res.offers.length; i<l; i++) {
|
|
var offer = res.offers[i];
|
|
var fundedAmount;
|
|
|
|
if (self.hasCachedFunds(offer.Account)) {
|
|
fundedAmount = self.getCachedFunds(offer.Account);
|
|
} else if (offer.hasOwnProperty('owner_funds')) {
|
|
fundedAmount = self.applyTransferRate(offer.owner_funds);
|
|
self.addCachedFunds(offer.Account, fundedAmount);
|
|
}
|
|
|
|
self.setFundedAmount(offer, fundedAmount);
|
|
self.incrementOfferCount(offer.Account);
|
|
self._offers.push(offer);
|
|
}
|
|
|
|
self._synchronized = true;
|
|
|
|
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);
|
|
}
|
|
|
|
callback(err);
|
|
};
|
|
|
|
var request = this._remote.requestBookOffers(this.toJSON());
|
|
request.once('success', handleOffers);
|
|
request.once('error', handleError);
|
|
request.request();
|
|
|
|
return request;
|
|
};
|
|
|
|
/**
|
|
* Subscribe to transactions stream
|
|
*/
|
|
|
|
OrderBook.prototype.subscribeTransactions = function(callback) {
|
|
var self = this;
|
|
|
|
if (typeof callback !== 'function') {
|
|
callback = function(){};
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* 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');
|
|
|
|
var self = this;
|
|
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* Insert an offer into the orderbook
|
|
*
|
|
* @param {Object} node
|
|
*/
|
|
|
|
OrderBook.prototype.insertOffer = function(node, fundedAmount) {
|
|
if (this._remote.trace) {
|
|
log.info('inserting offer', this._key, node.fields);
|
|
}
|
|
|
|
var nodeFields = node.fields;
|
|
|
|
nodeFields.index = node.ledgerIndex;
|
|
|
|
if (!isNaN(fundedAmount)) {
|
|
this.setFundedAmount(nodeFields, fundedAmount);
|
|
this.addCachedFunds(nodeFields.Account, fundedAmount);
|
|
}
|
|
|
|
var DATE_REF = {
|
|
reference_date: new Date()
|
|
};
|
|
|
|
// XXX Should use Amount#from_quality
|
|
var price = Amount.from_json(
|
|
nodeFields.TakerPays
|
|
).ratio_human(node.fields.TakerGets, DATE_REF);
|
|
|
|
for (var i=0, l=this._offers.length; i<l; i++) {
|
|
var offer = this._offers[i];
|
|
|
|
var priceItem = Amount.from_json(
|
|
offer.TakerPays
|
|
).ratio_human(offer.TakerGets, DATE_REF);
|
|
|
|
if (price.compareTo(priceItem) <= 0) {
|
|
this._offers.splice(i, 0, nodeFields);
|
|
break;
|
|
} else if (i === (l - 1)) {
|
|
this._offers.push(nodeFields);
|
|
}
|
|
}
|
|
|
|
this.emit('offer_added', nodeFields);
|
|
};
|
|
|
|
/**
|
|
* Modify an existing offer in the orderbook
|
|
*
|
|
* @param {Object} node
|
|
* @param {Boolean} isDeletedNode
|
|
*/
|
|
|
|
OrderBook.prototype.modifyOffer = function(node, isDeletedNode) {
|
|
if (this._remote.trace) {
|
|
if (isDeletedNode) {
|
|
log.info('deleting offer', this._key, node.fields);
|
|
} else {
|
|
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) {
|
|
if (isDeletedNode) {
|
|
// Multiple offers same account?
|
|
this._offers.splice(i, 1);
|
|
this.emit('offer_removed', offer);
|
|
} else {
|
|
// TODO: This assumes no fields are deleted, which is
|
|
// probably a safe assumption, but should be checked.
|
|
var previousOffer = extend({}, offer);
|
|
extend(offer, node.fieldsFinal);
|
|
this.emit('offer_changed', previousOffer, offer);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Update funded status on offers whose account's balance has changed
|
|
*
|
|
* Update cached account funds
|
|
*
|
|
* @param {String} account address
|
|
* @param {String|Object} offer funds
|
|
*/
|
|
|
|
OrderBook.prototype.updateOfferFunds = function(account, fundedAmount) {
|
|
assert(UInt160.is_valid(account), 'Account is invalid');
|
|
assert(!isNaN(fundedAmount), 'Funded amount is invalid');
|
|
|
|
if (this._remote.trace) {
|
|
log.info('updating offer funds', this._key, account, fundedAmount);
|
|
}
|
|
|
|
// Update cached account funds
|
|
this.addCachedFunds(account, fundedAmount);
|
|
|
|
for (var i=0; i<this._offers.length; i++) {
|
|
var offer = this._offers[i];
|
|
|
|
if (offer.Account !== account) {
|
|
continue;
|
|
}
|
|
|
|
var suffix = '/USD/rrrrrrrrrrrrrrrrrrrrBZbvji';
|
|
var previousOffer = extend({}, offer);
|
|
var previousFundedGets = Amount.from_json(offer.taker_gets_funded + suffix);
|
|
|
|
this.setFundedAmount(offer, fundedAmount);
|
|
|
|
var hasChangedFunds = !previousFundedGets.equals(
|
|
Amount.from_json(offer.taker_gets_funded + suffix)
|
|
);
|
|
|
|
if (hasChangedFunds) {
|
|
this.emit('offer_changed', previousOffer, offer);
|
|
this.emit(
|
|
'offer_funds_changed', offer,
|
|
previousOffer.taker_gets_funded,
|
|
offer.taker_gets_funded
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Notify orderbook of a relevant transaction
|
|
*
|
|
* @param {Object} transaction
|
|
* @api private
|
|
*/
|
|
|
|
OrderBook.prototype.notify = function(message) {
|
|
var self = this;
|
|
|
|
// Unsubscribed from OrderBook
|
|
if (!this._subscribed) {
|
|
return;
|
|
}
|
|
|
|
var affectedNodes = message.mmeta.getNodes({
|
|
entryType: 'Offer',
|
|
bookKey: this._key
|
|
});
|
|
|
|
if (affectedNodes.length < 1) {
|
|
return;
|
|
}
|
|
|
|
if (this._remote.trace) {
|
|
log.info('notifying', this._key, message.transaction.hash);
|
|
}
|
|
|
|
var tradeGets = Amount.from_json(
|
|
'0' + ((Currency.from_json(this._currencyGets).is_native())
|
|
? ''
|
|
: ('/' + this._currencyGets + '/' + this._issuerGets))
|
|
);
|
|
|
|
var tradePays = Amount.from_json(
|
|
'0' + ((Currency.from_json(this._currencyPays).is_native())
|
|
? ''
|
|
: ('/' + this._currencyPays + '/' + this._issuerPays))
|
|
);
|
|
|
|
function handleNode(node, callback) {
|
|
var isDeletedNode = node.nodeType === 'DeletedNode';
|
|
var isOfferCancel = message.transaction.TransactionType === 'OfferCancel';
|
|
|
|
switch (node.nodeType) {
|
|
case 'DeletedNode':
|
|
case 'ModifiedNode':
|
|
self.modifyOffer(node, isDeletedNode);
|
|
|
|
// We don't want to count an OfferCancel as a trade
|
|
if (!isOfferCancel) {
|
|
tradeGets = tradeGets.add(node.fieldsPrev.TakerGets);
|
|
tradePays = tradePays.add(node.fieldsPrev.TakerPays);
|
|
|
|
if (isDeletedNode) {
|
|
if (self.decrementOfferCount(node.fields.Account) < 1) {
|
|
self.removeCachedFunds(node.fields.Account);
|
|
}
|
|
} else {
|
|
tradeGets = tradeGets.subtract(node.fieldsFinal.TakerGets);
|
|
tradePays = tradePays.subtract(node.fieldsFinal.TakerPays);
|
|
}
|
|
}
|
|
callback();
|
|
break;
|
|
|
|
case 'CreatedNode':
|
|
self.incrementOfferCount(node.fields.Account);
|
|
|
|
var fundedAmount = message.transaction.owner_funds;
|
|
|
|
if (!isNaN(fundedAmount)) {
|
|
self.insertOffer(node, fundedAmount);
|
|
return callback();
|
|
}
|
|
|
|
// Get the offer account's balance from server
|
|
self.requestFundedAmount(
|
|
node.fields.Account, function(err, fundedAmount) {
|
|
if (err) {
|
|
// XXX Now what?
|
|
} else {
|
|
self.insertOffer(node, fundedAmount);
|
|
}
|
|
callback();
|
|
});
|
|
break;
|
|
}
|
|
};
|
|
|
|
async.eachSeries(affectedNodes, handleNode, function() {
|
|
self.emit('transaction', message);
|
|
self.emit('model', self._offers);
|
|
if (!tradeGets.is_zero()) {
|
|
self.emit('trade', tradePays, tradeGets);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
};
|
|
|
|
exports.OrderBook = OrderBook;
|
|
|
|
// vim:sw=2:sts=2:ts=8:et
|