ripplelib
Version:
A JavaScript API for interacting with Ripple in Node.js and the browser
518 lines (419 loc) • 14.2 kB
JavaScript
'use strict';
var util = require('util');
var assert = require('assert');
var async = require('async');
var EventEmitter = require('events').EventEmitter;
var Transaction = require('./transaction').Transaction;
var RippleError = require('./rippleerror').RippleError;
var PendingQueue = require('./transactionqueue').TransactionQueue;
var log = require('./log').internal.sub('transactionmanager');
var Listener = require('./listener');
/**
* @constructor TransactionManager
* @param {Account} account
*/
function TransactionManager(account) {
EventEmitter.call(this);
var self = this;
this._account = account;
this._accountID = account._account_id;
this._remote = account._remote;
this._nextSequence = undefined;
this._maxFee = this._remote.max_fee;
this._maxAttempts = this._remote.max_attempts;
this._lastLedgerOffset = this._remote.last_ledger_offset;
this._pending = new PendingQueue();
this._remote.on('load_changed', function (load) {
self._adjustFees(load);
self._handleLoadChanged();
});
function updatePendingStatus(ledger) {
self._updatePendingStatus(ledger);
self._lastClosedLedger = ledger.ledger_index;
if (!self._pause_resubmit && self.getPending().getQueue().length) {
self._resubmit();
}
}
this._account._listener.on('transaction-outbound', function(res) {
self._transactionReceived(res);
});
this._account._listener.on('reconnected', function (err) {
self._loadSequence(); // reset nextSequence
if (!err) self._resubmit(); // re-submit pending transactions
});
this._account._listener.on('ledger_closed', updatePendingStatus);
// Query server for next account transaction sequence
this._loadSequence();
}
util.inherits(TransactionManager, EventEmitter);
TransactionManager._isNoOp = function (transaction) {
return typeof transaction === 'object' && typeof transaction.tx_json === 'object' && transaction.tx_json.TransactionType === 'AccountSet' && transaction.tx_json.Flags === 0;
};
TransactionManager._isRemoteError = function (error) {
return typeof error === 'object' && error.error === 'remoteError' && typeof error.remote === 'object';
};
TransactionManager._isNotFound = function (error) {
return TransactionManager._isRemoteError(error) && /^(txnNotFound|transactionNotFound)$/.test(error.remote.error);
};
TransactionManager._isTooBusy = function (error) {
return TransactionManager._isRemoteError(error) && error.remote.error === 'tooBusy';
};
/**
* Normalize transactions received from account transaction stream and
* account_tx
*
* @param {Transaction}
* @return {Transaction} normalized
* @api private
*/
TransactionManager.normalizeTransaction = function(tx) {
if (tx.normalized) return tx;
return Listener.normalizeTransaction(tx);
};
/**
* Handle received transaction from two possible sources
*
* + Account transaction stream (normal operation)
* + account_tx (after reconnect)
*
* @param {Object} transaction
* @api private
*/
TransactionManager.prototype._transactionReceived = function (tx) {
var transaction = TransactionManager.normalizeTransaction(tx);
if (!transaction.validated) {
// Transaction has not been validated
return;
}
if (transaction.tx_json.Account !== this._accountID) {
// Received transaction's account does not match
return;
}
if (this._remote.trace) {
log.info('transaction received:', transaction.tx_json);
}
this._pending.addReceivedSequence(transaction.tx_json.Sequence);
if (this._nextSequence <= transaction.tx_json.Sequence) this._nextSequence = transaction.tx_json.Sequence + 1;
var hash = transaction.tx_json.hash;
var submission = this._pending.getSubmission(hash);
if (!(submission instanceof Transaction)) {
// The received transaction does not correlate to one submitted
this._pending.addReceivedId(hash, transaction);
return;
}
// ND: A `success` handler will `finalize` this later
switch (transaction.engine_result) {
case 'tesSUCCESS':
submission.emit('success', transaction);
break;
default:
submission.emit('error', transaction);
}
};
/**
* Pause resubmissions during high load
* resume when load decrease.
*
* @api private
*/
TransactionManager.prototype._handleLoadChanged = function() {
var servers = this._remote._servers;
var fees = [ ];
for (var i=0; i<servers.length; i++) {
var server = servers[i];
if (server._connected) {
var fee = 10 * (server._fee_base / server._fee_ref) * (server._load_factor / server._load_base);
fees.push(Math.ceil(fee));
}
}
if (fees.length == 0) return;
if (Math.min.apply(null, fees) > this._maxFee) {
this._pause_resubmit = true;
} else {
this._pause_resubmit = false;
}
};
/**
* Adjust pending transactions' fees in real-time. This does not resubmit
* pending transactions; they will be resubmitted periodically with an updated
* fee (and as a consequence, a new transaction ID) if not already validated
*
* ND: note, that `Fee` is a component of a transactionID
*
* @api private
*/
TransactionManager.prototype._adjustFees = function () {
var self = this;
if (!this._remote.local_fee) {
return;
}
this._pending.forEach(function (transaction) {
if (transaction._setFixedFee) {
return;
}
var oldFee = transaction.tx_json.Fee;
var newFee = transaction._computeFee();
var maxFee = transaction._maxFee || self._maxFee;
if (Number(newFee) > maxFee) newFee = String(maxFee);
transaction.tx_json.Fee = newFee;
transaction.emit('fee_adjusted', oldFee, newFee);
if (self._remote.trace) {
log.info('fee adjusted:', transaction.tx_json, oldFee, newFee);
}
});
};
/**
* Get pending transactions
*
* @return {Array} pending transactions
*/
TransactionManager.prototype.getPending = function () {
return this._pending;
};
/**
* Legacy code. Update transaction status after excessive ledgers pass.
*
* @param {Object} ledger data
* @api private
*/
TransactionManager.prototype._updatePendingStatus = function (ledger) {
assert.strictEqual(typeof ledger, 'object');
assert.strictEqual(typeof ledger.ledger_index, 'number');
var self = this;
this._pending.forEach(function (transaction) {
if (transaction.finalized) {
return;
}
if (ledger.ledger_index > transaction.tx_json.LastLedgerSequence) {
// Transaction must fail
transaction.emit('error', new RippleError('tejMaxLedger', 'Transaction LastLedgerSequence exceeded'));
// reset nextSequence
if (transaction.tx_json.Sequence < self._nextSequence) self._loadSequence();
}
});
};
/**
* Load account transaction sequence
*
* @param [Function] callback
* @api private
*/
TransactionManager.prototype._loadSequence = function (callback_) {
var self = this;
var callback = typeof callback_ === 'function' ? callback_ : function () {};
function sequenceLoaded(err, sequence) {
if (err || typeof sequence !== 'number') {
if (self._remote.trace) {
log.info('error requesting account transaction sequence', err);
return;
}
}
self._nextSequence = sequence;
self.emit('sequence_loaded', sequence);
callback(err, sequence);
}
this._account.getNextSequence(sequenceLoaded);
};
/**
* Resubmit pending transactions. If a transaction is specified, it will be
* resubmitted. Otherwise, all pending transactions will be resubmitted
*
* @param [Number] ledgers to wait before resubmitting
* @param [Transaction] pending transactions to resubmit
* @api private
*/
TransactionManager.prototype._resubmit = function (pending) {
var self = this;
pending = pending instanceof Transaction ? [pending] : this.getPending().getQueue();
function resubmitTransaction(transaction, next) {
if (!transaction || transaction.finalized) {
// Transaction has been finalized, nothing to do
return next();
}
if (self._lastClosedLedger <= transaction.submitIndex + 1) {
return next(true); // wait until ledger_closed > submitindex + 1
}
// Find ID within cache of received (validated) transaction IDs
var received = transaction.findId(self._pending._idCache);
if (received) {
switch (received.engine_result) {
case 'tesSUCCESS':
transaction.emit('success', received);
break;
default:
transaction.emit('error', received);
}
return next();
}
if (self._remote.trace) {
log.info('resubmit:', transaction.tx_json);
}
transaction.once('submitted', function (m) {
transaction.emit('resubmitted', m);
switch (m.result.slice(0, 3)) {
case 'tes':
case 'tec':
next();
break;
default:
if (m.result === 'tefALREADY' || m.result === 'tefPAST_SEQ') next();
else next(true); //break the submission series.
}
});
self._request(transaction);
}
async.eachSeries(pending, resubmitTransaction);
};
/**
* Prepare submit request
*
* @param {Transaction} transaction to submit
* @return {Request} submit request
* @api private
*/
TransactionManager.prototype._prepareRequest = function (tx) {
var submitRequest = this._remote.requestSubmit();
if (this._remote.local_signing) {
tx.sign();
var serialized = tx.serialize();
submitRequest.tx_blob(serialized.to_hex());
var hash = tx.hash(null, null, serialized);
tx.addId(hash);
} else {
// ND: `build_path` is completely ignored when doing local signing as
// `Paths` is a component of the signed blob, the `tx_blob` is signed,
// sealed and delivered, and the txn unmodified.
// TODO: perhaps an exception should be raised if build_path is attempted
// while local signing
submitRequest.build_path(tx._build_path);
submitRequest.secret(tx._secret);
submitRequest.tx_json(tx.tx_json);
}
return submitRequest;
};
/**
* Send `submit` request, handle response
*
* @param {Transaction} transaction to submit
* @api private
*/
TransactionManager.prototype._request = function (tx) {
var self = this;
var remote = this._remote;
if (tx.finalized) {
return;
}
if (tx.attempts > this._maxAttempts) {
tx.emit('error', new RippleError('tejAttemptsExceeded'));
return;
}
if (tx.attempts > 0 && !remote.local_signing) {
var errMessage = 'Automatic resubmission requires local signing';
tx.emit('error', new RippleError('tejLocalSigningRequired', errMessage));
return;
}
if (remote.trace) {
log.info('submit transaction:', tx.tx_json);
}
function submissionError(error) {
if (TransactionManager._isTooBusy(error)) {
// do nothing;
} else {
tx.emit('error', error);
self._loadSequence(); // reset nextSequence;
}
}
function submitted(message) {
if (tx.finalized) {
return;
}
// ND: If for some unknown reason our hash wasn't computed correctly this
// is an extra measure.
if (message.tx_json && message.tx_json.hash) {
tx.addId(message.tx_json.hash);
}
message.result = message.engine_result || '';
tx.result = message;
tx.responses += 1;
if (remote.trace) {
log.info('submit response:', message);
}
tx.emit('submitted', message);
switch (message.result.slice(0, 3)) {
case 'tes':
case 'tec':
tx.emit('proposed', message);
break;
case 'ter':
case 'tef':
case 'tel':
// do nothing;
break;
case 'tem':
default:
submissionError(message);
}
}
tx.submitIndex = this._remote._ledger_current_index;
if (tx.attempts === 0) {
tx.initialSubmitIndex = tx.submitIndex;
}
if (!tx._setLastLedger) {
// Honor LastLedgerSequence set with tx.lastLedger()
tx.tx_json.LastLedgerSequence = tx.initialSubmitIndex + this._lastLedgerOffset;
}
tx.lastLedgerSequence = tx.tx_json.LastLedgerSequence;
if (remote.local_signing) {
tx.sign();
}
var submitRequest = this._prepareRequest(tx);
submitRequest.once('error', submitted);
submitRequest.once('success', submitted);
tx.emit('presubmit');
var filterFn = function (res) {
if (! res.engine_result) return false;
var result = res.engine_result.slice(3);
return result == 'tes' || result == 'tec';
}
submitRequest.broadcast(filterFn).request();
tx.attempts++;
tx.emit('postsubmit');
};
/**
* Entry point for TransactionManager submission
*
* @param {Transaction} tx
*/
TransactionManager.prototype.submit = function (tx) {
var self = this;
if (typeof this._nextSequence !== 'number') {
// If sequence number is not yet known, defer until it is.
this.once('sequence_loaded', function () {
self.submit(tx);
});
return;
}
if (tx.finalized) {
// Finalized transactions must stop all activity
return;
}
if (typeof tx.tx_json.Sequence !== 'number') {
// Honor manually-set sequences
tx.tx_json.Sequence = this._nextSequence++;
tx._seqAutofilled = true;
}
tx.once('cleanup', function () {
self.getPending().remove(tx);
});
if (!tx.complete()) {
if (tx._seqAutofilled) this._nextSequence--;
return;
}
// ND: this is the ONLY place we put the tx into the queue. The
// TransactionQueue queue is merely a list, so any mutations to tx._hash
// will cause subsequent look ups (eg. inside 'transaction-outbound'
// validated transaction clearing) to fail.
this._pending.push(tx);
this._request(tx);
};
exports.TransactionManager = TransactionManager;