diff --git a/api/index.js b/api/index.js index b5626df3..2709f5fc 100644 --- a/api/index.js +++ b/api/index.js @@ -12,6 +12,8 @@ var errors = require('./lib/errors'); var serverLib = require('./lib/server-lib'); var createRemote = require('./lib/remote'); var DatabaseInterface = require('./lib/db-interface'); +var sign = require('./sign'); +var submit = require('./submit'); function RippleAPI(options) { this.remote = createRemote(options); @@ -50,6 +52,10 @@ RippleAPI.prototype = { wallet: Wallet, + prepareSettings: Settings.prepareSettings, + sign: sign, + submit: submit, + errors: errors, isConnected: function() { diff --git a/api/lib/utils.js b/api/lib/utils.js index 9d2da5d8..c15d6939 100644 --- a/api/lib/utils.js +++ b/api/lib/utils.js @@ -125,6 +125,52 @@ function isValidLedgerWord(ledger) { return (/^current$|^closed$|^validated$/.test(ledger)); } +function getFeeDrops(remote) { + var feeUnits = 10; // all transactions currently have a fee of 10 fee units + return remote.feeTx(feeUnits).to_text(); +} + +function addTxInstructions(tx_json, account, remote, options, callback) { + if (options.lastLedgerSequence !== undefined) { + tx_json.LastLedgerSequence = options.lastLedgerSequence; + } else { + var offset = options.lastLedgerOffset !== undefined ? + options.lastLedgerOffset : 3; + tx_json.LastLedgerSequence = remote.getLedgerSequence() + offset; + } + + if (options.fixedFee !== undefined) { + tx_json.Fee = xrpToDrops(options.fixedFee); + } else { + var serverFeeDrops = getFeeDrops(remote); + if (options.maxFee !== undefined) { + var maxFeeDrops = xrpToDrops(options.maxFee); + tx_json.Fee = bignum.min(serverFeeDrops, maxFeeDrops).toString(); + } else { + tx_json.Fee = serverFeeDrops; + } + } + + if (options.sequence !== undefined) { + tx_json.Sequence = options.sequence; + callback(null, {tx_json: tx_json}); + } else { + remote.findAccount(account).getNextSequence(function(error, sequence) { + tx_json.Sequence = sequence; + callback(null, {tx_json: tx_json}); + }); + } +} + +function createTxJSON(setTxParameters, remote, instructions, callback) { + var transaction = new ripple.Transaction(); + setTxParameters(transaction); + transaction.complete(); + var account = transaction.getAccount(); + var tx_json = transaction.tx_json; + addTxInstructions(tx_json, account, remote, instructions, callback); +} + module.exports = { isValidLedgerSequence: isValidLedgerSequence, isValidLedgerWord: isValidLedgerWord, @@ -136,6 +182,7 @@ module.exports = { parseCurrencyQuery: parseCurrencyQuery, txFromRestAmount: txFromRestAmount, compareTransactions: compareTransactions, - renameCounterpartyToIssuer: renameCounterpartyToIssuer + renameCounterpartyToIssuer: renameCounterpartyToIssuer, + createTxJSON: createTxJSON }; diff --git a/api/lib/validate.js b/api/lib/validate.js index dc764b31..22ff08ad 100644 --- a/api/lib/validate.js +++ b/api/lib/validate.js @@ -446,6 +446,28 @@ function validateValidated(validated) { } } +function validateTxJSON(txJSON) { + if (typeof txJSON !== 'object') { + throw error('tx_json must be an object, not: ' + typeof txJSON); + } + if (!isValidAddress(txJSON.Account)) { + throw error('tx_json.Account must be a valid Ripple address, got: ' + + txJSON.Account); + } +} + +function validateBlob(blob) { + if (typeof blob !== 'string') { + throw error('tx_blob must be a string, not: ' + typeof blob); + } + if (blob.length === 0) { + throw error('tx_blob must not be empty'); + } + if (!blob.match(/[0-9A-F]+/g)) { + throw error('tx_blob must be an uppercase hex string, got: ' + blob); + } +} + function createValidators(validatorMap) { var result = {}; _.forEach(validatorMap, function(validateFunction, key) { @@ -482,5 +504,7 @@ module.exports = createValidators({ pathfind: validatePathFind, settings: validateSettings, trustline: validateTrustline, - validated: validateValidated + validated: validateValidated, + txJSON: validateTxJSON, + blob: validateBlob }); diff --git a/api/settings.js b/api/settings.js index 28b0ebf3..f35dfc61 100644 --- a/api/settings.js +++ b/api/settings.js @@ -1,6 +1,7 @@ /* eslint-disable valid-jsdoc */ 'use strict'; var _ = require('lodash'); +var utils = require('./lib/utils'); var assert = require('assert'); var ripple = require('ripple-lib'); var transactions = require('./transactions.js'); @@ -226,6 +227,30 @@ function getSettings(account, callback) { }); } +function setTransactionParameters(account, settings, transaction) { + transaction.accountSet(account); + + transactions.setTransactionBitFlags(transaction, { + input: settings, + flags: AccountSetFlags, + clear_setting: CLEAR_SETTING + }); + setTransactionIntFlags(transaction, settings, AccountSetIntFlags); + setTransactionFields(transaction, settings, AccountRootFields); + + transaction.tx_json.TransferRate = RestToTxConverter.convertTransferRate( + transaction.tx_json.TransferRate); +} + +function prepareSettings(account, settings, instructions, callback) { + instructions = instructions || {}; + validate.address(account); + validate.settings(settings); + + utils.createTxJSON(_.partial(setTransactionParameters, account, settings), + this.remote, instructions, callback); +} + /** * Change account settings * @@ -239,48 +264,29 @@ function getSettings(account, callback) { * */ function changeSettings(account, settings, secret, options, callback) { + validate.address(account); + validate.settings(settings); + var params = { secret: secret, validated: options.validated }; - validate.address(account); - validate.settings(settings); - - function setTransactionParameters(transaction) { - transaction.accountSet(account); - - transactions.setTransactionBitFlags(transaction, { - input: settings, - flags: AccountSetFlags, - clear_setting: CLEAR_SETTING - }); - setTransactionIntFlags(transaction, settings, AccountSetIntFlags); - setTransactionFields(transaction, settings, AccountRootFields); - - transaction.tx_json.TransferRate = RestToTxConverter.convertTransferRate( - transaction.tx_json.TransferRate); - } - var hooks = { - formatTransactionResponse: TxToRestConverter.parseSettingResponseFromTx - .bind(undefined, settings), - setTransactionParameters: setTransactionParameters + formatTransactionResponse: _.partial( + TxToRestConverter.parseSettingResponseFromTx, settings), + setTransactionParameters: _.partial(setTransactionParameters, + account, settings) }; transactions.submit(this, params, new SubmitTransactionHooks(hooks), - function(err, settingsResult) { - if (err) { - return callback(err); - } - - callback(null, settingsResult); - }); + callback); } module.exports = { get: getSettings, change: changeSettings, + prepareSettings: prepareSettings, AccountSetIntFlags: AccountSetIntFlags, AccountRootFields: AccountRootFields }; diff --git a/api/sign.js b/api/sign.js new file mode 100644 index 00000000..3db19bf9 --- /dev/null +++ b/api/sign.js @@ -0,0 +1,66 @@ +'use strict'; +var sjcl = require('ripple-lib').sjcl; +var Seed = require('ripple-lib').Seed; +var SerializedObject = require('ripple-lib').SerializedObject; +var validate = require('./lib/validate'); + +/** + * 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". + */ +var HASH_TX_ID = 0x54584E00; // 'TXN' +var HASH_TX_SIGN = 0x53545800; // 'STX' +var HASH_TX_SIGN_TESTNET = 0x73747800; // 'stx' + +function getKeyPair(address, secret) { + return Seed.from_json(secret).get_key(address); +} + +function getPublicKeyHex(keypair) { + return keypair.to_hex_pub(); +} + +function serialize(txJSON) { + return SerializedObject.from_json(txJSON); +} + +function hashSerialization(serialized, prefix) { + return serialized.hash(prefix || HASH_TX_ID).to_hex(); +} + +function hashJSON(txJSON, prefix) { + return hashSerialization(serialize(txJSON), prefix); +} + +function signingHash(txJSON, isTestNet) { + return hashJSON(txJSON, isTestNet ? HASH_TX_SIGN_TESTNET : HASH_TX_SIGN); +} + +function computeSignature(txJSON, keypair) { + var signature = keypair.sign(signingHash(txJSON)); + return sjcl.codec.hex.fromBits(signature).toUpperCase(); +} + +function sign(txJSON, secret) { + validate.txJSON(txJSON); + validate.addressAndSecret({address: txJSON.Account, secret: secret}); + + var keypair = getKeyPair(txJSON.Acccount, secret); + if (txJSON.SigningPubKey === undefined) { + txJSON.SigningPubKey = getPublicKeyHex(keypair); + } + txJSON.TxnSignature = computeSignature(txJSON, keypair); + var serialized = serialize(txJSON); + return { + tx_blob: serialized.to_hex(), + hash: hashSerialization(serialized, HASH_TX_ID) + }; +} + +module.exports = sign; diff --git a/api/submit.js b/api/submit.js new file mode 100644 index 00000000..87cfaa0f --- /dev/null +++ b/api/submit.js @@ -0,0 +1,12 @@ +'use strict'; +var Request = require('ripple-lib').Request; +var validate = require('./lib/validate'); + +function submit(tx_blob, callback) { + validate.blob(tx_blob); + var request = new Request(this.remote, 'submit'); + request.message.tx_blob = tx_blob; + request.request(callback); +} + +module.exports = submit; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index e84b2f96..70d376a7 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -9,7 +9,7 @@ }, "bignumber.js": { "version": "1.5.0", - "from": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-1.5.0.tgz", + "from": "bignumber.js@1.5.0", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-1.5.0.tgz" }, "bluebird": { @@ -421,7 +421,7 @@ }, "escape-string-regexp": { "version": "1.0.3", - "from": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.3.tgz", + "from": "escape-string-regexp@1.0.3", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.3.tgz" }, "has-ansi": { @@ -639,7 +639,7 @@ }, "ini": { "version": "1.3.3", - "from": "https://registry.npmjs.org/ini/-/ini-1.3.3.tgz", + "from": "ini@1.3.3", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.3.tgz" }, "optimist": { @@ -667,9 +667,9 @@ "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.2.tgz" }, "ripple-lib": { - "version": "0.12.2", - "from": "https://registry.npmjs.org/ripple-lib/-/ripple-lib-0.12.2.tgz", - "resolved": "https://registry.npmjs.org/ripple-lib/-/ripple-lib-0.12.2.tgz", + "version": "0.12.3-rc1", + "from": "ripple-lib@0.12.3-rc1", + "resolved": "https://registry.npmjs.org/ripple-lib/-/ripple-lib-0.12.3-rc1.tgz", "dependencies": { "async": { "version": "0.9.0", @@ -861,7 +861,7 @@ "dependencies": { "bignumber.js": { "version": "1.4.1", - "from": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-1.4.1.tgz", + "from": "bignumber.js@1.4.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-1.4.1.tgz" } } @@ -1277,9 +1277,9 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-0.1.3.tgz" }, "ini": { - "version": "1.3.2", - "from": "ini@~1.3.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.2.tgz" + "version": "1.3.3", + "from": "ini@1.3.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.3.tgz" } } }, diff --git a/package.json b/package.json index 98345fde..e8510e5f 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "morgan": "^1.3.0", "nconf": "^0.6.9", "node-uuid": "^1.4.1", - "ripple-lib": "^0.12", + "ripple-lib": "^0.12.3-rc1", "ripple-lib-transactionparser": "^0.3.0", "sqlite3": "^3.0.2", "supertest": "^0.13.0", diff --git a/server/routes.js b/server/routes.js index b9ba10af..018c5f32 100644 --- a/server/routes.js +++ b/server/routes.js @@ -208,6 +208,25 @@ function cancelOrder(request, callback) { api.cancelOrder(account, sequence, secret, options, callback); } +function sign(request, callback) { + var tx_json = request.body.tx_json; + var secret = request.body.secret; + var response = api.sign(tx_json, secret); + callback(null, response); +} + +function submit(request, callback) { + var tx_blob = request.body.tx_blob; + api.submit(tx_blob, callback); +} + +function prepareSettings(request, callback) { + var address = request.body.address; + var settings = request.body.settings; + var instructions = request.body.instructions; + api.prepareSettings(address, settings, instructions, callback); +} + /* eslint-disable max-len */ module.exports = { GET: { @@ -233,7 +252,10 @@ module.exports = { '/accounts/:account/payments': makeMiddleware(submitPayment), '/accounts/:account/orders': makeMiddleware(submitOrder), '/accounts/:account/settings': makeMiddleware(changeSettings), - '/accounts/:account/trustlines': makeMiddleware(addTrustLine, respond.created) + '/accounts/:account/trustlines': makeMiddleware(addTrustLine, respond.created), + '/transaction/sign': makeMiddleware(sign), + '/transaction/submit': makeMiddleware(submit), + '/transaction/prepare/settings': makeMiddleware(prepareSettings) }, DELETE: { '/accounts/:account/orders/:sequence': makeMiddleware(cancelOrder) diff --git a/test/fixtures/settings.js b/test/fixtures/settings.js index 74524021..33180529 100644 --- a/test/fixtures/settings.js +++ b/test/fixtures/settings.js @@ -1,4 +1,5 @@ var _ = require('lodash'); +var addresses = require('./addresses'); const DEFAULTS = { require_destination_tag: true, @@ -228,4 +229,27 @@ module.exports.RESTAccountSettingsSubmitResponse = function(options) { ledger: options.current_ledger.toString(), state: options.state }); -}; \ No newline at end of file +}; + +module.exports.prepareSettingsRequest = { + "address": addresses.VALID, + "settings": { + "domain": "ripple.com" + }, + "instructions": { + "lastLedgerOffset": 100 + } +}; + +module.exports.prepareSettingsResponse = JSON.stringify({ + "success": true, + "tx_json": { + "Flags": 0, + "TransactionType": "AccountSet", + "Account": addresses.VALID, + "Domain": "726970706C652E636F6D", + "LastLedgerSequence": 8820241, + "Fee": "12", + "Sequence": 2938 + } +}); diff --git a/test/fixtures/sign.js b/test/fixtures/sign.js new file mode 100644 index 00000000..8411c9bc --- /dev/null +++ b/test/fixtures/sign.js @@ -0,0 +1,13 @@ +var settingsFixtures = require('./settings'); +var addresses = require('./addresses'); + +module.exports.signRequest = { + tx_json: JSON.parse(settingsFixtures.prepareSettingsResponse).tx_json, + secret: addresses.SECRET +}; + +module.exports.signResponse = JSON.stringify({ + success: true, + tx_blob: '12000322000000002400000B7A201B0086961168400000000000000C732102F89EAEC7667B30F33D0687BBA86C3FE2A08CCA40A9186C5BDE2DAA6FA97A37D87446304402207660BDEF67105CE1EBA9AD35DC7156BAB43FF1D47633199EE257D70B6B9AAFBF0220723E54B026DF8C6FF19DC7CBEB6AB458C7D367B2BE42827E91CBA934143F2729770A726970706C652E636F6D81144FBFF73DA4ECF9B701940F27341FA8020C313443', + hash: '144A196AB18CE00B69A4E289B6EB8A3A8D93FD8551B55C320BF575389D0AF457' +}); diff --git a/test/fixtures/submit.js b/test/fixtures/submit.js new file mode 100644 index 00000000..5b77c2fd --- /dev/null +++ b/test/fixtures/submit.js @@ -0,0 +1,50 @@ +/* eslint-disable max-len */ +'use strict'; +var signFixtures = require('./sign'); +var addresses = require('./addresses'); + + +module.exports.submitRequest = { + tx_blob: JSON.parse(signFixtures.signResponse).tx_blob +}; + +var tx_blob = '12000322000000002400000B7A201B0086961168400000000000000C732102F89EAEC7667B30F33D0687BBA86C3FE2A08CCA40A9186C5BDE2DAA6FA97A37D87446304402207660BDEF67105CE1EBA9AD35DC7156BAB43FF1D47633199EE257D70B6B9AAFBF0220723E54B026DF8C6FF19DC7CBEB6AB458C7D367B2BE42827E91CBA934143F2729770A726970706C652E636F6D81144FBFF73DA4ECF9B701940F27341FA8020C313443'; + +// Note: SingingPubKey, TxnSignature, and hash in tx_json are dummy data +var tx_json = { + Account: addresses.VALID, + Domain: '6162632E726970706C652E636F6D', + Fee: '12', + Flags: 0, + LastLedgerSequence: 8820241, + Sequence: 2938, + SigningPubKey: '039371D0465097AC8F9C02EB60D5599AAD08AADBD623D6D40D642CF2D7C0481B83', + TransactionType: 'AccountSet', + TxnSignature: '3045022100BD1C0F7A411773D84AEEBFE35749467C1EE6F815E9237FCA38850A2A3432AC300220064EDAB08B202685AEE3685E475D8008EC7349F80A021200B27475412C899454', + hash: '3455FA783DE44EACAD2243C53EA243C96A32BB8A1EC96E8939C43CFAFED802A9' +}; + +module.exports.submitRippledResponse = function(request) { + return JSON.stringify({ + id: request.id, + status: 'success', + type: 'response', + result: { + success: true, + engine_result: 'tesSUCCESS', + engine_result_code: 0, + engine_result_message: 'The transaction was applied. Only final in a validated ledger.', + tx_blob: tx_blob, + tx_json: tx_json + } + }); +}; + +module.exports.submitResponse = JSON.stringify({ + success: true, + engine_result: 'tesSUCCESS', + engine_result_code: 0, + engine_result_message: 'The transaction was applied. Only final in a validated ledger.', + tx_blob: tx_blob, + tx_json: tx_json +}); diff --git a/test/prngmock.js b/test/prngmock.js new file mode 100644 index 00000000..4b7d6c93 --- /dev/null +++ b/test/prngmock.js @@ -0,0 +1,27 @@ +var _ = require('lodash'); + +var SEED = '3045022100A58B0460BC5092CB4F96155C19125A4E079C870663F1D5E8BBC9BD0'; + +function PRNGMock(seed) { + if (seed && seed.length < 8) { + throw new Error('seed must be a hex string of at least 8 characters'); + } + this.position = 0; + this.seed = seed || SEED; +} + +PRNGMock.prototype.randomWord = function() { + var i = this.position; + this.position = (i + 8) % this.seed.length; + var data = this.seed + this.seed.slice(8); + return parseInt(data.slice(i, i + 8), 16); +} + +PRNGMock.prototype.randomWords = function(n) { + var self = this; + return _.times(n, function() { + return self.randomWord(); + }); +} + +module.exports = PRNGMock; diff --git a/test/settings-test.js b/test/settings-test.js index 34112aeb..580aa819 100644 --- a/test/settings-test.js +++ b/test/settings-test.js @@ -5,6 +5,28 @@ var fixtures = require('./fixtures').settings; var errors = require('./fixtures').errors; var addresses = require('./fixtures').addresses; +suite('prepareSettings', function() { + var self = this; + setup(testutils.setup.bind(self)); + teardown(testutils.teardown.bind(self)); + + test('/transaction/prepare/settings', function(done) { + self.wss.on('request_account_info', function(message, conn) { + assert.strictEqual(message.command, 'account_info'); + assert.strictEqual(message.account, addresses.VALID); + conn.send(fixtures.accountInfoResponse(message)); + }); + + self.app + .post(testutils.getPrepareURL('settings')) + .send(fixtures.prepareSettingsRequest) + .expect(testutils.checkStatus(200)) + .expect(testutils.checkHeaders) + .expect(testutils.checkBody(fixtures.prepareSettingsResponse)) + .end(done); + }); +}); + suite('get settings', function() { var self = this; diff --git a/test/sign-test.js b/test/sign-test.js new file mode 100644 index 00000000..f3835128 --- /dev/null +++ b/test/sign-test.js @@ -0,0 +1,20 @@ +var testutils = require('./testutils'); +var fixtures = require('./fixtures/sign'); + +suite('sign', function() { + var self = this; + setup(testutils.setup.bind(self)); + teardown(testutils.teardown.bind(self)); + + test('/transaction/sign', function(done) { + testutils.withDeterministicPRNG(function(_done) { + self.app + .post(testutils.getSignURL()) + .send(fixtures.signRequest) + .expect(testutils.checkBody(fixtures.signResponse)) + .expect(testutils.checkStatus(200)) + .expect(testutils.checkHeaders) + .end(_done); + }, done); + }); +}); diff --git a/test/submit-test.js b/test/submit-test.js new file mode 100644 index 00000000..7d742f19 --- /dev/null +++ b/test/submit-test.js @@ -0,0 +1,23 @@ +'use strict'; +var testutils = require('./testutils'); +var fixtures = require('./fixtures/submit'); + +suite('submit', function() { + var self = this; + setup(testutils.setup.bind(self)); + teardown(testutils.teardown.bind(self)); + + test('/transaction/submit', function(done) { + self.wss.once('request_submit', function(message, conn) { + conn.send(fixtures.submitRippledResponse(message)); + }); + + self.app + .post(testutils.getSubmitURL()) + .send(fixtures.submitRequest) + .expect(testutils.checkBody(fixtures.submitResponse)) + .expect(testutils.checkStatus(200)) + .expect(testutils.checkHeaders) + .end(done); + }); +}); diff --git a/test/testutils.js b/test/testutils.js index 368c48ed..6cfa1736 100644 --- a/test/testutils.js +++ b/test/testutils.js @@ -11,9 +11,36 @@ var app = require('../server/express_app'); var crypto = require('crypto'); var UInt256 = ripple.UInt256; var api = require('../server/api'); +var version = require('../server/version'); +var PRNGMock = require('./prngmock'); var LEDGER_OFFSET = 3; +function withDeterministicPRNG(callback, done) { + var prng = ripple.sjcl.random; + ripple.sjcl.random = new PRNGMock(); + callback(function(err, data) { + ripple.sjcl.random = prng; + done(err, data); + }); +} + +function getURLBase() { + return '/v' + version.getApiVersion(); +} + +function getSignURL() { + return getURLBase() + '/transaction/sign'; +} + +function getSubmitURL() { + return getURLBase() + '/transaction/submit'; +} + +function getPrepareURL(type) { + return getURLBase() + '/transaction/prepare/' + type; +} + function setup(done) { var self = this; @@ -87,7 +114,13 @@ function checkBody(expected) { return function(res, err) { // console.log(require('util').inspect(res.body,false,null)); assert.ifError(err); - assert.deepEqual(res.body, JSON.parse(expected)); + var expectedObject; + try { + expectedObject = JSON.parse(expected); + } catch (e) { + throw new Error('expected body is not JSON'); + } + assert.deepEqual(res.body, expectedObject); }; } @@ -128,7 +161,11 @@ module.exports = { checkHeaders: checkHeaders, checkBody: checkBody, generateHash: generateHash, - loadArguments: loadArguments + loadArguments: loadArguments, + getPrepareURL: getPrepareURL, + getSignURL: getSignURL, + getSubmitURL: getSubmitURL, + withDeterministicPRNG: withDeterministicPRNG }; module.exports.closeLedgers = closeLedgers;