api/transactions.js
/* eslint-disable valid-jsdoc */
'use strict';
var _ = require('lodash');
var assert = require('assert');
var async = require('async');
var utils = require('./lib/utils');
var validator = require('./lib/schema-validator');
var validate = require('./lib/validate');
var errors = require('./lib/errors.js');
var DEFAULT_RESULTS_PER_PAGE = 10;
/**
* Submit a normal ripple-lib transaction, blocking duplicates for payments and
* orders.
*
* @param {Object} options - Holds various options
* @param {String} options.secret - Secret of the user wishing to submit a
* transaction
* @param {Boolean} [options.validated] - Used to wait until transaction has
* been validated before returning response to client
* @param {Boolean} [options.blockDuplicates] - Used to block duplicate
* transactions
* @param {String} [options.clientResourceId] - Used in conjunction with
* blockDuplicates to identify duplicate transactions.
* Must be present if blockDuplicates is true
* @param {Boolean} [options.saveTransaction] - Used to save transaction on
* state and postsubmit events
* @param {SubmitTransactionHooks} hooks - Used to hold methods defined by
* caller to customize transaction submit
*
* @callback
* @param {Error} error
* @param {Object} transaction - Transaction data received from ripple
*/
function submitTransaction(api, tx, secret, options, callback) {
function blockDuplicates(transaction, _options, _callback) {
var transactionOptions = {
source_account: transaction.tx_json.Account,
client_resource_id: _options.clientResourceId,
type: transaction.tx_json.TransactionType.toLowerCase()
};
api.db.getTransaction(transactionOptions, function(error, db_record) {
if (error) {
return _callback(error);
}
if (db_record) {
return _callback(new errors.DuplicateTransactionError(
'Duplicate Transaction. A record already exists in the database ' +
'for a transaction of this type with the same client_resource_id.' +
' If this was not an accidental resubmission please submit ' +
'the transaction again with a unique client_resource_id'));
}
_callback(null, transaction);
});
}
function formatTransactionResponseWrapper(transaction, message, _callback) {
var summary = transaction.summary();
transaction.removeListener('error', _callback);
var meta = {};
if (summary.result) {
meta.hash = summary.result.transaction_hash;
meta.ledger = String(summary.submitIndex);
}
meta.state = message.validated === true ? 'validated' : 'pending';
_callback(null, message, meta);
}
var steps = [
function(_callback) {
if (!secret) {
return _callback(new errors.InvalidRequestError(
'Parameter missing: secret'));
}
_callback(null, tx);
},
// Duplicate blocking is performed here
function(transaction, _callback) {
transaction.remote = api.remote;
if (options.blockDuplicates === true) {
blockDuplicates(transaction, options, _callback);
} else {
_callback(null, transaction);
}
},
// Transaction parameters are set, listeners are registered,
// and is submitted here
function(transaction, _callback) {
try {
transaction.secret(secret);
} catch (exception) {
return _callback(exception);
}
transaction.once('error', _callback);
transaction.once('submitted', function(message) {
if (message.result.slice(0, 3) === 'tec'
&& options.validated !== true) {
return formatTransactionResponseWrapper(transaction, message,
_callback);
}
// Handle erred transactions that should not make it into ledger (all
// errors that aren't tec-class). This function is called before the
// transaction `error` listener.
switch (message.engine_result) {
case 'terNO_ACCOUNT':
case 'terNO_AUTH':
case 'terNO_LINE':
case 'terINSUF_FEE_B':
// The transaction needs to be aborted. Preserve the original ter-
// class error for presentation to the client
transaction.removeListener('error', _callback);
transaction.once('error', function() {
_callback(message);
});
transaction.abort();
break;
}
});
transaction.once('proposed', function(message) {
if (options.validated !== true) {
formatTransactionResponseWrapper(transaction, message, _callback);
}
});
transaction.once('success', function(message) {
if (options.validated === true) {
formatTransactionResponseWrapper(transaction, message, _callback);
}
});
if (options.saveTransaction === true) {
transaction.on('state', function() {
var transactionSummary = transaction.summary();
if (transactionSummary.submitIndex !== undefined) {
api.db.saveTransaction(transactionSummary);
}
});
transaction.on('postsubmit', function() {
api.db.saveTransaction(transaction.summary());
});
}
transaction.submit();
}
];
async.waterfall(steps, callback);
}
/**
* Retrieve a transaction from the Remote and local database
* based on the account and either hash or client_resource_id.
*
* Note that if any errors are encountered while executing this function
* they will be sent back to the client through the res. If the query is
* successful it will be passed to the callback function
*
* @global
* @param {Remote} remote
* @param {/lib/db-interface} dbinterface
*
* @param {RippleAddress} account
* @param {Hex-encoded String|ASCII printable character String} identifier
* @param {Object} options
* @param {Function} callback
*
* @callback
* @param {Error} error
* @param {Transaction} transaction
*/
function getTransaction(api, account, identifier, requestOptions, callback) {
try {
assert.strictEqual(typeof requestOptions, 'object');
validate.address(account, true);
validate.paymentIdentifier(identifier);
validate.options(requestOptions);
} catch(err) {
return callback(err);
}
var options = { };
if (validator.isValid(identifier, 'Hash256')) {
options.hash = identifier;
} else {
options.client_resource_id = identifier;
}
var isLedgerRangeRequest = !_.isUndefined(requestOptions.min_ledger)
&& !_.isUndefined(requestOptions.max_ledger);
if (isLedgerRangeRequest) {
var minLedger = Number(requestOptions.min_ledger);
var maxLedger = Number(requestOptions.max_ledger);
if (!utils.isValidLedgerSequence(minLedger)) {
return callback(new errors.InvalidRequestError(
'Invalid parameter: min_ledger must be a number'));
}
if (!utils.isValidLedgerSequence(maxLedger)) {
return callback(new errors.InvalidRequestError(
'Invalid parameter: max_ledger must be a number'));
}
if (minLedger > maxLedger) {
return callback(new errors.InvalidRequestError(
'Invalid parameter: max_ledger must be greater than min_ledger'));
}
for (; minLedger <= maxLedger; minLedger++) {
if (!api.remote.getServer().hasLedger(minLedger)) {
return callback(new errors.NotFoundError('Ledger not found'));
}
}
}
function queryTransaction(async_callback) {
api.db.getTransaction(options, function(error, entry) {
if (error) {
return async_callback(error);
}
var requestHash = options.hash;
var dbEntryHash = '';
if (!entry && !requestHash) {
// Transaction hash was not supplied in the request and a matching
// database entry was not found. There are no transaction hashes to
// look up
return async_callback(new errors.InvalidRequestError(
'Transaction not found. A transaction hash was not supplied and '
+ 'there were no entries matching the client_resource_id.'));
}
if (entry) {
// Check that the hash present in the database entry matches the one
// supplied in the request
dbEntryHash = entry.hash || (entry.transaction || {}).hash;
if (requestHash && requestHash !== dbEntryHash) {
// Requested hash and retrieved hash do not match
return async_callback(new errors.InvalidRequestError(
'Transaction not found. Hashes do not match'));
}
}
// Request transaction based on either the hash supplied in the request
// or the hash found in the database
api.remote.requestTx({hash: requestHash || dbEntryHash},
function(_error, transaction) {
if (_error) {
return async_callback(_error);
}
// we found a transaction
if (entry && transaction) {
transaction.client_resource_id = entry.client_resource_id;
}
return async_callback(null, transaction);
});
});
}
function checkIfRelatedToAccount(transaction, async_callback) {
if (options.account) {
var transactionString = JSON.stringify(transaction);
var account_regex = new RegExp(options.account);
if (!account_regex.test(transactionString)) {
return async_callback(new errors.InvalidRequestError(
'Transaction specified did not affect the given account'));
}
}
async_callback(null, transaction);
}
function attachResourceID(transaction, async_callback) {
if (transaction && options.client_resource_id) {
transaction.client_resource_id = options.client_resource_id;
}
async_callback(null, transaction);
}
function attachDate(transaction, async_callback) {
if (!transaction || transaction.date || !transaction.ledger_index) {
return async_callback(null, transaction);
}
api.remote.requestLedger(transaction.ledger_index,
function(error, ledgerRequest) {
if (error) {
return async_callback(new errors.NotFoundError(
'Transaction ledger not found'));
}
if (typeof ledgerRequest.ledger.close_time === 'number') {
transaction.date = ledgerRequest.ledger.close_time;
}
async_callback(null, transaction);
});
}
var steps = [
queryTransaction,
checkIfRelatedToAccount,
attachResourceID,
attachDate
];
async.waterfall(steps, callback);
}
/**
* Wrapper around getTransaction function that is
* meant to be used directly as a client-facing function.
* Unlike getTransaction, it will call next with any errors
* and send a JSON response to the client on success.
*
* See getTransaction for parameter details
*/
function getTransactionAndRespond(account, identifier, options, callback) {
getTransaction(
this,
account,
identifier,
options,
function(error, transaction) {
if (error) {
callback(error);
} else {
callback(null, {transaction: transaction});
}
}
);
}
/**
* Wrapper around the standard ripple-lib requestAccountTx function
*
* @param {Remote} remote
* @param {RippleAddress} options.account
* @param {Number} [-1] options.ledger_index_min
* @param {Number} [-1] options.ledger_index_max
* @param {Boolean} [false] options.earliestFirst
* @param {Boolean} [false] options.binary
* @param {opaque value} options.marker
* @param {Function} callback
*
* @callback
* @param {Error} error
* @param {Array of transactions in JSON format} response.transactions
* @param {opaque value} response.marker
*/
function getAccountTx(api, options, callback) {
var params = {
account: options.account,
ledger_index_min: options.ledger_index_min || options.ledger_index || -1,
ledger_index_max: options.ledger_index_max || options.ledger_index || -1,
limit: options.limit || DEFAULT_RESULTS_PER_PAGE,
forward: options.earliestFirst,
marker: options.marker
};
if (options.binary) {
params.binary = true;
}
api.remote.requestAccountTx(params, function(error, account_tx_results) {
if (error) {
return callback(error);
}
var transactions = [];
account_tx_results.transactions.forEach(function(tx_entry) {
if (!tx_entry.validated) {
return;
}
var tx = tx_entry.tx;
tx.meta = tx_entry.meta;
tx.validated = tx_entry.validated;
transactions.push(tx);
});
callback(null, {
transactions: transactions,
marker: account_tx_results.marker
});
});
}
/**
* Retrieve transactions from the Remote as well as the local database.
*
* @param {Remote} remote
* @param {/lib/db-interface} dbinterface
* @param {RippleAddress} options.account
* @param {Number} [-1] options.ledger_index_min
* @param {Number} [-1] options.ledger_index_max
* @param {Boolean} [false] options.earliestFirst
* @param {Boolean} [false] options.binary
* @param {Boolean} [false] options.exclude_failed
* @param {opaque value} options.marker
* @param {Function} callback
*
* @callback
* @param {Error} error
* @param {Array of transactions in JSON format} transactions
*/
function getLocalAndRemoteTransactions(api, options, callback) {
function queryRippled(_callback) {
getAccountTx(api, options, function(error, results) {
if (error) {
_callback(error);
} else {
// Set marker so that when this function is called again
// recursively it starts from the last place it left off
options.marker = results.marker;
_callback(null, results.transactions);
}
});
}
function queryDB(_callback) {
if (options.exclude_failed) {
_callback(null, []);
} else {
api.db.getFailedTransactions(options, _callback);
}
}
var transactionSources = [
queryRippled,
queryDB
];
async.parallel(transactionSources, function(error, sourceResults) {
if (error) {
return callback(error);
}
var results = sourceResults[0].concat(sourceResults[1]);
var transactions = _.uniq(results, function(tx) {
return tx.hash;
});
callback(null, transactions);
});
}
/**
* Filter transactions based on the given set of options.
*
* @param {Array of transactions in JSON format} transactions
* @param {Boolean} [false] options.exclude_failed
* @param {Array of Strings} options.types Possible values are "payment",
* "offercreate", "offercancel", "trustset", "accountset"
* @param {RippleAddress} options.source_account
* @param {RippleAddress} options.destination_account
* @param {String} options.direction Possible values are "incoming", "outgoing"
*
* @returns {Array of transactions in JSON format} filtered_transactions
*/
function transactionFilter(transactions, options) {
var filtered_transactions = transactions.filter(function(transaction) {
if (options.exclude_failed) {
if (transaction.state === 'failed' || (transaction.meta
&& transaction.meta.TransactionResult !== 'tesSUCCESS')) {
return false;
}
}
if (options.types && options.types.length > 0) {
if (options.types.indexOf(
transaction.TransactionType.toLowerCase()) === -1) {
return false;
}
}
if (options.source_account) {
if (transaction.Account !== options.source_account) {
return false;
}
}
if (options.destination_account) {
if (transaction.Destination !== options.destination_account) {
return false;
}
}
if (options.direction) {
if (options.direction === 'outgoing'
&& transaction.Account !== options.account) {
return false;
}
if (options.direction === 'incoming' && transaction.Destination
&& transaction.Destination !== options.account) {
return false;
}
}
return true;
});
return filtered_transactions;
}
/**
* Recursively get transactions for the specified account from
* the Remote and local database. If options.min is set, this will
* recurse until it has retrieved that number of transactions or
* it has reached the end of the account's transaction history.
*
* @param {Remote} remote
* @param {/lib/db-interface} dbinterface
* @param {RippleAddress} options.account
* @param {Number} [-1] options.ledger_index_min
* @param {Number} [-1] options.ledger_index_max
* @param {Boolean} [false] options.earliestFirst
* @param {Boolean} [false] options.binary
* @param {Boolean} [false] options.exclude_failed
* @param {Number} [DEFAULT_RESULTS_PER_PAGE] options.min
* @param {Number} [DEFAULT_RESULTS_PER_PAGE] options.max
* @param {Array of Strings} options.types Possible values are "payment",
* "offercreate", "offercancel", "trustset", "accountset"
* @param {opaque value} options.marker
* @param {Array of Transactions} options.previous_transactions
* Included automatically when this function is called recursively
* @param {Express.js Response} res
* @param {Function} callback
*
* @callback
* @param {Error} error
* @param {Array of transactions in JSON format} transactions
*/
function getAccountTransactions(api, options, callback) {
try {
validate.address(options.account);
} catch(err) {
return callback(err);
}
if (!options.min) {
options.min = module.exports.DEFAULT_RESULTS_PER_PAGE;
}
if (!options.max) {
options.max = Math.max(options.min,
module.exports.DEFAULT_RESULTS_PER_PAGE);
}
if (!options.limit) {
options.limit = module.exports.DEFAULT_LIMIT;
}
function queryTransactions(async_callback) {
getLocalAndRemoteTransactions(api, options, async_callback);
}
function filterTransactions(transactions, async_callback) {
async_callback(null, transactionFilter(transactions, options));
}
function sortTransactions(transactions, async_callback) {
var compare = options.earliestFirst ? utils.compareTransactions :
_.rearg(utils.compareTransactions, 1, 0);
transactions.sort(compare);
async_callback(null, transactions);
}
function mergeAndTruncateResults(transactions, async_callback) {
if (options.previous_transactions
&& options.previous_transactions.length > 0) {
transactions = options.previous_transactions.concat(transactions);
}
if (options.offset && options.offset > 0) {
var offset_remaining = options.offset - transactions.length;
transactions = transactions.slice(options.offset);
options.offset = offset_remaining;
}
if (transactions.length > options.max) {
transactions = transactions.slice(0, options.max);
}
async_callback(null, transactions);
}
function asyncWaterfallCallback(error, transactions) {
if (error) {
return callback(error);
}
if (!options.min || transactions.length >= options.min || !options.marker) {
callback(null, transactions);
} else {
options.previous_transactions = transactions;
setImmediate(function() {
getAccountTransactions(api, options, callback);
});
}
}
var steps = [
queryTransactions,
filterTransactions,
sortTransactions,
mergeAndTruncateResults
];
async.waterfall(steps, asyncWaterfallCallback);
}
module.exports = {
DEFAULT_LIMIT: 200,
DEFAULT_RESULTS_PER_PAGE: DEFAULT_RESULTS_PER_PAGE,
NUM_TRANSACTION_TYPES: 5,
DEFAULT_LEDGER_BUFFER: 3,
submit: submitTransaction,
get: getTransactionAndRespond,
getTransaction: getTransaction,
getAccountTransactions: getAccountTransactions
};