api/payments.js
/* eslint-disable valid-jsdoc */
'use strict';
var _ = require('lodash');
var async = require('async');
var asyncify = require('simple-asyncify');
var bignum = require('bignumber.js');
var ripple = require('ripple-lib');
var transactions = require('./transactions');
var validator = require('./lib/schema-validator');
var serverLib = require('./lib/server-lib');
var utils = require('./lib/utils');
var TxToRestConverter = require('./lib/tx-to-rest-converter.js');
var validate = require('./lib/validate');
var convertAmount = require('./transaction/utils').convertAmount;
var createPaymentTransaction =
require('./transaction').createPaymentTransaction;
var renameCounterpartyToIssuer =
require('./lib/utils').renameCounterpartyToIssuer;
var xrpToDrops = require('./transaction/utils').xrpToDrops;
var transact = require('./transact');
var errors = require('./lib/errors');
var InvalidRequestError = errors.InvalidRequestError;
var NotFoundError = errors.NotFoundError;
var TimeOutError = errors.TimeOutError;
var DEFAULT_RESULTS_PER_PAGE = 10;
/**
* Formats the local database transaction into ripple-rest Payment format
*
* @param {RippleAddress} account
* @param {Transaction} transaction
* @param {Function} callback
*
* @callback
* @param {Error} error
* @param {RippleRestTransaction} transaction
*/
function formatPaymentHelper(account, txJSON) {
if (!(txJSON && /^payment$/i.test(txJSON.TransactionType))) {
throw new InvalidRequestError('Not a payment. The transaction '
+ 'corresponding to the given identifier is not a payment.');
}
var metadata = {
client_resource_id: txJSON.client_resource_id || '',
hash: txJSON.hash || '',
ledger: String(!_.isUndefined(txJSON.inLedger) ?
txJSON.inLedger : txJSON.ledger_index),
state: txJSON.validated === true ? 'validated' : 'pending'
};
var message = {tx_json: txJSON};
var meta = txJSON.meta;
var parsed = TxToRestConverter.parsePaymentFromTx(account, message, meta);
return _.assign({payment: parsed.payment}, metadata);
}
/**
* Submit a payment in the ripple-rest format.
*
* @global
* @param {/config/config-loader} config
*
* @body
* @param {Payment} request.body.payment
* @param {String} request.body.secret
* @param {String} request.body.client_resource_id
* @param {Number String} req.body.last_ledger_sequence
* - last ledger sequence that this payment can end up in
* @param {Number String} req.body.max_fee
* - maximum fee the payer is willing to pay
* @param {Number String} req.body.fixed_fee - fixed fee the payer wants to pay
* the network for accepting this transaction
*
* @query
* @param {String "true"|"false"} request.query.validated - used to force
* request to wait until rippled has finished validating the
* submitted transaction
*/
function submitPayment(account, payment, clientResourceID, secret,
urlBase, options, callback) {
function formatTransactionResponse(message, meta) {
if (meta.state === 'validated') {
var txJSON = message.tx_json;
txJSON.meta = message.metadata;
txJSON.validated = message.validated;
txJSON.ledger_index = txJSON.inLedger = message.ledger_index;
return formatPaymentHelper(payment.source_account, txJSON);
}
return {
client_resource_id: clientResourceID,
status_url: urlBase + '/v1/accounts/' + payment.source_account
+ '/payments/' + clientResourceID
};
}
function prepareTransaction(_transaction, remote) {
validate.client_resource_id(clientResourceID);
_transaction.lastLedger(Number(options.last_ledger_sequence ||
(remote.getLedgerSequence() + transactions.DEFAULT_LEDGER_BUFFER)));
if (Number(options.max_fee) >= 0) {
_transaction.maxFee(Number(xrpToDrops(options.max_fee)));
}
if (Number(options.fixed_fee) >= 0) {
_transaction.setFixedFee(Number(xrpToDrops(options.fixed_fee)));
}
_transaction.clientID(clientResourceID);
return _transaction;
}
var isSubmitMode = options.submit !== false;
var _options = _.assign({}, options, {
clientResourceId: clientResourceID,
blockDuplicates: isSubmitMode,
saveTransaction: isSubmitMode
});
var initialTx = createPaymentTransaction(account, payment);
var transaction = isSubmitMode ? prepareTransaction(
initialTx, this.remote) : initialTx;
var converter = isSubmitMode ? formatTransactionResponse :
_.partial(TxToRestConverter.parsePaymentFromTx, account);
transact(transaction, this, secret, _options, converter, callback);
}
/**
* Retrieve the details of a particular payment from the Remote or
* the local database and return it in the ripple-rest Payment format.
*
* @param {Remote} remote
* @param {/lib/db-interface} dbinterface
* @param {RippleAddress} req.params.account
* @param {Hex-encoded String|ASCII printable character String}
* req.params.identifier
*/
function getPayment(account, identifier, callback) {
var self = this;
validate.address(account);
validate.paymentIdentifier(identifier);
// If the transaction was not in the outgoing_transactions db,
// get it from rippled
function getTransaction(_callback) {
transactions.getTransaction(self, account, identifier, {}, _callback);
}
var steps = [
getTransaction,
asyncify(_.partial(formatPaymentHelper, account))
];
async.waterfall(steps, callback);
}
/**
* Retrieve the details of multiple payments from the Remote
* and the local database.
*
* This function calls transactions.getAccountTransactions
* recursively to retrieve results_per_page number of transactions
* and filters the results by type "payment", along with the other
* client-specified parameters.
*
* @param {Remote} remote
* @param {/lib/db-interface} dbinterface
* @param {RippleAddress} req.params.account
* @param {RippleAddress} req.query.source_account
* @param {RippleAddress} req.query.destination_account
* @param {String "incoming"|"outgoing"} req.query.direction
* @param {Number} [-1] req.query.start_ledger
* @param {Number} [-1] req.query.end_ledger
* @param {Boolean} [false] req.query.earliest_first
* @param {Boolean} [false] req.query.exclude_failed
* @param {Number} [20] req.query.results_per_page
* @param {Number} [1] req.query.page
*/
function getAccountPayments(account, source_account, destination_account,
direction, options, callback) {
var self = this;
function getTransactions(_callback) {
var args = {
account: account,
source_account: source_account,
destination_account: destination_account,
direction: direction,
min: options.results_per_page,
max: options.results_per_page,
offset: (options.results_per_page || DEFAULT_RESULTS_PER_PAGE)
* ((options.page || 1) - 1),
types: ['payment'],
earliestFirst: options.earliest_first
};
transactions.getAccountTransactions(self,
_.merge(options, args), _callback);
}
function formatTransactions(_transactions) {
return _transactions.map(_.partial(formatPaymentHelper, account));
}
function attachResourceId(_transactions, _callback) {
async.map(_transactions, function(paymentResult, async_map_callback) {
var hash = paymentResult.hash;
self.db.getTransaction({hash: hash}, function(error, db_entry) {
if (error) {
return async_map_callback(error);
}
var client_resource_id = '';
if (db_entry && db_entry.client_resource_id) {
client_resource_id = db_entry.client_resource_id;
}
paymentResult.client_resource_id = client_resource_id;
async_map_callback(null, paymentResult);
});
}, _callback);
}
function formatResponse(_transactions) {
return {payments: _transactions};
}
var steps = [
getTransactions,
_.partial(utils.attachDate, self),
asyncify(formatTransactions),
attachResourceId,
asyncify(formatResponse)
];
async.waterfall(steps, callback);
}
/**
* Get a ripple path find, a.k.a. payment options,
* for a given set of parameters and respond to the
* client with an array of fully-formed Payments.
*
* @param {Remote} remote
* @param {/lib/db-interface} dbinterface
* @param {RippleAddress} req.params.source_account
* @param {Amount Array ["USD r...,XRP,..."]} req.query.source_currencies
* - Note that Express.js middleware replaces "+" signs with spaces.
* Clients should use "+" signs but the values here will end up
* as spaces
* @param {RippleAddress} req.params.destination_account
* @param {Amount "1+USD+r..."} req.params.destination_amount_string
*/
function getPathFind(source_account, destination_account,
destination_amount_string, source_currency_strings, callback) {
var self = this;
var destination_amount = renameCounterpartyToIssuer(
utils.parseCurrencyQuery(destination_amount_string || ''));
validate.pathfind({
source_account: source_account,
destination_account: destination_account,
destination_amount: destination_amount,
source_currency_strings: source_currency_strings
});
var source_currencies = [];
// Parse source currencies
// Note that the source_currencies should be in the form
// "USD r...,BTC,XRP". The issuer is optional but if provided should be
// separated from the currency by a single space.
if (source_currency_strings) {
var sourceCurrencyStrings = source_currency_strings.split(',');
for (var c = 0; c < sourceCurrencyStrings.length; c++) {
// Remove leading and trailing spaces
sourceCurrencyStrings[c] = sourceCurrencyStrings[c].replace(
/(^[ ])|([ ]$)/g, '');
// If there is a space, there should be a valid issuer after the space
if (/ /.test(sourceCurrencyStrings[c])) {
var currencyIssuerArray = sourceCurrencyStrings[c].split(' ');
var currencyObject = {
currency: currencyIssuerArray[0],
issuer: currencyIssuerArray[1]
};
if (validator.isValid(currencyObject.currency, 'Currency')
&& ripple.UInt160.is_valid(currencyObject.issuer)) {
source_currencies.push(currencyObject);
} else {
callback(new InvalidRequestError('Invalid parameter: '
+ 'source_currencies. Must be a list of valid currencies'));
return;
}
} else if (validator.isValid(sourceCurrencyStrings[c], 'Currency')) {
source_currencies.push({currency: sourceCurrencyStrings[c]});
} else {
callback(new InvalidRequestError('Invalid parameter: '
+ 'source_currencies. Must be a list of valid currencies'));
return;
}
}
}
function prepareOptions() {
var pathfindParams = {
src_account: source_account,
dst_account: destination_account,
dst_amount: convertAmount(destination_amount)
};
if (typeof pathfindParams.dst_amount === 'object'
&& !pathfindParams.dst_amount.issuer) {
// Convert blank issuer to sender's address
// (Ripple convention for 'any issuer')
// https://ripple.com/build/transactions/
// #special-issuer-values-for-sendmax-and-amount
// https://ripple.com/build/ripple-rest/#counterparties-in-payments
pathfindParams.dst_amount.issuer = pathfindParams.dst_account;
}
if (source_currencies.length > 0) {
pathfindParams.src_currencies = source_currencies;
}
return pathfindParams;
}
function findPath(pathfindParams, _callback) {
var request = self.remote.requestRipplePathFind(pathfindParams);
request.once('error', _callback);
request.once('success', function(pathfindResults) {
pathfindResults.source_account = pathfindParams.src_account;
pathfindResults.source_currencies = pathfindParams.src_currencies;
pathfindResults.destination_amount = pathfindParams.dst_amount;
_callback(null, pathfindResults);
});
function reconnectRippled() {
self.remote.disconnect(function() {
self.remote.connect();
});
}
request.timeout(serverLib.CONNECTION_TIMEOUT, function() {
request.removeAllListeners();
reconnectRippled();
_callback(new TimeOutError('Path request timeout'));
});
request.request();
}
function addDirectXrpPath(pathfindResults, _callback) {
// Check if destination_amount is XRP and if destination_account accepts XRP
if (typeof pathfindResults.destination_amount.currency === 'string'
|| pathfindResults.destination_currencies.indexOf('XRP') === -1) {
return _callback(null, pathfindResults);
}
// Check source_account balance
self.remote.requestAccountInfo({account: pathfindResults.source_account},
function(error, result) {
if (error) {
return _callback(new Error(
'Cannot get account info for source_account. ' + error));
}
if (!result || !result.account_data || !result.account_data.Balance) {
return _callback(new Error('Internal Error. Malformed account info : '
+ JSON.stringify(result)));
}
// Add XRP "path" only if the source_account has enough money
// to execute the payment
if (bignum(result.account_data.Balance).greaterThan(
pathfindResults.destination_amount)) {
pathfindResults.alternatives.unshift({
paths_canonical: [],
paths_computed: [],
source_amount: pathfindResults.destination_amount
});
}
_callback(null, pathfindResults);
});
}
function formatPath(pathfindResults) {
var alternatives = pathfindResults.alternatives;
if (alternatives && alternatives.length > 0) {
return TxToRestConverter.parsePaymentsFromPathFind(pathfindResults);
}
if (pathfindResults.destination_currencies.indexOf(
destination_amount.currency) === -1) {
throw new NotFoundError('No paths found. ' +
'The destination_account does not accept ' +
destination_amount.currency +
', they only accept: ' +
pathfindResults.destination_currencies.join(', '));
} else if (pathfindResults.source_currencies
&& pathfindResults.source_currencies.length > 0) {
throw new NotFoundError('No paths found. Please ensure' +
' that the source_account has sufficient funds to execute' +
' the payment in one of the specified source_currencies. If it does' +
' there may be insufficient liquidity in the network to execute' +
' this payment right now');
} else {
throw new NotFoundError('No paths found.' +
' Please ensure that the source_account has sufficient funds to' +
' execute the payment. If it does there may be insufficient liquidity' +
' in the network to execute this payment right now');
}
}
function formatResponse(payments) {
return {payments: payments};
}
var steps = [
asyncify(prepareOptions),
findPath,
addDirectXrpPath,
asyncify(formatPath),
asyncify(formatResponse)
];
async.waterfall(steps, callback);
}
module.exports = {
submit: utils.wrapCatch(submitPayment),
get: getPayment,
getAccountPayments: getAccountPayments,
getPathFind: getPathFind
};