divvy-rest
Version:
A RESTful API for submitting payments and monitoring accounts on the Divvy network.
568 lines (498 loc) • 18.4 kB
JavaScript
/* eslint-disable valid-jsdoc */
;
var _ = require('lodash');
var async = require('async');
var bignum = require('bignumber.js');
var divvy = require('divvy-lib');
var transactions = require('./transactions');
var validator = require('./lib/schema-validator');
var serverLib = require('./lib/server-lib');
var utils = require('./lib/utils');
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 validate = require('./lib/validate');
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 divvy-rest Payment format
*
* @param {DivvyAddress} account
* @param {Transaction} transaction
* @param {Function} callback
*
* @callback
* @param {Error} error
* @param {DivvyRestTransaction} 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) {
var clientResourceID = _transaction.client_resource_id || '';
var hash = _transaction.hash || '';
var ledger = !_.isUndefined(transaction.inLedger) ?
String(_transaction.inLedger) : String(_transaction.ledger_index);
var state = _transaction.validated === true ? 'validated' : 'pending';
return {
client_resource_id: clientResourceID,
hash: hash,
ledger: ledger,
state: state
};
}
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 Divvy ledger '
+ 'and it was not submitted through this divvy-rest instance. '
+ 'This error may also be seen if the databases of either divvy-rest '
+ 'or divvyd were recently created or deleted.'));
}
}
var steps = [
checkIsPayment,
formatTransaction
];
async.waterfall(steps, callback);
}
/**
* Submit a payment in the divvy-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 divvyd has finished validating the
* submitted transaction
*/
function submitPayment(account, payment, clientResourceID, secret,
lastLedgerSequence, urlBase, options, callback) {
var self = this;
var max_fee = Number(options.max_fee) > 0 ?
utils.xdvToDrops(options.max_fee) : undefined;
var fixed_fee = Number(options.fixed_fee) > 0 ?
utils.xdvToDrops(options.fixed_fee) : undefined;
var params = {
secret: secret,
validated: options.validated,
clientResourceId: clientResourceID,
blockDuplicates: true,
saveTransaction: true
};
validate.client_resource_id(clientResourceID);
// TODO: validate.addressAndSecret({address: account, secret: secret});
validate.address(account);
validate.payment(payment);
validate.last_ledger_sequence(lastLedgerSequence, true);
validate.validated(options.validated, true);
function initializeTransaction(_callback) {
RestToTxConverter.convert(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.validated = message.validated;
transaction.ledger_index = transaction.inLedger = message.ledger_index;
return formatPaymentHelper(payment.source_account, transaction,
_callback);
}
_callback(null, {
client_resource_id: clientResourceID,
status_url: urlBase + '/v1/accounts/' + payment.source_account
+ '/payments/' + clientResourceID
});
}
function setTransactionParameters(transaction) {
var ledgerIndex;
var maxFee = Number(max_fee);
var fixedFee = Number(fixed_fee);
if (Number(lastLedgerSequence) > 0) {
ledgerIndex = Number(lastLedgerSequence);
} else {
ledgerIndex = Number(self.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(clientResourceID);
}
var hooks = {
initializeTransaction: initializeTransaction,
formatTransactionResponse: formatTransactionResponse,
setTransactionParameters: setTransactionParameters
};
transactions.submit(this, params, new SubmitTransactionHooks(hooks),
function(err, paymentResult) {
if (err) {
return callback(err);
}
callback(null, paymentResult);
});
}
/**
* Retrieve the details of a particular payment from the Remote or
* the local database and return it in the divvy-rest Payment format.
*
* @param {Remote} remote
* @param {/lib/db-interface} dbinterface
* @param {DivvyAddress} 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 divvyd
function getTransaction(_callback) {
transactions.getTransaction(self, account, identifier, {},
function(error, transaction) {
_callback(error, transaction);
});
}
var steps = [
getTransaction,
function(transaction, _callback) {
return formatPaymentHelper(account, transaction, _callback);
}
];
async.waterfall(steps, function(error, result) {
if (error) {
callback(error);
} else {
callback(null, result);
}
});
}
/**
* 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 {DivvyAddress} req.params.account
* @param {DivvyAddress} req.query.source_account
* @param {DivvyAddress} 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 attachDate(_transactions, _callback) {
var groupedTx = _.groupBy(_transactions, function(tx) {
return tx.ledger_index;
});
async.each(_.keys(groupedTx), function(ledger, next) {
self.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);
}
async.map(_transactions,
function(transaction, async_map_callback) {
return formatPaymentHelper(account, transaction, async_map_callback);
},
_callback
);
}
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);
}
var steps = [
getTransactions,
attachDate,
formatTransactions,
attachResourceId
];
async.waterfall(steps, function(error, payments) {
if (error) {
callback(error);
} else {
callback(null, {payments: payments});
}
});
}
/**
* Get a divvy 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 {DivvyAddress} req.params.source_account
* @param {Amount Array ["USD r...,XDV,..."]} 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 {DivvyAddress} 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 = utils.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,XDV". 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')
&& divvy.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(_callback) {
var pathfindParams = {
src_account: source_account,
dst_account: destination_account,
dst_amount: utils.txFromRestAmount(destination_amount)
};
if (typeof pathfindParams.dst_amount === 'object'
&& !pathfindParams.dst_amount.issuer) {
// Convert blank issuer to sender's address
// (Divvy convention for 'any issuer')
// https://xdv.io/build/transactions/
// #special-issuer-values-for-sendmax-and-amount
// https://xdv.io/build/divvy-rest/#counterparties-in-payments
pathfindParams.dst_amount.issuer = pathfindParams.dst_account;
}
if (source_currencies.length > 0) {
pathfindParams.src_currencies = source_currencies;
}
_callback(null, pathfindParams);
}
function findPath(pathfindParams, _callback) {
var request = self.remote.requestDivvyPathFind(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 reconnectDivvyd() {
self.remote.disconnect(function() {
self.remote.connect();
});
}
request.timeout(serverLib.CONNECTION_TIMEOUT, function() {
request.removeAllListeners();
reconnectDivvyd();
_callback(new TimeOutError('Path request timeout'));
});
request.request();
}
function addDirectXdvPath(pathfindResults, _callback) {
// Check if destination_amount is XDV and if destination_account accepts XDV
if (typeof pathfindResults.destination_amount.currency === 'string'
|| pathfindResults.destination_currencies.indexOf('XDV') === -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 XDV "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(
destination_amount.currency) === -1) {
_callback(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) {
_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,
addDirectXdvPath,
formatPath
];
async.waterfall(steps, function(error, payments) {
if (error) {
callback(error);
} else {
callback(null, {payments: payments});
}
});
}
module.exports = {
submit: submitPayment,
get: getPayment,
getAccountPayments: getAccountPayments,
getPathFind: getPathFind
};