espend-rest
Version:
A RESTful API for submitting payments and monitoring accounts on the eSpend network.
558 lines (466 loc) • 19.7 kB
JavaScript
var _ = require('lodash');
var Promise = require('bluebird');
var ripple = require('espend-lib');
var remote = require('./lib/remote.js');
var transactions = require('./transactions');
var SubmitTransactionHooks = require('./lib/submit_transaction_hooks.js');
var respond = require('../server/response-handler.js');
var utils = require('./lib/utils');
var errors = require('./lib/errors.js');
var TxToRestConverter = require('./lib/tx-to-rest-converter.js');
var validator = require('./lib/schema-validator.js');
var bignum = require('bignumber.js');
const InvalidRequestError = errors.InvalidRequestError;
const OfferCreateFlags = {
Passive: { name: 'passive', set: 'Passive' },
ImmediateOrCancel: { name: 'immediate_or_cancel', set: 'ImmediateOrCancel' },
FillOrKill: { name: 'fill_or_kill', set: 'FillOrKill' }
};
const DefaultPageLimit = 200;
/**
* Get orders from the eSpend network
*
* @query
* @param {String} [request.query.limit] - Set a limit to the number of results returned
* @param {String} [request.query.marker] - Used to paginate results
* @param {String} [request.query.ledger] - The ledger index to query against (required if request.query.marker is present)
*
* @url
* @param {RippleAddress} request.params.account - The eSpend Address to query orders
*
* @param {Express.js Response} response
* @param {Express.js Next} next
*/
function getOrders(request, response, next) {
var options = request.params;
options.isAggregate = request.param('limit') === 'all';
Object.keys(request.query).forEach(function(param) {
options[param] = request.query[param];
});
validateOptions(options)
.then(getAccountOrders)
.then(respondWithOrders)
.catch(next);
function validateOptions(options) {
if (!ripple.UInt160.is_valid(options.account)) {
return Promise.reject(new InvalidRequestError('Parameter is not a valid Ripple address: account'));
}
return Promise.resolve(options);
};
function getAccountOrders(options, prevResult) {
if (prevResult && (!options.isAggregate || !prevResult.marker)) {
return Promise.resolve(prevResult);
}
var promise = new Promise(function(resolve, reject) {
var accountOrdersRequest;
var marker;
var ledger;
var limit;
if (prevResult) {
marker = prevResult.marker;
limit = prevResult.limit;
ledger = prevResult.ledger_index;
} else {
marker = request.query.marker;
limit = validator.isValid(request.query.limit, 'UINT32') ? Number(request.query.limit) : DefaultPageLimit;
ledger = utils.parseLedger(request.query.ledger);
}
accountOrdersRequest = remote.requestAccountOffers({
account: options.account,
marker: marker,
limit: limit,
ledger: ledger
});
accountOrdersRequest.once('error', reject);
accountOrdersRequest.once('success', function(nextResult) {
nextResult.offers = prevResult ? nextResult.offers.concat(prevResult.offers) : nextResult.offers;
resolve([options, nextResult]);
});
accountOrdersRequest.request();
});
return promise.spread(getAccountOrders);
};
function getParsedOrders(offers) {
return _.reduce(offers, function(orders, off) {
var sequence = off.seq;
var passive = off.flags === ripple.Remote.flags.offer.Passive;
var type = off.flags === ripple.Remote.flags.offer.Sell ? 'sell' : 'buy'
var taker_gets = utils.parseCurrencyAmount(off.taker_gets);
var taker_pays = utils.parseCurrencyAmount(off.taker_pays);
orders.push({
type: type,
taker_gets: taker_gets,
taker_pays: taker_pays,
sequence: sequence,
passive: passive,
});
return orders;
},[]);
}
function respondWithOrders(result) {
var promise = new Promise(function (resolve, reject) {
var orders = {};
if (result.marker) {
orders.marker = result.marker;
}
orders.limit = result.limit;
orders.ledger = result.ledger_index;
orders.validated = result.validated;
orders.orders = getParsedOrders(result.offers);
resolve(respond.success(response, orders));
});
return promise;
}
};
/**
* Submit an order to the eSpend network
*
* More information about order flags can be found at https://ripple.com/build/transactions/#offercreate-flags
*
* @body
* @param {Order} request.body.order - Object that holds information about the order
* @param {String "buy"|"sell"} request.body.order.type - Choose whether to submit a buy or sell order
* @param {Boolean} [request.body.order.passive] - Set whether order is passive
* @param {Boolean} [request.body.order.immediate_or_cancel] - Set whether order is immediate or cancel
* @param {Boolean} [request.body.order.fill_or_kill] - Set whether order is fill or kill
* @param {String} request.body.order.taker_gets - Amount of a currency the taker receives for consuming this order
* @param {String} request.body.order.taker_pays - Amount of a currency the taker must pay for consuming this order
* @param {String} request.body.secret - YOUR secret key. Do NOT submit to an unknown espend-rest server
*
* @query
* @param {String "true"|"false"} request.query.validated - used to force request to wait until rippled has finished validating the submitted transaction
*
* @param {Express.js Response} response
* @param {Express.js Next} next
*/
function placeOrder(request, response, next) {
var params = request.params;
Object.keys(request.body).forEach(function(param) {
params[param] = request.body[param];
});
var options = {
secret: params.secret,
validated: request.query.validated === 'true'
};
var hooks = {
validateParams: validateParams,
formatTransactionResponse: TxToRestConverter.parseSubmitOrderFromTx,
setTransactionParameters: setTransactionParameters
};
transactions.submit(options, new SubmitTransactionHooks(hooks), function(err, placedOrder) {
if (err) {
return next(err);
}
respond.success(response, placedOrder);
});
function validateParams(callback) {
if (!params.order) {
return callback(new InvalidRequestError('Missing parameter: order. Submission must have order object in JSON form'));
} else {
if (params.order.taker_gets && params.order.taker_gets.currency !== 'XEC') {
params.order.taker_gets.issuer = params.order.taker_gets.counterparty;
delete params.order.taker_gets.counterparty;
}
if (params.order.taker_pays && params.order.taker_pays.currency !== 'XEC') {
params.order.taker_pays.issuer = params.order.taker_pays.counterparty;
delete params.order.taker_pays.counterparty;
}
}
if (!ripple.UInt160.is_valid(params.account)) {
return callback(new errors.InvalidRequestError('Parameter is not a valid Ripple address: account'));
} else if (!/^buy|sell$/.test(params.order.type)) {
return callback(new InvalidRequestError('Parameter must be "buy" or "sell": type'));
} else if (!_.isUndefined(params.order.passive) && !_.isBoolean(params.order.passive)) {
return callback(new InvalidRequestError('Parameter must be a boolean: passive'));
} else if (!_.isUndefined(params.order.immediate_or_cancel) && !_.isBoolean(params.order.immediate_or_cancel)) {
return callback(new InvalidRequestError('Parameter must be a boolean: immediate_or_cancel'));
} else if (!_.isUndefined(params.order.fill_or_kill) && !_.isBoolean(params.order.fill_or_kill)) {
return callback(new InvalidRequestError('Parameter must be a boolean: fill_or_kill'));
} else if (!params.order.taker_gets || (!validator.isValid(params.order.taker_gets, 'Amount')) || (!params.order.taker_gets.issuer && params.order.taker_gets.currency !== 'XEC')) {
callback(new InvalidRequestError('Parameter must be a valid Amount object: taker_gets'));
} else if (!params.order.taker_pays || (!validator.isValid(params.order.taker_pays, 'Amount')) || (!params.order.taker_pays.issuer && params.order.taker_pays.currency !== 'XEC')) {
callback(new InvalidRequestError('Parameter must be a valid Amount object: taker_pays'));
} else {
callback();
}
};
function setTransactionParameters(transaction) {
var takerPays = params.order.taker_pays.currency !== 'XEC' ? params.order.taker_pays : utils.xrpToDrops(params.order.taker_pays.value);
var takerGets = params.order.taker_gets.currency !== 'XEC' ? params.order.taker_gets : utils.xrpToDrops(params.order.taker_gets.value);
transaction.offerCreate(params.account, ripple.Amount.from_json(takerPays), ripple.Amount.from_json(takerGets));
transactions.setTransactionBitFlags(transaction, {
input: params.order,
flags: OfferCreateFlags
});
if (params.order.type === 'sell') {
transaction.setFlags('Sell');
}
};
};
/**
* Cancel an order in the eSpend network
*
* @url
* @param {Number String} request.params.sequence - sequence number of order to cancel
*
* @query
* @param {String "true"|"false"} request.query.validated - used to force request to wait until rippled has finished validating the submitted transaction
*
* @param {Express.js Response} response
* @param {Express.js Next} next
*/
function cancelOrder(request, response, next) {
var params = request.params;
Object.keys(request.body).forEach(function(param) {
params[param] = request.body[param];
});
var options = {
secret: params.secret,
validated: request.query.validated === 'true'
};
var hooks = {
validateParams: validateParams,
formatTransactionResponse: TxToRestConverter.parseCancelOrderFromTx,
setTransactionParameters: setTransactionParameters
}
transactions.submit(options, new SubmitTransactionHooks(hooks), function(err, canceledOrder) {
if (err) {
return next(err);
}
respond.success(response, canceledOrder);
});
function validateParams(callback) {
if (!(Number(params.sequence) >= 0)) {
callback(new InvalidRequestError('Invalid parameter: sequence. Sequence must be a positive number'));
} else if (!ripple.UInt160.is_valid(params.account)) {
callback(new InvalidRequestError('Parameter is not a valid Ripple address: account'));
} else {
callback();
}
};
function setTransactionParameters(transaction) {
transaction.offerCancel(params.account, params.sequence);
};
};
/**
* Get the most recent spapshot of the order book for a currency pair
*
* @url
* @param {RippleAddress} request.params.account - The eSpend Address to use as point-of-view (returns unfunded orders for this account)
* @param {String ISO 4217 Currency Code + RippleAddress} request.params.base - Base currency as currency+issuer
* @param {String ISO 4217 Currency Code + RippleAddress} request.params.counter - Counter currency as currency+issuer
*
* @query
* @param {String} [request.query.limit] - Set a limit to the number of results returned
*
* @param {Express.js Request} request
* @param {Express.js Response} response
* @param {Express.js Next} next
*/
function getOrderBook(request, response, next) {
var options = request.params;
Object.keys(request.query).forEach(function(param) {
options[param] = request.query[param];
});
parseOptions(options)
.then(validateOptions)
.then(getLastValidatedLedger)
.then(getBidsAndAsks)
.spread(respondWithOrderBook)
.catch(next);
function parseOptions(options) {
options.validated = true;
options.order_book = options.base + '/' + options.counter;
options.base = utils.parseCurrencyQuery(options.base);
options.counter = utils.parseCurrencyQuery(options.counter);
return Promise.resolve(options);
}
function validateOptions(options) {
return new Promise(function(resolve, reject) {
if (!ripple.UInt160.is_valid(options.account)) {
reject(new InvalidRequestError('Parameter is not a valid Ripple address: account'));
}
if (!options.base.currency) {
reject(new InvalidRequestError('Invalid parameter: base. Must be a currency string in the form currency+counterparty'));
}
if (!validator.isValid(options.base.currency, 'Currency')) {
reject(new InvalidRequestError('Invalid parameter: base. Must be a currency string in the form currency+counterparty'));
}
if (options.base.currency !== 'XEC' && (!options.base.counterparty || !ripple.UInt160.is_valid(options.base.counterparty))) {
reject(new InvalidRequestError('Invalid parameter: base. Must be a currency string in the form currency+counterparty'));
}
if (!options.counter.currency) {
reject(new InvalidRequestError('Invalid parameter: counter. Must be a currency string in the form currency+counterparty'));
}
if (!validator.isValid(options.counter.currency, 'Currency')) {
reject(new InvalidRequestError('Invalid parameter: counter. Must be a currency string in the form currency+counterparty'));
}
if (options.counter.currency !== 'XEC' && (!options.counter.counterparty || !ripple.UInt160.is_valid(options.counter.counterparty))) {
reject(new InvalidRequestError('Invalid parameter: counter. Must be a currency string in the form currency+counterparty'));
}
if (options.counter.currency === 'XEC' && options.counter.counterparty) {
reject(new InvalidRequestError('Invalid parameter: counter. XEC cannot have counterparty'));
}
if (options.base.currency === 'XEC' && options.base.counterparty) {
reject(new InvalidRequestError('Invalid parameter: base. XEC cannot have counterparty'));
}
resolve(options);
});
}
function getLastValidatedLedger(options) {
var promise = new Promise(function (resolve, reject) {
var ledgerRequest = remote.requestLedger('validated');
ledgerRequest.once('success', function(res) {
options.ledger = res.ledger.ledger_index;
resolve(options);
});
ledgerRequest.once('error', reject);
ledgerRequest.request();
});
return promise;
}
function getBookOffers(taker_gets, taker_pays, options) {
var promise = new Promise(function (resolve, reject) {
var bookOffersRequest = remote.requestBookOffers({
taker_gets: { currency: taker_gets.currency, issuer: taker_gets.counterparty },
taker_pays: { currency: taker_pays.currency, issuer: taker_pays.counterparty },
ledger: options.ledger,
limit: options.limit,
taker: options.account
});
bookOffersRequest.once('success', resolve);
bookOffersRequest.once('error', reject);
bookOffersRequest.request();
});
return promise;
}
function getBids(options) {
var taker_gets = options.counter;
var taker_pays = options.base;
return getBookOffers(taker_gets, taker_pays, options);
}
function getAsks(options) {
var taker_gets = options.base;
var taker_pays = options.counter;
return getBookOffers(taker_gets, taker_pays, options);
}
function getBidsAndAsks(options) {
return Promise.join(
getBids(options),
getAsks(options),
function(bids, asks) {
return [bids,asks, options];
}
);
}
function respondWithOrderBook(bids, asks, options) {
var promise = new Promise(function (resolve, reject) {
var orderBook = {
order_book: options.order_book,
ledger: options.ledger,
validated: options.validated,
bids: getParsedBookOffers(bids.offers),
asks: getParsedBookOffers(asks.offers, true)
};
resolve(respond.success(response, orderBook));
});
return promise;
}
function getParsedBookOffers(offers, isAsk) {
return offers.reduce(function(orderBook, off) {
var price;
var order_maker = off.Account;
var sequence = off.Sequence;
// Transaction Flags
var passive = off.Flags === ripple.Remote.flags.offer.Passive;
var sell = off.Flags === ripple.Remote.flags.offer.Sell;
var taker_gets_total = utils.parseCurrencyAmount(off.TakerGets);
var taker_gets_funded = off.taker_gets_funded ? utils.parseCurrencyAmount(off.taker_gets_funded) : taker_gets_total;
var taker_pays_total = utils.parseCurrencyAmount(off.TakerPays);
var taker_pays_funded = off.taker_pays_funded ? utils.parseCurrencyAmount(off.taker_pays_funded) : taker_pays_total;
if (isAsk) {
price = {
currency: taker_pays_total.currency,
counterparty: taker_pays_total.counterparty,
value: bignum(taker_pays_total.value).div(bignum(taker_gets_total.value))
};
} else {
price = {
currency: taker_gets_total.currency,
counterparty: taker_gets_total.counterparty,
value: bignum(taker_gets_total.value).div(bignum(taker_pays_total.value))
};
}
price.value = price.value.toString();
orderBook.push({
price: price,
taker_gets_funded: taker_gets_funded,
taker_gets_total: taker_gets_total,
taker_pays_funded: taker_pays_funded,
taker_pays_total: taker_pays_total,
order_maker: order_maker,
sequence: sequence,
passive: passive,
sell: sell
});
return orderBook;
}, []);
}
}
/**
* Get an Order transaction (`OfferCreate` or `OfferCancel`)
*
* @url
* @param {RippleAddress} request.params.account
* @param {String} request.params.identifier
*
* @param {Express.js Request} request
* @param {Express.js Response} response
* @param {Express.js Next} next
*/
function getOrder(request, response, next) {
var options = request.params;
validateOptions(options)
.then(getOrderTx)
.then(respondWithOrder)
.catch(next);
function validateOptions(options) {
return new Promise(function(resolve, reject) {
if (!ripple.UInt160.is_valid(options.account)) {
reject(new InvalidRequestError('Parameter is not a valid Ripple address: account'));
}
if (!validator.isValid(options.identifier, 'Hash256')) {
reject(new InvalidRequestError('Parameter is not a valid transaction hash: identifier'));
}
resolve(options);
});
}
function getOrderTx(options) {
return new Promise(function(resolve, reject) {
var txRequest = remote.requestTx({
hash: options.identifier
});
txRequest.once('error', reject);
txRequest.once('transaction', function(res) {
if (res.TransactionType !== 'OfferCreate' && res.TransactionType !== 'OfferCancel') {
reject(new InvalidRequestError('Invalid parameter: identifier. The transaction corresponding to the given identifier is not an order'));
} else {
resolve(TxToRestConverter.parseOrderFromTx(res, options));
}
});
txRequest.request();
});
}
function respondWithOrder(order) {
return new Promise(function(resolve, reject) {
resolve(respond.success(response, order));
});
}
}
module.exports = {
getOrders: getOrders,
placeOrder: placeOrder,
cancelOrder: cancelOrder,
getOrderBook: getOrderBook,
getOrder: getOrder
};