From ebfe20defbcd2f62f4e90a1eaca4a744ecbcd8bf Mon Sep 17 00:00:00 2001 From: Chris Clark Date: Tue, 8 Dec 2015 12:56:47 -0800 Subject: [PATCH] Add multisignature support --- docs/index.md | 57 ++++++++- docs/src/combine.md.ejs | 24 ++++ docs/src/index.md.ejs | 1 + docs/src/sign.md.ejs | 2 +- docs/src/transactions.md.ejs | 2 +- src/api.js | 2 + src/common/schema-validator.js | 3 +- src/common/schemas/input/combine.json | 19 +++ src/common/schemas/input/sign.json | 11 ++ src/common/schemas/objects/settings.json | 29 +++++ src/common/validate.js | 1 + src/ledger/parse/fields.js | 21 ++++ src/ledger/parse/settings.js | 6 +- src/ledger/parse/transaction.js | 3 +- src/transaction/combine.js | 39 ++++++ src/transaction/settings.js | 34 +++++- src/transaction/sign.js | 29 +++-- src/transaction/submit.js | 15 +-- src/transaction/types.js | 9 ++ src/transaction/utils.js | 12 +- test/api-test.js | 28 ++++- test/fixtures/requests/combine.json | 2 + test/fixtures/requests/index.js | 11 +- .../requests/prepare-settings-signers.json | 19 +++ test/fixtures/requests/sign-as.json | 8 ++ test/fixtures/responses/combine.json | 4 + test/fixtures/responses/index.js | 9 +- .../responses/prepare-settings-signed.json | 4 +- .../responses/prepare-settings-signers.json | 8 ++ test/fixtures/responses/sign-as.json | 4 + test/integration/http-integration-test.js | 2 +- test/integration/integration-test.js | 112 ++++++++++++++++-- test/mock-rippled.js | 5 + 33 files changed, 476 insertions(+), 59 deletions(-) create mode 100644 docs/src/combine.md.ejs create mode 100644 src/common/schemas/input/combine.json create mode 100644 src/transaction/combine.js create mode 100644 test/fixtures/requests/combine.json create mode 100644 test/fixtures/requests/prepare-settings-signers.json create mode 100644 test/fixtures/requests/sign-as.json create mode 100644 test/fixtures/responses/combine.json create mode 100644 test/fixtures/responses/prepare-settings-signers.json create mode 100644 test/fixtures/responses/sign-as.json diff --git a/docs/index.md b/docs/index.md index 4d6eca03..99a2ff3c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,6 +54,7 @@ - [prepareSuspendedPaymentCancellation](#preparesuspendedpaymentcancellation) - [prepareSuspendedPaymentExecution](#preparesuspendedpaymentexecution) - [sign](#sign) + - [combine](#combine) - [submit](#submit) - [generateAddress](#generateaddress) - [computeLedgerHash](#computeledgerhash) @@ -269,7 +270,7 @@ Executing a transaction with `RippleAPI` requires the following four steps: * [prepareSuspendedPaymentCreation](#preparesuspendedpaymentcreation) * [prepareSuspendedPaymentCancellation](#preparesuspendedpaymentcancellation) * [prepareSuspendedPaymentExecution](#preparesuspendedpaymentexecution) -2. [Sign](#sign) - Cryptographically sign the transaction locally and save the [transaction ID](#transaction-id). Signing is how the owner of an account authorizes a transaction to take place. +2. [Sign](#sign) - Cryptographically sign the transaction locally and save the [transaction ID](#transaction-id). Signing is how the owner of an account authorizes a transaction to take place. For multisignature transactions, the `signedTransaction` fields returned by `sign` must be collected and passed to the [combine](#combine) method. 3. [Submit](#submit) - Submit the transaction to the connected server. 4. Verify - Verify that the transaction got validated by querying with [getTransaction](#gettransaction). This is necessary because transactions may fail even if they were successfully submitted. @@ -480,6 +481,12 @@ passwordSpent | boolean | *Optional* Indicates that the account has used its fre regularKey | [address](#ripple-address),null | *Optional* The public key of a new keypair, to use as the regular key to this account, as a base-58-encoded string in the same format as an account address. Use `null` to remove the regular key. requireAuthorization | boolean | *Optional* If set, this account must individually approve other users in order for those users to hold this account’s issuances. requireDestinationTag | boolean | *Optional* Requires incoming payments to specify a destination tag. +signers | object | *Optional* Settings that determine what sets of accounts can be used to sign a transaction on behalf of this account using multisigning. +*signers.* threshold | integer | *Optional* A target number for the signer weights. A multi-signature from this list is valid only if the sum weights of the signatures provided is equal or greater than this value. To delete the signers setting, use the value `0`. +*signers.* weights | array | *Optional* Weights of signatures for each signer. +*signers.* weights[] | object | An association of an address and a weight. +*signers.weights[].* address | [address](#ripple-address) | A Ripple account address +*signers.weights[].* weight | integer | The weight that the signature of this account counts as towards the threshold. transferRate | number,null | *Optional* The fee to charge when users transfer this account’s issuances, represented as billionths of a unit. Use `null` to set no fee. ### Example @@ -2644,6 +2651,12 @@ passwordSpent | boolean | *Optional* Indicates that the account has used its fre regularKey | [address](#ripple-address),null | *Optional* The public key of a new keypair, to use as the regular key to this account, as a base-58-encoded string in the same format as an account address. Use `null` to remove the regular key. requireAuthorization | boolean | *Optional* If set, this account must individually approve other users in order for those users to hold this account’s issuances. requireDestinationTag | boolean | *Optional* Requires incoming payments to specify a destination tag. +signers | object | *Optional* Settings that determine what sets of accounts can be used to sign a transaction on behalf of this account using multisigning. +*signers.* threshold | integer | *Optional* A target number for the signer weights. A multi-signature from this list is valid only if the sum weights of the signatures provided is equal or greater than this value. To delete the signers setting, use the value `0`. +*signers.* weights | array | *Optional* Weights of signatures for each signer. +*signers.* weights[] | object | An association of an address and a weight. +*signers.weights[].* address | [address](#ripple-address) | A Ripple account address +*signers.weights[].* weight | integer | The weight that the signature of this account counts as towards the threshold. transferRate | number,null | *Optional* The fee to charge when users transfer this account’s issuances, represented as billionths of a unit. Use `null` to set no fee. ### Example @@ -3282,7 +3295,7 @@ return api.prepareSuspendedPaymentExecution(address, suspendedPaymentExecution). ## sign -`sign(txJSON: string, secret: string): {signedTransaction: string, id: string}` +`sign(txJSON: string, secret: string, options: Object): {signedTransaction: string, id: string}` Sign a prepared transaction. The signed transaction must subsequently be [submitted](#submit). @@ -3292,6 +3305,8 @@ Name | Type | Description ---- | ---- | ----------- txJSON | string | Transaction represented as a JSON string in rippled format. secret | secret string | The secret of the account that is initiating the transaction. +options | object | *Optional* Options that control the type of signature that will be generated. +*options.* signAs | [address](#ripple-address) | *Optional* The account that the signature should count for in multisigning. ### Return Value @@ -3319,6 +3334,44 @@ return api.sign(txJSON, secret); ``` +## combine + +`combine(signedTransactions: Array): {signedTransaction: string, id: string}` + +Combines signed transactions from multiple accounts for a multisignature transaction. The signed transaction must subsequently be [submitted](#submit). + +### Parameters + +Name | Type | Description +---- | ---- | ----------- +signedTransactions | array\ | An array of signed transactions (from the output of [sign](#sign)) to combine. + +### Return Value + +This method returns an object with the following structure: + +Name | Type | Description +---- | ---- | ----------- +signedTransaction | string | The signed transaction represented as an uppercase hexadecimal string. +id | [id](#transaction-id) | The [Transaction ID](#transaction-id) of the signed transaction. + +### Example + +```javascript +const signedTransactions = [ "12000322800000002400000004201B000000116840000000000F42407300770B6578616D706C652E636F6D811407C532442A675C881BA1235354D4AB9D023243A6F3E0107321026C784C1987F83BACBF02CD3E484AFC84ADE5CA6B36ED4DCA06D5BA233B9D382774473045022100E484F54FF909469FA2033E22EFF3DF8EDFE62217062680BB2F3EDF2F185074FE0220350DB29001C710F0450DAF466C5D819DC6D6A3340602DE9B6CB7DA8E17C90F798114FE9337B0574213FA5BCC0A319DBB4A7AC0CCA894E1F1", + "12000322800000002400000004201B000000116840000000000F42407300770B6578616D706C652E636F6D811407C532442A675C881BA1235354D4AB9D023243A6F3E01073210287AAAB8FBE8C4C4A47F6F1228C6E5123A7ED844BFE88A9B22C2F7CC34279EEAA74473045022100B09DDF23144595B5A9523B20E605E138DC6549F5CA7B5984D7C32B0E3469DF6B022018845CA6C203D4B6288C87DDA439134C83E7ADF8358BD41A8A9141A9B631419F8114517D9B9609229E0CDFE2428B586738C5B2E84D45E1F1" ]; +return api.combine(signedTransactions); +``` + + +```json +{ + "signedTransaction": "12000322800000002400000004201B000000116840000000000F42407300770B6578616D706C652E636F6D811407C532442A675C881BA1235354D4AB9D023243A6F3E01073210287AAAB8FBE8C4C4A47F6F1228C6E5123A7ED844BFE88A9B22C2F7CC34279EEAA74473045022100B09DDF23144595B5A9523B20E605E138DC6549F5CA7B5984D7C32B0E3469DF6B022018845CA6C203D4B6288C87DDA439134C83E7ADF8358BD41A8A9141A9B631419F8114517D9B9609229E0CDFE2428B586738C5B2E84D45E1E0107321026C784C1987F83BACBF02CD3E484AFC84ADE5CA6B36ED4DCA06D5BA233B9D382774473045022100E484F54FF909469FA2033E22EFF3DF8EDFE62217062680BB2F3EDF2F185074FE0220350DB29001C710F0450DAF466C5D819DC6D6A3340602DE9B6CB7DA8E17C90F798114FE9337B0574213FA5BCC0A319DBB4A7AC0CCA894E1F1", + "id": "8A3BFD2214B4C8271ED62648FCE9ADE4EE82EF01827CF7D1F7ED497549A368CC" +} +``` + + ## submit `submit(signedTransaction: string): Promise` diff --git a/docs/src/combine.md.ejs b/docs/src/combine.md.ejs new file mode 100644 index 00000000..f65ac7a2 --- /dev/null +++ b/docs/src/combine.md.ejs @@ -0,0 +1,24 @@ +## combine + +`combine(signedTransactions: Array): {signedTransaction: string, id: string}` + +Combines signed transactions from multiple accounts for a multisignature transaction. The signed transaction must subsequently be [submitted](#submit). + +### Parameters + +<%- renderSchema("input/combine.json") %> + +### Return Value + +This method returns an object with the following structure: + +<%- renderSchema("output/sign.json") %> + +### Example + +```javascript +const signedTransactions = <%- importFile('test/fixtures/requests/combine.json') %>; +return api.combine(signedTransactions); +``` + +<%- renderFixture("responses/combine.json") %> diff --git a/docs/src/index.md.ejs b/docs/src/index.md.ejs index 40ec5aa8..9d854bb9 100644 --- a/docs/src/index.md.ejs +++ b/docs/src/index.md.ejs @@ -31,6 +31,7 @@ <% include prepareSuspendedPaymentCancellation.md.ejs %> <% include prepareSuspendedPaymentExecution.md.ejs %> <% include sign.md.ejs %> +<% include combine.md.ejs %> <% include submit.md.ejs %> <% include generateAddress.md.ejs %> <% include computeLedgerHash.md.ejs %> diff --git a/docs/src/sign.md.ejs b/docs/src/sign.md.ejs index be5e40a6..26247cf3 100644 --- a/docs/src/sign.md.ejs +++ b/docs/src/sign.md.ejs @@ -1,6 +1,6 @@ ## sign -`sign(txJSON: string, secret: string): {signedTransaction: string, id: string}` +`sign(txJSON: string, secret: string, options: Object): {signedTransaction: string, id: string}` Sign a prepared transaction. The signed transaction must subsequently be [submitted](#submit). diff --git a/docs/src/transactions.md.ejs b/docs/src/transactions.md.ejs index 3c1394f3..5e389447 100644 --- a/docs/src/transactions.md.ejs +++ b/docs/src/transactions.md.ejs @@ -30,7 +30,7 @@ Executing a transaction with `RippleAPI` requires the following four steps: * [prepareSuspendedPaymentCreation](#preparesuspendedpaymentcreation) * [prepareSuspendedPaymentCancellation](#preparesuspendedpaymentcancellation) * [prepareSuspendedPaymentExecution](#preparesuspendedpaymentexecution) -2. [Sign](#sign) - Cryptographically sign the transaction locally and save the [transaction ID](#transaction-id). Signing is how the owner of an account authorizes a transaction to take place. +2. [Sign](#sign) - Cryptographically sign the transaction locally and save the [transaction ID](#transaction-id). Signing is how the owner of an account authorizes a transaction to take place. For multisignature transactions, the `signedTransaction` fields returned by `sign` must be collected and passed to the [combine](#combine) method. 3. [Submit](#submit) - Submit the transaction to the connected server. 4. Verify - Verify that the transaction got validated by querying with [getTransaction](#gettransaction). This is necessary because transactions may fail even if they were successfully submitted. diff --git a/src/api.js b/src/api.js index adeef139..b8556b6e 100644 --- a/src/api.js +++ b/src/api.js @@ -44,6 +44,7 @@ const prepareSuspendedPaymentCancellation = require('./transaction/suspended-payment-cancellation'); const prepareSettings = require('./transaction/settings'); const sign = require('./transaction/sign'); +const combine = require('./transaction/combine'); const submit = require('./transaction/submit'); const errors = require('./common').errors; const generateAddress = @@ -125,6 +126,7 @@ _.assign(RippleAPI.prototype, { prepareSuspendedPaymentCancellation, prepareSettings, sign, + combine, submit, generateAddress, diff --git a/src/common/schema-validator.js b/src/common/schema-validator.js index abf45948..76eeb831 100644 --- a/src/common/schema-validator.js +++ b/src/common/schema-validator.js @@ -94,7 +94,8 @@ function loadSchemas() { require('./schemas/input/compute-ledger-hash'), require('./schemas/input/sign.json'), require('./schemas/input/submit.json'), - require('./schemas/input/generate-address.json') + require('./schemas/input/generate-address.json'), + require('./schemas/input/combine.json') ]; const titles = _.map(schemas, schema => schema.title); const duplicates = _.keys(_.pick(_.countBy(titles), count => count > 1)); diff --git a/src/common/schemas/input/combine.json b/src/common/schemas/input/combine.json new file mode 100644 index 00000000..fb487476 --- /dev/null +++ b/src/common/schemas/input/combine.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "combineParameters", + "type": "object", + "properties": { + "signedTransactions": { + "type": "array", + "description": "An array of signed transactions (from the output of [sign](#sign)) to combine.", + "items": { + "type": "string", + "pattern": "^[A-F0-9]+$", + "description": "A single-signed transaction represented as an uppercase hexadecimal string (from the output of [sign](#sign))" + }, + "minLength": 1 + } + }, + "additionalProperties": false, + "required": ["signedTransactions"] +} diff --git a/src/common/schemas/input/sign.json b/src/common/schemas/input/sign.json index 4e9d6db9..883906df 100644 --- a/src/common/schemas/input/sign.json +++ b/src/common/schemas/input/sign.json @@ -11,6 +11,17 @@ "type": "string", "format": "secret", "description": "The secret of the account that is initiating the transaction." + }, + "options": { + "type": "object", + "description": "Options that control the type of signature that will be generated.", + "properties": { + "signAs": { + "$ref": "address", + "description": "The account that the signature should count for in multisigning." + } + }, + "additionalProperties": false } }, "additionalProperties": false, diff --git a/src/common/schemas/objects/settings.json b/src/common/schemas/objects/settings.json index 5fb97c31..2b4cad16 100644 --- a/src/common/schemas/objects/settings.json +++ b/src/common/schemas/objects/settings.json @@ -68,6 +68,35 @@ ], "description": "The public key of a new keypair, to use as the regular key to this account, as a base-58-encoded string in the same format as an account address. Use `null` to remove the regular key." }, + "signers": { + "type": "object", + "description": "Settings that determine what sets of accounts can be used to sign a transaction on behalf of this account using multisigning.", + "properties": { + "threshold": { + "$ref": "uint32", + "description": "A target number for the signer weights. A multi-signature from this list is valid only if the sum weights of the signatures provided is equal or greater than this value. To delete the signers setting, use the value `0`." + }, + "weights": { + "type": "array", + "description": "Weights of signatures for each signer.", + "items": { + "type": "object", + "description": "An association of an address and a weight.", + "properties": { + "address": {"$ref": "address"}, + "weight": { + "$ref": "uint32", + "description": "The weight that the signature of this account counts as towards the threshold." + } + }, + "required": ["address", "weight"], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 8 + } + } + }, "memos": {"$ref": "memos"} }, "additionalProperties": false diff --git a/src/common/validate.js b/src/common/validate.js index bff6620b..7600cfe0 100644 --- a/src/common/validate.js +++ b/src/common/validate.js @@ -47,6 +47,7 @@ module.exports = { prepareSuspendedPaymentExecution: _.partial(schemaValidate, 'prepareSuspendedPaymentExecutionParameters'), sign: _.partial(schemaValidate, 'signParameters'), + combine: _.partial(schemaValidate, 'combineParameters'), submit: _.partial(schemaValidate, 'submitParameters'), computeLedgerHash: _.partial(schemaValidate, 'computeLedgerHashParameters'), generateAddress: _.partial(schemaValidate, 'generateAddressParameters'), diff --git a/src/ledger/parse/fields.js b/src/ledger/parse/fields.js index fb04704c..022a23a0 100644 --- a/src/ledger/parse/fields.js +++ b/src/ledger/parse/fields.js @@ -1,5 +1,6 @@ /* @flow */ 'use strict'; +const _ = require('lodash'); const BigNumber = require('bignumber.js'); const AccountFields = require('./utils').constants.AccountFields; @@ -22,6 +23,26 @@ function parseFields(data: Object): Object { settings[info.name] = parseField(info, fieldValue); } } + + if (data.RegularKey) { + settings.regularKey = data.RegularKey; + } + + // TODO: this isn't implemented in rippled yet, may have to change this later + if (data.SignerQuorum || data.SignerEntries) { + settings.signers = {}; + if (data.SignerQuorum) { + settings.signers.threshold = data.SignerQuorum; + } + if (data.SignerEntries) { + settings.signers.weights = _.map(data.SignerEntries, entry => { + return { + address: entry.SignerEntry.Account, + weight: entry.SignerEntry.SignerWeight + }; + }); + } + } return settings; } diff --git a/src/ledger/parse/settings.js b/src/ledger/parse/settings.js index d8eccc39..36b48aa7 100644 --- a/src/ledger/parse/settings.js +++ b/src/ledger/parse/settings.js @@ -52,10 +52,10 @@ function parseFlags(tx: Object) { function parseSettings(tx: Object) { const txType = tx.TransactionType; - assert(txType === 'AccountSet' || txType === 'SetRegularKey'); + assert(txType === 'AccountSet' || txType === 'SetRegularKey' || + txType === 'SignerListSet'); - const regularKey = tx.RegularKey ? {regularKey: tx.RegularKey} : {}; - return _.assign(regularKey, parseFlags(tx), parseFields(tx)); + return _.assign({}, parseFlags(tx), parseFields(tx)); } module.exports = parseSettings; diff --git a/src/ledger/parse/transaction.js b/src/ledger/parse/transaction.js index 0043ebd3..ee56795f 100644 --- a/src/ledger/parse/transaction.js +++ b/src/ledger/parse/transaction.js @@ -22,7 +22,8 @@ function parseTransactionType(type) { SetRegularKey: 'settings', SuspendedPaymentCreate: 'suspendedPaymentCreation', SuspendedPaymentFinish: 'suspendedPaymentExecution', - SuspendedPaymentCancel: 'suspendedPaymentCancellation' + SuspendedPaymentCancel: 'suspendedPaymentCancellation', + SignerListSet: 'settings' }; return mapping[type] || null; } diff --git a/src/transaction/combine.js b/src/transaction/combine.js new file mode 100644 index 00000000..d2098f53 --- /dev/null +++ b/src/transaction/combine.js @@ -0,0 +1,39 @@ +/* @flow */ +'use strict'; +const _ = require('lodash'); +const binary = require('ripple-binary-codec'); +const utils = require('./utils'); +const BigNumber = require('bignumber.js'); +const {decodeAddress} = require('ripple-address-codec'); +const {validate} = utils.common; +const {computeBinaryTransactionHash} = require('ripple-hashes'); + +function addressToBigNumber(address) { + const hex = (new Buffer(decodeAddress(address))).toString('hex'); + return new BigNumber(hex, 16); +} + +function compareSigners(a, b) { + return addressToBigNumber(a.Signer.Account) + .comparedTo(addressToBigNumber(b.Signer.Account)); +} + +function combine(signedTransactions: Array): Object { + validate.combine({signedTransactions}); + + const txs = _.map(signedTransactions, binary.decode); + const tx = _.omit(txs[0], 'Signers'); + if (!_.every(txs, _tx => _.isEqual(tx, _.omit(_tx, 'Signers')))) { + throw new utils.common.errors.ValidationError( + 'txJSON is not the same for all signedTransactions'); + } + const unsortedSigners = _.reduce(txs, (accumulator, _tx) => + accumulator.concat(_tx.Signers || []), []); + const signers = unsortedSigners.sort(compareSigners); + const signedTx = _.assign({}, tx, {Signers: signers}); + const signedTransaction = binary.encode(signedTx); + const id = computeBinaryTransactionHash(signedTransaction); + return {signedTransaction, id}; +} + +module.exports = combine; diff --git a/src/transaction/settings.js b/src/transaction/settings.js index b4d09c3b..d6682821 100644 --- a/src/transaction/settings.js +++ b/src/transaction/settings.js @@ -70,7 +70,17 @@ function convertTransferRate(transferRate: number | string): number | string { return (new BigNumber(transferRate)).shift(9).toNumber(); } -function createSettingsTransaction(account: string, settings: Settings +function formatSignerEntry(signer: Object): Object { + return { + SignerEntry: { + Account: signer.address, + SignerWeight: signer.weight + } + }; +} + +function createSettingsTransactionWithoutMemos( + account: string, settings: Settings ): Object { if (settings.regularKey !== undefined) { const removeRegularKey = { @@ -83,15 +93,20 @@ function createSettingsTransaction(account: string, settings: Settings return _.assign({}, removeRegularKey, {RegularKey: settings.regularKey}); } + if (settings.signers !== undefined) { + return { + TransactionType: 'SignerListSet', + Account: account, + SignerQuorum: settings.signers.threshold, + SignerEntries: _.map(settings.signers.weights, formatSignerEntry) + }; + } + const txJSON: Object = { TransactionType: 'AccountSet', Account: account }; - if (settings.memos !== undefined) { - txJSON.Memos = _.map(settings.memos, utils.convertMemo); - } - setTransactionFlags(txJSON, _.omit(settings, 'memos')); setTransactionFields(txJSON, settings); @@ -101,6 +116,15 @@ function createSettingsTransaction(account: string, settings: Settings return txJSON; } +function createSettingsTransaction(account: string, settings: Settings +): Object { + const txJSON = createSettingsTransactionWithoutMemos(account, settings); + if (settings.memos !== undefined) { + txJSON.Memos = _.map(settings.memos, utils.convertMemo); + } + return txJSON; +} + function prepareSettings(address: string, settings: Settings, instructions: Instructions = {} ): Promise { diff --git a/src/transaction/sign.js b/src/transaction/sign.js index aeba262c..92be4342 100644 --- a/src/transaction/sign.js +++ b/src/transaction/sign.js @@ -6,23 +6,38 @@ const binary = require('ripple-binary-codec'); const {computeBinaryTransactionHash} = require('ripple-hashes'); const validate = utils.common.validate; -function computeSignature(txJSON, privateKey) { - const signingData = binary.encodeForSigning(txJSON); +function computeSignature(tx: Object, privateKey: string, signAs: ?string) { + const signingData = signAs ? + binary.encodeForMultisigning(tx, signAs) : binary.encodeForSigning(tx); return keypairs.sign(signingData, privateKey); } -function sign(txJSON: string, secret: string +function sign(txJSON: string, secret: string, options: Object = {} ): {signedTransaction: string; id: string} { validate.sign({txJSON, secret}); // we can't validate that the secret matches the account because // the secret could correspond to the regular key const tx = JSON.parse(txJSON); - const keypair = keypairs.deriveKeypair(secret); - if (tx.SigningPubKey === undefined) { - tx.SigningPubKey = keypair.publicKey; + if (tx.TxnSignature || tx.Signers) { + throw new utils.common.errors.ValidationError( + 'txJSON must not contain "TxnSignature" or "Signers" properties'); } - tx.TxnSignature = computeSignature(tx, keypair.privateKey); + + const keypair = keypairs.deriveKeypair(secret); + tx.SigningPubKey = options.signAs ? '' : keypair.publicKey; + + if (options.signAs) { + const signer = { + Account: options.signAs, + SigningPubKey: keypair.publicKey, + TxnSignature: computeSignature(tx, keypair.privateKey, options.signAs) + }; + tx.Signers = [{Signer: signer}]; + } else { + tx.TxnSignature = computeSignature(tx, keypair.privateKey); + } + const serialized = binary.encode(tx); return { signedTransaction: serialized, diff --git a/src/transaction/submit.js b/src/transaction/submit.js index 21cd7a45..5b438d24 100644 --- a/src/transaction/submit.js +++ b/src/transaction/submit.js @@ -3,15 +3,7 @@ const _ = require('lodash'); const utils = require('./utils'); const {validate} = utils.common; - -type Submit = { - success: boolean, - engineResult: string, - engineResultCode: number, - engineResultMessage?: string, - txBlob?: string, - txJson?: Object -} +import type {Submit} from './types.js'; function isImmediateRejection(engineResult: string): boolean { // note: "tel" errors mean the local server refused to process the @@ -23,7 +15,7 @@ function isImmediateRejection(engineResult: string): boolean { return _.startsWith(engineResult, 'tem') || _.startsWith(engineResult, 'tej'); } -function formatResponse(response) { +function formatSubmitResponse(response) { const data = { resultCode: response.engine_result, resultMessage: response.engine_result_message @@ -36,11 +28,12 @@ function formatResponse(response) { function submit(signedTransaction: string): Promise { validate.submit({signedTransaction}); + const request = { command: 'submit', tx_blob: signedTransaction }; - return this.connection.request(request).then(formatResponse); + return this.connection.request(request).then(formatSubmitResponse); } module.exports = submit; diff --git a/src/transaction/types.js b/src/transaction/types.js index c7e8290d..09b7393b 100644 --- a/src/transaction/types.js +++ b/src/transaction/types.js @@ -17,3 +17,12 @@ export type Prepare = { maxLedgerVersion?: number } } + +export type Submit = { + success: boolean, + engineResult: string, + engineResultCode: number, + engineResultMessage?: string, + txBlob?: string, + txJson?: Object +} diff --git a/src/transaction/utils.js b/src/transaction/utils.js index 532e67d9..edaa8aa8 100644 --- a/src/transaction/utils.js +++ b/src/transaction/utils.js @@ -27,6 +27,10 @@ function setCanonicalFlag(txJSON) { txJSON.Flags = txJSON.Flags >>> 0; } +function scaleValue(value, multiplier) { + return (new BigNumber(value)).times(multiplier).toString(); +} + function prepareTransaction(txJSON: Object, api: Object, instructions: Instructions ): Promise { @@ -51,8 +55,9 @@ function prepareTransaction(txJSON: Object, api: Object, } function prepareFee(): Promise { + const multiplier = (txJSON.Signers || []).length + 1; if (instructions.fee !== undefined) { - txJSON.Fee = common.xrpToDrops(instructions.fee); + txJSON.Fee = scaleValue(common.xrpToDrops(instructions.fee), multiplier); return Promise.resolve(txJSON); } const cushion = api._feeCushion; @@ -60,9 +65,10 @@ function prepareTransaction(txJSON: Object, api: Object, const feeDrops = common.xrpToDrops(fee); if (instructions.maxFee !== undefined) { const maxFeeDrops = common.xrpToDrops(instructions.maxFee); - txJSON.Fee = BigNumber.min(feeDrops, maxFeeDrops).toString(); + const normalFee = BigNumber.min(feeDrops, maxFeeDrops).toString(); + txJSON.Fee = scaleValue(normalFee, multiplier); } else { - txJSON.Fee = feeDrops; + txJSON.Fee = scaleValue(feeDrops, multiplier); } return txJSON; }); diff --git a/test/api-test.js b/test/api-test.js index 84462a2e..802a2707 100644 --- a/test/api-test.js +++ b/test/api-test.js @@ -183,20 +183,20 @@ describe('RippleAPI', function() { it('prepareSettings', function() { return this.api.prepareSettings( - address, requests.prepareSettings, instructions).then( + address, requests.prepareSettings.domain, instructions).then( _.partial(checkResult, responses.prepareSettings.flags, 'prepare')); }); it('prepareSettings - no maxLedgerVersion', function() { return this.api.prepareSettings( - address, requests.prepareSettings, {maxLedgerVersion: null}).then( + address, requests.prepareSettings.domain, {maxLedgerVersion: null}).then( _.partial(checkResult, responses.prepareSettings.noMaxLedgerVersion, 'prepare')); }); it('prepareSettings - no instructions', function() { return this.api.prepareSettings( - address, requests.prepareSettings).then( + address, requests.prepareSettings.domain).then( _.partial( checkResult, responses.prepareSettings.noInstructions, @@ -244,6 +244,13 @@ describe('RippleAPI', function() { 'prepare')); }); + it('prepareSettings - set signers', function() { + const settings = requests.prepareSettings.signers; + return this.api.prepareSettings(address, settings, instructions).then( + _.partial(checkResult, responses.prepareSettings.signers, + 'prepare')); + }); + it('prepareSuspendedPaymentCreation', function() { const localInstructions = _.defaults({ maxFee: '0.000012' @@ -312,6 +319,14 @@ describe('RippleAPI', function() { schemaValidator.schemaValidate('sign', result); }); + it('sign - signAs', function() { + const txJSON = requests.sign.signAs; + const secret = 'snoPBrXtMeMyMHUVTgbuqAfg1SUTb'; + const signature = this.api.sign(JSON.stringify(txJSON), secret, + {signAs: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh'}); + assert.deepEqual(signature, responses.sign.signAs); + }); + it('submit', function() { return this.api.submit(responses.sign.normal.signedTransaction).then( _.partial(checkResult, responses.submit, 'submit')); @@ -326,6 +341,11 @@ describe('RippleAPI', function() { }); }); + it('combine', function() { + const combined = this.api.combine(requests.combine.setDomain); + checkResult(responses.combine.single, 'sign', combined); + }); + describe('RippleAPI', function() { it('getBalances', function() { @@ -1255,7 +1275,7 @@ describe('RippleAPI - offline', function() { it('prepareSettings and sign', function() { const api = new RippleAPI(); const secret = 'shsWGZcmZz6YsWWmcnpfr6fLTdtFV'; - const settings = requests.prepareSettings; + const settings = requests.prepareSettings.domain; const instructions = { sequence: 23, maxLedgerVersion: 8820051, diff --git a/test/fixtures/requests/combine.json b/test/fixtures/requests/combine.json new file mode 100644 index 00000000..480ebbc9 --- /dev/null +++ b/test/fixtures/requests/combine.json @@ -0,0 +1,2 @@ +[ "12000322800000002400000004201B000000116840000000000F42407300770B6578616D706C652E636F6D811407C532442A675C881BA1235354D4AB9D023243A6F3E0107321026C784C1987F83BACBF02CD3E484AFC84ADE5CA6B36ED4DCA06D5BA233B9D382774473045022100E484F54FF909469FA2033E22EFF3DF8EDFE62217062680BB2F3EDF2F185074FE0220350DB29001C710F0450DAF466C5D819DC6D6A3340602DE9B6CB7DA8E17C90F798114FE9337B0574213FA5BCC0A319DBB4A7AC0CCA894E1F1", + "12000322800000002400000004201B000000116840000000000F42407300770B6578616D706C652E636F6D811407C532442A675C881BA1235354D4AB9D023243A6F3E01073210287AAAB8FBE8C4C4A47F6F1228C6E5123A7ED844BFE88A9B22C2F7CC34279EEAA74473045022100B09DDF23144595B5A9523B20E605E138DC6549F5CA7B5984D7C32B0E3469DF6B022018845CA6C203D4B6288C87DDA439134C83E7ADF8358BD41A8A9141A9B631419F8114517D9B9609229E0CDFE2428B586738C5B2E84D45E1F1" ] diff --git a/test/fixtures/requests/index.js b/test/fixtures/requests/index.js index 84b9ca16..199a5c0c 100644 --- a/test/fixtures/requests/index.js +++ b/test/fixtures/requests/index.js @@ -20,7 +20,10 @@ module.exports = { allOptions: require('./prepare-payment-all-options'), noCounterparty: require('./prepare-payment-no-counterparty') }, - prepareSettings: require('./prepare-settings'), + prepareSettings: { + domain: require('./prepare-settings'), + signers: require('./prepare-settings-signers') + }, prepareSuspendedPaymentCreation: { normal: require('./prepare-suspended-payment-creation'), full: require('./prepare-suspended-payment-creation-full') @@ -40,7 +43,8 @@ module.exports = { }, sign: { normal: require('./sign'), - suspended: require('./sign-suspended.json') + suspended: require('./sign-suspended.json'), + signAs: require('./sign-as') }, getPaths: { normal: require('./getpaths/normal'), @@ -61,5 +65,8 @@ module.exports = { computeLedgerHash: { header: require('./compute-ledger-hash'), transactions: require('./compute-ledger-hash-transactions') + }, + combine: { + setDomain: require('./combine.json') } }; diff --git a/test/fixtures/requests/prepare-settings-signers.json b/test/fixtures/requests/prepare-settings-signers.json new file mode 100644 index 00000000..abb8723a --- /dev/null +++ b/test/fixtures/requests/prepare-settings-signers.json @@ -0,0 +1,19 @@ +{ + "signers": { + "threshold": 2, + "weights": [ + { + "address": "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", + "weight": 1 + }, + { + "address": "rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo", + "weight": 1 + }, + { + "address": "rwBYyfufTzk77zUSKEu4MvixfarC35av1J", + "weight": 1 + } + ] + } +} diff --git a/test/fixtures/requests/sign-as.json b/test/fixtures/requests/sign-as.json new file mode 100644 index 00000000..df1e9e04 --- /dev/null +++ b/test/fixtures/requests/sign-as.json @@ -0,0 +1,8 @@ +{ + "Account": "rnUy2SHTrB9DubsPmkJZUXTf5FcNDGrYEA", + "Amount": "1000000000", + "Destination": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "Fee": "50", + "Sequence": 2, + "TransactionType": "Payment" +} diff --git a/test/fixtures/responses/combine.json b/test/fixtures/responses/combine.json new file mode 100644 index 00000000..151ac1e5 --- /dev/null +++ b/test/fixtures/responses/combine.json @@ -0,0 +1,4 @@ +{ + "signedTransaction": "12000322800000002400000004201B000000116840000000000F42407300770B6578616D706C652E636F6D811407C532442A675C881BA1235354D4AB9D023243A6F3E01073210287AAAB8FBE8C4C4A47F6F1228C6E5123A7ED844BFE88A9B22C2F7CC34279EEAA74473045022100B09DDF23144595B5A9523B20E605E138DC6549F5CA7B5984D7C32B0E3469DF6B022018845CA6C203D4B6288C87DDA439134C83E7ADF8358BD41A8A9141A9B631419F8114517D9B9609229E0CDFE2428B586738C5B2E84D45E1E0107321026C784C1987F83BACBF02CD3E484AFC84ADE5CA6B36ED4DCA06D5BA233B9D382774473045022100E484F54FF909469FA2033E22EFF3DF8EDFE62217062680BB2F3EDF2F185074FE0220350DB29001C710F0450DAF466C5D819DC6D6A3340602DE9B6CB7DA8E17C90F798114FE9337B0574213FA5BCC0A319DBB4A7AC0CCA894E1F1", + "id": "8A3BFD2214B4C8271ED62648FCE9ADE4EE82EF01827CF7D1F7ED497549A368CC" +} diff --git a/test/fixtures/responses/index.js b/test/fixtures/responses/index.js index c17e8751..495791dd 100644 --- a/test/fixtures/responses/index.js +++ b/test/fixtures/responses/index.js @@ -87,7 +87,8 @@ module.exports = { fieldClear: require('./prepare-settings-field-clear.json'), noInstructions: require('./prepare-settings-no-instructions.json'), signed: require('./prepare-settings-signed.json'), - noMaxLedgerVersion: require('./prepare-settings-no-maxledgerversion.json') + noMaxLedgerVersion: require('./prepare-settings-no-maxledgerversion.json'), + signers: require('./prepare-settings-signers.json') }, prepareSuspendedPaymentCreation: { normal: require('./prepare-suspended-payment-creation'), @@ -108,7 +109,11 @@ module.exports = { }, sign: { normal: require('./sign.json'), - suspended: require('./sign-suspended.json') + suspended: require('./sign-suspended.json'), + signAs: require('./sign-as') + }, + combine: { + single: require('./combine.json') }, submit: require('./submit.json'), ledgerEvent: require('./ledger-event.json') diff --git a/test/fixtures/responses/prepare-settings-signed.json b/test/fixtures/responses/prepare-settings-signed.json index be63f9cd..8d421d46 100644 --- a/test/fixtures/responses/prepare-settings-signed.json +++ b/test/fixtures/responses/prepare-settings-signed.json @@ -1,4 +1,4 @@ -{ +{ "signedTransaction": "12000322800000002400000017201B0086955368400000000000000C732102F89EAEC7667B30F33D0687BBA86C3FE2A08CCA40A9186C5BDE2DAA6FA97A37D87446304402202FBF6A6F74DFDA17C7341D532B66141206BC71A147C08DBDA6A950AA9A1741DC022055859A39F2486A46487F8DA261E3D80B4FDD26178A716A929F26377D1BEC7E43770A726970706C652E636F6D81145E7B112523F68D2F5E879DB4EAC51C6698A69304F9EA7C04746573747D0B74657874656420646174617E0A706C61696E2F74657874E1F1", "id": "4755D26FAC39E3E477870D4E03CC6783DDDF967FFBE240606755D3D03702FC16" -} \ No newline at end of file +} diff --git a/test/fixtures/responses/prepare-settings-signers.json b/test/fixtures/responses/prepare-settings-signers.json new file mode 100644 index 00000000..88c891f4 --- /dev/null +++ b/test/fixtures/responses/prepare-settings-signers.json @@ -0,0 +1,8 @@ +{ + "txJSON": "{\"TransactionType\":\"SignerListSet\",\"Account\":\"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59\",\"SignerQuorum\":2,\"SignerEntries\":[{\"SignerEntry\":{\"Account\":\"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59\",\"SignerWeight\":1}},{\"SignerEntry\":{\"Account\":\"rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo\",\"SignerWeight\":1}},{\"SignerEntry\":{\"Account\":\"rwBYyfufTzk77zUSKEu4MvixfarC35av1J\",\"SignerWeight\":1}}],\"Flags\":2147483648,\"LastLedgerSequence\":8820051,\"Fee\":\"12\",\"Sequence\":23}", + "instructions": { + "fee": "0.000012", + "sequence": 23, + "maxLedgerVersion": 8820051 + } +} diff --git a/test/fixtures/responses/sign-as.json b/test/fixtures/responses/sign-as.json new file mode 100644 index 00000000..afe039b8 --- /dev/null +++ b/test/fixtures/responses/sign-as.json @@ -0,0 +1,4 @@ +{ + "signedTransaction": "120000240000000261400000003B9ACA00684000000000000032730081142E244E6F20104E57C0C60BD823CB312BF10928C78314B5F762798A53D543A014CAF8B297CFF8F2F937E8F3E01073210330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD02074473045022100BB6FC77F26BC88587204CAA79B2230C420D7EC937B8AC3A0CF9B0BE988BAB0D002203BF86893BA3B764375FFFAD9D54A4AAEDABD07C4D72ADB9C1B20C10B4DD712898114B5F762798A53D543A014CAF8B297CFF8F2F937E8E1F1", + "id": "AB7632D7C07E591658635CED6A5DDE832B22CA066907CB131DEFAAA925B98185" +} diff --git a/test/integration/http-integration-test.js b/test/integration/http-integration-test.js index 6b96e9da..de8bb5f6 100644 --- a/test/integration/http-integration-test.js +++ b/test/integration/http-integration-test.js @@ -110,7 +110,7 @@ describe('http server integration tests', function() { 'prepareSettings', [ {address}, - {settings: apiRequests.prepareSettings}, + {settings: apiRequests.prepareSettings.domain}, {instructions: { maxFee: '0.000012', sequence: 23, diff --git a/test/integration/integration-test.js b/test/integration/integration-test.js index 06719b6f..5dd20c14 100644 --- a/test/integration/integration-test.js +++ b/test/integration/integration-test.js @@ -14,36 +14,45 @@ const {isValidSecret} = require('../../src/common'); const TIMEOUT = 30000; // how long before each test case times out const INTERVAL = 1000; // how long to wait between checks for validated ledger +function acceptLedger(api) { + return api.connection.request({command: 'ledger_accept'}); +} -function verifyTransaction(testcase, hash, type, options, txData) { +function verifyTransaction(testcase, hash, type, options, txData, address) { console.log('VERIFY...'); return testcase.api.getTransaction(hash, options).then(data => { assert(data && data.outcome); assert.strictEqual(data.type, type); - assert.strictEqual(data.address, wallet.getAddress()); + assert.strictEqual(data.address, address); assert.strictEqual(data.outcome.result, 'tesSUCCESS'); - testcase.transactions.push(hash); + if (testcase.transactions !== undefined) { + testcase.transactions.push(hash); + } return {txJSON: JSON.stringify(txData), id: hash, tx: data}; }).catch(error => { if (error instanceof errors.PendingLedgerVersionError) { console.log('NOT VALIDATED YET...'); return new Promise((resolve, reject) => { setTimeout(() => verifyTransaction(testcase, hash, type, - options, txData).then(resolve, reject), INTERVAL); + options, txData, address).then(resolve, reject), INTERVAL); }); } + console.log(error.stack); assert(false, 'Transaction not successful: ' + error.message); }); } -function testTransaction(testcase, type, lastClosedLedgerVersion, prepared) { +function testTransaction(testcase, type, lastClosedLedgerVersion, prepared, + address = wallet.getAddress(), secret = wallet.getSecret()) { const txJSON = prepared.txJSON; assert(txJSON, 'missing txJSON'); const txData = JSON.parse(txJSON); - assert.strictEqual(txData.Account, wallet.getAddress()); - const signedData = testcase.api.sign(txJSON, wallet.getSecret()); + assert.strictEqual(txData.Account, address); + const signedData = testcase.api.sign(txJSON, secret); console.log('PREPARED...'); - return testcase.api.submit(signedData.signedTransaction).then(data => { + return testcase.api.submit(signedData.signedTransaction) + .then(data => testcase.test.title.indexOf('multisign') !== -1 ? + acceptLedger(testcase.api).then(() => data) : data).then(data => { console.log('SUBMITTED...'); assert.strictEqual(data.resultCode, 'tesSUCCESS'); const options = { @@ -52,13 +61,13 @@ function testTransaction(testcase, type, lastClosedLedgerVersion, prepared) { }; return new Promise((resolve, reject) => { setTimeout(() => verifyTransaction(testcase, signedData.id, type, - options, txData).then(resolve, reject), INTERVAL); + options, txData, address).then(resolve, reject), INTERVAL); }); }); } -function setup() { - this.api = new RippleAPI({server: 'wss://s1.ripple.com'}); +function setup(server = 'wss://s1.ripple.com') { + this.api = new RippleAPI({server}); console.log('CONNECTING...'); return this.api.connect().then(() => { console.log('CONNECTED...'); @@ -91,7 +100,7 @@ describe('integration tests', function() { it('settings', function() { return this.api.getLedgerVersion().then(ledgerVersion => { return this.api.prepareSettings(address, - requests.prepareSettings, instructions).then(prepared => + requests.prepareSettings.domain, instructions).then(prepared => testTransaction(this, 'settings', ledgerVersion, prepared)); }); }); @@ -232,7 +241,7 @@ describe('integration tests', function() { it('getSettings', function() { return this.api.getSettings(address).then(data => { assert(data); - assert.strictEqual(data.domain, requests.prepareSettings.domain); + assert.strictEqual(data.domain, requests.prepareSettings.domain.domain); }); }); @@ -313,3 +322,80 @@ describe('integration tests', function() { }); }); + +function createAccount(api, address) { + const root = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh'; + const secret = 'snoPBrXtMeMyMHUVTgbuqAfg1SUTb'; + const amount = { + currency: 'XRP', + value: '10000' + }; + return api.preparePayment(root, { + source: {address: root, maxAmount: amount}, + destination: {address, amount} + }).then(prepared => { + return api.submit(api.sign(prepared.txJSON, secret).signedTransaction); + }).then(() => { + return acceptLedger(api); + }); +} + +describe.skip('integration tests - standalone rippled', function() { + const instructions = {maxLedgerVersionOffset: 10, fee: '1'}; + this.timeout(TIMEOUT); + + const url = 'ws://127.0.0.1:6006'; + // const url = 'wss://s.altnet.rippletest.net:51233'; + beforeEach(_.partial(setup, url)); + afterEach(teardown); + const address = 'r5nx8ZkwEbFztnc8Qyi22DE9JYjRzNmvs'; + const secret = 'ss6F8381Br6wwpy9p582H8sBt19J3'; + const signer1address = 'rQDhz2ZNXmhxzCYwxU6qAbdxsHA4HV45Y2'; + const signer1secret = 'shK6YXzwYfnFVn3YZSaMh5zuAddKx'; + const signer2address = 'r3RtUvGw9nMoJ5FuHxuoVJvcENhKtuF9ud'; + const signer2secret = 'shUHQnL4EH27V4EiBrj6EfhWvZngF'; + + it('submit multisigned transaction', function() { + const signers = { + threshold: 2, + weights: [ + {address: signer1address, weight: 1}, + {address: signer2address, weight: 1} + ] + }; + let minLedgerVersion = null; + return createAccount(this.api, address).then(() => { + return this.api.getLedgerVersion().then(ledgerVersion => { + minLedgerVersion = ledgerVersion; + return this.api.prepareSettings(address, {signers}, instructions) + .then(prepared => { + return testTransaction(this, 'settings', ledgerVersion, prepared, + address, secret); + }); + }); + }).then(() => { + return this.api.prepareSettings( + address, {domain: 'example.com'}, instructions) + .then(prepared => { + const signed1 = this.api.sign( + prepared.txJSON, signer1secret, {signAs: signer1address}); + const signed2 = this.api.sign( + prepared.txJSON, signer2secret, {signAs: signer2address}); + const combined = this.api.combine([ + signed1.signedTransaction, signed2.signedTransaction + ]); + return this.api.submit(combined.signedTransaction) + .then(response => acceptLedger(this.api).then(() => response)) + .then(response => { + assert.strictEqual(response.resultCode, 'tesSUCCESS'); + const options = {minLedgerVersion}; + return verifyTransaction(this, combined.id, 'settings', + options, {}, address); + }).catch(error => { + console.log(error.message); + throw error; + }); + }); + }); + }); +}); diff --git a/test/mock-rippled.js b/test/mock-rippled.js index 3b41b636..2ab30abe 100644 --- a/test/mock-rippled.js +++ b/test/mock-rippled.js @@ -249,6 +249,11 @@ module.exports = function(port) { } }); + mock.on('request_submit_multisigned', function(request, conn) { + assert.strictEqual(request.command, 'submit_multisigned'); + conn.send(createResponse(request, fixtures.submit.success)); + }); + mock.on('request_account_lines', function(request, conn) { if (request.account === addresses.ACCOUNT) { conn.send(accountLinesResponse.normal(request));