diff --git a/package.json b/package.json index c3c33d167a..eaaefd4cd4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "simple-jsonrpc": "~0.0.2" }, "scripts": { + "pretest": "node test/pretest.js", "test": "mocha test/websocket-test.js test/server-test.js test/*-test.{js,coffee}" }, "repository": { diff --git a/test/pretest.js b/test/pretest.js new file mode 100644 index 0000000000..62a98ac2e4 --- /dev/null +++ b/test/pretest.js @@ -0,0 +1,12 @@ +var fs = require('fs'); +var path = require('path'); +var joinPath = path.join.bind(path, __dirname); + +fs.readdirSync(joinPath('ripple-lib')).forEach(function(fileName) { + var src_path = joinPath('ripple-lib', fileName); + var dst_path = joinPath('../node_modules/ripple-lib/dist/npm/core/', fileName); + + console.log(src_path + ' > ' + dst_path); + + fs.writeFileSync(dst_path, fs.readFileSync(src_path)); +}); diff --git a/test/ripple-lib/binformat.js b/test/ripple-lib/binformat.js new file mode 100644 index 0000000000..d259db23e2 --- /dev/null +++ b/test/ripple-lib/binformat.js @@ -0,0 +1,301 @@ +'use strict'; + +/*eslint-disable max-len,spaced-comment,array-bracket-spacing,key-spacing*/ +/*eslint-disable no-multi-spaces,comma-spacing*/ +/*eslint-disable no-multi-spaces:0,space-in-brackets:0,key-spacing:0,comma-spacing:0*/ + +/** + * Data type map. + * + * Mapping of type ids to data types. The type id is specified by the high + * + * For reference, see rippled's definition: + * https://github.com/ripple/rippled/blob/develop/src/ripple/data/protocol + * /SField.cpp + */ + +exports.types = [undefined, + +// Common +'Int16', // 1 +'Int32', // 2 +'Int64', // 3 +'Hash128', // 4 +'Hash256', // 5 +'Amount', // 6 +'VL', // 7 +'Account', // 8 + +// 9-13 reserved +undefined, // 9 +undefined, // 10 +undefined, // 11 +undefined, // 12 +undefined, // 13 + +'Object', // 14 +'Array', // 15 + +// Uncommon +'Int8', // 16 +'Hash160', // 17 +'PathSet', // 18 +'Vector256' // 19 +]; + +/** + * Field type map. + * + * Mapping of field type id to field type name. + */ + +var FIELDS_MAP = exports.fields = { + // Common types + 1: { // Int16 + 1: 'LedgerEntryType', + 2: 'TransactionType', + 3: 'SignerWeight' + }, + 2: { // Int32 + 2: 'Flags', + 3: 'SourceTag', + 4: 'Sequence', + 5: 'PreviousTxnLgrSeq', + 6: 'LedgerSequence', + 7: 'CloseTime', + 8: 'ParentCloseTime', + 9: 'SigningTime', + 10: 'Expiration', + 11: 'TransferRate', + 12: 'WalletSize', + 13: 'OwnerCount', + 14: 'DestinationTag', + // Skip 15 + 16: 'HighQualityIn', + 17: 'HighQualityOut', + 18: 'LowQualityIn', + 19: 'LowQualityOut', + 20: 'QualityIn', + 21: 'QualityOut', + 22: 'StampEscrow', + 23: 'BondAmount', + 24: 'LoadFee', + 25: 'OfferSequence', + 26: 'FirstLedgerSequence', + 27: 'LastLedgerSequence', + 28: 'TransactionIndex', + 29: 'OperationLimit', + 30: 'ReferenceFeeUnits', + 31: 'ReserveBase', + 32: 'ReserveIncrement', + 33: 'SetFlag', + 34: 'ClearFlag', + 35: 'SignerQuorum', + 36: 'CancelAfter', + 37: 'FinishAfter', + 38: 'SignerListID' + }, + 3: { // Int64 + 1: 'IndexNext', + 2: 'IndexPrevious', + 3: 'BookNode', + 4: 'OwnerNode', + 5: 'BaseFee', + 6: 'ExchangeRate', + 7: 'LowNode', + 8: 'HighNode' + }, + 4: { // Hash128 + 1: 'EmailHash' + }, + 5: { // Hash256 + 1: 'LedgerHash', + 2: 'ParentHash', + 3: 'TransactionHash', + 4: 'AccountHash', + 5: 'PreviousTxnID', + 6: 'LedgerIndex', + 7: 'WalletLocator', + 8: 'RootIndex', + 9: 'AccountTxnID', + 16: 'BookDirectory', + 17: 'InvoiceID', + 18: 'Nickname', + 19: 'Amendment', + 20: 'TicketID', + 21: 'Digest' + }, + 6: { // Amount + 1: 'Amount', + 2: 'Balance', + 3: 'LimitAmount', + 4: 'TakerPays', + 5: 'TakerGets', + 6: 'LowLimit', + 7: 'HighLimit', + 8: 'Fee', + 9: 'SendMax', + 16: 'MinimumOffer', + 17: 'RippleEscrow', + 18: 'DeliveredAmount' + }, + 7: { // VL + 1: 'PublicKey', + 2: 'MessageKey', + 3: 'SigningPubKey', + 4: 'TxnSignature', + 5: 'Generator', + 6: 'Signature', + 7: 'Domain', + 8: 'FundCode', + 9: 'RemoveCode', + 10: 'ExpireCode', + 11: 'CreateCode', + 12: 'MemoType', + 13: 'MemoData', + 14: 'MemoFormat', + 17: 'Proof' + }, + 8: { // Account + 1: 'Account', + 2: 'Owner', + 3: 'Destination', + 4: 'Issuer', + 7: 'Target', + 8: 'RegularKey' + }, + 14: { // Object + 1: undefined, // end of Object + 2: 'TransactionMetaData', + 3: 'CreatedNode', + 4: 'DeletedNode', + 5: 'ModifiedNode', + 6: 'PreviousFields', + 7: 'FinalFields', + 8: 'NewFields', + 9: 'TemplateEntry', + 10: 'Memo', + 11: 'SignerEntry', + 16: 'Signer' + }, + 15: { // Array + 1: undefined, // end of Array + 2: 'SigningAccounts', + 3: 'Signers', + 4: 'SignerEntries', + 5: 'Template', + 6: 'Necessary', + 7: 'Sufficient', + 8: 'AffectedNodes', + 9: 'Memos' + }, + + // Uncommon types + 16: { // Int8 + 1: 'CloseResolution', + 2: 'Method', + 3: 'TransactionResult' + }, + 17: { // Hash160 + 1: 'TakerPaysCurrency', + 2: 'TakerPaysIssuer', + 3: 'TakerGetsCurrency', + 4: 'TakerGetsIssuer' + }, + 18: { // PathSet + 1: 'Paths' + }, + 19: { // Vector256 + 1: 'Indexes', + 2: 'Hashes', + 3: 'Amendments' + } +}; + +var INVERSE_FIELDS_MAP = exports.fieldsInverseMap = {}; + +Object.keys(FIELDS_MAP).forEach(function (k1) { + Object.keys(FIELDS_MAP[k1]).forEach(function (k2) { + INVERSE_FIELDS_MAP[FIELDS_MAP[k1][k2]] = [Number(k1), Number(k2)]; + }); +}); + +var REQUIRED = exports.REQUIRED = 0; +var OPTIONAL = exports.OPTIONAL = 1; +var DEFAULT = exports.DEFAULT = 2; + +var base = [['TransactionType', REQUIRED], ['Flags', OPTIONAL], ['SourceTag', OPTIONAL], ['LastLedgerSequence', OPTIONAL], ['Account', REQUIRED], ['Sequence', REQUIRED], ['Fee', REQUIRED], ['OperationLimit', OPTIONAL], ['SigningPubKey', REQUIRED], ['TxnSignature', OPTIONAL], ['AccountTxnID', OPTIONAL], ['Memos', OPTIONAL], ['Signers', OPTIONAL]]; + +exports.tx = { + AccountSet: [3].concat(base, [['EmailHash', OPTIONAL], ['WalletLocator', OPTIONAL], ['WalletSize', OPTIONAL], ['MessageKey', OPTIONAL], ['Domain', OPTIONAL], ['TransferRate', OPTIONAL], ['SetFlag', OPTIONAL], ['ClearFlag', OPTIONAL]]), + TrustSet: [20].concat(base, [['LimitAmount', OPTIONAL], ['QualityIn', OPTIONAL], ['QualityOut', OPTIONAL]]), + OfferCreate: [7].concat(base, [['TakerPays', REQUIRED], ['TakerGets', REQUIRED], ['Expiration', OPTIONAL], ['OfferSequence', OPTIONAL]]), + OfferCancel: [8].concat(base, [['OfferSequence', REQUIRED]]), + SetRegularKey: [5].concat(base, [['RegularKey', OPTIONAL]]), + Payment: [0].concat(base, [['Destination', REQUIRED], ['Amount', REQUIRED], ['SendMax', OPTIONAL], ['Paths', DEFAULT], ['InvoiceID', OPTIONAL], ['DestinationTag', OPTIONAL]]), + Contract: [9].concat(base, [['Expiration', REQUIRED], ['BondAmount', REQUIRED], ['StampEscrow', REQUIRED], ['RippleEscrow', REQUIRED], ['CreateCode', OPTIONAL], ['FundCode', OPTIONAL], ['RemoveCode', OPTIONAL], ['ExpireCode', OPTIONAL]]), + RemoveContract: [10].concat(base, [['Target', REQUIRED]]), + EnableFeature: [100].concat(base, [['Feature', REQUIRED]]), + EnableAmendment: [100].concat(base, [['Amendment', REQUIRED]]), + SetFee: [101].concat(base, [['BaseFee', REQUIRED], ['ReferenceFeeUnits', REQUIRED], ['ReserveBase', REQUIRED], ['ReserveIncrement', REQUIRED]]), + TicketCreate: [10].concat(base, [['Target', OPTIONAL], ['Expiration', OPTIONAL]]), + TicketCancel: [11].concat(base, [['TicketID', REQUIRED]]), + SignerListSet: [12].concat(base, [['SignerQuorum', REQUIRED], ['SignerEntries', OPTIONAL]]), + SuspendedPaymentCreate: [1].concat(base, [['Destination', REQUIRED], ['Amount', REQUIRED], ['Digest', OPTIONAL], ['CancelAfter', OPTIONAL], ['FinishAfter', OPTIONAL], ['DestinationTag', OPTIONAL]]), + SuspendedPaymentFinish: [2].concat(base, [['Owner', REQUIRED], ['OfferSequence', REQUIRED], ['Method', OPTIONAL], ['Digest', OPTIONAL], ['Proof', OPTIONAL]]), + SuspendedPaymentCancel: [4].concat(base, [['Owner', REQUIRED], ['OfferSequence', REQUIRED]]) +}; + +var sleBase = [['LedgerIndex', OPTIONAL], ['LedgerEntryType', REQUIRED], ['Flags', REQUIRED]]; + +exports.ledger = { + AccountRoot: [97].concat(sleBase, [['Sequence', REQUIRED], ['PreviousTxnLgrSeq', REQUIRED], ['TransferRate', OPTIONAL], ['WalletSize', OPTIONAL], ['OwnerCount', REQUIRED], ['EmailHash', OPTIONAL], ['PreviousTxnID', REQUIRED], ['AccountTxnID', OPTIONAL], ['WalletLocator', OPTIONAL], ['Balance', REQUIRED], ['MessageKey', OPTIONAL], ['Domain', OPTIONAL], ['Account', REQUIRED], ['RegularKey', OPTIONAL]]), + Contract: [99].concat(sleBase, [['PreviousTxnLgrSeq', REQUIRED], ['Expiration', REQUIRED], ['BondAmount', REQUIRED], ['PreviousTxnID', REQUIRED], ['Balance', REQUIRED], ['FundCode', OPTIONAL], ['RemoveCode', OPTIONAL], ['ExpireCode', OPTIONAL], ['CreateCode', OPTIONAL], ['Account', REQUIRED], ['Owner', REQUIRED], ['Issuer', REQUIRED]]), + DirectoryNode: [100].concat(sleBase, [['IndexNext', OPTIONAL], ['IndexPrevious', OPTIONAL], ['ExchangeRate', OPTIONAL], ['RootIndex', REQUIRED], ['Owner', OPTIONAL], ['TakerPaysCurrency', OPTIONAL], ['TakerPaysIssuer', OPTIONAL], ['TakerGetsCurrency', OPTIONAL], ['TakerGetsIssuer', OPTIONAL], ['Indexes', REQUIRED]]), + EnabledFeatures: [102].concat(sleBase, [['Features', REQUIRED]]), + FeeSettings: [115].concat(sleBase, [['ReferenceFeeUnits', REQUIRED], ['ReserveBase', REQUIRED], ['ReserveIncrement', REQUIRED], ['BaseFee', REQUIRED], ['LedgerIndex', OPTIONAL]]), + GeneratorMap: [103].concat(sleBase, [['Generator', REQUIRED]]), + LedgerHashes: [104].concat(sleBase, [['LedgerEntryType', REQUIRED], ['Flags', REQUIRED], ['FirstLedgerSequence', OPTIONAL], ['LastLedgerSequence', OPTIONAL], ['LedgerIndex', OPTIONAL], ['Hashes', REQUIRED]]), + Nickname: [110].concat(sleBase, [['LedgerEntryType', REQUIRED], ['Flags', REQUIRED], ['LedgerIndex', OPTIONAL], ['MinimumOffer', OPTIONAL], ['Account', REQUIRED]]), + Offer: [111].concat(sleBase, [['LedgerEntryType', REQUIRED], ['Flags', REQUIRED], ['Sequence', REQUIRED], ['PreviousTxnLgrSeq', REQUIRED], ['Expiration', OPTIONAL], ['BookNode', REQUIRED], ['OwnerNode', REQUIRED], ['PreviousTxnID', REQUIRED], ['LedgerIndex', OPTIONAL], ['BookDirectory', REQUIRED], ['TakerPays', REQUIRED], ['TakerGets', REQUIRED], ['Account', REQUIRED]]), + RippleState: [114].concat(sleBase, [['LedgerEntryType', REQUIRED], ['Flags', REQUIRED], ['PreviousTxnLgrSeq', REQUIRED], ['HighQualityIn', OPTIONAL], ['HighQualityOut', OPTIONAL], ['LowQualityIn', OPTIONAL], ['LowQualityOut', OPTIONAL], ['LowNode', OPTIONAL], ['HighNode', OPTIONAL], ['PreviousTxnID', REQUIRED], ['LedgerIndex', OPTIONAL], ['Balance', REQUIRED], ['LowLimit', REQUIRED], ['HighLimit', REQUIRED]]), + SignerList: [83].concat(sleBase, [['OwnerNode', REQUIRED], ['SignerQuorum', REQUIRED], ['SignerEntries', REQUIRED], ['SignerListID', REQUIRED], ['PreviousTxnID', REQUIRED], ['PreviousTxnLgrSeq', REQUIRED]]) +}; + +exports.metadata = [['DeliveredAmount', OPTIONAL], ['TransactionIndex', REQUIRED], ['TransactionResult', REQUIRED], ['AffectedNodes', REQUIRED]]; + +exports.ter = { + tesSUCCESS: 0, + tecCLAIM: 100, + tecPATH_PARTIAL: 101, + tecUNFUNDED_ADD: 102, + tecUNFUNDED_OFFER: 103, + tecUNFUNDED_PAYMENT: 104, + tecFAILED_PROCESSING: 105, + tecDIR_FULL: 121, + tecINSUF_RESERVE_LINE: 122, + tecINSUF_RESERVE_OFFER: 123, + tecNO_DST: 124, + tecNO_DST_INSUF_XRP: 125, + tecNO_LINE_INSUF_RESERVE: 126, + tecNO_LINE_REDUNDANT: 127, + tecPATH_DRY: 128, + tecUNFUNDED: 129, // Deprecated, old ambiguous unfunded. + tecNO_ALTERNATIVE_KEY: 130, + tecNO_REGULAR_KEY: 131, + tecOWNERS: 132, + tecNO_ISSUER: 133, + tecNO_AUTH: 134, + tecNO_LINE: 135, + tecINSUFF_FEE: 136, + tecFROZEN: 137, + tecNO_TARGET: 138, + tecNO_PERMISSION: 139, + tecNO_ENTRY: 140, + tecINSUFFICIENT_RESERVE: 141, + tecNEED_MASTER_KEY: 142, + tecDST_TAG_NEEDED: 143, + tecINTERNAL: 144, + tecOVERSIZE: 145 +}; + diff --git a/test/ripple-lib/hashprefixes.js b/test/ripple-lib/hashprefixes.js new file mode 100644 index 0000000000..8c151ba718 --- /dev/null +++ b/test/ripple-lib/hashprefixes.js @@ -0,0 +1,39 @@ +'use strict'; + +// TODO: move in helpers from serializedtypes to utils +function toBytes(n) { + return [n >>> 24, n >>> 16 & 0xff, n >>> 8 & 0xff, n & 0xff]; +} + +/** + * Prefix for hashing functions. + * + * These prefixes are inserted before the source material used to + * generate various hashes. This is done to put each hash in its own + * "space." This way, two different types of objects with the + * same binary data will produce different hashes. + * + * Each prefix is a 4-byte value with the last byte set to zero + * and the first three bytes formed from the ASCII equivalent of + * some arbitrary string. For example "TXN". + */ + +// transaction plus signature to give transaction ID +exports.HASH_TX_ID = 0x54584E00; // 'TXN' +// transaction plus metadata +exports.HASH_TX_NODE = 0x534E4400; // 'TND' +// inner node in tree +exports.HASH_INNER_NODE = 0x4D494E00; // 'MIN' +// leaf node in tree +exports.HASH_LEAF_NODE = 0x4D4C4E00; // 'MLN' +// inner transaction to sign +exports.HASH_TX_SIGN = 0x53545800; // 'STX' +// inner transaction to sign (TESTNET) +exports.HASH_TX_SIGN_TESTNET = 0x73747800; // 'stx' +// inner transaction to multisign +exports.HASH_TX_MULTISIGN = 0x534D5400; // 'SMT' + +Object.keys(exports).forEach(function (k) { + exports[k + '_BYTES'] = toBytes(exports[k]); +}); + diff --git a/test/ripple-lib/remote.js b/test/ripple-lib/remote.js new file mode 100644 index 0000000000..7463ce2150 --- /dev/null +++ b/test/ripple-lib/remote.js @@ -0,0 +1,2332 @@ +'use strict'; + +// Interface to manage connections to rippled servers +// +// - We never send binary data. +// - We use the W3C interface for node and browser compatibility: +// http://www.w3.org/TR/websockets/#the-websocket-interface +// +// This class is intended for both browser and Node.js use. +// +// This class is designed to work via peer protocol via either the public or +// private WebSocket interfaces. The JavaScript class for the peer protocol +// has not yet been implemented. However, this class has been designed for it +// to be a very simple drop option. + +var _toConsumableArray = require('babel-runtime/helpers/to-consumable-array')['default']; + +var _Object$keys = require('babel-runtime/core-js/object/keys')['default']; + +var util = require('util'); +var assert = require('assert'); +var _ = require('lodash'); +var LRU = require('lru-cache'); +var async = require('async'); +var EventEmitter = require('events').EventEmitter; +var Server = require('./server').Server; +var Request = require('./request').Request; +var Amount = require('./amount').Amount; +var Currency = require('./currency').Currency; +var UInt160 = require('./uint160').UInt160; +var UInt256 = require('./uint256').UInt256; +var Transaction = require('./transaction').Transaction; +var Account = require('./account').Account; +var Meta = require('./meta').Meta; +var OrderBook = require('./orderbook').OrderBook; +var PathFind = require('./pathfind').PathFind; +var SerializedObject = require('./serializedobject').SerializedObject; +var RippleError = require('./rippleerror').RippleError; +var utils = require('./utils'); +var hashprefixes = require('./hashprefixes'); +var log = require('./log').internal.sub('remote'); + +/** + * Interface to manage connections to rippled servers + * + * @param {Object} Options + */ + +function Remote() { + var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; + + EventEmitter.call(this); + + var self = this; + + _.merge(this, _.defaults(options, Remote.DEFAULTS)); + + this.state = 'offline'; // 'online', 'offline' + this._server_fatal = false; // server exited + this._stand_alone = undefined; + this._testnet = undefined; + + this._ledger_current_index = undefined; + this._ledger_hash = undefined; + this._ledger_time = undefined; + + this._connection_count = 0; + this._connected = false; + this._should_connect = true; + + this._transaction_listeners = 0; + this._received_tx = new LRU({ max: 100 }); + this._cur_path_find = null; + this._queued_path_finds = []; + + if (this.local_signing) { + // Local signing implies local fees and sequences + this.local_sequence = true; + this.local_fee = true; + } + + this._servers = []; + this._primary_server = undefined; + + // Cache information for accounts. + // DEPRECATED, will be removed + // Consider sequence numbers stable if you know you're not generating bad + // transactions. + // Otherwise, clear it to have it automatically refreshed from the network. + // account : { seq : __ } + this.accounts = {}; + + // Account objects by AccountId. + this._accounts = {}; + + // OrderBook objects + this._books = {}; + + // Secrets that we know about. + // Secrets can be set by calling setSecret(account, secret). + // account : secret + this.secrets = {}; + + // Cache for various ledgers. + // XXX Clear when ledger advances. + this.ledgers = { + current: { + account_root: {} + } + }; + + if (typeof this.trusted !== 'boolean') { + throw new TypeError('trusted must be a boolean'); + } + if (typeof this.trace !== 'boolean') { + throw new TypeError('trace must be a boolean'); + } + if (typeof this.allow_partial_history !== 'boolean') { + throw new TypeError('allow_partial_history must be a boolean'); + } + if (typeof this.max_fee !== 'number') { + throw new TypeError('max_fee must be a number'); + } + if (typeof this.max_attempts !== 'number') { + throw new TypeError('max_attempts must be a number'); + } + if (typeof this.fee_cushion !== 'number') { + throw new TypeError('fee_cushion must be a number'); + } + if (typeof this.local_signing !== 'boolean') { + throw new TypeError('local_signing must be a boolean'); + } + if (typeof this.local_fee !== 'boolean') { + throw new TypeError('local_fee must be a boolean'); + } + if (typeof this.local_sequence !== 'boolean') { + throw new TypeError('local_sequence must be a boolean'); + } + if (typeof this.canonical_signing !== 'boolean') { + throw new TypeError('canonical_signing must be a boolean'); + } + if (typeof this.submission_timeout !== 'number') { + throw new TypeError('submission_timeout must be a number'); + } + if (typeof this.automatic_resubmission !== 'boolean') { + throw new TypeError('automatic_resubmission must be a boolean'); + } + if (typeof this.last_ledger_offset !== 'number') { + throw new TypeError('last_ledger_offset must be a number'); + } + if (!Array.isArray(this.servers)) { + throw new TypeError('servers must be an array'); + } + + this.setMaxListeners(this.max_listeners); + + this.servers.forEach(function (serverOptions) { + var server = self.addServer(serverOptions); + server.setMaxListeners(self.max_listeners); + }); + + function listenersModified(action, event) { + // Automatically subscribe and unsubscribe to orderbook + // on the basis of existing event listeners + if (_.contains(Remote.TRANSACTION_EVENTS, event)) { + switch (action) { + case 'add': + if (++self._transaction_listeners === 1) { + self.requestSubscribe('transactions').request(); + } + break; + case 'remove': + if (--self._transaction_listeners === 0) { + self.requestUnsubscribe('transactions').request(); + } + break; + } + } + } + + this.on('newListener', function (event) { + listenersModified('add', event); + }); + + this.on('removeListener', function (event) { + listenersModified('remove', event); + }); +} + +util.inherits(Remote, EventEmitter); + +Remote.DEFAULTS = { + trusted: false, + trace: false, + allow_partial_history: true, + local_sequence: true, + local_fee: true, + local_signing: true, + canonical_signing: true, + fee_cushion: 1.2, + max_fee: 1000000, // 1 XRP + max_attempts: 10, + submission_timeout: 1000 * 20, + automatic_resubmission: true, + last_ledger_offset: 3, + servers: [], + max_listeners: 0 // remove Node EventEmitter warnings +}; + +Remote.TRANSACTION_EVENTS = ['transaction', 'transaction_all']; + +// Flags for ledger entries. In support of accountRoot(). +Remote.flags = { + // AccountRoot + account_root: { + PasswordSpent: 0x00010000, // password set fee is spent + RequireDestTag: 0x00020000, // require a DestinationTag for payments + RequireAuth: 0x00040000, // require a authorization to hold IOUs + DisallowXRP: 0x00080000, // disallow sending XRP + DisableMaster: 0x00100000, // force regular key + NoFreeze: 0x00200000, // permanently disallowed freezing trustlines + GlobalFreeze: 0x00400000, // trustlines globally frozen + DefaultRipple: 0x00800000 + }, + // Offer + offer: { + Passive: 0x00010000, + Sell: 0x00020000 // offer was placed as a sell + }, + // Ripple tate + state: { + LowReserve: 0x00010000, // entry counts toward reserve + HighReserve: 0x00020000, + LowAuth: 0x00040000, + HighAuth: 0x00080000, + LowNoRipple: 0x00100000, + HighNoRipple: 0x00200000 + } +}; + +/** + * Check that server message is valid + * + * @param {Object} message + * @return Boolean + */ + +Remote.isValidMessage = function (message) { + return typeof message === 'object' && typeof message.type === 'string'; +}; + +/** + * Check that server message contains valid + * ledger data + * + * @param {Object} message + * @return {Boolean} + */ + +Remote.isValidLedgerData = function (message) { + return typeof message === 'object' && typeof message.fee_base === 'number' && typeof message.fee_ref === 'number' && typeof message.ledger_hash === 'string' && typeof message.ledger_index === 'number' && typeof message.ledger_time === 'number' && typeof message.reserve_base === 'number' && typeof message.reserve_inc === 'number'; +}; + +/** + * Check that server message contains valid + * load status data + * + * @param {Object} message + * @return {Boolean} + */ + +Remote.isValidLoadStatus = function (message) { + return typeof message.load_base === 'number' && typeof message.load_factor === 'number'; +}; + +/** + * Check that provided ledger is validated + * + * @param {Object} ledger + * @return {Boolean} + */ + +Remote.isValidated = function (message) { + return message && typeof message === 'object' && message.validated === true; +}; + +/** + * Set the emitted state: 'online' or 'offline' + * + * @param {String} state + */ + +Remote.prototype._setState = function (state) { + if (this.state !== state) { + if (this.trace) { + log.info('set_state:', state); + } + + this.state = state; + this.emit('state', state); + + switch (state) { + case 'online': + this._online_state = 'open'; + this._connected = true; + this.emit('connect'); + this.emit('connected'); + break; + case 'offline': + this._online_state = 'closed'; + this._connected = false; + this.emit('disconnect'); + this.emit('disconnected'); + break; + } + } +}; + +/** + * Inform remote that the remote server is not comming back. + */ + +Remote.prototype.setServerFatal = function () { + this._server_fatal = true; +}; + +/** + * Enable debug output + * + * @param {Boolean} trace + */ + +Remote.prototype.setTrace = function (trace) { + this.trace = trace === undefined || trace; + return this; +}; + +Remote.prototype._trace = function () { + if (this.trace) { + log.info.apply(log, arguments); + } +}; + +/** + * Store a secret - allows the Remote to automatically fill + * out auth information. + * + * @param {String} account + * @param {String} secret + */ + +Remote.prototype.setSecret = function (account, secret) { + this.secrets[account] = secret; +}; + +Remote.prototype.addServer = function (options) { + var self = this; + var server = new Server(this, options); + + function serverMessage(data) { + self._handleMessage(data, server); + } + + server.on('message', serverMessage); + + function serverConnect() { + self._connection_count += 1; + + if (options.primary) { + self._setPrimaryServer(server); + } + if (self._connection_count === 1) { + self._setState('online'); + } + if (self._connection_count === self._servers.length) { + self.emit('ready'); + } + } + + server.on('connect', serverConnect); + + function serverDisconnect() { + self._connection_count--; + if (self._connection_count === 0) { + self._setState('offline'); + } + } + + server.on('disconnect', serverDisconnect); + + this._servers.push(server); + + return server; +}; + +/** + * Reconnect to Ripple network + */ + +Remote.prototype.reconnect = function () { + if (!this._should_connect) { + return; + } + + log.info('reconnecting'); + + this._servers.forEach(function (server) { + server.reconnect(); + }); +}; + +/** + * Connect to the Ripple network + * + * @param {Function} callback + * @api public + */ + +Remote.prototype.connect = function (callback) { + if (!this._servers.length) { + throw new Error('No servers available.'); + } + + if (typeof callback === 'function') { + this.once('connect', callback); + } + + this._should_connect = true; + + this._servers.forEach(function (server) { + server.connect(); + }); + + return this; +}; + +/** + * Disconnect from the Ripple network. + * + * @param {Function} callback + * @api public + */ + +Remote.prototype.disconnect = function (callback_) { + if (!this._servers.length) { + throw new Error('No servers available, not disconnecting'); + } + + var callback = _.isFunction(callback_) ? callback_ : function () {}; + + this._should_connect = false; + + if (!this.isConnected()) { + callback(); + return this; + } + + this.once('disconnect', callback); + + this._servers.forEach(function (server) { + server.disconnect(); + }); + + this._setState('offline'); + + return this; +}; + +/** + * Handle server message. Server messages are proxied to + * the Remote, such that global events can be handled + * + * It is possible for messages to be dispatched after the + * connection is closed. + * + * @param {JSON} message + * @param {Server} server + */ + +Remote.prototype._handleMessage = function (message, server) { + if (!Remote.isValidMessage(message)) { + // Unexpected response from remote. + var error = new RippleError('remoteUnexpected', 'Unexpected response from remote: ' + JSON.stringify(message)); + + this.emit('error', error); + log.error(error); + return; + } + + switch (message.type) { + case 'ledgerClosed': + this._handleLedgerClosed(message, server); + break; + case 'serverStatus': + this._handleServerStatus(message, server); + break; + case 'transaction': + this._handleTransaction(message, server); + break; + case 'path_find': + this._handlePathFind(message, server); + break; + case 'validationReceived': + this._handleValidationReceived(message, server); + break; + default: + if (this.trace) { + log.info(message.type + ': ', message); + } + break; + } +}; + +Remote.prototype.getLedgerSequence = function () { + if (!this._ledger_current_index) { + throw new Error('Ledger sequence has not yet been initialized'); + } + // the "current" ledger is the one after the most recently closed ledger + return this._ledger_current_index - 1; +}; + +/** + * Handle server ledger_closed event + * + * @param {Object} message + */ + +Remote.prototype._handleLedgerClosed = function (message, server) { + var self = this; + + // XXX If not trusted, need to verify we consider ledger closed. + // XXX Also need to consider a slow server or out of order response. + // XXX Be more defensive fields could be missing or of wrong type. + // YYY Might want to do some cache management. + if (!Remote.isValidLedgerData(message)) { + return; + } + + var ledgerAdvanced = message.ledger_index >= this._ledger_current_index; + + if (isNaN(this._ledger_current_index) || ledgerAdvanced) { + this._ledger_time = message.ledger_time; + this._ledger_hash = message.ledger_hash; + this._ledger_current_index = message.ledger_index + 1; + + if (this.isConnected()) { + this.emit('ledger_closed', message, server); + } else { + this.once('connect', function () { + // Delay until server is 'online' + self.emit('ledger_closed', message, server); + }); + } + } +}; + +/** + * Handle server validation_received event + * + * @param {Object} message + */ + +Remote.prototype._handleValidationReceived = function (message, server) { + this.emit('validation_received', message, server); +}; + +/** + * Handle server server_status event + * + * @param {Object} message + */ + +Remote.prototype._handleServerStatus = function (message, server) { + this.emit('server_status', message, server); +}; + +/** + * Handle server transaction event + * + * @param {Object} message + */ + +Remote.prototype._handleTransaction = function (message, server) { + // XXX If not trusted, need proof. + var transactionHash = message.transaction.hash; + + if (this._received_tx.get(transactionHash)) { + // De-duplicate transactions + return; + } + + if (message.validated) { + this._received_tx.set(transactionHash, true); + } + + if (this.trace) { + log.info('tx:', message); + } + + var metadata = message.meta || message.metadata; + + if (metadata) { + // Process metadata + message.mmeta = new Meta(metadata); + + // Pass the event on to any related Account objects + message.mmeta.getAffectedAccounts().forEach(function (account) { + if (this._accounts[account]) { + this._accounts[account].notify(message); + } + }, this); + + // Pass the event on to any related OrderBooks + message.mmeta.getAffectedBooks().forEach(function (book) { + if (this._books[book]) { + this._books[book].notify(message); + } + }, this); + } else { + // Transaction could be from proposed transaction stream + // XX + ['Account', 'Destination'].forEach(function (prop) { + if (this._accounts[message.transaction[prop]]) { + this._accounts[message.transaction[prop]].notify(message); + } + }, this); + } + + this.emit('transaction', message, server); + this.emit('transaction_all', message, server); +}; + +/** + * Handle server path_find event + * + * @param {Object} message + */ + +Remote.prototype._handlePathFind = function (message, server) { + // Pass the event to the currently open PathFind object + if (this._cur_path_find) { + this._cur_path_find.notify_update(message); + } + + this.emit('path_find_all', message, server); +}; + +/** + * Returns the current ledger hash + * + * @return {String} ledger hash + */ + +Remote.prototype.getLedgerHash = function () { + return this._ledger_hash; +}; + +/** + * Set primary server. Primary server will be selected + * to handle requested regardless of its internally-tracked + * priority score + * + * @param {Server} server + */ + +Remote.prototype._setPrimaryServer = Remote.prototype.setPrimaryServer = function (server) { + if (this._primary_server) { + this._primary_server._primary = false; + } + this._primary_server = server; + this._primary_server._primary = true; +}; + +/** + * Get connected state + * + * @return {Boolean} connected + */ + +Remote.prototype.isConnected = function () { + return this._connected; +}; + +/** + * Get array of connected servers + */ + +Remote.prototype.getConnectedServers = function () { + return this._servers.filter(function (server) { + return server.isConnected(); + }); +}; + +/** + * Select a server to handle a request. Servers are + * automatically prioritized + */ + +Remote.prototype._getServer = Remote.prototype.getServer = function () { + if (this._primary_server && this._primary_server.isConnected()) { + return this._primary_server; + } + + if (!this._servers.length) { + return null; + } + + var connectedServers = this.getConnectedServers(); + if (connectedServers.length === 0 || !connectedServers[0]) { + return null; + } + + var server = connectedServers[0]; + var cScore = server._score + server._fee; + + for (var i = 1; i < connectedServers.length; i++) { + var _server = connectedServers[i]; + var bScore = _server._score + _server._fee; + if (bScore < cScore) { + server = _server; + cScore = bScore; + } + } + + return server; +}; + +/** + * Send a request. This method is called internally by Request + * objects. Each Request contains a reference to Remote, and + * Request.request calls Request.remote.request + * + * @param {Request} request + */ + +Remote.prototype.request = function (request) { + if (typeof request === 'string') { + var prefix = /^request_/.test(request) ? '' : 'request_'; + var requestName = prefix + request; + var methodName = requestName.replace(/(\_\w)/g, function (m) { + return m[1].toUpperCase(); + }); + + if (typeof this[methodName] === 'function') { + var args = _.slice(arguments, 1); + return this[methodName].apply(this, args); + } + + throw new Error('Command does not exist: ' + requestName); + } + + if (!(request instanceof Request)) { + throw new Error('Argument is not a Request'); + } + + if (!this._servers.length) { + return request.emit('error', new Error('No servers available')); + } + if (!this.isConnected()) { + return this.once('connect', this.request.bind(this, request)); + } + if (request.server === null) { + return request.emit('error', new Error('Server does not exist')); + } + + var server = request.server || this.getServer(); + if (server) { + server._request(request); + } else { + request.emit('error', new Error('No servers available')); + } +}; + +/** + * Request ping + * + * @param [String] server host + * @param [Function] callback + * @return {Request} request + */ + +Remote.prototype.ping = Remote.prototype.requestPing = function (host, callback_) { + var request = new Request(this, 'ping'); + var callback = callback_; + + switch (typeof host) { + case 'function': + callback = host; + break; + case 'string': + request.setServer(host); + break; + } + + var then = Date.now(); + + request.once('success', function () { + request.emit('pong', Date.now() - then); + }); + + request.callback(callback, 'pong'); + + return request; +}; + +/** + * Request server_info + * + * @param [Function] callback + * @return {Request} request + */ + +Remote.prototype.requestServerInfo = function (callback) { + return new Request(this, 'server_info').callback(callback); +}; + +/** + * Request ledger + * + * @return {Request} request + */ + +Remote.prototype.requestLedger = function (options, callback_) { + // XXX This is a bad command. Some variants don't scale. + // XXX Require the server to be trusted. + // utils.assert(this.trusted); + + var request = new Request(this, 'ledger'); + var callback = callback_; + + switch (typeof options) { + case 'undefined': + break; + case 'function': + callback = options; + break; + + case 'object': + if (!options) { + break; + } + + _Object$keys(options).forEach(function (o) { + switch (o) { + case 'full': + case 'expand': + case 'transactions': + case 'accounts': + request.message[o] = options[o] ? true : false; + break; + case 'ledger': + request.selectLedger(options.ledger); + break; + case 'ledger_index': + case 'ledger_hash': + request.message[o] = options[o]; + break; + case 'closed': + case 'current': + case 'validated': + request.message.ledger_index = o; + break; + } + }, options); + break; + + default: + request.selectLedger(options); + break; + } + + request.callback(callback); + + return request; +}; + +/** + * Request ledger_closed + * + * @param [Function] callback + * @return {Request} request + */ + +Remote.prototype.requestLedgerClosed = Remote.prototype.requestLedgerHash = function (callback) { + // utils.assert(this.trusted); // If not trusted, need to check proof. + return new Request(this, 'ledger_closed').callback(callback); +}; + +/** + * Request ledger_header + * + * @param [Function] callback + * @return {Request} request + */ + +Remote.prototype.requestLedgerHeader = function (callback) { + return new Request(this, 'ledger_header').callback(callback); +}; + +/** + * Request ledger_current + * + * Get the current proposed ledger entry. May be closed (and revised) + * at any time (even before returning). + * + * Only for unit testing. + * + * @param [Function] callback + * @return {Request} request + */ + +Remote.prototype.requestLedgerCurrent = function (callback) { + return new Request(this, 'ledger_current').callback(callback); +}; + +/** + * Request ledger_data + * + * Get the contents of a specified ledger + * + * @param {Object} options + * @property {Boolean} [options.binary]- Flag which determines if rippled + * returns binary or parsed JSON + * @property {String|Number} [options.ledger] - Hash or sequence of a ledger + * to get contents for + * @property {Number} [options.limit] - Number of contents to retrieve + * from the ledger + * @property {Function} callback + * + * @callback + * @param {Error} error + * @param {LedgerData} ledgerData + * + * @return {Request} request + */ + +Remote.prototype.requestLedgerData = function (options, callback) { + var request = new Request(this, 'ledger_data'); + + request.message.binary = options.binary !== false; + request.selectLedger(options.ledger); + request.message.limit = options.limit; + + request.once('success', function (res) { + if (options.binary === false) { + request.emit('state', res); + return; + } + + function iterator(ledgerData, next) { + async.setImmediate(function () { + next(null, Remote.parseBinaryLedgerData(ledgerData)); + }); + } + + function complete(err, state) { + if (err) { + request.emit('error', err); + } else { + res.state = state; + request.emit('state', res); + } + } + + async.mapSeries(res.state, iterator, complete); + }); + + request.callback(callback, 'state'); + + return request; +}; + +/** + * Request ledger_entry + * + * @param [String] type + * @param [Function] callback + * @return {Request} request + */ + +Remote.prototype.requestLedgerEntry = function (type, callback_) { + // utils.assert(this.trusted); + // If not trusted, need to check proof, maybe talk packet protocol. + + var self = this; + + var request = new Request(this, 'ledger_entry'); + var callback = _.isFunction(type) ? type : callback_; + + // Transparent caching. When .request() is invoked, look in the Remote object + // for the result. If not found, listen, cache result, and emit it. + // + // Transparent caching: + if (type === 'account_root') { + request.request_default = request.request; + + request.request = function () { + // Intercept default request. + var bDefault = true; + + if (!self._ledger_hash && type === 'account_root') { + var cache = self.ledgers.current.account_root; + + if (!cache) { + cache = self.ledgers.current.account_root = {}; + } + + var node = self.ledgers.current.account_root[request.message.account_root]; + + if (node) { + // Emulate fetch of ledger entry. + // YYY Missing lots of fields. + request.emit('success', { node: node }); + bDefault = false; + } else { + // Was not cached. + // XXX Only allow with trusted mode. Must sync response with advance + switch (type) { + case 'account_root': + request.once('success', function (message) { + // Cache node. + self.ledgers.current.account_root[message.node.Account] = message.node; + }); + break; + + default: + // This type not cached. + } + } + } + + if (bDefault) { + request.request_default(); + } + }; + } + + request.callback(callback); + + return request; +}; + +/** + * Request subscribe + * + * @param {Array} streams + * @param [Function] callback + * @return {Request} request + */ + +Remote.prototype.requestSubscribe = function (streams, callback) { + var request = new Request(this, 'subscribe'); + + if (streams) { + request.message.streams = Array.isArray(streams) ? streams : [streams]; + } + + request.callback(callback); + + return request; +}; + +/** + * Request usubscribe + * + * @param {Array} streams + * @param [Function] callback + * @return {Request} request + */ + +Remote.prototype.requestUnsubscribe = function (streams, callback) { + var request = new Request(this, 'unsubscribe'); + + if (streams) { + request.message.streams = Array.isArray(streams) ? streams : [streams]; + } + + request.callback(callback); + + return request; +}; + +/** + * Request transaction_entry + * + * @param {Object} options - + * @param {String} [options.transaction] - hash + * @param {String|Number} [options.ledger='validated'] - hash or sequence + * @param [Function] callback + * @return {Request} request + */ + +Remote.prototype.requestTransactionEntry = function (options, callback) { + var request = new Request(this, 'transaction_entry'); + request.txHash(options.hash); + request.selectLedger(options.ledger, 'validated'); + request.callback(callback); + return request; +}; + +/** + * Request tx + * + * @param {Object|String} hash + * @property {String} hash.hash - Transaction hash + * @property {Boolean} [hash.binary=true] - Flag which determines if rippled + * returns binary or parsed JSON + * @param [Function] callback + * @return {Request} request + */ + +Remote.prototype.requestTransaction = function (options, callback) { + var request = new Request(this, 'tx'); + request.message.binary = options.binary !== false; + request.message.transaction = options.hash; + + request.once('success', function (res) { + if (options.binary === false) { + request.emit('transaction', res); + } else { + request.emit('transaction', Remote.parseBinaryTransaction(res)); + } + }); + + request.callback(callback, 'transaction'); + + return request; +}; + +/** + * Account Request + * + * Optional paging with limit and marker options + * supported in rippled for 'account_lines' and 'account_offers' + * + * The paged responses aren't guaranteed to be reliable between + * ledger closes. You have to supply a ledger_index or ledger_hash + * when paging to ensure a complete response + * + * @param {String} command - request command, e.g. 'account_lines' + * @param {Object} options - all optional + * @param {String} account - ripple address + * @param {String} peer - ripple address + * @param [String|Number] ledger identifier + * @param [Number] limit - max results per response + * @param {String} marker - start position in response paging + * @param [Function] callback + * @return {Request} + * @throws {Error} if a marker is provided, but no ledger_index or ledger_hash + */ + +Remote.accountRequest = function (command, options, callback) { + if (options.marker) { + if (!(Number(options.ledger) > 0) && !UInt256.is_valid(options.ledger)) { + throw new Error('A ledger_index or ledger_hash must be provided when using a marker'); + } + } + + var request = new Request(this, command); + + request.message.account = UInt160.json_rewrite(options.account); + request.selectLedger(options.ledger); + + if (UInt160.is_valid(options.peer)) { + request.message.peer = UInt160.json_rewrite(options.peer); + } + + if (!isNaN(options.limit)) { + var _limit = Number(options.limit); + + // max for 32-bit unsigned int is 4294967295 + // we'll clamp to 1e9 + if (_limit > 1e9) { + _limit = 1e9; + } + // min for 32-bit unsigned int is 0 + // we'll clamp to 0 + if (_limit < 0) { + _limit = 0; + } + + request.message.limit = _limit; + } + + if (options.marker) { + request.message.marker = options.marker; + } + + request.callback(callback); + + return request; +}; + +/** + * Request account_info + * + * @param {Object} options + * @param {String} account - ripple address + * @param {String} peer - ripple address + * @param [String|Number] ledger identifier + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestAccountInfo = function () { + for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { + args[_key] = arguments[_key]; + } + + var options = ['account_info'].concat(args); + return Remote.accountRequest.apply(this, options); +}; + +/** + * Request account_currencies + * + * @param {Object} options + * @param {String} account - ripple address + * @param {String} peer - ripple address + * @param [String|Number] ledger identifier + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestAccountCurrencies = function () { + for (var _len2 = arguments.length, args = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + args[_key2] = arguments[_key2]; + } + + var options = ['account_currencies'].concat(args); + return Remote.accountRequest.apply(this, options); +}; + +/** + * Request account_lines + * + * Requests for account_lines support paging, provide a limit and marker + * to page through responses. + * + * The paged responses aren't guaranteed to be reliable between + * ledger closes. You have to supply a ledger_index or ledger_hash + * when paging to ensure a complete response + * + * @param {Object} options + * @param {String} account - ripple address + * @param {String} peer - ripple address + * @param [String|Number] ledger identifier + * @param [Number] limit - max results per response + * @param {String} marker - start position in response paging + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestAccountLines = function () { + // XXX Does this require the server to be trusted? + // utils.assert(this.trusted); + var options = ['account_lines']; + + for (var _len3 = arguments.length, args = Array(_len3), _key3 = 0; _key3 < _len3; _key3++) { + args[_key3] = arguments[_key3]; + } + + if (_.isPlainObject(args[0])) { + options = options.concat(args); + } else { + var account = args[0]; + var peer = args[1]; + var ledger = args[2]; + + options = options.concat([account, ledger, peer].concat(_toConsumableArray(args.slice(3)))); + } + + return Remote.accountRequest.apply(this, options); +}; + +/** + * Request account_offers + * + * Requests for account_offers support paging, provide a limit and marker + * to page through responses. + * + * The paged responses aren't guaranteed to be reliable between + * ledger closes. You have to supply a ledger_index or ledger_hash + * when paging to ensure a complete response + * + * @param {Object} options + * @param {String} account - ripple address + * @param [String|Number] ledger identifier + * @param [Number] limit - max results per response + * @param {String} marker - start position in response paging + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestAccountOffers = function () { + for (var _len4 = arguments.length, args = Array(_len4), _key4 = 0; _key4 < _len4; _key4++) { + args[_key4] = arguments[_key4]; + } + + var options = ['account_offers'].concat(args); + return Remote.accountRequest.apply(this, options); +}; + +/** + * Request account_tx + * + * @param {Object} options + * + * @param {String} account + * @param [Number] ledger_index_min defaults to -1 + * @param [Number] ledger_index_max defaults to -1 + * @param [Boolean] binary, defaults to true + * @param [Boolean] parseBinary, defaults to true + * @param [Boolean] count, defaults to false + * @param [Boolean] descending, defaults to false + * @param [Number] offset, defaults to 0 + * @param [Number] limit + * + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestAccountTransactions = Remote.prototype.requestAccountTx = function (options, callback) { + // XXX Does this require the server to be trusted? + // utils.assert(this.trusted); + + var request = new Request(this, 'account_tx'); + + options.binary = options.binary !== false; + + if (options.min_ledger !== undefined) { + options.ledger_index_min = options.min_ledger; + } + + if (options.max_ledger !== undefined) { + options.ledger_index_max = options.max_ledger; + } + + if (options.binary && options.parseBinary === undefined) { + options.parseBinary = true; + } + + _Object$keys(options).forEach(function (o) { + switch (o) { + case 'account': + case 'ledger_index_min': // earliest + case 'ledger_index_max': // latest + case 'binary': // false + case 'count': // false + case 'descending': // false + case 'offset': // 0 + case 'limit': + + // extended account_tx + case 'forward': // false + case 'marker': + request.message[o] = this[o]; + break; + } + }, options); + + request.once('success', function (res) { + if (!options.parseBinary) { + request.emit('transactions', res); + return; + } + + function iterator(transaction, next) { + async.setImmediate(function () { + next(null, Remote.parseBinaryAccountTransaction(transaction)); + }); + } + + function complete(err, transactions) { + if (err) { + request.emit('error', err); + } else { + res.transactions = transactions; + request.emit('transactions', res); + } + } + + async.mapSeries(res.transactions, iterator, complete); + }); + + request.callback(callback, 'transactions'); + + return request; +}; + +/** + * @param {Object} transaction + * @return {Transaction} + */ + +Remote.parseBinaryAccountTransaction = function (transaction) { + var tx_obj = new SerializedObject(transaction.tx_blob); + var tx_obj_json = tx_obj.to_json(); + var meta = new SerializedObject(transaction.meta).to_json(); + + var tx_result = { + validated: transaction.validated + }; + + tx_result.meta = meta; + tx_result.tx = tx_obj_json; + tx_result.tx.hash = tx_obj.hash(hashprefixes.HASH_TX_ID).to_hex(); + tx_result.tx.ledger_index = transaction.ledger_index; + tx_result.tx.inLedger = transaction.ledger_index; + + if (typeof meta.DeliveredAmount === 'object') { + tx_result.meta.delivered_amount = meta.DeliveredAmount; + } else { + switch (typeof tx_obj_json.Amount) { + case 'string': + case 'object': + tx_result.meta.delivered_amount = tx_obj_json.Amount; + break; + } + } + + return tx_result; +}; + +Remote.parseBinaryTransaction = function (transaction) { + var tx_obj = new SerializedObject(transaction.tx).to_json(); + var meta = new SerializedObject(transaction.meta).to_json(); + + var tx_result = tx_obj; + + tx_result.date = transaction.date; + tx_result.hash = transaction.hash; + tx_result.inLedger = transaction.inLedger; + tx_result.ledger_index = transaction.ledger_index; + tx_result.meta = meta; + tx_result.validated = transaction.validated; + + switch (typeof meta.DeliveredAmount) { + case 'string': + case 'object': + tx_result.meta.delivered_amount = meta.DeliveredAmount; + break; + default: + switch (typeof tx_obj.Amount) { + case 'string': + case 'object': + tx_result.meta.delivered_amount = tx_obj.Amount; + break; + } + } + + return tx_result; +}; + +/** + * Parse binary ledger state data + * + * @param {Object} ledgerData + * @property {String} ledgerData.data + * @property {String} ledgerData.index + * + * @return {State} + */ + +Remote.parseBinaryLedgerData = function (ledgerData) { + var data = new SerializedObject(ledgerData.data).to_json(); + data.index = ledgerData.index; + return data; +}; + +/** + * Request the overall transaction history. + * + * Returns a list of transactions that happened recently on the network. The + * default number of transactions to be returned is 20. + * + * @param [Number] start + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestTransactionHistory = function (options, callback) { + // XXX Does this require the server to be trusted? + // utils.assert(this.trusted); + var request = new Request(this, 'tx_history'); + request.message.start = options.start; + request.callback(callback); + + return request; +}; + +/** + * Request book_offers + * + * @param {Object} options + * @param {Object} options.gets - taker_gets with issuer and currency + * @param {Object} options.pays - taker_pays with issuer and currency + * @param {String} [options.taker] + * @param {String} [options.ledger] + * @param {String|Number} [options.limit] + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestBookOffers = function (options, callback) { + var gets = options.gets; + var pays = options.pays; + var taker = options.taker; + var ledger = options.ledger; + var limit = options.limit; + + var request = new Request(this, 'book_offers'); + + request.message.taker_gets = { + currency: Currency.json_rewrite(gets.currency, { force_hex: true }) + }; + + if (!Currency.from_json(request.message.taker_gets.currency).is_native()) { + request.message.taker_gets.issuer = UInt160.json_rewrite(gets.issuer); + } + + request.message.taker_pays = { + currency: Currency.json_rewrite(pays.currency, { force_hex: true }) + }; + + if (!Currency.from_json(request.message.taker_pays.currency).is_native()) { + request.message.taker_pays.issuer = UInt160.json_rewrite(pays.issuer); + } + + request.message.taker = taker ? taker : UInt160.ACCOUNT_ONE; + request.selectLedger(ledger); + + if (!isNaN(limit)) { + var _limit = Number(limit); + + // max for 32-bit unsigned int is 4294967295 + // we'll clamp to 1e9 + if (_limit > 1e9) { + _limit = 1e9; + } + // min for 32-bit unsigned int is 0 + // we'll clamp to 0 + if (_limit < 0) { + _limit = 0; + } + + request.message.limit = _limit; + } + + request.callback(callback); + return request; +}; + +/** + * Request wallet_accounts + * + * @param {String} seed + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestWalletAccounts = function (options, callback) { + utils.assert(this.trusted); // Don't send secrets. + var request = new Request(this, 'wallet_accounts'); + request.message.seed = options.seed; + request.callback(callback); + + return request; +}; + +/** + * Request sign + * + * @param {String} secret + * @param {Object} tx_json + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestSign = function (options, callback) { + utils.assert(this.trusted); // Don't send secrets. + + var request = new Request(this, 'sign'); + request.message.secret = options.secret; + request.message.tx_json = options.tx_json; + request.callback(callback); + + return request; +}; + +/** + * Request submit + * + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestSubmit = function (callback) { + return new Request(this, 'submit').callback(callback); +}; + +/** + * Create a subscribe request with current subscriptions. + * + * Other classes can add their own subscriptions to this request by listening + * to the server_subscribe event. + * + * This function will create and return the request, but not submit it. + * + * @param [Function] callback + * @api private + */ + +Remote.prototype._serverPrepareSubscribe = function (server, callback_) { + var self = this; + var feeds = ['ledger', 'server']; + var callback = _.isFunction(server) ? server : callback_; + + if (this._transaction_listeners) { + feeds.push('transactions'); + } + + var request = this.requestSubscribe(feeds); + + function serverSubscribed(message) { + self._stand_alone = Boolean(message.stand_alone); + self._testnet = Boolean(message.testnet); + self._handleLedgerClosed(message, server); + self.emit('subscribed'); + } + + request.on('error', function (err) { + if (self.trace) { + log.info('Initial server subscribe failed', err); + } + }); + + request.once('success', serverSubscribed); + + self.emit('prepare_subscribe', request); + + request.callback(callback, 'subscribed'); + + return request; +}; + +/** + * For unit testing: ask the remote to accept the current ledger. + * To be notified when the ledger is accepted, server_subscribe() then listen + * to 'ledger_hash' events. A good way to be notified of the result of this is: + * remote.on('ledger_closed', function(ledger_closed, ledger_index) { ... } ); + * + * @param [Function] callback + */ + +Remote.prototype.ledgerAccept = Remote.prototype.requestLedgerAccept = function (callback) { + /* eslint-disable consistent-return */ + var request = new Request(this, 'ledger_accept'); + + if (!this._stand_alone) { + // XXX This should emit error on the request + this.emit('error', new RippleError('notStandAlone')); + return; + } + + this.once('ledger_closed', function (ledger) { + request.emit('ledger_closed', ledger); + }); + + request.callback(callback, 'ledger_closed'); + request.request(); + + return request; + /* eslint-enable consistent-return */ +}; + +/** + * Account root request abstraction + * + * @this Remote + * @api private + */ + +Remote.accountRootRequest = function (command, filter, options, callback) { + var request = this.requestLedgerEntry('account_root'); + request.accountRoot(options.account); + request.selectLedger(options.ledger); + + request.once('success', function (message) { + request.emit(command, filter(message)); + }); + + request.callback(callback, command); + + return request; +}; + +/** + * Request account balance + * + * @param {String} account + * @param [String|Number] ledger + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestAccountBalance = function () { + function responseFilter(message) { + return Amount.from_json(message.node.Balance); + } + + for (var _len5 = arguments.length, args = Array(_len5), _key5 = 0; _key5 < _len5; _key5++) { + args[_key5] = arguments[_key5]; + } + + var options = ['account_balance', responseFilter].concat(args); + return Remote.accountRootRequest.apply(this, options); +}; + +/** + * Request account flags + * + * @param {String} account + * @param [String|Number] ledger + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestAccountFlags = function () { + function responseFilter(message) { + return message.node.Flags; + } + + for (var _len6 = arguments.length, args = Array(_len6), _key6 = 0; _key6 < _len6; _key6++) { + args[_key6] = arguments[_key6]; + } + + var options = ['account_flags', responseFilter].concat(args); + return Remote.accountRootRequest.apply(this, options); +}; + +/** + * Request owner count + * + * @param {String} account + * @param [String|Number] ledger + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestOwnerCount = function () { + function responseFilter(message) { + return message.node.OwnerCount; + } + + for (var _len7 = arguments.length, args = Array(_len7), _key7 = 0; _key7 < _len7; _key7++) { + args[_key7] = arguments[_key7]; + } + + var options = ['owner_count', responseFilter].concat(args); + return Remote.accountRootRequest.apply(this, options); +}; + +/** + * Get an account by accountID (address) + * + * + * @param {String} account + * @return {Account} + */ + +Remote.prototype.getAccount = function (accountID) { + return this._accounts[UInt160.json_rewrite(accountID)]; +}; + +/** + * Add an account by accountID (address) + * + * @param {String} account + * @return {Account} + */ + +Remote.prototype.addAccount = function (accountID) { + var account = new Account(this, accountID); + + if (account.isValid()) { + this._accounts[accountID] = account; + } + + return account; +}; + +/** + * Add an account if it does not exist, return the + * account by accountID (address) + * + * @param {String} account + * @return {Account} + */ + +Remote.prototype.account = Remote.prototype.findAccount = function (accountID) { + var account = this.getAccount(accountID); + return account ? account : this.addAccount(accountID); +}; + +/** + * Create a pathfind + * + * @param {Object} options - + * @param {Function} callback - + * @return {PathFind} - + */ +Remote.prototype.createPathFind = function (options, callback) { + if (this._cur_path_find !== null) { + this._queued_path_finds.push({ options: options, callback: callback }); + return null; + } + + var pathFind = new PathFind(this, options.src_account, options.dst_account, options.dst_amount, options.src_currencies); + + if (this._cur_path_find) { + this._cur_path_find.notify_superceded(); + } + + if (callback) { + pathFind.on('update', function (data) { + if (data.full_reply) { + pathFind.close(); + callback(null, data); + } + }); + pathFind.on('error', callback); + } + + this._cur_path_find = pathFind; + pathFind.create(); + return pathFind; +}; + +Remote.prepareTrade = function (currency, issuer) { + var suffix = Currency.from_json(currency).is_native() ? '' : '/' + issuer; + return currency + suffix; +}; + +/** + * Create an OrderBook if it does not exist, return + * the order book + * + * @param {Object} options + * @return {OrderBook} + */ + +Remote.prototype.book = Remote.prototype.createOrderBook = function (options) { + var gets = Remote.prepareTrade(options.currency_gets, options.issuer_gets); + var pays = Remote.prepareTrade(options.currency_pays, options.issuer_pays); + var key = gets + ':' + pays; + + if (this._books.hasOwnProperty(key)) { + return this._books[key]; + } + + var book = new OrderBook(this, options.currency_gets, options.issuer_gets, options.currency_pays, options.issuer_pays, key); + + if (book.is_valid()) { + this._books[key] = book; + } + + return book; +}; + +/** + * Return the next account sequence + * + * @param {String} account + * @param {String} sequence modifier (ADVANCE or REWIND) + * @return {Number} sequence + */ + +Remote.prototype.accountSeq = Remote.prototype.getAccountSequence = function (account_, advance) { + var account = UInt160.json_rewrite(account_); + var accountInfo = this.accounts[account]; + + if (!accountInfo) { + return NaN; + } + + var seq = accountInfo.seq; + var change = ({ ADVANCE: 1, REWIND: -1 })[advance.toUpperCase()] || 0; + + accountInfo.seq += change; + + return seq; +}; + +/** + * Set account sequence + * + * @param {String} account + * @param {Number} sequence + */ + +Remote.prototype.setAccountSequence = Remote.prototype.setAccountSeq = function (account_, sequence) { + var account = UInt160.json_rewrite(account_); + + if (!this.accounts.hasOwnProperty(account)) { + this.accounts[account] = {}; + } + + this.accounts[account].seq = sequence; +}; + +/** + * Refresh an account's sequence from server + * + * @param {String} account + * @param [String|Number] ledger + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.accountSeqCache = function (options, callback) { + if (!this.accounts.hasOwnProperty(options.account)) { + this.accounts[options.account] = {}; + } + + var account_info = this.accounts[options.account]; + var request = account_info.caching_seq_request; + + function accountRootSuccess(message) { + delete account_info.caching_seq_request; + + var seq = message.node.Sequence; + account_info.seq = seq; + + request.emit('success_cache', message); + } + + function accountRootError(message) { + delete account_info.caching_seq_request; + + request.emit('error_cache', message); + } + + if (!request) { + request = this.requestLedgerEntry('account_root'); + request.accountRoot(options.account); + + if (!_.isUndefined(options.ledger)) { + request.selectLedger(options.ledger); + } + + request.once('success', accountRootSuccess); + request.once('error', accountRootError); + + account_info.caching_seq_request = request; + } + + request.callback(callback, 'success_cache', 'error_cache'); + + return request; +}; + +/** + * Mark an account's root node as dirty. + * + * @param {String} account + */ + +Remote.prototype.dirtyAccountRoot = function (account_) { + var account = UInt160.json_rewrite(account_); + delete this.ledgers.current.account_root[account]; +}; + +/** + * Get an Offer from the ledger + * + * @param {Object} options + * @param {String|Number} options.ledger + * @param {String} [options.account] - Required unless using options.index + * @param {Number} [options.sequence] - Required unless using options.index + * @param {String} [options.index] - Required only if options.account and + * options.sequence not provided + * + * @callback + * @param {Error} error + * @param {Object} message + * + * @return {Request} + */ + +Remote.prototype.requestOffer = function (options, callback) { + var request = this.requestLedgerEntry('offer'); + + if (options.account && options.sequence) { + request.offerId(options.account, options.sequence); + } else if (options.index) { + request.offerIndex(options.index); + } + + request.ledgerSelect(options.ledger); + + request.once('success', function (res) { + request.emit('offer', res); + }); + + request.callback(callback, 'offer'); + + return request; +}; + +/** + * Get an account's balance + * + * @param {String} account + * @param [String] issuer + * @param [String] currency + * @param [String|Number] ledger + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestRippleBalance = function (options, callback) { + // YYY Could be cached per ledger. + var request = this.requestLedgerEntry('ripple_state'); + request.rippleState(options.account, options.issuer, options.currency); + + if (!_.isUndefined(options.ledger)) { + request.selectLedger(options.ledger); + } + + function rippleState(message) { + var node = message.node; + var lowLimit = Amount.from_json(node.LowLimit); + var highLimit = Amount.from_json(node.HighLimit); + + // The amount the low account holds of issuer. + var balance = Amount.from_json(node.Balance); + + // accountHigh implies for account: balance is negated. highLimit is the + // limit set by account. + var accountHigh = UInt160.from_json(options.account).equals(highLimit.issuer()); + + request.emit('ripple_state', { + account_balance: (accountHigh ? balance.negate() : balance.clone()).parse_issuer(options.account), + peer_balance: (!accountHigh ? balance.negate() : balance.clone()).parse_issuer(options.issuer), + account_limit: (accountHigh ? highLimit : lowLimit).clone().parse_issuer(options.issuer), + peer_limit: (!accountHigh ? highLimit : lowLimit).clone().parse_issuer(options.account), + account_quality_in: accountHigh ? node.HighQualityIn : node.LowQualityIn, + peer_quality_in: !accountHigh ? node.HighQualityIn : node.LowQualityIn, + account_quality_out: accountHigh ? node.HighQualityOut : node.LowQualityOut, + peer_quality_out: !accountHigh ? node.HighQualityOut : node.LowQualityOut + }); + } + + request.once('success', rippleState); + request.callback(callback, 'ripple_state'); + + return request; +}; + +Remote.prepareCurrency = Remote.prepareCurrencies = function (currency) { + var newCurrency = {}; + + if (currency.hasOwnProperty('issuer')) { + newCurrency.issuer = UInt160.json_rewrite(currency.issuer); + } + + if (currency.hasOwnProperty('currency')) { + newCurrency.currency = Currency.json_rewrite(currency.currency, { force_hex: true }); + } + + return newCurrency; +}; + +/** + * Request ripple_path_find + * + * @param {Object} options + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestRipplePathFind = function (options, callback) { + var request = new Request(this, 'ripple_path_find'); + + request.message.source_account = UInt160.json_rewrite(options.source_account); + + request.message.destination_account = UInt160.json_rewrite(options.destination_account); + + request.message.destination_amount = Amount.json_rewrite(options.destination_amount); + + if (Array.isArray(options.source_currencies)) { + request.message.source_currencies = options.source_currencies.map(Remote.prepareCurrency); + } + + request.callback(callback); + + return request; +}; + +/** + * Request path_find/create + * + * @param {Object} options + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestPathFindCreate = function (options, callback) { + var request = new Request(this, 'path_find'); + request.message.subcommand = 'create'; + + request.message.source_account = UInt160.json_rewrite(options.source_account); + + request.message.destination_account = UInt160.json_rewrite(options.destination_account); + + request.message.destination_amount = Amount.json_rewrite(options.destination_amount); + + if (Array.isArray(options.source_currencies)) { + request.message.source_currencies = options.source_currencies.map(Remote.prepareCurrency); + } + + request.callback(callback); + return request; +}; + +/** + * Request path_find/close + * + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestPathFindClose = function (callback) { + var request = new Request(this, 'path_find'); + + request.message.subcommand = 'close'; + request.callback(callback); + this._cur_path_find = null; + if (this._queued_path_finds.length > 0) { + var pathfind = this._queued_path_finds.shift(); + this.createPathFind(pathfind.options, pathfind.callback); + } + + return request; +}; + +/** + * Request unl_list + * + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestUnlList = function (callback) { + return new Request(this, 'unl_list').callback(callback); +}; + +/** + * Request unl_add + * + * @param {String} address + * @param {String} comment + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestUnlAdd = function (address, comment, callback) { + var request = new Request(this, 'unl_add'); + + request.message.node = address; + + if (comment) { + // note is not specified anywhere, should remove? + request.message.comment = undefined; + } + + request.callback(callback); + + return request; +}; + +/** + * Request unl_delete + * + * @param {String} node + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestUnlDelete = function (node, callback) { + var request = new Request(this, 'unl_delete'); + + request.message.node = node; + request.callback(callback); + + return request; +}; + +/** + * Request peers + * + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestPeers = function (callback) { + return new Request(this, 'peers').callback(callback); +}; + +/** + * Request connect + * + * @param {String} ip + * @param {Number} port + * @param [Function] callback + * @return {Request} + */ + +Remote.prototype.requestConnect = function (ip, port, callback) { + var request = new Request(this, 'connect'); + + request.message.ip = ip; + + if (port) { + request.message.port = port; + } + + request.callback(callback); + + return request; +}; + +/** + * Create a Transaction + * + * @param {String} TransactionType + * @param {Object} options + * @return {Transaction} + */ + +Remote.prototype.transaction = Remote.prototype.createTransaction = function (type) { + var options = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; + + var transaction = new Transaction(this); + + if (arguments.length === 0) { + // Fallback + return transaction; + } + + assert.strictEqual(typeof type, 'string', 'TransactionType must be a string'); + + var constructorMap = { + Payment: transaction.payment, + AccountSet: transaction.accountSet, + TrustSet: transaction.trustSet, + OfferCreate: transaction.offerCreate, + OfferCancel: transaction.offerCancel, + SetRegularKey: transaction.setRegularKey, + SignerListSet: transaction.setSignerList + }; + + var transactionConstructor = constructorMap[type]; + + if (!transactionConstructor) { + throw new Error('TransactionType must be a valid transaction type'); + } + + return transactionConstructor.call(transaction, options); +}; + +/** + * Calculate a transaction fee for a number of tx fee units. + * + * This takes into account the last known network and local load fees. + * + * @param {Number} fee units + * @return {Amount} Final fee in XRP for specified number of fee units. + */ + +Remote.prototype.feeTx = function (units) { + var server = this.getServer(); + + if (!server) { + throw new Error('No connected servers'); + } + + return server._feeTx(units); +}; + +/** + * Get the current recommended transaction fee unit. + * + * Multiply this value with the number of fee units in order to calculate the + * recommended fee for the transaction you are trying to submit. + * + * @return {Number} Recommended amount for one fee unit as float. + */ + +Remote.prototype.feeTxUnit = function () { + var server = this.getServer(); + + if (!server) { + throw new Error('No connected servers'); + } + + return server._feeTxUnit(); +}; + +/** + * Get the current recommended reserve base. + * + * Returns the base reserve with load fees and safety margin applied. + * + * @param {Number} owner count + * @return {Amount} + */ + +Remote.prototype.reserve = function (owner_count) { + var server = this.getServer(); + + if (!server) { + throw new Error('No connected servers'); + } + + return server._reserve(owner_count); +}; + +exports.Remote = Remote; + +// vim:sw=2:sts=2:ts=8:et diff --git a/test/ripple-lib/transaction.js b/test/ripple-lib/transaction.js new file mode 100644 index 0000000000..717c771a64 --- /dev/null +++ b/test/ripple-lib/transaction.js @@ -0,0 +1,1651 @@ +'use strict'; + +var assert = require('assert'); +var util = require('util'); +var _ = require('lodash'); +var EventEmitter = require('events').EventEmitter; +var utils = require('./utils'); +var sjclcodec = require('sjcl-codec'); +var Amount = require('./amount').Amount; +var Currency = require('./amount').Currency; +var UInt160 = require('./amount').UInt160; +var Seed = require('./seed').Seed; +var KeyPair = require('ripple-keypairs').KeyPair; +var SerializedObject = require('./serializedobject').SerializedObject; +var RippleError = require('./rippleerror').RippleError; +var hashprefixes = require('./hashprefixes'); +var log = require('./log').internal.sub('transaction'); + +/** + * @constructor Transaction + * + * Notes: + * All transactions including those with local and malformed errors may be + * forwarded anyway. + * + * A malicous server can: + * - may or may not forward + * - give any result + * + it may declare something correct as incorrect or something incorrect + * as correct + * + it may not communicate with the rest of the network + */ + +function Transaction(remote) { + EventEmitter.call(this); + + var self = this; + var remoteExists = typeof remote === 'object'; + + this.remote = remote; + this.tx_json = { Flags: 0 }; + this._secret = undefined; + this._build_path = false; + this._should_resubmit = remoteExists ? this.remote.automatic_resubmission : true; + this._maxFee = remoteExists ? this.remote.max_fee : undefined; + this._lastLedgerOffset = remoteExists ? this.remote.last_ledger_offset : 3; + this.state = 'unsubmitted'; + this.finalized = false; + this.previousSigningHash = undefined; + this.submitIndex = undefined; + this.canonical = remoteExists ? this.remote.canonical_signing : true; + this.submittedIDs = []; + this.attempts = 0; + this.submissions = 0; + this.responses = 0; + + this.once('success', function (message) { + // Transaction definitively succeeded + self.setState('validated'); + self.finalize(message); + if (self._successHandler) { + self._successHandler(message); + } + }); + + this.once('error', function (message) { + // Transaction definitively failed + self.setState('failed'); + self.finalize(message); + if (self._errorHandler) { + self._errorHandler(message); + } + }); + + this.once('submitted', function () { + // Transaction was submitted to the network + self.setState('submitted'); + }); + + this.once('proposed', function () { + // Transaction was submitted successfully to the network + self.setState('pending'); + }); +} + +util.inherits(Transaction, EventEmitter); + +// This is currently a constant in rippled known as the "base reference" +// https://wiki.ripple.com/Transaction_Fee#Base_Fees +Transaction.fee_units = { + 'default': 10 +}; + +Transaction.flags = { + // Universal flags can apply to any transaction type + Universal: { + FullyCanonicalSig: 0x80000000 + }, + + AccountSet: { + RequireDestTag: 0x00010000, + OptionalDestTag: 0x00020000, + RequireAuth: 0x00040000, + OptionalAuth: 0x00080000, + DisallowXRP: 0x00100000, + AllowXRP: 0x00200000 + }, + + TrustSet: { + SetAuth: 0x00010000, + NoRipple: 0x00020000, + SetNoRipple: 0x00020000, + ClearNoRipple: 0x00040000, + SetFreeze: 0x00100000, + ClearFreeze: 0x00200000 + }, + + OfferCreate: { + Passive: 0x00010000, + ImmediateOrCancel: 0x00020000, + FillOrKill: 0x00040000, + Sell: 0x00080000 + }, + + Payment: { + NoRippleDirect: 0x00010000, + PartialPayment: 0x00020000, + LimitQuality: 0x00040000 + } +}; + +// The following are integer (as opposed to bit) flags +// that can be set for particular transactions in the +// SetFlag or ClearFlag field +Transaction.set_clear_flags = { + AccountSet: { + asfRequireDest: 1, + asfRequireAuth: 2, + asfDisallowXRP: 3, + asfDisableMaster: 4, + asfAccountTxnID: 5, + asfNoFreeze: 6, + asfGlobalFreeze: 7, + asfDefaultRipple: 8 + } +}; + +Transaction.MEMO_TYPES = {}; + +/* eslint-disable max-len */ + +// URL characters per RFC 3986 +Transaction.MEMO_REGEX = /^[0-9a-zA-Z-\.\_\~\:\/\?\#\[\]\@\!\$\&\'\(\)\*\+\,\;\=\%]+$/; +/* eslint-enable max-len */ + +Transaction.formats = require('./binformat').tx; + +Transaction.prototype.consts = { + telLOCAL_ERROR: -399, + temMALFORMED: -299, + tefFAILURE: -199, + terRETRY: -99, + tesSUCCESS: 0, + tecCLAIMED: 100 +}; + +Transaction.prototype.isTelLocal = function (ter) { + return ter >= this.consts.telLOCAL_ERROR && ter < this.consts.temMALFORMED; +}; + +Transaction.prototype.isTemMalformed = function (ter) { + return ter >= this.consts.temMALFORMED && ter < this.consts.tefFAILURE; +}; + +Transaction.prototype.isTefFailure = function (ter) { + return ter >= this.consts.tefFAILURE && ter < this.consts.terRETRY; +}; + +Transaction.prototype.isTerRetry = function (ter) { + return ter >= this.consts.terRETRY && ter < this.consts.tesSUCCESS; +}; + +Transaction.prototype.isTepSuccess = function (ter) { + return ter >= this.consts.tesSUCCESS; +}; + +Transaction.prototype.isTecClaimed = function (ter) { + return ter >= this.consts.tecCLAIMED; +}; + +Transaction.prototype.isRejected = function (ter) { + return this.isTelLocal(ter) || this.isTemMalformed(ter) || this.isTefFailure(ter); +}; + +Transaction.from_json = function (j) { + return new Transaction().setJson(j); +}; + +Transaction.prototype.setJson = Transaction.prototype.parseJson = function (v) { + this.tx_json = _.merge({}, v); + return this; +}; + +/** + * Set state on the condition that the state is different + * + * @param {String} state + */ + +Transaction.prototype.setState = function (state) { + if (this.state !== state) { + this.state = state; + this.emit('state', state); + } +}; + +Transaction.prototype.setResubmittable = function (v) { + if (typeof v === 'boolean') { + this._should_resubmit = v; + } +}; +Transaction.prototype.isResubmittable = function () { + return this._should_resubmit; +}; + +/** + * Finalize transaction. This will prevent future activity + * + * @param {Object} message + * @api private + */ + +Transaction.prototype.finalize = function (message) { + this.finalized = true; + + if (this.result) { + this.result.ledger_index = message.ledger_index; + this.result.ledger_hash = message.ledger_hash; + } else { + this.result = message; + this.result.tx_json = this.tx_json; + } + + this.emit('cleanup'); + this.emit('final', message); + + if (this.remote && this.remote.trace) { + log.info('transaction finalized:', this.tx_json, this.getManager()._pending.getLength()); + } + + return this; +}; + +/** + * Get transaction Account + * + * @return {Account} + */ + +Transaction.prototype.getAccount = function () { + return this.tx_json.Account; +}; + +/** + * Get TransactionType + * + * @return {String} + */ + +Transaction.prototype.getType = Transaction.prototype.getTransactionType = function () { + return this.tx_json.TransactionType; +}; + +/** + * Get transaction TransactionManager + * + * @param [String] account + * @return {TransactionManager] + */ + +Transaction.prototype.getManager = function (account) { + if (!this.remote) { + return undefined; + } + + return this.remote.account(account || this.getAccount())._transactionManager; +}; + +/** + * Get transaction secret + * + * @param [String] account + */ + +Transaction.prototype.getSecret = Transaction.prototype._accountSecret = function (account) { + if (!this.remote) { + return undefined; + } + + return this.remote.secrets[account || this.getAccount()]; +}; + +/** + * Returns the number of fee units this transaction will cost. + * + * Each Ripple transaction based on its type and makeup costs a certain number + * of fee units. The fee units are calculated on a per-server basis based on the + * current load on both the network and the server. + * + * @see https://ripple.com/wiki/Transaction_Fee + * + * @return {Number} Number of fee units for this transaction. + */ + +Transaction.prototype._getFeeUnits = Transaction.prototype.feeUnits = function () { + return Transaction.fee_units['default']; +}; + +/** + * Compute median server fee + * + * @return {String} median fee + */ + +Transaction.prototype._computeFee = function () { + if (!this.remote) { + return undefined; + } + + var servers = this.remote._servers; + var fees = []; + + for (var i = 0; i < servers.length; i++) { + var server = servers[i]; + if (server.isConnected()) { + fees.push(Number(server._computeFee(this._getFeeUnits()))); + } + } + + switch (fees.length) { + case 0: + return undefined; + case 1: + return String(fees[0]); + } + + fees.sort(function ascending(a, b) { + if (a > b) { + return 1; + } else if (a < b) { + return -1; + } + return 0; + }); + + var midInd = Math.floor(fees.length / 2); + var median = fees.length % 2 === 0 ? Math.floor(0.5 + (fees[midInd] + fees[midInd - 1]) / 2) : fees[midInd]; + + return String(median); +}; + +/** + * Attempts to complete the transaction for submission. + * + * This function seeks to fill out certain fields, such as Fee and + * SigningPubKey, which can be determined by the library based on network + * information and other fields. + * + * @return {Boolean|Transaction} If succeeded, return transaction. Otherwise + * return `false` + */ + +Transaction.prototype.err = function (error, errorMessage) { + this.emit('error', new RippleError(error, errorMessage)); + return false; +}; + +Transaction.prototype.complete = function () { + // Auto-fill the secret + this._secret = this._secret || this.getSecret(); + + if (_.isUndefined(this._secret)) { + return this.err('tejSecretUnknown', 'Missing secret'); + } + + if (this.remote && !(this.remote.local_signing || this.remote.trusted)) { + return this.err('tejServerUntrusted', 'Attempt to give secret to untrusted server'); + } + + if (_.isUndefined(this.tx_json.SigningPubKey)) { + try { + this.setSigningPubKey(this.getSigningPubKey()); + } catch (e) { + return this.err('tejSecretInvalid', 'Invalid secret'); + } + } + + // Auto-fill transaction Fee + if (_.isUndefined(this.tx_json.Fee)) { + if (this.remote && (this.remote.local_fee || !this.remote.trusted)) { + var computedFee = this._computeFee(); + + if (!computedFee) { + // Unable to compute fee due to no connected servers + return this.err('tejUnconnected'); + } + + this.tx_json.Fee = computedFee; + } + } + + if (Number(this.tx_json.Fee) > this._maxFee) { + return this.err('tejMaxFeeExceeded', 'Max fee exceeded'); + } + + // Set canonical flag - this enables canonicalized signature checking + this.setCanonicalFlag(); + + return this.tx_json; +}; + +Transaction.prototype.getKeyPair = function (secret_) { + if (this._keyPair) { + return this._keyPair; + } + + var secret = secret_ || this._secret; + assert(secret, 'Secret missing'); + + var keyPair = Seed.from_json(secret).get_key(); + this._keyPair = keyPair; + + return keyPair; +}; + +Transaction.prototype.getSigningPubKey = function (secret) { + return this.getKeyPair(secret).pubKeyHex(); +}; + +Transaction.prototype.setSigningPubKey = function (key) { + if (_.isString(key)) { + this.tx_json.SigningPubKey = key; + } else if (key instanceof KeyPair) { + this.tx_json.SigningPubKey = key.pubKeyHex(); + } + + return this; +}; + +Transaction.prototype.setCanonicalFlag = function () { + if (this.remote && this.remote.local_signing && this.canonical) { + this.tx_json.Flags |= Transaction.flags.Universal.FullyCanonicalSig; + + // JavaScript converts operands to 32-bit signed ints before doing bitwise + // operations. We need to convert it back to an unsigned int. + this.tx_json.Flags = this.tx_json.Flags >>> 0; + } + + return this; +}; + +Transaction.prototype.serialize = function () { + return SerializedObject.from_json(this.tx_json); +}; + +Transaction.prototype.signingHash = function (testnet) { + return this.hash(testnet ? 'HASH_TX_SIGN_TESTNET' : 'HASH_TX_SIGN'); +}; + +Transaction.prototype.signingData = function () { + var so = new SerializedObject(); + so.append(hashprefixes.HASH_TX_SIGN_BYTES); + so.parse_json(this.tx_json); + return so; +}; + +Transaction.prototype.multiSigningData = function (account) { + var so = new SerializedObject(); + so.append(hashprefixes.HASH_TX_MULTISIGN_BYTES); + so.parse_json(this.tx_json); + so.append(UInt160.from_json(account).to_bytes()); + return so; +}; + +Transaction.prototype.hash = function (prefix_, asUINT256, serialized) { + var prefix = undefined; + + if (typeof prefix_ !== 'string') { + prefix = hashprefixes.HASH_TX_ID; + } else if (!hashprefixes.hasOwnProperty(prefix_)) { + throw new Error('Unknown hashing prefix requested: ' + prefix_); + } else { + prefix = hashprefixes[prefix_]; + } + + var hash = (serialized || this.serialize()).hash(prefix); + + return asUINT256 ? hash : hash.to_hex(); +}; + +Transaction.prototype.sign = function () { + if (this.hasMultiSigners()) { + return this; + } + + var keyPair = this.getKeyPair(); + var prev_sig = this.tx_json.TxnSignature; + + delete this.tx_json.TxnSignature; + + var hash = this.signingHash(); + + // If the hash is the same, we can re-use the previous signature + if (prev_sig && hash === this.previousSigningHash) { + this.tx_json.TxnSignature = prev_sig; + return this; + } + + this.tx_json.TxnSignature = keyPair.signHex(this.signingData().buffer); + this.previousSigningHash = hash; + + return this; +}; + +/** + * Add an ID to cached list of submitted IDs + * + * @param {String} transaction id + * @api private + */ + +Transaction.prototype.addId = function (id) { + if (!_.contains(this.submittedIDs, id)) { + this.submittedIDs.unshift(id); + } +}; + +/** + * Find ID within cached received (validated) IDs. If this transaction has + * an ID that is within the cache, it has been seen validated, so return the + * received message + * + * @param {Object} cache + * @return {Object} message + * @api private + */ + +Transaction.prototype.findId = function (cache) { + var cachedTransactionID = _.detect(this.submittedIDs, function (id) { + return cache.hasOwnProperty(id); + }); + return cache[cachedTransactionID]; +}; + +/** + * Set client ID. This is an identifier specified by the user of the API to + * identify a transaction in the event of a disconnect. It is not currently + * persisted in the transaction itself, but used offline for identification. + * In applications that require high reliability, client-specified ID should + * be persisted such that one could map it to submitted transactions. Use + * .summary() for a consistent transaction summary output for persisitng. In + * the future, this ID may be stored in the transaction itself (in the ledger) + * + * @param {String} id + */ + +Transaction.prototype.setClientID = Transaction.prototype.clientID = function (id) { + if (typeof id === 'string') { + this._clientID = id; + } + return this; +}; + +Transaction.prototype.setLastLedgerSequenceOffset = function (offset) { + this._lastLedgerOffset = offset; +}; + +Transaction.prototype.getLastLedgerSequenceOffset = function () { + return this._lastLedgerOffset; +}; + +Transaction.prototype.lastLedger = Transaction.prototype.setLastLedger = Transaction.prototype.setLastLedgerSequence = function (sequence) { + if (!_.isUndefined(sequence)) { + this._setUInt32('LastLedgerSequence', sequence); + } else { + // Autofill LastLedgerSequence + assert(this.remote, 'Unable to set LastLedgerSequence, missing Remote'); + + this._setUInt32('LastLedgerSequence', this.remote.getLedgerSequence() + 1 + this.getLastLedgerSequenceOffset()); + } + + this._setLastLedger = true; + + return this; +}; + +/** + * Set max fee. Submission will abort if this is exceeded. Specified fee must + * be >= 0. + * + * @param {Number} fee The proposed fee + */ + +Transaction.prototype.setMaxFee = Transaction.prototype.maxFee = function (fee) { + if (typeof fee === 'number' && fee >= 0) { + this._setMaxFee = true; + this._maxFee = fee; + } + return this; +}; + +/* + * Set the fee user will pay to the network for submitting this transaction. + * Specified fee must be >= 0. + * + * @param {Number} fee The proposed fee + * + * @returns {Transaction} calling instance for chaining + */ +Transaction.prototype.setFixedFee = function (fee) { + return this.setFee(fee, { fixed: true }); +}; + +Transaction.prototype.setFee = function (fee) { + var options = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; + + if (_.isNumber(fee) && fee >= 0) { + this.tx_json.Fee = String(fee); + if (options.fixed) { + this._setFixedFee = true; + } + } + + return this; +}; + +Transaction.prototype.setSequence = function (sequence) { + if (_.isNumber(sequence)) { + this._setUInt32('Sequence', sequence); + this._setSequence = true; + } + + return this; +}; + +/** + * Set secret If the secret has been set with Remote.setSecret, it does not + * need to be provided + * + * @param {String} secret + */ + +Transaction.prototype.setSecret = Transaction.prototype.secret = function (secret) { + if (typeof secret === 'string') { + this._secret = secret; + } + return this; +}; + +Transaction.prototype.setType = function (type) { + if (_.isUndefined(Transaction.formats, type)) { + throw new Error('TransactionType must be a valid transaction type'); + } + + this.tx_json.TransactionType = type; + + return this; +}; + +Transaction.prototype._setUInt32 = function (name, value, options_) { + var options = _.merge({}, options_); + var isValidUInt32 = typeof value === 'number' && value >= 0 && value < Math.pow(256, 4); + + if (!isValidUInt32) { + throw new Error(name + ' must be a valid UInt32'); + } + if (!_.isUndefined(options.min_value) && value < options.min_value) { + throw new Error(name + ' must be >= ' + options.min_value); + } + + this.tx_json[name] = value; + + return this; +}; + +/** + * Set SourceTag + * + * @param {Number} source tag + */ + +Transaction.prototype.setSourceTag = Transaction.prototype.sourceTag = function (tag) { + return this._setUInt32('SourceTag', tag); +}; + +Transaction.prototype._setAccount = function (name, value) { + var uInt160 = UInt160.from_json(value); + + if (!uInt160.is_valid()) { + throw new Error(name + ' must be a valid account'); + } + + this.tx_json[name] = uInt160.to_json(); + + return this; +}; + +Transaction.prototype.setAccount = function (account) { + return this._setAccount('Account', account); +}; + +Transaction.prototype._setAmount = function (name, amount, options_) { + var options = _.merge({ no_native: false }, options_); + var parsedAmount = Amount.from_json(amount); + + if (parsedAmount.is_negative()) { + throw new Error(name + ' value must be non-negative'); + } + + var isNative = parsedAmount.currency().is_native(); + + if (isNative && options.no_native) { + throw new Error(name + ' must be a non-native amount'); + } + if (!(isNative || parsedAmount.currency().is_valid())) { + throw new Error(name + ' must have a valid currency'); + } + if (!(isNative || parsedAmount.issuer().is_valid())) { + throw new Error(name + ' must have a valid issuer'); + } + + this.tx_json[name] = parsedAmount.to_json(); + + return this; +}; + +Transaction.prototype._setHash256 = function (name, value, options_) { + if (typeof value !== 'string') { + throw new Error(name + ' must be a valid Hash256'); + } + + var options = _.merge({ pad: false }, options_); + var hash256 = value; + + if (options.pad) { + while (hash256.length < 64) { + hash256 += '0'; + } + } + + if (!/^[0-9A-Fa-f]{64}$/.test(hash256)) { + throw new Error(name + ' must be a valid Hash256'); + } + + this.tx_json[name] = hash256; + + return this; +}; + +Transaction.prototype.setAccountTxnID = Transaction.prototype.accountTxnID = function (id) { + return this._setHash256('AccountTxnID', id); +}; + +/** + * Set Flags. You may specify flags as a number, as the string name of the + * flag, or as an array of strings. + * + * setFlags(Transaction.flags.AccountSet.RequireDestTag) + * setFlags('RequireDestTag') + * setFlags('RequireDestTag', 'RequireAuth') + * setFlags([ 'RequireDestTag', 'RequireAuth' ]) + * + * @param {Number|String|Array} flags + */ + +Transaction.prototype.setFlags = function (flags) { + if (flags === undefined) { + return this; + } + + if (typeof flags === 'number') { + this.tx_json.Flags = flags; + return this; + } + + var transaction_flags = Transaction.flags[this.getType()] || {}; + var flag_set = Array.isArray(flags) ? flags : [].slice.call(arguments); + + for (var i = 0, l = flag_set.length; i < l; i++) { + var flag = flag_set[i]; + + if (transaction_flags.hasOwnProperty(flag)) { + this.tx_json.Flags += transaction_flags[flag]; + } else { + // XXX Should throw? + this.emit('error', new RippleError('tejInvalidFlag')); + return this; + } + } + + return this; +}; + +function convertStringToHex(string) { + var utf8String = sjclcodec.utf8String.toBits(string); + return sjclcodec.hex.fromBits(utf8String).toUpperCase(); +} + +/** + * Add a Memo to transaction. + * + * @param [String] memoType + * - describes what the data represents, must contain valid URL characters + * @param [String] memoFormat + * - describes what format the data is in, MIME type, must contain valid URL + * - characters + * @param [String] memoData + * - data for the memo, can be any JS object. Any object other than string will + * be stringified (JSON) for transport + */ + +Transaction.prototype.addMemo = function (options_) { + var options = undefined; + + if (typeof options_ === 'object') { + options = _.merge({}, options_); + } else { + options = { + memoType: arguments[0], + memoFormat: arguments[1], + memoData: arguments[2] + }; + } + + var memo = {}; + var memoRegex = Transaction.MEMO_REGEX; + var memoType = options.memoType; + var memoFormat = options.memoFormat; + var memoData = options.memoData; + + if (memoType) { + if (!(_.isString(memoType) && memoRegex.test(memoType))) { + throw new Error('MemoType must be a string containing only valid URL characters'); + } + if (Transaction.MEMO_TYPES[memoType]) { + // XXX Maybe in the future we want a schema validator for + // memo types + memoType = Transaction.MEMO_TYPES[memoType]; + } + memo.MemoType = convertStringToHex(memoType); + } + + if (memoFormat) { + if (!(_.isString(memoFormat) && memoRegex.test(memoFormat))) { + throw new Error('MemoFormat must be a string containing only valid URL characters'); + } + + memo.MemoFormat = convertStringToHex(memoFormat); + } + + if (memoData) { + if (typeof memoData !== 'string') { + if (memoFormat.toLowerCase() === 'json') { + try { + memoData = JSON.stringify(memoData); + } catch (e) { + throw new Error('MemoFormat json with invalid JSON in MemoData field'); + } + } else { + throw new Error('MemoData can only be a JSON object with a valid json MemoFormat'); + } + } + + memo.MemoData = convertStringToHex(memoData); + } + + this.tx_json.Memos = (this.tx_json.Memos || []).concat({ Memo: memo }); + + return this; +}; + +/** + * Construct an 'AccountSet' transaction + * + * Note that bit flags can be set using the .setFlags() method but for + * 'AccountSet' transactions there is an additional way to modify AccountRoot + * flags. The values available for the SetFlag and ClearFlag are as follows: + * + * asfRequireDest: Require a destination tag + * asfRequireAuth: Authorization is required to extend trust + * asfDisallowXRP: XRP should not be sent to this account + * asfDisableMaster: Disallow use of the master key + * asfNoFreeze: Permanently give up the ability to freeze individual + * trust lines. This flag can never be cleared. + * asfGlobalFreeze: Freeze all assets issued by this account + * + * @param [String] set flag + * @param [String] clear flag + */ + +Transaction.prototype.accountSet = function (options_) { + var options = undefined; + + if (typeof options_ === 'object') { + options = _.merge({}, options_); + + if (_.isUndefined(options.account)) { + options.account = options.src; + } + if (_.isUndefined(options.set_flag)) { + options.set_flag = options.set; + } + if (_.isUndefined(options.clear_flag)) { + options.clear_flag = options.clear; + } + } else { + options = { + account: arguments[0], + set_flag: arguments[1], + clear_flag: arguments[2] + }; + } + + this.setType('AccountSet'); + this.setAccount(options.account); + + if (!_.isUndefined(options.set_flag)) { + this.setSetFlag(options.set_flag); + } + if (!_.isUndefined(options.clear_flag)) { + this.setClearFlag(options.clear_flag); + } + + return this; +}; + +Transaction.prototype.setAccountSetFlag = function (name, value) { + var accountSetFlags = Transaction.set_clear_flags.AccountSet; + var flagValue = value; + + if (typeof flagValue === 'string') { + flagValue = /^asf/.test(flagValue) ? accountSetFlags[flagValue] : accountSetFlags['asf' + flagValue]; + } + + if (!_.contains(_.values(accountSetFlags), flagValue)) { + throw new Error(name + ' must be a valid AccountSet flag'); + } + + this.tx_json[name] = flagValue; + + return this; +}; + +Transaction.prototype.setSetFlag = function (flag) { + return this.setAccountSetFlag('SetFlag', flag); +}; + +Transaction.prototype.setClearFlag = function (flag) { + return this.setAccountSetFlag('ClearFlag', flag); +}; + +/** + * Set TransferRate for AccountSet + * + * @param {Number} transfer rate + */ + +Transaction.prototype.setTransferRate = Transaction.prototype.transferRate = function (rate) { + var transferRate = rate; + + if (transferRate === 0) { + // Clear TransferRate + this.tx_json.TransferRate = transferRate; + return this; + } + + // if (rate >= 1 && rate < 2) { + // transferRate *= 1e9; + // } + + return this._setUInt32('TransferRate', transferRate, { min_value: 1e9 }); +}; + +/** + * Construct a 'SetRegularKey' transaction + * + * If the RegularKey is set, the private key that corresponds to it can be + * used to sign transactions instead of the master key + * + * The RegularKey must be a valid Ripple Address, or a Hash160 of the public + * key corresponding to the new private signing key. + * + * @param {String} account + * @param {String} regular key + */ + +Transaction.prototype.setRegularKey = function (options_) { + var options = undefined; + + if (typeof options_ === 'object') { + options = _.merge({}, options_); + + if (_.isUndefined(options.account)) { + options.account = options.src; + } + } else { + options = { + account: arguments[0], + regular_key: arguments[1] + }; + } + + this.setType('SetRegularKey'); + this.setAccount(options.account); + + if (!_.isUndefined(options.regular_key)) { + this._setAccount('RegularKey', options.regular_key); + } + + return this; +}; + +/** + * Construct a 'TrustSet' transaction + * + * @param {String} account + * @param [Amount] limit + * @param [Number] quality in + * @param [Number] quality out + */ + +Transaction.prototype.trustSet = Transaction.prototype.rippleLineSet = function (options_) { + var options = undefined; + + if (typeof options_ === 'object') { + options = _.merge({}, options_); + + if (_.isUndefined(options.account)) { + options.account = options.src; + } + } else { + options = { + account: arguments[0], + limit: arguments[1], + quality_in: arguments[2], + quality_out: arguments[3] + }; + } + + this.setType('TrustSet'); + this.setAccount(options.account); + + if (!_.isUndefined(options.limit)) { + this.setLimit(options.limit); + } + if (!_.isUndefined(options.quality_in)) { + this.setQualityIn(options.quality_in); + } + if (!_.isUndefined(options.quality_out)) { + this.setQualityOut(options.quality_out); + } + + // XXX Throw an error if nothing is set. + + return this; +}; + +Transaction.prototype.setLimit = function (amount) { + return this._setAmount('LimitAmount', amount, { no_native: true }); +}; + +Transaction.prototype.setQualityIn = function (quality) { + return this._setUInt32('QualityIn', quality); +}; + +Transaction.prototype.setQualityOut = function (quality) { + return this._setUInt32('QualityOut', quality); +}; + +/** + * Construct a 'Payment' transaction + * + * Relevant setters: + * - setPaths() + * - setBuildPath() + * - addPath() + * - setSourceTag() + * - setDestinationTag() + * - setSendMax() + * - setFlags() + * + * @param {String} source account + * @param {String} destination account + * @param {Amount} payment amount + */ + +Transaction.prototype.payment = function (options_) { + var options = undefined; + + if (typeof options_ === 'object') { + options = _.merge({}, options_); + + if (_.isUndefined(options.account)) { + options.account = options.src || options.from; + } + if (_.isUndefined(options.destination)) { + options.destination = options.dst || options.to; + } + } else { + options = { + account: arguments[0], + destination: arguments[1], + amount: arguments[2] + }; + } + + this.setType('Payment'); + this.setAccount(options.account); + this.setDestination(options.destination); + this.setAmount(options.amount); + + return this; +}; + +Transaction.prototype.setAmount = function (amount) { + return this._setAmount('Amount', amount); +}; + +Transaction.prototype.setDestination = function (destination) { + return this._setAccount('Destination', destination); +}; + +/** + * Set SendMax for Payment + * + * @param {String|Object} send max amount + */ + +Transaction.prototype.setSendMax = Transaction.prototype.sendMax = function (send_max) { + return this._setAmount('SendMax', send_max); +}; + +/** + * Set DeliverMin for Payment + * + * @param {String|Object} deliver_min minimum amount to deliver + */ + +Transaction.prototype.setDeliverMin = function (deliver_min) { + return this._setAmount('DeliverMin', deliver_min); +}; + +/** + * Filter invalid properties from path objects in a path array + * + * Valid properties are: + * - account + * - currency + * - issuer + * - type_hex + * + * @param {Array} path + * @return {Array} filtered path + */ + +Transaction._rewritePath = function (path) { + var newPath = path.map(function (node) { + var newNode = {}; + + if (node.hasOwnProperty('account')) { + newNode.account = UInt160.json_rewrite(node.account); + } + + if (node.hasOwnProperty('issuer')) { + newNode.issuer = UInt160.json_rewrite(node.issuer); + } + + if (node.hasOwnProperty('currency')) { + newNode.currency = Currency.json_rewrite(node.currency); + } + + if (node.hasOwnProperty('type_hex')) { + newNode.type_hex = node.type_hex; + } + + return newNode; + }); + + return newPath; +}; + +/** + * Add a path for Payment transaction + * + * @param {Array} path + */ + +Transaction.prototype.addPath = Transaction.prototype.pathAdd = function (path) { + if (!Array.isArray(path)) { + throw new Error('Path must be an array'); + } + + this.tx_json.Paths = this.tx_json.Paths || []; + this.tx_json.Paths.push(Transaction._rewritePath(path)); + + return this; +}; + +/** + * Set paths for Payment transaction + * + * @param {Array} paths + */ + +Transaction.prototype.setPaths = Transaction.prototype.paths = function (paths) { + if (!Array.isArray(paths)) { + throw new Error('Paths must be an array'); + } + + if (paths.length === 0) { + return this; + } + + this.tx_json.Paths = []; + paths.forEach(this.addPath, this); + + return this; +}; + +/** + * Set build_path to have server blindly construct a path for Payment + * + * "blindly" because the sender has no idea of the actual cost must be less + * than send max. + * + * @param {Boolean} build path + */ + +Transaction.prototype.setBuildPath = Transaction.prototype.buildPath = function (build) { + this._build_path = build === undefined || build; + + return this; +}; + +/** + * Set DestinationTag for Payment transaction + * + * @param {Number} destination tag + */ + +Transaction.prototype.setDestinationTag = Transaction.prototype.destinationTag = function (tag) { + return this._setUInt32('DestinationTag', tag); +}; + +/** + * Set InvoiceID for Payment transaction + * + * @param {String} id + */ + +Transaction.prototype.setInvoiceID = Transaction.prototype.invoiceID = function (id) { + return this._setHash256('InvoiceID', id, { pad: true }); +}; + +/** + * Construct an 'OfferCreate transaction + * + * @param {String} account + * @param {Amount} taker pays amount + * @param {Amount} taker gets amount + * @param [Number|Date] expiration + * @param [Number] sequence of an existing offer to replace + */ + +Transaction.prototype.offerCreate = function (options_) { + var options = undefined; + + if (typeof options_ === 'object') { + options = _.merge({}, options_); + + if (_.isUndefined(options.account)) { + options.account = options.src; + } + if (_.isUndefined(options.taker_pays)) { + options.taker_pays = options.buy; + } + if (_.isUndefined(options.taker_gets)) { + options.taker_gets = options.sell; + } + if (_.isUndefined(options.offer_sequence)) { + options.offer_sequence = options.cancel_sequence || options.sequence; + } + } else { + options = { + account: arguments[0], + taker_pays: arguments[1], + taker_gets: arguments[2], + expiration: arguments[3], + offer_sequence: arguments[4] + }; + } + + this.setType('OfferCreate'); + this.setAccount(options.account); + this.setTakerGets(options.taker_gets); + this.setTakerPays(options.taker_pays); + + if (!_.isUndefined(options.expiration)) { + this.setExpiration(options.expiration); + } + if (!_.isUndefined(options.offer_sequence)) { + this.setOfferSequence(options.offer_sequence); + } + + return this; +}; + +Transaction.prototype.setTakerGets = function (amount) { + return this._setAmount('TakerGets', amount); +}; + +Transaction.prototype.setTakerPays = function (amount) { + return this._setAmount('TakerPays', amount); +}; + +Transaction.prototype.setExpiration = function (expiration) { + var timeOffset = expiration instanceof Date ? expiration.getTime() : expiration; + + return this._setUInt32('Expiration', utils.time.toRipple(timeOffset)); +}; + +Transaction.prototype.setOfferSequence = function (offerSequence) { + return this._setUInt32('OfferSequence', offerSequence); +}; + +/** + * Construct an 'OfferCancel' transaction + * + * @param {String} account + * @param [Number] sequence of an existing offer + */ + +Transaction.prototype.offerCancel = function (options_) { + var options = undefined; + + if (typeof options_ === 'object') { + options = _.merge({}, options_); + + if (_.isUndefined(options.account)) { + options.account = options.src; + } + if (_.isUndefined(options.offer_sequence)) { + options.offer_sequence = options.sequence || options.cancel_sequence; + } + } else { + options = { + account: arguments[0], + offer_sequence: arguments[1] + }; + } + + this.setType('OfferCancel'); + this.setAccount(options.account); + this.setOfferSequence(options.offer_sequence); + + return this; +}; + +Transaction._prepareSignerEntry = function (signer) { + var account = signer.account; + var weight = signer.weight; + + assert(UInt160.is_valid(account), 'Signer account invalid'); + assert(_.isNumber(weight), 'Signer weight missing'); + assert(weight > 0 && weight <= 65535, 'Signer weight must be 1-65535'); + + return { + SignerEntry: { + Account: account, + SignerWeight: weight + } + }; +}; + +Transaction.prototype.setSignerList = function () { + var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; + + this.setType('SignerListSet'); + this.setAccount(options.account); + this.setSignerQuorum(options.signerQuorum); + + if (!_.isEmpty(options.signers)) { + this.tx_json.SignerEntries = options.signers.map(Transaction._prepareSignerEntry); + } + + return this; +}; + +Transaction.prototype.setSignerQuorum = function (quorum) { + this._setUInt32('SignerQuorum', quorum); +}; + +/** + * Submit transaction to the network + * + * @param [Function] callback + */ + +Transaction.prototype.submit = function () { + var callback = arguments.length <= 0 || arguments[0] === undefined ? function () {} : arguments[0]; + + var self = this; + + this.callback = callback; + + this._errorHandler = function transactionError(error_, message) { + var error = error_; + + if (!(error instanceof RippleError)) { + error = new RippleError(error, message); + } + + self.callback(error); + }; + + this._successHandler = function transactionSuccess(message) { + self.callback(null, message); + }; + + if (!this.remote) { + this.emit('error', new Error('No remote found')); + return this; + } + + this.getManager().submit(this); + + return this; +}; + +Transaction.prototype.abort = function () { + if (!this.finalized) { + this.emit('error', new RippleError('tejAbort', 'Transaction aborted')); + } + + return this; +}; + +/** + * Return summary object containing important information for persistence + * + * @return {Object} transaction summary + */ + +Transaction.prototype.getSummary = Transaction.prototype.summary = function () { + var txSummary = { + tx_json: this.tx_json, + clientID: this._clientID, + submittedIDs: this.submittedIDs, + submissionAttempts: this.attempts, + submitIndex: this.submitIndex, + initialSubmitIndex: this.initialSubmitIndex, + lastLedgerSequence: this.tx_json.LastLedgerSequence, + state: this.state, + finalized: this.finalized + }; + + if (this.result) { + var transaction_hash = this.result.tx_json ? this.result.tx_json.hash : undefined; + + txSummary.result = { + engine_result: this.result.engine_result, + engine_result_message: this.result.engine_result_message, + ledger_hash: this.result.ledger_hash, + ledger_index: this.result.ledger_index, + transaction_hash: transaction_hash + }; + } + + return txSummary; +}; + +/** + * Construct a 'SuspendedPaymentCreate' transaction + * + * Relevant setters: + * - setSourceTag() + * - setFlags() + * - setDigest() + * - setAllowCancelAfter() + * - setAllowExecuteAfter() + * + * @param {String} options.account source account + * @param {String} options.destination account + * @param {Amount} options.amount payment amount + */ + +Transaction.prototype.suspendedPaymentCreate = function (options) { + this.setType('SuspendedPaymentCreate'); + this.setAccount(options.account); + this.setDestination(options.destination); + this.setAmount(options.amount); + return this; +}; + +/** + * Construct a 'SuspendedPaymentFinish' transaction + * + * Relevant setters: + * - setSourceTag() + * - setFlags() + * - setOwner() + * - setOfferSequence() + * - setMethod() + * - setDigest() + * - setProof() + * + * @param {String} options.account source account + * @param {String} options.owner SuspendedPaymentCreate's Account + * @param {Integer} options.paymentSequence SuspendedPaymentCreate's Sequence + */ + +Transaction.prototype.suspendedPaymentFinish = function (options) { + this.setType('SuspendedPaymentFinish'); + this.setAccount(options.account); + this.setOwner(options.owner); + this.setOfferSequence(options.paymentSequence); + return this; +}; + +/** + * Construct a 'SuspendedPaymentCancel' transaction + * + * Relevant setters: + * - setSourceTag() + * - setFlags() + * - setOwner() + * - setOfferSequence() + * + * @param {String} options.account source account + * @param {String} options.owner SuspendedPaymentCreate's Account + * @param {Integer} options.paymentSequence SuspendedPaymentCreate's Sequence + */ + +Transaction.prototype.suspendedPaymentCancel = function (options) { + this.setType('SuspendedPaymentCancel'); + this.setAccount(options.account); + this.setOwner(options.owner); + this.setOfferSequence(options.paymentSequence); + return this; +}; + +Transaction.prototype.setDigest = function (digest) { + return this._setHash256('Digest', digest); +}; + +Transaction.prototype.setAllowCancelAfter = function (after) { + return this._setUInt32('CancelAfter', utils.time.toRipple(after)); +}; + +Transaction.prototype.setAllowExecuteAfter = function (after) { + return this._setUInt32('FinishAfter', utils.time.toRipple(after)); +}; + +Transaction.prototype.setOwner = function (owner) { + return this._setAccount('Owner', owner); +}; + +Transaction.prototype.setMethod = function (method) { + return this._setUInt8('Method', method); +}; + +Transaction.prototype.setProof = function (proof) { + this.tx_json.Proof = convertStringToHex(proof); + return this; +}; + +Transaction.prototype._setUInt8 = function (name, value) { + var isValidUInt8 = typeof value === 'number' && value >= 0 && value < 256; + if (!isValidUInt8) { + throw new Error(name + ' must be a valid UInt8'); + } + this.tx_json[name] = value; + return this; +}; + +Transaction.prototype.setSigners = function (signers) { + if (_.isArray(signers)) { + this.tx_json.Signers = signers; + } + + return this; +}; + +Transaction.prototype.addMultiSigner = function (signer) { + assert(UInt160.is_valid(signer.Account), 'Signer must have a valid Account'); + + if (_.isUndefined(this.tx_json.Signers)) { + this.tx_json.Signers = []; + } + + this.tx_json.Signers.push({ Signer: signer }); + + this.tx_json.Signers.sort(function (a, b) { + return UInt160.from_json(a.Signer.Account).cmp(UInt160.from_json(b.Signer.Account)); + }); + + return this; +}; + +Transaction.prototype.hasMultiSigners = function () { + return !_.isEmpty(this.tx_json.Signers); +}; + +Transaction.prototype.getMultiSigners = function () { + return this.tx_json.Signers; +}; + +Transaction.prototype.getMultiSigningJson = function () { + assert(this.tx_json.Sequence, 'Sequence must be set before multi-signing'); + assert(this.tx_json.Fee, 'Fee must be set before multi-signing'); + + if (_.isUndefined(this.tx_json.LastLedgerSequence)) { + // Auto-fill LastLedgerSequence + this.setLastLedgerSequence(); + } + + var cleanedJson = _.omit(this.tx_json, ['SigningPubKey', 'Signers', 'TxnSignature']); + + var signingTx = Transaction.from_json(cleanedJson); + signingTx.remote = this.remote; + signingTx.setSigningPubKey(''); + signingTx.setCanonicalFlag(); + + return signingTx.tx_json; +}; + +Transaction.prototype.multiSign = function (account, secret) { + var signingData = this.multiSigningData(account); + var keyPair = Seed.from_json(secret).get_key(); + + var signer = { + Account: account, + TxnSignature: keyPair.signHex(signingData.buffer), + SigningPubKey: keyPair.pubKeyHex() + }; + + return signer; +}; + +exports.Transaction = Transaction; + diff --git a/test/ripple-lib/transactionmanager.js b/test/ripple-lib/transactionmanager.js new file mode 100644 index 0000000000..0cdde4950a --- /dev/null +++ b/test/ripple-lib/transactionmanager.js @@ -0,0 +1,740 @@ +'use strict'; + +var _ = require('lodash'); +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'); + +/** + * @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._submissionTimeout = this._remote.submission_timeout; + this._pending = new PendingQueue(); + + this._account.on('transaction-outbound', function (res) { + self._transactionReceived(res); + }); + + this._remote.on('load_changed', function (load) { + self._adjustFees(load); + }); + + function updatePendingStatus(ledger) { + self._updatePendingStatus(ledger); + } + + this._remote.on('ledger_closed', updatePendingStatus); + + function handleReconnect() { + self._handleReconnect(function () { + // Handle reconnect, account_tx procedure first, before + // hooking back into ledger_closed + self._remote.on('ledger_closed', updatePendingStatus); + }); + } + + this._remote.on('disconnect', function () { + self._remote.removeListener('ledger_closed', updatePendingStatus); + self._remote.once('connect', handleReconnect); + }); + + // 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) { + var transaction = {}; + var keys = Object.keys(tx); + + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + switch (k) { + case 'transaction': + // Account transaction stream + transaction.tx_json = tx[k]; + break; + case 'tx': + // account_tx response + transaction.engine_result = tx.meta.TransactionResult; + transaction.result = transaction.engine_result; + transaction.tx_json = tx[k]; + transaction.hash = tx[k].hash; + transaction.ledger_index = tx[k].ledger_index; + transaction.type = 'transaction'; + transaction.validated = tx.validated; + break; + case 'meta': + case 'metadata': + transaction.metadata = tx[k]; + break; + case 'mmeta': + // Don't copy mmeta + break; + default: + transaction[k] = tx[k]; + } + } + + return transaction; +}; + +/** + * 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); + + 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); + } +}; + +/** + * 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; + } + + function maxFeeExceeded(transaction) { + // Don't err until attempting to resubmit + transaction.once('presubmit', function () { + transaction.emit('error', 'tejMaxFeeExceeded'); + }); + } + + this._pending.forEach(function (transaction) { + if (transaction._setFixedFee) { + return; + } + + var oldFee = transaction.tx_json.Fee; + var newFee = transaction._computeFee(); + + if (Number(newFee) > self._maxFee) { + // Max transaction fee exceeded, abort submission + maxFeeExceeded(transaction); + return; + } + + 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. One of + * either "missing" or "lost" + * + * @param {Object} ledger data + * @api private + */ + +TransactionManager.prototype._updatePendingStatus = function (ledger) { + assert.strictEqual(typeof ledger, 'object'); + assert.strictEqual(typeof ledger.ledger_index, 'number'); + + this._pending.forEach(function (transaction) { + if (transaction.finalized) { + return; + } + + switch (ledger.ledger_index - transaction.submitIndex) { + case 4: + transaction.emit('missing', ledger); + break; + case 8: + transaction.emit('lost', ledger); + break; + } + + if (ledger.ledger_index > transaction.tx_json.LastLedgerSequence) { + // Transaction must fail + transaction.emit('error', new RippleError('tejMaxLedger', 'Transaction LastLedgerSequence exceeded')); + } + }); +}; + +// Fill an account transaction sequence +TransactionManager.prototype._fillSequence = function (tx, callback) { + var self = this; + + function submitFill(sequence, fCallback) { + var fillTransaction = self._remote.createTransaction('AccountSet', { + account: self._accountID + }); + fillTransaction.tx_json.Sequence = sequence; + + // Secrets may be set on a per-transaction basis + if (tx._secret) { + fillTransaction.secret(tx._secret); + } + + fillTransaction.once('submitted', fCallback); + fillTransaction.submit(); + } + + function sequenceLoaded(err, sequence) { + if (typeof sequence !== 'number') { + log.info('fill sequence: failed to fetch account transaction sequence'); + return callback(); + } + + var sequenceDiff = tx.tx_json.Sequence - sequence; + var submitted = 0; + + async.whilst(function () { + return submitted < sequenceDiff; + }, function (asyncCallback) { + submitFill(sequence, function (res) { + ++submitted; + if (res.engine_result === 'tesSUCCESS') { + self.emit('sequence_filled', err); + } + asyncCallback(); + }); + }, function () { + if (callback) { + callback(); + } + }); + } + + this._loadSequence(sequenceLoaded); +}; + +/** + * 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); +}; + +/** + * On reconnect, load account_tx in case a pending transaction succeeded while + * disconnected + * + * @param [Function] callback + * @api private + */ + +TransactionManager.prototype._handleReconnect = function (callback_) { + var self = this; + var callback = typeof callback_ === 'function' ? callback_ : function () {}; + + if (!this._pending.length()) { + callback(); + return; + } + + function handleTransactions(err, transactions) { + if (err || typeof transactions !== 'object') { + if (self._remote.trace) { + log.info('error requesting account_tx', err); + } + callback(); + return; + } + + if (Array.isArray(transactions.transactions)) { + // Treat each transaction in account transaction history as received + transactions.transactions.forEach(self._transactionReceived, self); + } + + callback(); + + self._loadSequence(function () { + // Resubmit pending transactions after sequence is loaded + self._resubmit(); + }); + } + + var options = { + account: this._accountID, + ledger_index_min: this._pending.getMinLedger(), + ledger_index_max: -1, + binary: true, + parseBinary: true, + limit: 20 + }; + + this._remote.requestAccountTx(options, handleTransactions); +}; + +/** + * Wait for specified number of ledgers to pass + * + * @param {Number} ledgers + * @param {Function} callback + * @api private + */ + +TransactionManager.prototype._waitLedgers = function (ledgers, callback) { + assert.strictEqual(typeof ledgers, 'number'); + assert.strictEqual(typeof callback, 'function'); + + if (ledgers < 1) { + return callback(); + } + + var self = this; + var closes = 0; + + function ledgerClosed() { + if (++closes === ledgers) { + self._remote.removeListener('ledger_closed', ledgerClosed); + callback(); + } + } + + this._remote.on('ledger_closed', ledgerClosed); +}; + +/** + * 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 (ledgers_, pending_) { + var self = this; + + var ledgers = ledgers_; + var pending = pending_; + + if (arguments.length === 1) { + pending = ledgers; + ledgers = 0; + } + + ledgers = ledgers || 0; + pending = pending instanceof Transaction ? [pending] : this.getPending().getQueue(); + + function resubmitTransaction(transaction, next) { + if (!transaction || transaction.finalized) { + // Transaction has been finalized, nothing to do + return; + } + + // 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); + } + } + + if (!transaction.isResubmittable()) { + // Rather than resubmit, wait for the transaction to fail due to + // LastLedgerSequence's being exceeded. The ultimate error emitted on + // transaction is 'tejMaxLedger'; should be definitive + return; + } + + while (self._pending.hasSequence(transaction.tx_json.Sequence)) { + // Sequence number has been consumed by another transaction + transaction.tx_json.Sequence += 1; + + if (self._remote.trace) { + log.info('incrementing sequence:', transaction.tx_json); + } + } + + if (self._remote.trace) { + log.info('resubmit:', transaction.tx_json); + } + + transaction.once('submitted', function (m) { + transaction.emit('resubmitted', m); + next(); + }); + + self._request(transaction); + } + + this._waitLedgers(ledgers, function () { + 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.txBlob(serialized.to_hex()); + + var hash = tx.hash(null, null, serialized); + tx.addId(hash); + } else { + if (tx.hasMultiSigners()) { + submitRequest.message.command = 'submit_multisigned'; + } + + // 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.buildPath(tx._build_path); + submitRequest.secret(tx._secret); + submitRequest.txJson(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 (Number(tx.tx_json.Fee) > tx._maxFee) { + tx.emit('error', new RippleError('tejMaxFeeExceeded')); + return; + } + + if (remote.trace) { + log.info('submit transaction:', tx.tx_json); + } + + function transactionFailed(message) { + if (message.engine_result === 'tefPAST_SEQ') { + // Transaction may succeed after Sequence is updated + self._resubmit(1, tx); + } + } + + function transactionRetry() { + // XXX This may no longer be necessary. Instead, update sequence numbers + // after a transaction fails definitively + self._fillSequence(tx, function () { + self._resubmit(1, tx); + }); + } + + function transactionFailedLocal(message) { + if (message.engine_result === 'telINSUF_FEE_P') { + // Transaction may succeed after Fee is updated + self._resubmit(1, tx); + } + } + + function submissionError(error) { + // Either a tem-class error or generic server error such as tooBusy. This + // should be a definitive failure + if (TransactionManager._isTooBusy(error)) { + self._waitLedgers(1, function () { + tx.once('submitted', function (m) { + tx.emit('resubmitted', m); + }); + self._request(tx); + }); + } else { + self._nextSequence--; + tx.emit('error', error); + } + } + + 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': + tx.emit('proposed', message); + break; + case 'tec': + break; + case 'ter': + transactionRetry(message); + break; + case 'tef': + transactionFailed(message); + break; + case 'tel': + transactionFailedLocal(message); + break; + default: + // tem + submissionError(message); + } + } + + function requestTimeout() { + // ND: What if the response is just slow and we get a response that + // `submitted` above will cause to have concurrent resubmit logic streams? + // It's simpler to just mute handlers and look out for finalized + // `transaction` messages. + if (tx.finalized) { + return; + } + + tx.emit('timeout'); + + if (remote.isConnected()) { + if (remote.trace) { + log.info('timeout:', tx.tx_json); + } + self._resubmit(1, tx); + } + } + + tx.submitIndex = this._remote.getLedgerSequence() + 1; + + if (tx.attempts === 0) { + tx.initialSubmitIndex = tx.submitIndex; + } + + var submitRequest = this._prepareRequest(tx); + submitRequest.once('error', submitted); + submitRequest.once('success', submitted); + + tx.emit('presubmit'); + + submitRequest.broadcast().request(); + tx.attempts++; + + tx.emit('postsubmit'); + + submitRequest.timeout(self._submissionTimeout, requestTimeout); +}; + +/** + * 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 (!_.isNumber(tx.tx_json.Sequence)) { + // Honor manually-set sequences + tx.setSequence(this._nextSequence++); + } + + if (_.isUndefined(tx.tx_json.LastLedgerSequence)) { + tx.setLastLedgerSequence(); + } + + if (tx.hasMultiSigners()) { + tx.setResubmittable(false); + tx.setSigningPubKey(''); + } + + tx.once('cleanup', function () { + self.getPending().remove(tx); + }); + + if (!tx.complete()) { + this._nextSequence -= 1; + 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; +