UNPKG

espend-rest

Version:

A RESTful API for submitting payments and monitoring accounts on the eSpend network.

661 lines (586 loc) 21.9 kB
var _ = require('lodash'); var async = require('async'); var ripple = require('espend-lib'); var validator = require('./lib/schema-validator'); var remote = require('./lib/remote.js'); var dbinterface = require('./lib/db-interface.js'); var respond = require('../server/response-handler.js'); var errors = require('./lib/errors.js'); /** * Submit a normal espend-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(options, hooks, callback) { var steps = [ // General options validation is performed here before passing the options to the caller to validate function(callback) { if (!options.secret) { return callback(new errors.InvalidRequestError('Parameter missing: secret')); } hooks.validateParams(callback); }, // Transaction object is constructed here function(callback) { return hooks.initializeTransaction(callback); }, // Duplicate blocking is performed here function(transaction, callback) { transaction.remote = 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(options.secret); hooks.setTransactionParameters(transaction); } 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, options.validated, 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, options.validated, callback); } }); transaction.once('success', function(message) { if (options.validated === true) { formatTransactionResponseWrapper(transaction, message, options.validated, callback); } }); if (options.saveTransaction === true) { transaction.on('state', function() { var transactionSummary = transaction.summary(); if (transactionSummary.submitIndex !== void(0)) { dbinterface.saveTransaction(transactionSummary); } }); transaction.on('postsubmit', function() { dbinterface.saveTransaction(transaction.summary()); }); } transaction.submit(); } ]; function formatTransactionResponseWrapper(transaction, message, waitForValidated, 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'; hooks.formatTransactionResponse(message, meta, callback, waitForValidated); }; function blockDuplicates(transaction, options, callback) { dbinterface.getTransaction({ source_account: transaction.tx_json.Account, client_resource_id: options.clientResourceId, type: transaction.tx_json.TransactionType.toLowerCase() }, 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); }); }; async.waterfall(steps, callback); }; /** * Helper that sets bit flags on transactions * * @param {Transaction} transaction - Transaction object that is used to submit requests to ripple * @param {Object} options * @param {Object} options.flags - Holds flag names to set on transaction when parameter values are true or false on input * @param {Object} options.input - Holds parameter values * @param {String} options.clear_setting - Used to check if parameter values besides false mean false * * * @returns undefined */ function setTransactionBitFlags(transaction, options) { for (var flagName in options.flags) { var flag = options.flags[flagName]; // Set transaction flags if (!(flag.name in options.input)) { continue; } var value = options.input[flag.name]; if (value === options.clear_setting) { value = false; } if (flag.unset) { transaction.setFlags(value ? flag.set : flag.unset); } else if (flag.set && value) { transaction.setFlags(flag.set); } } }; /** * 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(request, response, next) { getTransaction(request.params.account, request.params.identifier, function(error, transaction) { if (error) { next(error); } else { respond.success(response, { transaction: transaction }); } }); }; /** * 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 {Function} callback * * @callback * @param {Error} error * @param {Transaction} transaction */ function getTransaction(account, identifier, callback) { var options = {}; var steps = [ validateOptions, queryTransaction, checkIfRelatedToAccount, attachResourceID, attachDate ]; async.waterfall(steps, callback); function validateOptions(async_callback) { if (account && !ripple.UInt160.is_valid(account)) { return callback(new errors.InvalidRequestError('Invalid parameter: account. Must be a valid eSpend Address')); } if (!_.isString(identifier)) { return callback(new errors.InvalidRequestError('Missing parameter: identifier')); } if (validator.isValid(identifier, 'Hash256')) { options.hash = identifier; async_callback(); } else if (validator.isValid(identifier, 'ResourceId')) { options.client_resource_id = identifier; async_callback(); } else { return callback(new errors.InvalidRequestError('Parameter not a valid transaction hash or client_resource_id: identifier')); } }; function queryTransaction(async_callback) { dbinterface.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 remote.requestTx(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); } 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); }); }; }; /** * 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(options, response, callback) { var steps = [ validateOptions, queryTransactions, filterTransactions, sortTransactions, mergeAndTruncateResults ]; async.waterfall(steps, asyncWaterfallCallback); 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 validateOptions(async_callback) { if (!options.account) { return async_callback(new errors.InvalidRequestError('Missing parameter: account. ' + 'Must supply a valid eSpend Address to query account transactions') ); } if (!ripple.UInt160.is_valid(options.account)) { return async_callback(new errors.InvalidRequestError('Invalid parameter: account. ' + 'Must supply a valid eSpend Address to query account transactions') ); } async_callback(); }; function queryTransactions(async_callback) { getLocalAndRemoteTransactions(options, async_callback); }; function filterTransactions(transactions, async_callback) { async_callback(null, transactionFilter(transactions, options)); }; function sortTransactions(transactions, async_callback) { transactions.sort(function(first, second) { return compareTransactions(first, second, options.earliestFirst); }); 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(options, response, callback); }); } }; }; /** * 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(options, callback) { function queryRippled(callback) { getAccountTx(remote, 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 { dbinterface.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; }; /** * Order two transactions based on their ledger_index. * If two transactions took place in the same ledger, sort * them based on a lexicographical comparison of their hashes * to ensure the ordering is deterministic. * * @param {transaction in JSON format} first * @param {transaction in JSON format} second * @param {Boolean} [false] earliestFirst * @returns {Number} comparison Returns -1 or 1 */ function compareTransactions(first, second, earliestFirst) { var firstIndex = first.ledger || first.ledger_index; var secondIndex = second.ledger || second.ledger_index; var firstLessThanSecond = true; if (firstIndex === secondIndex) { if (first.hash <= second.hash) { firstLessThanSecond = true; } else { firstLessThanSecond = false; } } else if (firstIndex < secondIndex) { firstLessThanSecond = true; } else { firstLessThanSecond = false; } if (earliestFirst) { if (firstLessThanSecond) { return -1; } else { return 1; } } else { if (firstLessThanSecond) { return 1; } else { return -1; } } }; /** * Wrapper around the standard espend-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(remote, 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; } 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; transactions.push(tx); }); callback(null, { transactions: transactions, marker: account_tx_results.marker }); }); }; module.exports = { DEFAULT_LIMIT: 200, DEFAULT_RESULTS_PER_PAGE: 10, NUM_TRANSACTION_TYPES: 5, DEFAULT_LEDGER_BUFFER: 3, submit: submitTransaction, get: getTransactionAndRespond, getTransaction: getTransaction, getAccountTransactions: getAccountTransactions, setTransactionBitFlags: setTransactionBitFlags };