Add multisignature support

This commit is contained in:
Chris Clark
2015-12-08 12:56:47 -08:00
parent 28b148348d
commit ebfe20defb
33 changed files with 476 additions and 59 deletions

View File

@@ -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,

View File

@@ -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));

View File

@@ -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"]
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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'),

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -22,7 +22,8 @@ function parseTransactionType(type) {
SetRegularKey: 'settings',
SuspendedPaymentCreate: 'suspendedPaymentCreation',
SuspendedPaymentFinish: 'suspendedPaymentExecution',
SuspendedPaymentCancel: 'suspendedPaymentCancellation'
SuspendedPaymentCancel: 'suspendedPaymentCancellation',
SignerListSet: 'settings'
};
return mapping[type] || null;
}

View File

@@ -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<string>): 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;

View File

@@ -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<Prepare> {

View File

@@ -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,

View File

@@ -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<Submit> {
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;

View File

@@ -17,3 +17,12 @@ export type Prepare = {
maxLedgerVersion?: number
}
}
export type Submit = {
success: boolean,
engineResult: string,
engineResultCode: number,
engineResultMessage?: string,
txBlob?: string,
txJson?: Object
}

View File

@@ -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<Prepare> {
@@ -51,8 +55,9 @@ function prepareTransaction(txJSON: Object, api: Object,
}
function prepareFee(): Promise<Object> {
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;
});