UNPKG

espend-rest

Version:

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

704 lines (616 loc) 26.6 kB
var _ = require('lodash'); var async = require('async'); var bignum = require('bignumber.js'); var ripple = require('espend-lib'); var transactions = require('./transactions'); var validator = require('./lib/schema-validator'); var remote = require('./lib/remote.js'); var serverLib = require('./lib/server-lib'); var utils = require('./lib/utils'); var remote = require('./lib/remote.js'); var dbinterface = require('./lib/db-interface.js'); var config = require('./lib/config.js'); var RestToTxConverter = require('./lib/rest-to-tx-converter.js'); var TxToRestConverter = require('./lib/tx-to-rest-converter.js'); var SubmitTransactionHooks = require('./lib/submit_transaction_hooks.js'); var respond = require('../server/response-handler.js'); var errors = require('./lib/errors.js'); var InvalidRequestError = errors.InvalidRequestError; var NetworkError = errors.NetworkError; var NotFoundError = errors.NotFoundError; var TimeOutError = errors.TimeOutError; var DEFAULT_RESULTS_PER_PAGE = 10; module.exports = { submit: submitPayment, get: getPayment, getAccountPayments: getAccountPayments, getPathFind: getPathFind }; /** * Submit a payment in the espend-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 * * @param {Express.js Response} response * @param {Express.js Next} next */ function submitPayment(request, response, next) { var params = request.params; Object.keys(request.body).forEach(function(param) { params[param] = request.body[param]; }); params.max_fee = Number(request.body.max_fee) > 0 ? utils.xrpToDrops(request.body.max_fee) : void(0); params.fixed_fee = Number(request.body.fixed_fee) > 0 ? utils.xrpToDrops(request.body.fixed_fee) : void(0); var options = { secret: params.secret, validated: request.query.validated === 'true', blockDuplicates: true, clientResourceId: params.client_resource_id, saveTransaction: true }; var hooks = { validateParams: validateParams, initializeTransaction: initializeTransaction, formatTransactionResponse: formatTransactionResponse, setTransactionParameters: setTransactionParameters }; transactions.submit(options, new SubmitTransactionHooks(hooks), function(err, payment) { if (err) { return next(err); } respond.success(response, payment); }); function validateParams(callback) { var payment = params.payment; if (!payment) { return callback(new InvalidRequestError('Missing parameter: payment. Submission must have payment object in JSON form')); } if (!params.client_resource_id) { return callback(new InvalidRequestError('Missing parameter: client_resource_id. All payments must be submitted with a client_resource_id to prevent duplicate payments')); } if (!validator.isValid(params.client_resource_id, 'ResourceId')) { return callback(new InvalidRequestError('Invalid parameter: client_resource_id. Must be a string of ASCII-printable characters. Note that 256-bit hex strings are disallowed because of the potential confusion with transaction hashes.')); } if (!ripple.UInt160.is_valid(payment.source_account)) { return callback(new InvalidRequestError('Invalid parameter: source_account. Must be a valid Ripple address')); } if (!ripple.UInt160.is_valid(payment.destination_account)) { return callback(new InvalidRequestError('Invalid parameter: destination_account. Must be a valid Ripple address')); } // Tags if (payment.source_tag && (!validator.isValid(payment.source_tag, 'UINT32'))) { return callback(new InvalidRequestError('Invalid parameter: source_tag. Must be a string representation of an unsiged 32-bit integer')); } if (payment.destination_tag && (!validator.isValid(payment.destination_tag, 'UINT32'))) { return callback(new InvalidRequestError('Invalid parameter: destination_tag. Must be a string representation of an unsiged 32-bit integer')); } // Amounts // destination_amount is required, source_amount is optional if (!payment.destination_amount || (!validator.isValid(payment.destination_amount, 'Amount'))) { return callback(new InvalidRequestError('Invalid parameter: destination_amount. Must be a valid Amount object')); } if (payment.source_amount && (!validator.isValid(payment.source_amount, 'Amount'))) { return callback(new InvalidRequestError('Invalid parameter: source_amount. Must be a valid Amount object')); } // No counterparty for XEC if (payment.destination_amount && payment.destination_amount.currency.toUpperCase() === 'XEC' && payment.destination_amount.counterparty) { return callback(new InvalidRequestError('Invalid parameter: destination_amount. XEC cannot have counterparty')); } if (payment.source_amount && payment.source_amount.currency.toUpperCase() === 'XEC' && payment.source_amount.counterparty) { return callback(new InvalidRequestError('Invalid parameter: source_amount. XEC cannot have counterparty')); } // Slippage if (payment.source_slippage && !validator.isValid(payment.source_slippage, 'FloatString')) { return callback(new InvalidRequestError('Invalid parameter: source_slippage. Must be a valid FloatString')); } // Advanced options // Invoice id if (payment.invoice_id && !validator.isValid(payment.invoice_id, 'Hash256')) { return callback(new InvalidRequestError('Invalid parameter: invoice_id. Must be a valid Hash256')); } // paths if (payment.paths) { if (typeof payment.paths === 'string') { try { JSON.parse(payment.paths); } catch (exception) { return callback(new InvalidRequestError('Invalid parameter: paths. Must be a valid JSON string or object')); } } else if (typeof payment.paths === 'object') { try { JSON.parse(JSON.stringify(payment.paths)); } catch (exception) { return callback(new InvalidRequestError('Invalid parameter: paths. Must be a valid JSON string or object')); } } } // partial payment if (payment.hasOwnProperty('partial_payment') && typeof payment.partial_payment !== 'boolean') { return callback(new InvalidRequestError('Invalid parameter: partial_payment. Must be a boolean')); } // direct ripple if (payment.hasOwnProperty('no_direct_ripple') && typeof payment.no_direct_ripple !== 'boolean') { return callback(new InvalidRequestError('Invalid parameter: no_direct_ripple. Must be a boolean')); } // memos if (payment.hasOwnProperty('memos')) { if (!Array.isArray(payment.memos)) { return callback(new InvalidRequestError('Invalid parameter: memos. Must be an array with memo objects')); } if (payment.memos.length === 0) { return callback(new InvalidRequestError('Invalid parameter: memos. Must contain at least one Memo object, otherwise omit the memos property')); } for (var m = 0; m < payment.memos.length; m++) { var memo = payment.memos[m]; if (memo.MemoType && !/(undefined|string)/.test(typeof memo.MemoType)) { return callback(new InvalidRequestError('Invalid parameter: MemoType. MemoType must be a string')); } if (!/(undefined|string)/.test(typeof memo.MemoData)) { return callback(new InvalidRequestError('Invalid parameter: MemoData. MemoData must be a string')); } if (!memo.MemoData && !memo.MemoType) { return callback(new InvalidRequestError('Missing parameter: MemoData or MemoType. For a memo object MemoType or MemoData are both optional, as long as one of them is present')); } } } callback(null); }; function initializeTransaction(callback) { RestToTxConverter.convert(params.payment, function(error, transaction) { if (error) { return callback(error); } callback(null, transaction); }); }; function formatTransactionResponse(message, meta, callback) { if (meta.state === 'validated') { var transaction = message.tx_json; transaction.meta = message.metadata; transaction.ledger_index = transaction.inLedger = message.ledger_index; return formatPaymentHelper(params.payment.source_account, transaction, callback); } var urlBase = utils.getUrlBase(request); callback(null, { client_resource_id: params.client_resource_id, status_url: urlBase + '/v1/accounts/' + params.payment.source_account + '/payments/' + params.client_resource_id }); }; function setTransactionParameters(transaction) { var ledgerIndex; var maxFee = Number(params.max_fee); var fixedFee = Number(params.fixed_fee); if (Number(params.last_ledger_sequence) > 0) { ledgerIndex = Number(params.last_ledger_sequence); } else { ledgerIndex = Number(remote._ledger_current_index) + transactions.DEFAULT_LEDGER_BUFFER; } transaction.lastLedger(ledgerIndex); if (maxFee >= 0) { transaction.maxFee(maxFee); } if (fixedFee >= 0) { transaction.setFixedFee(fixedFee); } transaction.clientID(params.client_resource_id); }; }; /** * Retrieve the details of a particular payment from the Remote or * the local database and return it in the espend-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 * @param {Express.js Response} res * @param {Express.js Next} next */ function getPayment(request, response, next) { var options = { account: request.params.account, identifier: request.params.identifier }; function validateOptions(callback) { var invalid; if (!options.account) { invalid = 'Missing parameter: account. Must provide account to get payment details'; } if (!ripple.UInt160.is_valid(options.account)) { invalid = 'Parameter is not a valid Ripple address: account'; } if (!options.identifier) { invalid = 'Missing parameter: hash or client_resource_id. '+ 'Must provide transaction hash or client_resource_id to get payment details'; } if (!validator.isValid(options.identifier, 'Hash256') && !validator.isValid(options.identifier, 'ResourceId')) { invalid = 'Invalid Parameter: hash or client_resource_id. ' + 'Must provide a transaction hash or client_resource_id to get payment details'; } if (invalid) { callback(new InvalidRequestError(invalid)); } else { callback(); } }; // If the transaction was not in the outgoing_transactions db, get it from rippled function getTransaction(callback) { transactions.getTransaction(request.params.account, request.params.identifier, function(error, transaction) { callback(error, transaction); }); }; var steps = [ validateOptions, getTransaction, function (transaction, callback) { return formatPaymentHelper(options.account, transaction, callback); } ]; async.waterfall(steps, function(error, result) { if (error) { next(error); } else { respond.success(response, result); } }); }; /** * Formats the local database transaction into espend-rest Payment format * * @param {RippleAddress} account * @param {Transaction} transaction * @param {Function} callback * * @callback * @param {Error} error * @param {RippleRestTransaction} transaction */ function formatPaymentHelper(account, transaction, callback) { function checkIsPayment(callback) { var isPayment = transaction && /^payment$/i.test(transaction.TransactionType); if (isPayment) { callback(null, transaction); } else { callback(new InvalidRequestError('Not a payment. The transaction corresponding to the given identifier is not a payment.')); } }; function getPaymentMetadata(transaction) { return { client_resource_id: transaction.client_resource_id || '', hash: transaction.hash || '', ledger: !_.isUndefined(transaction.inLedger) ? String(transaction.inLedger) : String(transaction.ledger_index), state: transaction.state || transaction.meta ? (transaction.meta.TransactionResult === 'tesSUCCESS' ? 'validated' : 'failed') : '' }; } function formatTransaction(transaction, callback) { if (transaction) { TxToRestConverter.parsePaymentFromTx(transaction, { account: account }, function(err, parsedPayment) { if (err) { return callback(err); } var result = { payment: parsedPayment }; _.extend(result, getPaymentMetadata(transaction)); return callback(null, result); }); } else { callback(new NotFoundError('Payment Not Found. This may indicate that the payment was never validated and written into ' + 'the Ripple ledger and it was not submitted through this espend-rest instance. ' + 'This error may also be seen if the databases of either espend-rest ' + 'or rippled were recently created or deleted.')); } }; var steps = [ checkIsPayment, formatTransaction ]; 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 * @param {Express.js Response} res * @param {Express.js Next} next */ function getAccountPayments(request, response, next) { var options; function getTransactions(callback) { options = { account: request.params.account, source_account: request.query.source_account, destination_account: request.query.destination_account, direction: request.query.direction, ledger_index_min: request.query.start_ledger, ledger_index_max: request.query.end_ledger, earliest_first: (request.query.earliest_first === 'true'), exclude_failed: (request.query.exclude_failed === 'true'), min: request.query.results_per_page, max: request.query.results_per_page, offset: (request.query.results_per_page || DEFAULT_RESULTS_PER_PAGE) * ((request.query.page || 1) - 1), types: [ 'payment' ] }; transactions.getAccountTransactions(options, response, callback); }; function attachDate(transactions, callback) { var groupedTx = _.groupBy(transactions, function(tx) { return tx.ledger_index; }); async.each(_.keys(groupedTx), function(ledger, next) { remote.requestLedger({ ledger_index: Number(ledger) }, function(err, data) { if (err) { return next(err); } _.each(groupedTx[ledger], function(tx) { tx.date = data.ledger.close_time; }) return next(null); }); }, function(err) { if (err) { return callback(err); } return callback(null, transactions); }); } function formatTransactions(transactions, callback) { if (!Array.isArray(transactions)) { return callback(null); } else { async.map(transactions, function (transaction, async_map_callback) { return formatPaymentHelper(options.account, transaction, async_map_callback); }, callback ); } }; function attachResourceId(transactions, callback) { async.map(transactions, function(paymentResult, async_map_callback) { var hash = paymentResult.hash; var payment = paymentResult.payment; dbinterface.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); }; var steps = [ getTransactions, attachDate, formatTransactions, attachResourceId ]; async.waterfall(steps, function(error, payments) { if (error) { next(error); } else { respond.success(response, { payments: payments }); } }); }; /** * 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...,XEC,..."]} 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 * @param {Express.js Response} res * @param {Express.js Next} next */ function getPathFind(request, response, next) { // Parse and validate parameters var params = { source_account: request.params.account, destination_account: request.params.destination_account, destination_amount: {}, source_currencies: [] }; if (!params.source_account) { next(new InvalidRequestError('Missing parameter: source_account. Must be a valid Ripple address')); return; } if (!params.destination_account) { next(new InvalidRequestError('Missing parameter: destination_account. Must be a valid Ripple address')); return; } if (!ripple.UInt160.is_valid(params.source_account)) { return next(new errors.InvalidRequestError('Parameter is not a valid Ripple address: account')); } if (!ripple.UInt160.is_valid(params.destination_account)) { return next(new errors.InvalidRequestError('Parameter is not a valid Ripple address: destination_account')); } // Parse destination amount if (!request.params.destination_amount_string) { next(new InvalidRequestError('Missing parameter: destination_amount. Must be an amount string in the form value+currency+counterparty')); return; } params.destination_amount = utils.parseCurrencyQuery(request.params.destination_amount_string); if (!ripple.UInt160.is_valid(params.source_account)) { next(new InvalidRequestError('Invalid parameter: source_account. Must be a valid Ripple address')); return; } if (!ripple.UInt160.is_valid(params.destination_account)){ next(new InvalidRequestError('Invalid parameter: destination_account. Must be a valid Ripple address')); return; } if (!validator.isValid(params.destination_amount, 'Amount')) { next(new InvalidRequestError('Invalid parameter: destination_amount. Must be an amount string in the form value+currency+counterparty')); return; } // Parse source currencies // Note that the source_currencies should be in the form // "USD r...,BTC,XEC". The counterparty is optional but if provided should be // separated from the currency by a single space. if (request.query.source_currencies) { var sourceCurrencyStrings = request.query.source_currencies.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 currencyCounterpartyArray = sourceCurrencyStrings[c].split(' '); var currencyObject = { currency: currencyCounterpartyArray[0], issuer: currencyCounterpartyArray[1] }; if (validator.isValid(currencyObject.currency, 'Currency') && ripple.UInt160.is_valid(currencyObject.issuer)) { params.source_currencies.push(currencyObject); } else { next(new InvalidRequestError('Invalid parameter: source_currencies. Must be a list of valid currencies')); return; } } else { if (validator.isValid(sourceCurrencyStrings[c], 'Currency')) { params.source_currencies.push({ currency: sourceCurrencyStrings[c] }); } else { next(new InvalidRequestError('Invalid parameter: source_currencies. Must be a list of valid currencies')); return; } } } } function prepareOptions(callback) { var pathfindParams = { src_account: params.source_account, dst_account: params.destination_account, dst_amount: utils.txFromRestAmount(params.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/espend-rest/#counterparties-in-payments pathfindParams.dst_amount.issuer = pathfindParams.dst_account; } if (params.source_currencies.length > 0) { pathfindParams.src_currencies = params.source_currencies; } callback(null, pathfindParams); }; function findPath(pathfindParams, callback) { var request = 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() { remote.disconnect(function() { 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 XEC and if destination_account accepts XEC if (typeof pathfindResults.destination_amount.currency === 'string' || pathfindResults.destination_currencies.indexOf('XEC') === -1) { return callback(null, pathfindResults); } // Check source_account balance 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 XEC "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, callback) { if (pathfindResults.alternatives && pathfindResults.alternatives.length > 0) { return TxToRestConverter.parsePaymentsFromPathFind(pathfindResults, callback); } if (pathfindResults.destination_currencies.indexOf(params.destination_amount.currency) === -1) { callback(new NotFoundError('No paths found. ' + 'The destination_account does not accept ' + params.destination_amount.currency + ', they only accept: ' + pathfindResults.destination_currencies.join(', '))); } else if (pathfindResults.source_currencies && pathfindResults.source_currencies.length > 0) { callback(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 { callback(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')); } }; var steps = [ prepareOptions, findPath, addDirectXrpPath, formatPath ]; async.waterfall(steps, function(error, payments) { if (error) { next(error); } else { respond.success(response, { payments: payments }); } }); };