diff --git a/README.md b/README.md index 9efbca86..c852fb8c 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ remote.request_server_info(function(err, res) { **request_unsubscribe(streams, [callback])** -**request_transaction_entry(hash, [callback])** +**request_transaction_entry(tx_hash, [ledger_hash], [callback])** **request_tx(hash, [callback])** @@ -90,11 +90,11 @@ remote.request_server_info(function(err, res) { **request_wallet_accounts(seed, [callback])** -+ requires trusted **remote ++ requires trusted remote **request_sign(secret, tx_json, [callback])** -+ requires trusted **remote ++ requires trusted remote **request_submit([callback])** @@ -118,6 +118,6 @@ remote.request_server_info(function(err, res) { **request_connect(ip, port, [callback])** -**transaction()** +**transaction([destination], [source], [amount], [callback])** + returns a [Transaction](https://github.com/ripple/ripple-lib/blob/develop/src/js/ripple/transaction.js) object diff --git a/package.json b/package.json index 578dd90f..8c84028b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ripple-lib", - "version": "0.7.18", + "version": "0.7.19", "description": "Ripple JavaScript client library", "files": [ "src/js/ripple/*.js", @@ -33,7 +33,7 @@ }, "repository": { "type": "git", - "url": "git://github.com/rippleFoundation/ripple-lib.git" + "url": "git://github.com/ripple/ripple-lib.git" }, "readmeFilename": "README.md", "engines": { diff --git a/src/js/ripple/account.js b/src/js/ripple/account.js index 603ad7d1..09b15574 100644 --- a/src/js/ripple/account.js +++ b/src/js/ripple/account.js @@ -11,67 +11,81 @@ // var network = require("./network.js"); -var EventEmitter = require('events').EventEmitter; -var util = require('util'); +var EventEmitter = require('events').EventEmitter; +var util = require('util'); +var extend = require('extend'); -var Amount = require('./amount').Amount; -var UInt160 = require('./uint160').UInt160; +var Amount = require('./amount').Amount; +var UInt160 = require('./uint160').UInt160; +var TransactionManager = require('./transactionmanager').TransactionManager; -var extend = require('extend'); -var Account = function (remote, account) { +function Account(remote, account) { EventEmitter.call(this); + var self = this; - this._remote = remote; - this._account = UInt160.from_json(account); + this._tx_manager = null; + this._remote = remote; + this._account = UInt160.from_json(account); this._account_id = this._account.to_json(); - this._subs = 0; + this._subs = 0; // Ledger entry object // Important: This must never be overwritten, only extend()-ed - this._entry = {}; + this._entry = { }; - this.on('newListener', function (type, listener) { - if (Account.subscribe_events.indexOf(type) !== -1) { - if (!self._subs && 'open' === self._remote._online_state) { + function listener_added(type, listener) { + if (~Account.subscribe_events.indexOf(type)) { + if (!self._subs && self._remote._connected) { self._remote.request_subscribe() - .accounts(self._account_id) - .request(); + .accounts(self._account_id) + .request(); } - self._subs += 1; + self._subs += 1; } - }); + } - this.on('removeListener', function (type, listener) { - if (Account.subscribe_events.indexOf(type) !== -1) { - self._subs -= 1; - - if (!self._subs && 'open' === self._remote._online_state) { + function listener_removed(type, listener) { + if (~Account.subscribe_events.indexOf(type)) { + self._subs -= 1; + if (!self._subs && self._remote._connected) { self._remote.request_unsubscribe() - .accounts(self._account_id) - .request(); + .accounts(self._account_id) + .request(); } } - }); + } - this._remote.on('prepare_subscribe', function (request) { - if (self._subs) request.accounts(self._account_id); - }); + this.on('newListener', listener_added); + this.on('removeListener', listener_removed); - this.on('transaction', function (msg) { + function prepare_subscribe(request) { + if (self._subs) { + request.accounts(self._account_id); + } + } + + this._remote.on('prepare_subscribe', prepare_subscribe); + + function handle_transaction(transaction) { var changed = false; - msg.mmeta.each(function (an) { - if (an.entryType === 'AccountRoot' && - an.fields.Account === self._account_id) { + + transaction.mmeta.each(function(an) { + var isAccountRoot = an.entryType === 'AccountRoot' + && an.fields.Account === self._account_id; + if (isAccountRoot) { extend(self._entry, an.fieldsNew, an.fieldsFinal); changed = true; } }); + if (changed) { self.emit('entry', self._entry); } - }); + } + + this.on('transaction', handle_transaction); return this; }; @@ -81,10 +95,9 @@ util.inherits(Account, EventEmitter); /** * List of events that require a remote subscription to the account. */ -Account.subscribe_events = ['transaction', 'entry']; +Account.subscribe_events = [ 'transaction', 'entry' ]; -Account.prototype.to_json = function () -{ +Account.prototype.to_json = function () { return this._account.to_json(); }; @@ -93,11 +106,15 @@ Account.prototype.to_json = function () * * Note: This does not tell you whether the account exists in the ledger. */ -Account.prototype.is_valid = function () -{ +Account.prototype.is_valid = function () { return this._account.is_valid(); }; +Account.prototype.get_info = function(callback) { + var callback = typeof callback === 'function' ? callback : function(){}; + this._remote.request_account_info(this._account_id, callback); +}; + /** * Retrieve the current AccountRoot entry. * @@ -106,14 +123,53 @@ Account.prototype.is_valid = function () * * @param {function (err, entry)} callback Called with the result */ -Account.prototype.entry = function (callback) +Account.prototype.entry = function (callback) { + var self = this; + var callback = typeof callback === 'function' ? callback : function(){}; + + this.get_info(function account_info(err, info) { + if (err) { + callback(err); + } else { + extend(self._entry, info.account_data); + self.emit('entry', self._entry); + callback(null, info); + } + }); + + return this; +}; + +Account.prototype.get_next_sequence = function(callback) { + var callback = typeof callback === 'function' ? callback : function(){}; + + this.get_info(function account_info(err, info) { + if (err) { + callback(err); + } else { + callback(null, info.account_data.Sequence); + } + }); + + return this; +}; + +/** + * Retrieve this account's Ripple trust lines. + * + * To keep up-to-date with changes to the AccountRoot entry, subscribe to the + * "lines" event. (Not yet implemented.) + * + * @param {function (err, lines)} callback Called with the result + */ +Account.prototype.lines = function (callback) { var self = this; - self._remote.request_account_info(this._account_id) + self._remote.request_account_lines(this._account_id) .on('success', function (e) { - extend(self._entry, e.account_data); - self.emit('entry', self._entry); + self._lines = e.lines; + self.emit('lines', self._lines); if ("function" === typeof callback) { callback(null, e); @@ -133,8 +189,7 @@ Account.prototype.entry = function (callback) * This is only meant to be called by the Remote class. You should never have to * call this yourself. */ -Account.prototype.notifyTx = function (message) -{ +Account.prototype.notifyTx = function (message) { // Only trigger the event if the account object is actually // subscribed - this prevents some weird phantom events from // occurring. @@ -143,6 +198,13 @@ Account.prototype.notifyTx = function (message) } }; -exports.Account = Account; +Account.prototype.submit = function(tx) { + if (!this._tx_manager) { + this._tx_manager = new TransactionManager(this); + } + this._tx_manager.submit(tx); +}; + +exports.Account = Account; // vim:sw=2:sts=2:ts=8:et diff --git a/src/js/ripple/amount.js b/src/js/ripple/amount.js index 2f3c671e..441bde90 100644 --- a/src/js/ripple/amount.js +++ b/src/js/ripple/amount.js @@ -556,6 +556,21 @@ Amount.prototype.negate = function () { return this.clone('NEGATE'); }; +/** + * Invert this amount and return the new value. + * + * Creates a new Amount object as a copy of the current one (including the same + * unit (currency & issuer), inverts it (1/x) and returns the result. + */ +Amount.prototype.invert = function () { + var one = this.clone(); + one._value = BigInteger.ONE; + one._offset = 0; + one._is_negative = false; + one.canonicalize(); + return one.ratio_human(this); +}; + /** * Tries to correctly interpret an amount as entered by a user. * diff --git a/src/js/ripple/index.js b/src/js/ripple/index.js index 1cbf2d1e..692155f3 100644 --- a/src/js/ripple/index.js +++ b/src/js/ripple/index.js @@ -10,6 +10,7 @@ exports.SerializedObject = require('./serializedobject').SerializedObject; exports.binformat = require('./binformat'); exports.utils = require('./utils'); +exports.Server = require('./server').Server; // Important: We do not guarantee any specific version of SJCL or for any // specific features to be included. The version and configuration may change at diff --git a/src/js/ripple/pathfind.js b/src/js/ripple/pathfind.js new file mode 100644 index 00000000..3c456140 --- /dev/null +++ b/src/js/ripple/pathfind.js @@ -0,0 +1,88 @@ +var EventEmitter = require('events').EventEmitter; +var util = require('util'); + +var Amount = require('./amount').Amount; + +var extend = require('extend'); + +/** + * Represents a persistent path finding request. + * + * Only one path find request is allowed per connection, so when another path + * find request is triggered it will supercede the existing one, making it emit + * the 'end' and 'superceded' events. + */ +var PathFind = function (remote, src_account, dst_account, + dst_amount, src_currencies) +{ + EventEmitter.call(this); + + this.remote = remote; + + this.src_account = src_account; + this.dst_account = dst_account; + this.dst_amount = dst_amount; + this.src_currencies = src_currencies; +}; + +util.inherits(PathFind, EventEmitter); + +/** + * Submits a path_find_create request to the network. + * + * This starts a path find request, superceding all previous path finds. + * + * This will be called automatically by Remote when this object is instantiated, + * so you should only have to call it if the path find was closed or superceded + * and you wish to restart it. + */ +PathFind.prototype.create = function () +{ + var self = this; + + var req = this.remote.request_path_find_create(this.src_account, + this.dst_account, + this.dst_amount, + this.src_currencies, + handleInitialPath); + + function handleInitialPath(err, msg) { + if (err) { + // XXX Handle error + return; + } + self.notify_update(msg); + } + + req.request(); +}; + +PathFind.prototype.close = function () +{ + this.remote.request_path_find_close().request(); + this.emit('end'); + this.emit('close'); +}; + +PathFind.prototype.notify_update = function (message) +{ + var src_account = message.source_account; + var dst_account = message.destination_account; + var dst_amount = Amount.from_json(message.destination_amount); + + // Only pass the event along if this path find response matches what we were + // looking for. + if (this.src_account === src_account && + this.dst_account === dst_account && + this.dst_amount.equals(dst_amount)) { + this.emit('update', message); + } +}; + +PathFind.prototype.notify_superceded = function () +{ + this.emit('end'); + this.emit('superceded'); +}; + +exports.PathFind = PathFind; diff --git a/src/js/ripple/remote.js b/src/js/ripple/remote.js index 6606e7d5..403d41e0 100644 --- a/src/js/ripple/remote.js +++ b/src/js/ripple/remote.js @@ -15,266 +15,26 @@ // // npm -var EventEmitter = require('events').EventEmitter; -var util = require('util'); +var EventEmitter = require('events').EventEmitter; +var util = require('util'); -var Server = require('./server').Server; -var Amount = require('./amount').Amount; -var Currency = require('./currency').Currency; -var UInt160 = require('./uint160').UInt160; -var Transaction = require('./transaction').Transaction; -var Account = require('./account').Account; -var Meta = require('./meta').Meta; -var OrderBook = require('./orderbook').OrderBook; +var Request = require('./request').Request; +var Server = require('./server').Server; +var Amount = require('./amount').Amount; +var Currency = require('./currency').Currency; +var UInt160 = require('./uint160').UInt160; +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 RippleError = require('./rippleerror').RippleError; -var utils = require('./utils'); -var config = require('./config'); -var sjcl = require('../../../build/sjcl'); +var utils = require('./utils'); +var config = require('./config'); +var sjcl = require('../../../build/sjcl'); -// Request events emitted: -// 'success' : Request successful. -// 'error' : Request failed. -// 'remoteError' -// 'remoteUnexpected' -// 'remoteDisconnected' -function Request(remote, command) { - EventEmitter.call(this); - this.remote = remote; - this.requested = false; - this.message = { - command : command, - id : void(0) - }; -}; -util.inherits(Request, EventEmitter); - -// Send the request to a remote. -Request.prototype.request = function (remote) { - if (!this.requested) { - this.requested = true; - this.remote.request(this); - this.emit('request', remote); - } -}; - -Request.prototype.callback = function(callback, successEvent, errorEvent) { - if (callback && typeof callback === 'function') { - this.once(successEvent || 'success', callback.bind(this, null)); - this.once(errorEvent || 'error' , callback.bind(this)); - this.request(); - } - - return this; -}; - -Request.prototype.timeout = function(duration, callback) { - if (!this.requested) { - this.once('request', this.timeout.bind(this, duration, callback)); - return; - }; - - var self = this; - var emit = this.emit; - var timed_out = false; - - var timeout = setTimeout(function() { - timed_out = true; - if (typeof callback === 'function') callback(); - emit.call(self, 'timeout'); - }, duration); - - this.emit = function() { - if (timed_out) return; - else clearTimeout(timeout); - emit.apply(self, arguments); - }; - - return this; -}; - -Request.prototype.build_path = function (build) { - if (build) { - this.message.build_path = true; - } - - return this; -}; - -Request.prototype.ledger_choose = function (current) { - if (current) { - this.message.ledger_index = this.remote._ledger_current_index; - } else { - this.message.ledger_hash = this.remote._ledger_hash; - } - - return this; -}; - -// Set the ledger for a request. -// - ledger_entry -// - transaction_entry -Request.prototype.ledger_hash = function (h) { - this.message.ledger_hash = h; - - return this; -}; - -// Set the ledger_index for a request. -// - ledger_entry -Request.prototype.ledger_index = function (ledger_index) { - this.message.ledger_index = ledger_index; - - return this; -}; - -Request.prototype.ledger_select = function (ledger_spec) { - switch (ledger_spec) { - case 'current': - case 'closed': - case 'verified': - this.message.ledger_index = ledger_spec; - break; - - default: - // XXX Better test needed - if (String(ledger_spec).length > 12) { - this.message.ledger_hash = ledger_spec; - } else { - this.message.ledger_index = ledger_spec; - } - break; - } - - return this; -}; - -Request.prototype.account_root = function (account) { - this.message.account_root = UInt160.json_rewrite(account); - - return this; -}; - -Request.prototype.index = function (hash) { - this.message.index = hash; - - return this; -}; - -// Provide the information id an offer. -// --> account -// --> seq : sequence number of transaction creating offer (integer) -Request.prototype.offer_id = function (account, seq) { - this.message.offer = { - account: UInt160.json_rewrite(account), - seq: seq - }; - - return this; -}; - -// --> index : ledger entry index. -Request.prototype.offer_index = function (index) { - this.message.offer = index; - - return this; -}; - -Request.prototype.secret = function (s) { - if (s) { - this.message.secret = s; - } - - return this; -}; - -Request.prototype.tx_hash = function (h) { - this.message.tx_hash = h; - - return this; -}; - -Request.prototype.tx_json = function (j) { - this.message.tx_json = j; - - return this; -}; - -Request.prototype.tx_blob = function (j) { - this.message.tx_blob = j; - - return this; -}; - -Request.prototype.ripple_state = function (account, issuer, currency) { - this.message.ripple_state = { - 'accounts' : [ - UInt160.json_rewrite(account), - UInt160.json_rewrite(issuer) - ], - 'currency' : currency - }; - - return this; -}; - -Request.prototype.accounts = function (accounts, realtime) { - if (!Array.isArray(accounts)) { - accounts = [ accounts ]; - } - - // Process accounts parameters - var procAccounts = accounts.map(function(account) { - return UInt160.json_rewrite(account); - }); - - if (realtime) { - this.message.rt_accounts = procAccounts; - } else { - this.message.accounts = procAccounts; - } - - return this; -}; - -Request.prototype.rt_accounts = function (accounts) { - return this.accounts(accounts, true); -}; - -Request.prototype.books = function (books, snapshot) { - var procBooks = []; - - for (var i = 0, l = books.length; i < l; i++) { - var book = books[i]; - var json = {}; - - function processSide(side) { - if (!book[side]) throw new Error('Missing '+side); - - var obj = json[side] = { - currency: Currency.json_rewrite(book[side].currency) - }; - - if (obj.currency !== 'XRP') { - obj.issuer = UInt160.json_rewrite(book[side].issuer); - } - } - - processSide('taker_gets'); - processSide('taker_pays'); - - if (snapshot) json.snapshot = true; - if (book.both) json.both = true; - - procBooks.push(json); - } - - this.message.books = procBooks; - - return this; -}; - -//------------------------------------------------------------------------------ /** Interface to manage the connection to a Ripple server. @@ -282,13 +42,19 @@ Request.prototype.books = function (books, snapshot) { Keys for opts: - trusted : truthy, if remote is trusted - websocket_ip - websocket_port - websocket_ssl trace - maxListeners - fee_cushion : Extra fee multiplier to account for async fee changes. + max_listeners : Set maxListeners for remote; prevents EventEmitter warnings + connection_offset : Connect to remote servers on supplied interval (in seconds) + trusted : truthy, if remote is trusted + max_fee : Maximum acceptable transaction fee + fee_cushion : Extra fee multiplier to account for async fee changes. + servers : Array of server objects with the following form + + { + host: + , port: + , secure: + } Events: 'connect' @@ -318,14 +84,12 @@ function Remote(opts, trace) { this.trusted = opts.trusted; this.local_sequence = opts.local_sequence; // Locally track sequence numbers this.local_fee = opts.local_fee; // Locally set fees - this.local_signing = (typeof opts.local_signing === 'undefined') - ? true : opts.local_signing; - this.fee_cushion = (typeof opts.fee_cushion === 'undefined') - ? 1.5 : opts.fee_cushion; - + this.local_signing = (typeof opts.local_signing === 'undefined') ? true : Boolean(opts.local_signing); + this.fee_cushion = (typeof opts.fee_cushion === 'undefined') ? 1.5 : Number(opts.fee_cushion); + this.max_fee = (typeof opts.max_fee === 'undefined') ? Infinity : Number(opts.max_fee); this.id = 0; - this.trace = opts.trace || trace; - this._server_fatal = false; // True, if we know server exited. + this.trace = Boolean(opts.trace); + this._server_fatal = false; // True, if we know server exited. this._ledger_current_index = void(0); this._ledger_hash = void(0); this._ledger_time = void(0); @@ -333,8 +97,8 @@ function Remote(opts, trace) { this._testnet = void(0); this._transaction_subs = 0; this.online_target = false; - this._online_state = 'closed'; // 'open', 'closed', 'connecting', 'closing' - this.state = 'offline'; // 'online', 'offline' + this._online_state = 'closed'; // 'open', 'closed', 'connecting', 'closing' + this.state = 'offline'; // 'online', 'offline' this.retry_timer = void(0); this.retry = void(0); @@ -346,16 +110,18 @@ function Remote(opts, trace) { this._reserve_inc = void(0); this._connection_count = 0; this._connected = false; + this._connection_offset = 1000 * (Number(opts.connection_offset) || 5); this._last_tx = null; + this._cur_path_find = null; // Local signing implies local fees and sequences if (this.local_signing) { this.local_sequence = true; - this.local_fee = true; + this.local_fee = true; } - this._servers = [ ]; + this._servers = [ ]; this._primary_server = void(0); // Cache information for accounts. @@ -367,13 +133,13 @@ function Remote(opts, trace) { // account : { seq : __ } }; - // Hash map of Account objects by AccountId. - this._accounts = {}; + // Account objects by AccountId. + this._accounts = { }; - // Hash map of OrderBook objects - this._books = {}; + // OrderBook objects + this._books = { }; - // List of secrets that we know about. + // Secrets that we know about. this.secrets = { // Secrets can be set by calling set_secret(account, secret). @@ -383,8 +149,8 @@ function Remote(opts, trace) { // Cache for various ledgers. // XXX Clear when ledger advances. this.ledgers = { - 'current' : { - 'account_root' : {} + current : { + account_root : {} } }; @@ -397,57 +163,68 @@ function Remote(opts, trace) { secure: opts.websocket_ssl, trusted: opts.trusted } - ] + ]; } opts.servers.forEach(function(server) { - var i = Number(server.pool) || 1; - while (i--) { self.add_server(server); } + var pool = Number(server.pool) || 1; + while (pool--) { self.add_server(server); }; }); // This is used to remove Node EventEmitter warnings - var maxListeners = opts.maxListeners || 0; + var maxListeners = opts.maxListeners || opts.max_listeners || 0; this._servers.concat(this).forEach(function(emitter) { emitter.setMaxListeners(maxListeners); }); - this.on('newListener', function (type, listener) { + function listener_added(type, listener) { if (type === 'transaction_all') { if (!self._transaction_subs && self._connected) { self.request_subscribe('transactions').request(); } self._transaction_subs += 1; } - }); + } - this.on('removeListener', function (type, listener) { + function listener_removed(type, listener) { if (type === 'transaction_all') { self._transaction_subs -= 1; if (!self._transaction_subs && self._connected) { self.request_unsubscribe('transactions').request(); } } - }); -}; + } + + this.on('newListener', listener_added); + this.on('removeListener', listener_removed); +} util.inherits(Remote, EventEmitter); // Flags for ledger entries. In support of account_root(). Remote.flags = { - 'account_root' : { - 'PasswordSpent' : 0x00010000, - 'RequireDestTag' : 0x00020000, - 'RequireAuth' : 0x00040000, - 'DisallowXRP' : 0x00080000, + account_root : { + PasswordSpent: 0x00010000, + RequireDestTag: 0x00020000, + RequireAuth: 0x00040000, + DisallowXRP: 0x00080000 } }; +function isTemMalformed(engine_result_code) { + return (engine_result_code >= -299 && engine_result_code < 199); +}; + +function isTefFailure(engine_result_code) { + return (engine_result_code >= -299 && engine_result_code < 199); +}; + Remote.from_config = function (obj, trace) { var serverConfig = typeof obj === 'string' ? config.servers[obj] : obj; var remote = new Remote(serverConfig, trace); - for (var account in config.accounts) { + function initialize_account(account) { var accountInfo = config.accounts[account]; if (typeof accountInfo === 'object') { if (accountInfo.secret) { @@ -459,6 +236,12 @@ Remote.from_config = function (obj, trace) { } } + if (typeof config.accounts === 'object') { + for (var account in config.accounts) { + initialize_account(account); + } + } + return remote; }; @@ -468,42 +251,40 @@ Remote.create_remote = function(options, callback) { return remote; }; -var isTemMalformed = function (engine_result_code) { - return (engine_result_code >= -299 && engine_result_code < 199); -}; - -var isTefFailure = function (engine_result_code) { - return (engine_result_code >= -299 && engine_result_code < 199); -}; - Remote.prototype.add_server = function (opts) { var self = this; - var url = ((opts.secure || opts.websocket_ssl) ? 'wss://' : 'ws://') - + (opts.host || opts.websocket_ip) + ':' - + (opts.port || opts.websocket_port) - ; - - var server = new Server(this, {url: url}); - - server.on('message', function (data) { - self._handle_message(data); + var server = new Server(this, { + host : opts.host || opts.websocket_ip, + port : opts.port || opts.websocket_port, + secure : opts.secure || opts.websocket_ssl }); - server.on('connect', function () { + function server_message(data) { + self._handle_message(data, server); + } + + function server_connect() { + self._connection_count++; + self._set_state('online'); if (opts.primary || !self._primary_server) { self._set_primary_server(server); } - self._connection_count++; - self._set_state('online'); - }); + if (self._connection_count === self._servers.length) { + self.emit('ready'); + } + } - server.on('disconnect', function () { + function server_disconnect() { self._connection_count--; if (!self._connection_count) { self._set_state('offline'); } - }); + } + + server.on('message', server_message); + server.on('connect', server_connect); + server.on('disconnect', server_disconnect); this._servers.push(server); @@ -517,7 +298,7 @@ Remote.prototype.server_fatal = function () { // Set the emitted state: 'online' or 'offline' Remote.prototype._set_state = function (state) { - if (this.trace) console.log('remote: set_state: %s', state); + this._trace('remote: set_state: %s', state); if (this.state !== state) { this.state = state; @@ -547,30 +328,46 @@ Remote.prototype.set_trace = function (trace) { return this; }; +Remote.prototype._trace = function() { + if (this.trace) { + utils.logObject.apply(utils, arguments); + } +}; + /** * Connect to the Ripple network. */ Remote.prototype.connect = function (online) { - // Downwards compatibility - switch(typeof online) { + if (!this._servers.length) { + throw new Error('No servers available.'); + } + + switch (typeof online) { case 'undefined': break; case 'function': this.once('connect', online); break; default: - if (!Boolean(online)) - return this.disconnect() - break; + // Downwards compatibility + if (!Boolean(online)) { + return this.disconnect(); + } } - if (!this._servers.length) { - throw new Error('No servers available.'); - } else { - for (var i=0; i= 3) { + tx = tx.payment(source, destination, amount); + if (typeof callback === 'function') { + tx.submit(callback); + } + } + + return tx; }; /** @@ -1444,11 +1292,9 @@ Remote.prototype.transaction = function () { * * @return {Amount} Final fee in XRP for specified number of fee units. */ -Remote.prototype.fee_tx = function (units) -{ +Remote.prototype.fee_tx = function (units) { var fee_unit = this.fee_tx_unit(); - - return Amount.from_json(""+Math.ceil(units * fee_unit)); + return Amount.from_json(String(Math.ceil(units * fee_unit))); }; /** @@ -1459,8 +1305,7 @@ Remote.prototype.fee_tx = function (units) * * @return {Number} Recommended amount for one fee unit as float. */ -Remote.prototype.fee_tx_unit = function () -{ +Remote.prototype.fee_tx_unit = function () { var fee_unit = this._fee_base / this._fee_ref; // Apply load fees @@ -1477,20 +1322,48 @@ Remote.prototype.fee_tx_unit = function () * * Returns the base reserve with load fees and safety margin applied. */ -Remote.prototype.reserve = function (owner_count) -{ - var reserve_base = Amount.from_json(""+this._reserve_base); - var reserve_inc = Amount.from_json(""+this._reserve_inc); - - owner_count = owner_count || 0; +Remote.prototype.reserve = function (owner_count) { + var reserve_base = Amount.from_json(String(this._reserve_base)); + var reserve_inc = Amount.from_json(String(this._reserve_inc)); + var owner_count = owner_count || 0; if (owner_count < 0) { - throw new Error("Owner count must not be negative."); + throw new Error('Owner count must not be negative.'); } return reserve_base.add(reserve_inc.product_human(owner_count)); }; -exports.Remote = Remote; +Remote.prototype.ping = function(host, callback) { + var request = new Request(this, 'ping'); + + switch (typeof host) { + case 'function': + callback = host; + break; + case 'string': + var server = null; + for (var i=0, s; s=this._servers[i]; i++) { + if (s._host === host) { + server = s; + break; + } + } + request.set_server(server); + break; + } + + var then = Date.now(); + + request.once('success', function() { + request.emit('pong', Date.now() - then); + }); + + request.callback(callback, 'pong'); + + return request; +}; + +exports.Remote = Remote; // vim:sw=2:sts=2:ts=8:et diff --git a/src/js/ripple/request.js b/src/js/ripple/request.js new file mode 100644 index 00000000..960c927e --- /dev/null +++ b/src/js/ripple/request.js @@ -0,0 +1,269 @@ +var EventEmitter = require('events').EventEmitter; +var util = require('util'); +var UInt160 = require('./uint160').UInt160; +var Currency = require('./currency').Currency; +var Transaction = require('./transaction').Transaction; +var Account = require('./account').Account; +var Meta = require('./meta').Meta; +var OrderBook = require('./orderbook').OrderBook; +var RippleError = require('./rippleerror').RippleError; + +// Request events emitted: +// 'success' : Request successful. +// 'error' : Request failed. +// 'remoteError' +// 'remoteUnexpected' +// 'remoteDisconnected' +function Request(remote, command) { + EventEmitter.call(this); + + this.remote = remote; + this.requested = false; + this.message = { + command : command, + id : void(0) + }; +}; + +util.inherits(Request, EventEmitter); + +// Send the request to a remote. +Request.prototype.request = function (remote) { + if (!this.requested) { + this.requested = true; + this.remote.request(this); + this.emit('request', remote); + } +}; + +Request.prototype.callback = function(callback, successEvent, errorEvent) { + if (callback && typeof callback === 'function') { + function request_success(message) { + callback.call(this, null, message); + } + + function request_error(error) { + if (!(error instanceof RippleError)) { + error = new RippleError(error); + } + callback.call(this, error); + } + + this.once(successEvent || 'success', request_success); + this.once(errorEvent || 'error' , request_error); + this.request(); + } + + return this; +}; + +Request.prototype.timeout = function(duration, callback) { + var self = this; + + if (!this.requested) { + function requested() { + self.timeout(duration, callback); + } + this.once('request', requested); + return; + } + + var emit = this.emit; + var timed_out = false; + + var timeout = setTimeout(function() { + timed_out = true; + if (typeof callback === 'function') callback(); + emit.call(self, 'timeout'); + }, duration); + + this.emit = function() { + if (!timed_out) { + clearTimeout(timeout); + emit.apply(self, arguments); + } + }; + + return this; +}; + +Request.prototype.set_server = function(server) { + this.server = server; +}; + +Request.prototype.build_path = function (build) { + if (build) { + this.message.build_path = true; + } + return this; +}; + +Request.prototype.ledger_choose = function (current) { + if (current) { + this.message.ledger_index = this.remote._ledger_current_index; + } else { + this.message.ledger_hash = this.remote._ledger_hash; + } + return this; +}; + +// Set the ledger for a request. +// - ledger_entry +// - transaction_entry +Request.prototype.ledger_hash = function (hash) { + this.message.ledger_hash = hash; + return this; +}; + +// Set the ledger_index for a request. +// - ledger_entry +Request.prototype.ledger_index = function (ledger_index) { + this.message.ledger_index = ledger_index; + return this; +}; + +Request.prototype.ledger_select = function (ledger_spec) { + switch (ledger_spec) { + case 'current': + case 'closed': + case 'verified': + this.message.ledger_index = ledger_spec; + break; + + default: + // XXX Better test needed + if (Number(ledger_spec)) { + this.message.ledger_index = ledger_spec; + } else { + this.message.ledger_hash = ledger_spec; + } + break; + } + + return this; +}; + +Request.prototype.account_root = function (account) { + this.message.account_root = UInt160.json_rewrite(account); + return this; +}; + +Request.prototype.index = function (hash) { + this.message.index = hash; + return this; +}; + +// Provide the information id an offer. +// --> account +// --> seq : sequence number of transaction creating offer (integer) +Request.prototype.offer_id = function (account, seq) { + this.message.offer = { + account: UInt160.json_rewrite(account), + seq: seq + }; + return this; +}; + +// --> index : ledger entry index. +Request.prototype.offer_index = function (index) { + this.message.offer = index; + return this; +}; + +Request.prototype.secret = function (secret) { + if (secret) { + this.message.secret = secret; + } + return this; +}; + +Request.prototype.tx_hash = function (hash) { + this.message.tx_hash = hash; + return this; +}; + +Request.prototype.tx_json = function (json) { + this.message.tx_json = json; + return this; +}; + +Request.prototype.tx_blob = function (json) { + this.message.tx_blob = json; + return this; +}; + +Request.prototype.ripple_state = function (account, issuer, currency) { + this.message.ripple_state = { + currency : currency, + accounts : [ + UInt160.json_rewrite(account), + UInt160.json_rewrite(issuer) + ] + }; + return this; +}; + +Request.prototype.accounts = function (accounts, realtime) { + if (!Array.isArray(accounts)) { + accounts = [ accounts ]; + } + + // Process accounts parameters + var processedAccounts = accounts.map(function(account) { + return UInt160.json_rewrite(account); + }); + + if (realtime) { + this.message.rt_accounts = processedAccounts; + } else { + this.message.accounts = processedAccounts; + } + + return this; +}; + +Request.prototype.rt_accounts = function (accounts) { + return this.accounts(accounts, true); +}; + +Request.prototype.books = function (books, snapshot) { + var processedBooks = [ ]; + + for (var i = 0, l = books.length; i < l; i++) { + var book = books[i]; + var json = { }; + + function processSide(side) { + if (!book[side]) { + throw new Error('Missing ' + side); + } + + var obj = json[side] = { + currency: Currency.json_rewrite(book[side].currency) + }; + + if (obj.currency !== 'XRP') { + obj.issuer = UInt160.json_rewrite(book[side].issuer); + } + } + + processSide('taker_gets'); + processSide('taker_pays'); + + if (snapshot) { + json.snapshot = true; + } + + if (book.both) { + json.both = true; + } + + processedBooks.push(json); + } + + this.message.books = processedBooks; + + return this; +}; + +exports.Request = Request; diff --git a/src/js/ripple/rippleerror.js b/src/js/ripple/rippleerror.js new file mode 100644 index 00000000..a1f9a4e6 --- /dev/null +++ b/src/js/ripple/rippleerror.js @@ -0,0 +1,22 @@ +var util = require('util'); +var extend = require('extend'); + +function RippleError(code, message) { + if (typeof code === 'object') { + extend(this, code); + } else { + this.result = code; + this.message = message; + this.result_message = message; + } + + this.message = this.result_message || 'Error'; + + Error.captureStackTrace(this, code || this); +} + +util.inherits(RippleError, Error); + +RippleError.prototype.name = 'RippleError'; + +exports.RippleError = RippleError; diff --git a/src/js/ripple/server.js b/src/js/ripple/server.js index a9275f5d..536fb6b8 100644 --- a/src/js/ripple/server.js +++ b/src/js/ripple/server.js @@ -5,16 +5,16 @@ var utils = require('./utils'); /** * @constructor Server * @param remote The Remote object - * @param cfg Configuration parameters. + * @param opts Configuration parameters. * * Keys for cfg: * url */ -var Server = function (remote, opts) { +function Server(remote, opts) { EventEmitter.call(this); - if (typeof opts !== 'object' || typeof opts.url !== 'string') { + if (typeof opts !== 'object') { throw new Error('Invalid server configuration.'); } @@ -22,17 +22,20 @@ var Server = function (remote, opts) { this._remote = remote; this._opts = opts; - + this._host = opts.host; + this._port = opts.port; + this._secure = typeof opts.secure === Boolean ? opts.secure : true; this._ws = void(0); this._connected = false; this._should_connect = false; this._state = void(0); - this._id = 0; this._retry = 0; - this._requests = { }; + this._opts.url = (opts.secure ? 'wss://' : 'ws://') + + [ opts.host, opts.port ].join(':'); + this.on('message', function(message) { self._handle_message(message); }); @@ -40,7 +43,7 @@ var Server = function (remote, opts) { this.on('response_subscribe', function(message) { self._handle_response_subscribe(message); }); -}; +} util.inherits(Server, EventEmitter); @@ -68,16 +71,40 @@ Server.prototype._set_state = function (state) { this.emit('state', state); - if (state === 'online') { - this._connected = true; - this.emit('connect'); - } else if (state === 'offline') { - this._connected = false; - this.emit('disconnect'); + switch (state) { + case 'online': + this._connected = true; + this.emit('connect'); + break; + case 'offline': + this._connected = false; + this.emit('disconnect'); + break; } } }; +Server.prototype._trace = function() { + if (this._remote.trace) { + utils.logObject.apply(utils, arguments); + } +}; + +Server.prototype._remote_address = function() { + try { var address = this._ws._socket.remoteAddress; } catch (e) { } + return address; +}; + +// This is the final interface between client code and a socket connection to a +// `rippled` server. As such, this is a decent hook point to allow a WebSocket +// interface conforming object to be used as a basis to mock rippled. This +// avoids the need to bind a websocket server to a port and allows a more +// synchronous style of code to represent a client <-> server message sequence. +// We can also use this to log a message sequence to a buffer. +Server.prototype.websocket_constructor = function () { + return require('ws'); +}; + Server.prototype.connect = function () { var self = this; @@ -85,16 +112,20 @@ Server.prototype.connect = function () { // recently received a message from the server and the WebSocket has not // reported any issues either. If we do fail to ping or the connection drops, // we will automatically reconnect. - if (this._connected === true) return; + if (this._connected) { + return; + } - if (this._remote.trace) console.log('server: connect: %s', this._opts.url); + this._trace('server: connect: %s', this._opts.url); // Ensure any existing socket is given the command to close first. - if (this._ws) this._ws.close(); + if (this._ws) { + this._ws.close(); + } // We require this late, because websocket shims may be loaded after // ripple-lib. - var WebSocket = require('ws'); + var WebSocket = this.websocket_constructor(); var ws = this._ws = new WebSocket(this._opts.url); this._should_connect = true; @@ -103,46 +134,44 @@ Server.prototype.connect = function () { ws.onopen = function () { // If we are no longer the active socket, simply ignore any event - if (ws !== self._ws) return; - - self.emit('socket_open'); - - // Subscribe to events - var request = self._remote._server_prepare_subscribe(); - self.request(request); + if (ws === self._ws) { + self.emit('socket_open'); + // Subscribe to events + var request = self._remote._server_prepare_subscribe(); + self.request(request); + } }; ws.onerror = function (e) { // If we are no longer the active socket, simply ignore any event - if (ws !== self._ws) return; + if (ws === self._ws) { + self._trace('server: onerror: %s', e.data || e); - if (self._remote.trace) console.log('server: onerror: %s', e.data || e); + // Most connection errors for WebSockets are conveyed as 'close' events with + // code 1006. This is done for security purposes and therefore unlikely to + // ever change. - // Most connection errors for WebSockets are conveyed as 'close' events with - // code 1006. This is done for security purposes and therefore unlikely to - // ever change. + // This means that this handler is hardly ever called in practice. If it is, + // it probably means the server's WebSocket implementation is corrupt, or + // the connection is somehow producing corrupt data. - // This means that this handler is hardly ever called in practice. If it is, - // it probably means the server's WebSocket implementation is corrupt, or - // the connection is somehow producing corrupt data. + // Most WebSocket applications simply log and ignore this error. Once we + // support for multiple servers, we may consider doing something like + // lowering this server's quality score. - // Most WebSocket applications simply log and ignore this error. Once we - // support for multiple servers, we may consider doing something like - // lowering this server's quality score. - - // However, in Node.js this event may be triggered instead of the close - // event, so we need to handle it. - handleConnectionClose(); + // However, in Node.js this event may be triggered instead of the close + // event, so we need to handle it. + handleConnectionClose(); + } }; // Failure to open. ws.onclose = function () { // If we are no longer the active socket, simply ignore any event - if (ws !== self._ws) return; - - if (self._remote.trace) console.log('server: onclose: %s', ws.readyState); - - handleConnectionClose(); + if (ws === self._ws) { + self._trace('server: onclose: %s', ws.readyState); + handleConnectionClose(); + } }; function handleConnectionClose() { @@ -153,14 +182,17 @@ Server.prototype.connect = function () { ws.onopen = ws.onerror = ws.onclose = ws.onmessage = function () {}; // Should we be connected? - if (!self._should_connect) return; + if (!self._should_connect) { + return; + } // Delay and retry. self._retry += 1; self._retry_timer = setTimeout(function () { - if (self._remote.trace) console.log('server: retry'); - - if (!self._should_connect) return; + self._trace('server: retry'); + if (!self._should_connect) { + return; + } self.connect(); }, self._retry < 40 ? 1000/20 // First, for 2 seconds: 20 times per second @@ -185,7 +217,9 @@ Server.prototype.disconnect = function () { }; Server.prototype.send_message = function (message) { - this._ws.send(JSON.stringify(message)); + if (this._ws) { + this._ws.send(JSON.stringify(message)); + } }; /** @@ -195,56 +229,54 @@ Server.prototype.request = function (request) { var self = this; // Only bother if we are still connected. - if (self._ws) { - request.message.id = self._id; + if (this._ws) { + request.server = this; + request.message.id = this._id; - self._requests[request.message.id] = request; + this._requests[request.message.id] = request; // Advance message ID - self._id++; + this._id++; - if (self._connected || (request.message.command === 'subscribe' && self._ws.readyState === 1)) { - if (self._remote.trace) { - utils.logObject('server: request: %s', request.message); - } - - self.send_message(request.message); + var is_connected = this._connected || (request.message.command === 'subscribe' && this._ws.readyState === 1); + + if (is_connected) { + this._trace('server: request: %s', request.message); + this.send_message(request.message); } else { - // XXX There are many ways to make self smarter. - self.once('connect', function () { - if (self._remote.trace) { - utils.logObject('server: request: %s', request.message); - } + // XXX There are many ways to make this smarter. + function server_reconnected() { + self._trace('server: request: %s', request.message); self.send_message(request.message); - }); + } + this.once('connect', server_reconnected); } } else { - if (self._remote.trace) { - utils.logObject('server: request: DROPPING: %s', request.message); - } + this._trace('server: request: DROPPING: %s', request.message); } }; -Server.prototype._handle_message = function (json) { +Server.prototype._handle_message = function (message) { var self = this; - var message; - - try { - message = JSON.parse(json); - } catch(exception) { return; } + try { message = JSON.parse(message); } catch(e) { } - switch(message.type) { + var unexpected = typeof message !== 'object' || typeof message.type !== 'string'; + + if (unexpected) { + return; + } + + switch (message.type) { case 'response': // A response to a request. var request = self._requests[message.id]; - delete self._requests[message.id]; if (!request) { - if (self._remote.trace) utils.logObject('server: UNEXPECTED: %s', message); + this._trace('server: UNEXPECTED: %s', message); } else if ('success' === message.status) { - if (self._remote.trace) utils.logObject('server: response: %s', message); + this._trace('server: response: %s', message); request.emit('success', message.result); @@ -252,32 +284,33 @@ Server.prototype._handle_message = function (json) { emitter.emit('response_' + request.message.command, message.result, request, message); }); } else if (message.error) { - if (self._remote.trace) utils.logObject('server: error: %s', message); + this._trace('server: error: %s', message); request.emit('error', { - 'error' : 'remoteError', - 'error_message' : 'Remote reported an error.', - 'remote' : message + error : 'remoteError', + error_message : 'Remote reported an error.', + remote : message }); } break; + case 'path_find': + if (self._remote.trace) utils.logObject('server: path_find: %s', message); + break; + case 'serverStatus': // This message is only received when online. As we are connected, it is the definative final state. - self._set_state(self._is_online(message.server_status) ? 'online' : 'offline'); + this._set_state(this._is_online(message.server_status) ? 'online' : 'offline'); break; } -}; +} Server.prototype._handle_response_subscribe = function (message) { - var self = this; - - self._server_status = message.server_status; - - if (self._is_online(message.server_status)) { - self._set_state('online'); + this._server_status = message.server_status; + if (this._is_online(message.server_status)) { + this._set_state('online'); } -}; +} exports.Server = Server; diff --git a/src/js/ripple/transaction.js b/src/js/ripple/transaction.js index f0b47a15..5e6fee95 100644 --- a/src/js/ripple/transaction.js +++ b/src/js/ripple/transaction.js @@ -1,4 +1,3 @@ -// // Transactions // // Construction: @@ -53,86 +52,65 @@ var Currency = require('./amount').Currency; var UInt160 = require('./amount').UInt160; var Seed = require('./seed').Seed; var SerializedObject = require('./serializedobject').SerializedObject; +var RippleError = require('./rippleerror').RippleError; var config = require('./config'); -var SUBMIT_MISSING = 4; // Report missing. -var SUBMIT_LOST = 8; // Give up tracking. - // A class to implement transactions. // - Collects parameters // - Allow event listeners to be attached to determine the outcome. -var Transaction = function (remote) { +function Transaction(remote) { EventEmitter.call(this); - // YYY Make private as many variables as possible. var self = this; - this.callback = undefined; - this.remote = remote; - this._secret = undefined; - this._build_path = false; + this.remote = remote; + this._secret = void(0); + this._build_path = false; // Transaction data. - this.tx_json = { - 'Flags' : 0, // XXX Would be nice if server did not require this. - }; + this.tx_json = { Flags: 0 }; - this.hash = undefined; - this.submit_index = undefined; // ledger_current_index was this when transaction was submited. - this.state = undefined; // Under construction. - this.finalized = false; + this.hash = void(0); - this.on('success', function (message) { - if (message.engine_result) { - self.hash = message.tx_json.hash; + // ledger_current_index was this when transaction was submited. + this.submit_index = void(0); - self.set_state('client_proposed'); + // Under construction. + this.state = void(0); - self.emit('proposed', { - 'tx_json' : message.tx_json, - 'result' : message.engine_result, - 'result_code' : message.engine_result_code, - 'result_message' : message.engine_result_message, - 'rejected' : self.isRejected(message.engine_result_code), // If server is honest, don't expect a final if rejected. - }); - } - }); - - this.on('error', function (message) { - // Might want to give more detailed information. - self.set_state('remoteError'); - }); + this.finalized = false; + this._previous_signing_hash = void(0); }; util.inherits(Transaction, EventEmitter); // XXX This needs to be determined from the network. Transaction.fee_units = { - 'default' : 10, + default: 10, }; Transaction.flags = { - 'AccountSet' : { - 'RequireDestTag' : 0x00010000, - 'OptionalDestTag' : 0x00020000, - 'RequireAuth' : 0x00040000, - 'OptionalAuth' : 0x00080000, - 'DisallowXRP' : 0x00100000, - 'AllowXRP' : 0x00200000, + AccountSet: { + RequireDestTag: 0x00010000, + OptionalDestTag: 0x00020000, + RequireAuth: 0x00040000, + OptionalAuth: 0x00080000, + DisallowXRP: 0x00100000, + AllowXRP: 0x00200000 }, - 'OfferCreate' : { - 'Passive' : 0x00010000, - 'ImmediateOrCancel' : 0x00020000, - 'FillOrKill' : 0x00040000, - 'Sell' : 0x00080000, + OfferCreate: { + Passive: 0x00010000, + ImmediateOrCancel: 0x00020000, + FillOrKill: 0x00040000, + Sell: 0x00080000 }, - 'Payment' : { - 'NoRippleDirect' : 0x00010000, - 'PartialPayment' : 0x00020000, - 'LimitQuality' : 0x00040000, + Payment: { + NoRippleDirect: 0x00010000, + PartialPayment: 0x00020000, + LimitQuality: 0x00040000 }, }; @@ -142,12 +120,12 @@ Transaction.HASH_SIGN = 0x53545800; Transaction.HASH_SIGN_TESTNET = 0x73747800; Transaction.prototype.consts = { - 'telLOCAL_ERROR' : -399, - 'temMALFORMED' : -299, - 'tefFAILURE' : -199, - 'terRETRY' : -99, - 'tesSUCCESS' : 0, - 'tecCLAIMED' : 100, + telLOCAL_ERROR : -399, + temMALFORMED : -299, + tefFAILURE : -199, + terRETRY : -99, + tesSUCCESS : 0, + tecCLAIMED : 100, }; Transaction.prototype.isTelLocal = function (ter) { @@ -185,6 +163,15 @@ Transaction.prototype.set_state = function (state) { } }; +/** + * TODO + * Actually do this right + */ + +Transaction.prototype.get_fee = function() { + return Transaction.fees['default'].to_json(); +}; + /** * Attempts to complete the transaction for submission. * @@ -195,11 +182,13 @@ Transaction.prototype.set_state = function (state) { Transaction.prototype.complete = function () { var tx_json = this.tx_json; - if ("undefined" === typeof tx_json.Fee && this.remote.local_fee) { - this.tx_json.Fee = this.remote.fee_tx(this.fee_units()).to_json(); + if (typeof tx_json.Fee === 'undefined') { + if (this.remote.local_fee || !this.remote.trusted) { + this.tx_json.Fee = this.remote.fee_tx(this.fee_units()).to_json(); + } } - if ("undefined" === typeof tx_json.SigningPubKey && (!this.remote || this.remote.local_signing)) { + if (typeof tx_json.SigningPubKey === 'undefined' && (!this.remote || this.remote.local_signing)) { var seed = Seed.from_json(this._secret); var key = seed.get_key(this.tx_json.Account); tx_json.SigningPubKey = key.to_hex_pub(); @@ -213,16 +202,19 @@ Transaction.prototype.serialize = function () { }; Transaction.prototype.signing_hash = function () { - var prefix = config.testnet - ? Transaction.HASH_SIGN_TESTNET - : Transaction.HASH_SIGN; - + var prefix = Transaction[config.testnet ? 'HASH_SIGN_TESTNET' : 'HASH_SIGN']; return SerializedObject.from_json(this.tx_json).signing_hash(prefix); }; Transaction.prototype.sign = function () { var seed = Seed.from_json(this._secret); var hash = this.signing_hash(); + + var previously_signed = this.tx_json.TxnSignature + && hash === this._previous_signing_hash; + + if (previously_signed) return; + var key = seed.get_key(this.tx_json.Account); var sig = key.sign(hash, 0); var hex = sjcl.codec.hex.fromBits(sig).toUpperCase(); @@ -230,213 +222,6 @@ Transaction.prototype.sign = function () { this.tx_json.TxnSignature = hex; }; -Transaction.prototype._hasTransactionListeners = function() { - return this.listeners('final').length - || this.listeners('lost').length - || this.listeners('pending').length -}; - -// Submit a transaction to the network. -// XXX Don't allow a submit without knowing ledger_index. -// XXX Have a network canSubmit(), post events for following. -// XXX Also give broader status for tracking through network disconnects. -// callback = function (status, info) { -// // status is final status. Only works under a ledger_accepting conditions. -// switch status: -// case 'tesSUCCESS': all is well. -// case 'tejSecretUnknown': unable to sign transaction - secret unknown -// case 'tejServerUntrusted': sending secret to untrusted server. -// case 'tejInvalidAccount': locally detected error. -// case 'tejLost': locally gave up looking -// default: some other TER -// } - -Transaction.prototype.submit = function (callback) { - var self = this; - var tx_json = this.tx_json; - - this.callback = typeof callback === 'function' - ? callback - : function(){}; - - function finish(err) { - self.emit('error', err); - self.callback('error', err); - } - - if (typeof tx_json.Account !== 'string') { - finish({ - 'error' : 'tejInvalidAccount', - 'error_message' : 'Bad account.' - }); - return this; - } - - // YYY Might check paths for invalid accounts. - - this.complete(); - - //console.log('Callback or has listeners'); - - // There are listeners for callback, 'final', 'lost', or 'pending' arrange to emit them. - - this.submit_index = this.remote._ledger_current_index; - - // When a ledger closes, look for the result. - function on_ledger_closed(message) { - if (self.finalized) return; - - var ledger_hash = message.ledger_hash; - var ledger_index = message.ledger_index; - var stop = false; - - // XXX make sure self.hash is available. - var transaction_entry = self.remote.request_transaction_entry(self.hash) - - transaction_entry.ledger_hash(ledger_hash) - - transaction_entry.on('success', function (message) { - if (self.finalized) return; - self.set_state(message.metadata.TransactionResult); - self.remote.removeListener('ledger_closed', on_ledger_closed); - self.emit('final', message); - self.finalized = true; - self.callback(message.metadata.TransactionResult, message); - }); - - transaction_entry.on('error', function (message) { - if (self.finalized) return; - - if (message.error === 'remoteError' && message.remote.error === 'transactionNotFound') { - if (self.submit_index + SUBMIT_LOST < ledger_index) { - self.set_state('client_lost'); // Gave up. - self.emit('lost'); - self.callback('tejLost', message); - self.remote.removeListener('ledger_closed', on_ledger_closed); - self.emit('final', message); - self.finalized = true; - } else if (self.submit_index + SUBMIT_MISSING < ledger_index) { - self.set_state('client_missing'); // We don't know what happened to transaction, still might find. - self.emit('pending'); - } else { - self.emit('pending'); - } - } - // XXX Could log other unexpectedness. - }); - - transaction_entry.request(); - }; - - this.remote.on('ledger_closed', on_ledger_closed); - - this.once('error', function (message) { - self.callback(message.error, message); - }); - - this.set_state('client_submitted'); - - if (self.remote.local_sequence && !self.tx_json.Sequence) { - - self.tx_json.Sequence = this.remote.account_seq(self.tx_json.Account, 'ADVANCE'); - // console.log("Sequence: %s", self.tx_json.Sequence); - - if (!self.tx_json.Sequence) { - //console.log('NO SEQUENCE'); - - // Look in the last closed ledger. - var account_seq = this.remote.account_seq_cache(self.tx_json.Account, false) - - account_seq.on('success_account_seq_cache', function () { - // Try again. - self.submit(); - }) - - account_seq.on('error_account_seq_cache', function (message) { - // XXX Maybe be smarter about this. Don't want to trust an untrusted server for this seq number. - // Look in the current ledger. - self.remote.account_seq_cache(self.tx_json.Account, 'CURRENT') - .on('success_account_seq_cache', function () { - // Try again. - self.submit(); - }) - .on('error_account_seq_cache', function (message) { - // Forward errors. - self.emit('error', message); - }) - .request(); - }) - - account_seq.request(); - - return this; - } - - // If the transaction fails we want to either undo incrementing the sequence - // or submit a noop transaction to consume the sequence remotely. - this.once('success', function (res) { - if (typeof res.engine_result === 'string') { - switch (res.engine_result.slice(0, 3)) { - // Synchronous local error - case 'tej': - self.remote.account_seq(self.tx_json.Account, 'REWIND'); - break; - - case 'ter': - // XXX: What do we do in case of ter? - break; - - case 'tel': - case 'tem': - case 'tef': - // XXX Once we have a transaction submission manager class, we can - // check if there are any other transactions pending. If there are, - // we should submit a dummy transaction to ensure those - // transactions are still valid. - //var noop = self.remote.transaction().account_set(self.tx_json.Account); - //noop.submit(); - - // XXX Hotfix. This only works if no other transactions are pending. - self.remote.account_seq(self.tx_json.Account, 'REWIND'); - break; - } - } - }); - } - - // Prepare request - var request = this.remote.request_submit(); - - // Forward events - request.emit = this.emit.bind(this); - - if (!this._secret && !this.tx_json.Signature) { - finish({ - 'result' : 'tejSecretUnknown', - 'result_message' : "Could not sign transactions because we." - }); - return this; - } else if (this.remote.local_signing) { - this.sign(); - request.tx_blob(this.serialize().to_hex()); - } else { - if (!this.remote.trusted) { - finish({ - 'result' : 'tejServerUntrusted', - 'result_message' : "Attempt to give a secret to an untrusted server." - }); - } - - request.secret(this._secret); - request.build_path(this._build_path); - request.tx_json(this.tx_json); - } - - request.request(); - - return this; -} - // // Set options for Transactions // @@ -446,38 +231,36 @@ Transaction.prototype.submit = function (callback) { // "blindly" because the sender has no idea of the actual cost except that is must be less than send max. Transaction.prototype.build_path = function (build) { this._build_path = build; - return this; } // tag should be undefined or a 32 bit integer. // YYY Add range checking for tag. Transaction.prototype.destination_tag = function (tag) { - if (tag !== undefined) { + if (tag !== void(0)) { this.tx_json.DestinationTag = tag; } - return this; } Transaction._path_rewrite = function (path) { - var path_new = []; + var props = [ + 'account' + , 'issuer' + , 'currency' + ] - for (var i = 0, l = path.length; i < l; i++) { - var node = path[i]; - var node_new = {}; + var path_new = path.map(function(node) { + var node_new = { }; - if ('account' in node) - node_new.account = UInt160.json_rewrite(node.account); + for (var prop in node) { + if (~props.indexOf(prop)) { + node_new[prop] = UInt160.json_rewrite(node[prop]); + } + } - if ('issuer' in node) - node_new.issuer = UInt160.json_rewrite(node.issuer); - - if ('currency' in node) - node_new.currency = Currency.json_rewrite(node.currency); - - path_new.push(node_new); - } + return node_new; + }); return path_new; } @@ -485,32 +268,29 @@ Transaction._path_rewrite = function (path) { Transaction.prototype.path_add = function (path) { this.tx_json.Paths = this.tx_json.Paths || []; this.tx_json.Paths.push(Transaction._path_rewrite(path)); - return this; } // --> paths: undefined or array of path // A path is an array of objects containing some combination of: account, currency, issuer Transaction.prototype.paths = function (paths) { - for (var i = 0, l = paths.length; i < l; i++) { + for (var i=0, l=paths.length; i rate: In billionths. Transaction.prototype.transfer_rate = function (rate) { @@ -531,7 +310,7 @@ Transaction.prototype.transfer_rate = function (rate) { } return this; -} +}; // Add flags to a transaction. // --> flags: undefined, _flag_, or [ _flags_ ] @@ -540,18 +319,15 @@ Transaction.prototype.set_flags = function (flags) { var transaction_flags = Transaction.flags[this.tx_json.TransactionType]; // We plan to not define this field on new Transaction. - if (this.tx_json.Flags === undefined) { + if (this.tx_json.Flags === void(0)) { this.tx_json.Flags = 0; } var flag_set = Array.isArray(flags) ? flags : [ flags ]; - for (var index in flag_set) { - if (!flag_set.hasOwnProperty(index)) continue; - - var flag = flag_set[index]; - - if (flag in transaction_flags) { + for (var i=0, l=flag_set.length; i expiration : if not undefined, Date or Number // --> cancel_sequence : if not undefined, Sequence Transaction.prototype.offer_create = function (src, taker_pays, taker_gets, expiration, cancel_sequence) { - this._secret = this._account_secret(src); - this.tx_json.TransactionType = 'OfferCreate'; - this.tx_json.Account = UInt160.json_rewrite(src); - this.tx_json.TakerPays = Amount.json_rewrite(taker_pays); - this.tx_json.TakerGets = Amount.json_rewrite(taker_gets); + this._secret = this._account_secret(src); + this.tx_json.TransactionType = 'OfferCreate'; + this.tx_json.Account = UInt160.json_rewrite(src); + this.tx_json.TakerPays = Amount.json_rewrite(taker_pays); + this.tx_json.TakerGets = Amount.json_rewrite(taker_gets); + + if (this.remote.local_fee) { + //this.tx_json.Fee = Transaction.fees.offer.to_json(); + } if (expiration) { this.tx_json.Expiration = expiration instanceof Date @@ -630,21 +407,19 @@ Transaction.prototype.offer_create = function (src, taker_pays, taker_gets, expi }; Transaction.prototype.password_fund = function (src, dst) { - this._secret = this._account_secret(src); - this.tx_json.TransactionType = 'PasswordFund'; - this.tx_json.Destination = UInt160.json_rewrite(dst); - + this._secret = this._account_secret(src); + this.tx_json.TransactionType = 'PasswordFund'; + this.tx_json.Destination = UInt160.json_rewrite(dst); return this; } Transaction.prototype.password_set = function (src, authorized_key, generator, public_key, signature) { - this._secret = this._account_secret(src); - this.tx_json.TransactionType = 'PasswordSet'; - this.tx_json.RegularKey = authorized_key; - this.tx_json.Generator = generator; - this.tx_json.PublicKey = public_key; - this.tx_json.Signature = signature; - + this._secret = this._account_secret(src); + this.tx_json.TransactionType = 'PasswordSet'; + this.tx_json.RegularKey = authorized_key; + this.tx_json.Generator = generator; + this.tx_json.PublicKey = public_key; + this.tx_json.Signature = signature; return this; } @@ -666,29 +441,40 @@ Transaction.prototype.password_set = function (src, authorized_key, generator, p // .set_flags() // .source_tag() Transaction.prototype.payment = function (src, dst, deliver_amount) { - this._secret = this._account_secret(src); - this.tx_json.TransactionType = 'Payment'; - this.tx_json.Account = UInt160.json_rewrite(src); - this.tx_json.Amount = Amount.json_rewrite(deliver_amount); - this.tx_json.Destination = UInt160.json_rewrite(dst); + if (!UInt160.is_valid(src)) { + throw new Error('Payment source address invalid'); + } + + if (!UInt160.is_valid(dst)) { + throw new Error('Payment destination address invalid'); + } + + this._secret = this._account_secret(src); + this.tx_json.TransactionType = 'Payment'; + this.tx_json.Account = UInt160.json_rewrite(src); + this.tx_json.Amount = Amount.json_rewrite(deliver_amount); + this.tx_json.Destination = UInt160.json_rewrite(dst); return this; } Transaction.prototype.ripple_line_set = function (src, limit, quality_in, quality_out) { - this._secret = this._account_secret(src); - this.tx_json.TransactionType = 'TrustSet'; - this.tx_json.Account = UInt160.json_rewrite(src); + this._secret = this._account_secret(src); + this.tx_json.TransactionType = 'TrustSet'; + this.tx_json.Account = UInt160.json_rewrite(src); // Allow limit of 0 through. - if (limit !== undefined) - this.tx_json.LimitAmount = Amount.json_rewrite(limit); + if (limit !== void(0)) { + this.tx_json.LimitAmount = Amount.json_rewrite(limit); + } + + if (quality_in) { + this.tx_json.QualityIn = quality_in; + } - if (quality_in) - this.tx_json.QualityIn = quality_in; - - if (quality_out) - this.tx_json.QualityOut = quality_out; + if (quality_out) { + this.tx_json.QualityOut = quality_out; + } // XXX Throw an error if nothing is set. @@ -702,7 +488,6 @@ Transaction.prototype.wallet_add = function (src, amount, authorized_key, public this.tx_json.RegularKey = authorized_key; this.tx_json.PublicKey = public_key; this.tx_json.Signature = signature; - return this; }; @@ -717,11 +502,42 @@ Transaction.prototype.wallet_add = function (src, amount, authorized_key, public * * @return {Number} Number of fee units for this transaction. */ -Transaction.prototype.fee_units = function () -{ - return Transaction.fee_units["default"]; +Transaction.prototype.fee_units = function () { + return Transaction.fee_units['default']; }; -exports.Transaction = Transaction; +// Submit a transaction to the network. +Transaction.prototype.submit = function (callback) { + var self = this; + + this.callback = typeof callback === 'function' ? callback : function(){}; + + function submission_error(error, message) { + if (!(error instanceof RippleError)) { + error = new RippleError(error, message); + } + self.callback(error); + } + + function submission_success(message) { + self.callback(null, message); + } + + this.once('error', submission_error); + this.once('success', submission_success); + + var account = this.tx_json.Account; + + if (typeof account !== 'string') { + this.emit('error', new RippleError('tejInvalidAccount', 'Account is unspecified')); + } else { + // YYY Might check paths for invalid accounts. + this.remote.get_account(account).submit(this); + } + + return this; +} + +exports.Transaction = Transaction; // vim:sw=2:sts=2:ts=8:et diff --git a/src/js/ripple/transactionmanager.js b/src/js/ripple/transactionmanager.js new file mode 100644 index 00000000..65505804 --- /dev/null +++ b/src/js/ripple/transactionmanager.js @@ -0,0 +1,363 @@ +var util = require('util'); +var EventEmitter = require('events').EventEmitter; +var RippleError = require('./rippleerror').RippleError; +var Queue = require('./transactionqueue').TransactionQueue; + +/** + * @constructor TransactionManager + * @param {Object} account + */ + +function TransactionManager(account) { + EventEmitter.call(this); + + var self = this; + + this.account = account; + this.remote = account._remote; + this._timeout = void(0); + this._resubmitting = false; + this._pending = new Queue; + this._next_sequence = void(0); + this._cache = { }; + + //XX Fee units + this._max_fee = Number(this.remote.max_fee) || Infinity; + + function remote_disconnected() { + function remote_reconnected() { + self._resubmit(); + }; + self.remote.once('connect', remote_reconnected); + }; + + this.remote.on('disconnect', remote_disconnected); + + function sequence_loaded(err, sequence) { + self._next_sequence = sequence; + self.emit('sequence_loaded', sequence); + }; + + this.account.get_next_sequence(sequence_loaded); + + function cache_transaction(message) { + var transaction = { + ledger_hash: message.ledger_hash, + ledger_index: message.ledger_index, + metadata: message.meta, + tx_json: message.transaction + } + + transaction.tx_json.ledger_index = transaction.ledger_index; + transaction.tx_json.inLedger = transaction.ledger_index; + + self._cache[message.transaction.Sequence] = transaction; + } + + this.account.on('transaction', cache_transaction); +}; + +util.inherits(TransactionManager, EventEmitter); + +// request_tx presents transactions in +// a format slightly different from +// request_transaction_entry +function rewrite_transaction(tx) { + try { + var result = { + ledger_index: tx.ledger_index, + metadata: tx.meta, + tx_json: { + Account: tx.Account, + Amount: tx.Amount, + Destination: tx.Destination, + Fee: tx.Fee, + Flags: tx.Flags, + Sequence: tx.Sequence, + SigningPubKey: tx.SigningPubKey, + TransactionType: tx.TransactionType, + hash: tx.hash + } + } + } catch(exception) { + } + return result || { }; +}; + +TransactionManager.prototype._resubmit = function() { + var self = this; + + function resubmit(pending, index) { + if (pending.finalized) { + // Transaction has been finalized, nothing to do + return; + } + + var sequence = pending.tx_json.Sequence; + var cached = self._cache[sequence]; + + pending.emit('resubmit'); + + if (cached) { + // Transaction was received while waiting for + // resubmission + pending.emit('success', cached); + delete self._cache[sequence]; + } else if (pending.hash) { + // Transaction was successfully submitted, and + // its hash discovered, but not validated + + function pending_check(err, res) { + if (self._is_not_found(err)) { + self._request(pending); + } else { + pending.emit('success', rewrite_transaction(res)); + } + } + + self.remote.request_tx(pending.hash, pending_check); + } else { + self._request(pending); + } + } + + this._wait_ledgers(3, function() { + self._pending.forEach(resubmit); + }); +} + +TransactionManager.prototype._wait_ledgers = function(ledgers, callback) { + var self = this; + var closes = 0; + + function ledger_closed() { + if (++closes === ledgers) { + callback(); + self.remote.removeListener('ledger_closed', ledger_closed); + } + } + + this.remote.on('ledger_closed', ledger_closed); +} + +TransactionManager.prototype._request = function(tx) { + var self = this; + var remote = this.remote; + + if (!tx._secret && !tx.tx_json.TxnSignature) { + tx.emit('error', new RippleError('tejSecretUnknown', 'Missing secret')); + return; + } + + if (!remote.trusted && !remote.local_signing) { + tx.emit('error', new RippleError('tejServerUntrusted', 'Attempt to give secret to untrusted server')); + return; + } + + function finalize(message) { + if (!tx.finalized) { + tx.finalized = true; + tx.emit('final', message); + self._pending.removeSequence(tx.tx_json.Sequence); + } + } + + tx.once('error', finalize); + tx.once('success', finalize); + + // Listen for 'ledger closed' events to verify + // that the transaction is discovered in the + // ledger before considering the transaction + // successful + this._detect_ledger_entry(tx); + + var submit_request = remote.request_submit(); + + if (remote.local_signing) { + tx.sign(); + submit_request.tx_blob(tx.serialize().to_hex()); + } else { + submit_request.secret(tx._secret); + submit_request.build_path(tx._build_path); + submit_request.tx_json(tx.tx_json); + } + + tx.submit_index = remote._ledger_current_index; + + function transaction_proposed(message) { + tx.hash = message.tx_json.hash; + tx.set_state('client_proposed'); + tx.emit('proposed', { + tx_json: message.tx_json, + + result: message.engine_result, + engine_result: message.engine_result, + + result_code: message.engine_result_code, + engine_result_code: message.engine_result_code, + + result_message: message.engine_result_message, + engine_result_message: message.engine_result_message, + + // If server is honest, don't expect a final if rejected. + rejected: tx.isRejected(message.engine_result_code), + }); + } + + function transaction_failed(message) { + if (!tx.hash) tx.hash = message.tx_json.hash; + + function transaction_requested(err, res) { + if (self._is_not_found(err)) { + self._resubmit(); + } else { + tx.emit('error', new RippleError(message)); + self._pending.removeSequence(tx.tx_json.Sequence); + } + } + + self.remote.request_tx(tx.hash, transaction_requested); + } + + function submission_error(err) { + tx.set_state('remoteError'); + tx.emit('error', new RippleError(err)); + } + + function submission_success(message) { + var engine_result = message.engine_result || ''; + + tx.hash = message.tx_json.hash; + + switch (engine_result.slice(0, 3)) { + case 'tef': + //tefPAST_SEQ + transaction_failed(message); + break; + case 'tes': + transaction_proposed(message); + break; + default: + submission_error(message); + } + } + + submit_request.once('success', submission_success); + submit_request.once('error', submission_error); + submit_request.request(); + + submit_request.timeout(1000 * 10, function() { + if (self.remote._connected) { + self._resubmit(); + } + }); + + tx.set_state('client_submitted'); + tx.emit('submitted'); + + return submit_request; +}; + +TransactionManager.prototype._is_not_found = function(error) { + var not_found_re = /^(txnNotFound|transactionNotFound)$/; + return error && typeof error === 'object' + && error.error === 'remoteError' + && typeof error.remote === 'object' + && not_found_re.test(error.remote.error); +}; + +TransactionManager.prototype._detect_ledger_entry = function(tx) { + var self = this; + var remote = this.remote; + var checked_ledgers = { }; + + function entry_callback(err, message) { + if (typeof message !== 'object') return; + + var ledger_hash = message.ledger_hash; + var ledger_index = message.ledger_index; + + if (tx.finalized || checked_ledgers[ledger_hash]) { + // Transaction submission has already erred or + // this ledger has already been checked for + // transaction + return; + } + + checked_ledgers[ledger_hash] = true; + + if (self._is_not_found(err)) { + var dif = ledger_index - tx.submit_index; + if (dif >= 8) { + // Lost + tx.emit('error', message); + tx.emit('lost', message); + } else if (dif >= 4) { + // Missing + tx.set_state('client_missing'); + tx.emit('missing', message); + } else { + // Pending + tx.emit('pending', message); + } + } else { + // Transaction was found in the ledger, + // consider this transaction successful + if (message.metadata) { + tx.set_state(message.metadata.TransactionResult); + } + tx.emit('success', message); + } + } + + function ledger_closed(message) { + if (!tx.finalized && !checked_ledgers[message.ledger_hash]) { + remote.request_transaction_entry(tx.hash, message.ledger_hash, entry_callback); + } + } + + function transaction_proposed() { + // Check the ledger for transaction entry + remote.addListener('ledger_closed', ledger_closed); + } + + function transaction_finalized() { + // Stop checking the ledger + remote.removeListener('ledger_closed', ledger_closed); + tx.removeListener('proposed', transaction_proposed); + } + + tx.once('proposed', transaction_proposed); + tx.once('final', transaction_finalized); + tx.once('resubmit', transaction_finalized); +}; + +/** + * @param {Object} tx + */ + +TransactionManager.prototype.submit = function(tx) { + // If sequence number is not yet known, defer until it is. + var self = this; + + if (!this._next_sequence) { + function resubmit_transaction() { + self.submit(tx); + } + this.once('sequence_loaded', resubmit_transaction); + return; + } + + tx.tx_json.Sequence = this._next_sequence++; + tx.complete(); + + this._pending.push(tx); + + var fee = tx.tx_json.Fee; + + if (fee === void(0) || fee <= this._max_fee) { + this._request(tx); + } +}; + +exports.TransactionManager = TransactionManager; diff --git a/src/js/ripple/transactionqueue.js b/src/js/ripple/transactionqueue.js new file mode 100644 index 00000000..ce2f8116 --- /dev/null +++ b/src/js/ripple/transactionqueue.js @@ -0,0 +1,59 @@ + +function TransactionQueue() { + this._queue = [ ]; +} + +TransactionQueue.prototype.length = function() { + return this._queue.length; +}; + +TransactionQueue.prototype.push = function(o) { + return this._queue.push(o); +}; + +TransactionQueue.prototype.hasHash = function(hash) { + return this.indexOf('hash', hash) !== -1; +}; + +TransactionQueue.prototype.hasSequence = function(sequence) { + return this.indexOf('sequence', sequence) !== -1; +}; + +TransactionQueue.prototype.indexOf = function(prop, val) { + var index = -1; + for (var i=0, tx; tx=this._queue[i]; i++) { + if (tx[prop] === val) { + index = i; + break; + } + } + return index; +}; + +TransactionQueue.prototype.removeSequence = function(sequence) { + var result = [ ]; + for (var i=0, tx; tx=this._queue[i]; i++) { + if (!tx.tx_json) continue; + if (tx.tx_json.Sequence !== sequence) + result.push(tx); + } + this._queue = result; +}; + +TransactionQueue.prototype.removeHash = function(hash) { + var result = [ ]; + for (var i=0, tx; tx=this._queue[i]; i++) { + if (!tx.tx_json) continue; + if (tx.hash !== hash) + result.push(tx); + } + this._queue = result; +}; + +TransactionQueue.prototype.forEach = function(fn) { + for (var i=0, tx; tx=this._queue[i]; i++) { + fn(tx, i); + } +}; + +exports.TransactionQueue = TransactionQueue; diff --git a/web_modules/ws.js b/web_modules/ws.js index b2fca770..f384ff5d 100644 --- a/web_modules/ws.js +++ b/web_modules/ws.js @@ -1 +1,6 @@ -module.exports = WebSocket; +// If there is no WebSocket, try MozWebSocket (support for some old browsers) +try { + module.exports = WebSocket +} catch(err) { + module.exports = MozWebSocket +} \ No newline at end of file