UNPKG

@colony/colony-js-contract-client

Version:

Method-like interface for Smart Contracts

350 lines (295 loc) 10.2 kB
/* @flow */ /* eslint-disable import/no-cycle */ import { padLeft, soliditySha3, isHexStrict, hexToBytes } from 'web3-utils'; import defaultAssert from 'assert'; import { isValidAddress, makeAssert } from '@colony/colony-js-utils'; import isPlainObject from 'lodash.isplainobject'; import isEqual from 'lodash.isequal'; import ContractMethodMultisigSender from './ContractMethodMultisigSender'; import ContractClient from './ContractClient'; import type { CombinedSignatures, MultisigOperationConstructorArgs, MultisigOperationPayload, SendOptions, Signature, Signers, SigningMode, } from '../flowtypes'; import { SIGNING_MODES } from '../constants'; export default class MultisigOperation< InputValues: { [inputValueName: string]: any }, OutputValues: { [outputValueName: string]: any }, IContractClient: ContractClient, ContractData: { [dataValueName: string]: any }, Sender: ContractMethodMultisigSender< InputValues, OutputValues, IContractClient, ContractData, >, > { sender: Sender; payload: MultisigOperationPayload<InputValues>; // Immutable _messageHash: string; _nonce: number; _onReset: ?Function; _requiredSignees: Array<string>; _signers: Signers; static _validatePayload(payload: any) { const assert = makeAssert('Invalid payload'); assert(isPlainObject(payload), 'Payload must be an object'); const { data, destinationAddress, sourceAddress, value } = payload || {}; assert(isHexStrict(data), 'data must be a hex string'); assert( isValidAddress(destinationAddress), 'destinationAddress must be a valid address', ); assert( isValidAddress(sourceAddress), 'sourceAddress must be a valid address', ); return assert( Number(value) === value && value >= 0, 'value must be a positive number', ); } static _validateSignature(signature: any, assert: Function = defaultAssert) { assert(isPlainObject(signature), 'Signature must be an object'); const { sigV, sigR, sigS, mode } = signature; return ( assert([27, 28].includes(sigV), 'v must be 27 or 28') && assert(isHexStrict(sigR), 'r must be a hex string') && assert(isHexStrict(sigS), 's must be a hex string') && assert( Object.values(SIGNING_MODES).includes(mode), 'mode must be a valid signing mode', ) ); } static _validateSigners(signers: any) { const assert = makeAssert('Invalid _signers'); assert(isPlainObject(signers), 'Signers must be an object'); return Object.entries(signers || {}).every( ([address, signature]) => assert( isValidAddress(address), `"${address}" is not a valid address`, ) && this._validateSignature(signature, assert), ); } constructor( sender: Sender, args: MultisigOperationConstructorArgs<InputValues>, ) { const { payload, signers = {}, nonce, onReset } = args; // eslint-disable-next-line no-underscore-dangle this.constructor._validatePayload(payload); // eslint-disable-next-line no-underscore-dangle this.constructor._validateSigners(signers); defaultAssert( nonce == null || Number.isInteger(nonce), 'The optional `nonce` parameter should be an integer', ); this.sender = sender; this.payload = Object.freeze(Object.assign({}, payload)); this._signers = signers; if (onReset) this._onReset = onReset; if (nonce !== undefined && Number(nonce) === nonce) this._nonce = nonce; } toJSON() { const { _nonce: nonce, payload, _signers: signers } = this; return JSON.stringify({ nonce, payload, signers }); } /** * Given the state of an operation as JSON, validate the parsed state and * add in the signers. */ addSignersFromJSON(json: string) { let parsed = {}; try { parsed = JSON.parse(json); } catch (error) { throw new Error('Unable to add signers: could not parse JSON'); } const { payload, signers } = parsed; defaultAssert( isEqual(this.payload, payload), 'Unable to add state; incompatible payloads', ); // eslint-disable-next-line no-underscore-dangle this.constructor._validateSigners(signers); this._signers = Object.assign({}, this._signers, signers); return this; } get requiredSignees(): Array<string> { defaultAssert( Array.isArray(this._requiredSignees), 'Required signees not defined; call `.refresh` to refresh signees', ); return this._requiredSignees; } get missingSignees(): Array<string> { return this.requiredSignees.filter(address => !this._signers[address]); } get _signedMessageDigest() { return hexToBytes( soliditySha3('\x19Ethereum Signed Message:\n32', this._messageHash), ); } get _signedTrezorMessageDigest() { return hexToBytes( soliditySha3('\x19Ethereum Signed Message:\n\x20', this._messageHash), ); } _getMessageDigest(mode: SigningMode) { return mode === SIGNING_MODES.TREZOR ? this._signedTrezorMessageDigest : this._signedMessageDigest; } /** * Given a signature and a wallet address, determine the signing mode by * trying different digests with `ecRecover` until the wallet address matches * the recovered address. */ _findSignatureMode(signature: Signature, address: string): SigningMode { let foundMode; const { adapter } = this.sender.client; Object.keys(SIGNING_MODES) .map(key => SIGNING_MODES[key]) .forEach(mode => { const digest = this._getMessageDigest(mode); const recovered = adapter.ecRecover(digest, signature); if (address.toLowerCase() === recovered.toLowerCase()) foundMode = mode; }); if (foundMode !== undefined) return foundMode; throw new Error(`Unable to confirm signature mode for address ${address}`); } /** * Given multiple signers, combine each part of the signatures together. */ _combineSignatures(): CombinedSignatures { const combined = { sigV: [], sigR: [], sigS: [], mode: [] }; // Sort by address so that the order is always the same Object.keys(this._signers) .sort() .forEach(address => { const { sigV, sigR, sigS, mode } = this._signers[address]; combined.sigV.push(sigV); combined.sigR.push(sigR); combined.sigS.push(sigS); combined.mode.push(mode); }); return combined; } /** * Given the payload and signatures for this operation, combine the signatures * and return the arguments in the order the contract expects. */ _getArgs() { const { payload: { value, data }, } = this; const { sigV, sigR, sigS, mode } = this._combineSignatures(); return [sigV, sigR, sigS, mode, value, data]; } /** * Ensure that there are no missing signees (based on the input values for * this operation). */ _validateRequiredSignees() { const missing = this.missingSignees; defaultAssert( missing.length === 0, `Missing signatures (from address${ missing.length > 1 ? 'es' : '' } ${missing.join(', ')})`, ); return true; } /** * Given send options, ensure that the necessary signees have signed the * operation, then get the arguments and send the transaction. */ async send(options: SendOptions) { await this.refresh(); this._validateRequiredSignees(); return this.sender.sendMultisig(this._getArgs(), options); } /** * Given a signature and an address, find the signature mode and * add the address/signature to the signers. */ _addSignature(signature: Signature, address: string) { const normalisedAddress = address.toLowerCase(); const mode = this._findSignatureMode(signature, normalisedAddress); this._signers = Object.assign({}, this._signers, { [normalisedAddress]: { mode, ...signature, }, }); return this; } /** * Sign the message hash with the current wallet and add the signature. */ async sign() { await this.refresh(); const { adapter } = this.sender.client; const signature = await adapter.signMessage(this._messageHash); const address = await adapter.wallet.getAddress(); this._addSignature(signature, address); return this; } /** * Refresh the required signees, nonce value and message hash. * If the nonce value has changed, `_signers` will be reset. */ async refresh() { await this._refreshNonce(); await this._refreshRequiredSignees(); this._refreshMessageHash(); return this; } async _refreshNonce() { // If the nonce has not yet been set, simply set it; Don't reset signers, // because we don't have a way of knowing whether they're valid or nor; // assume they are still valid. if (!Object.hasOwnProperty.call(this, '_nonce')) { this._nonce = await this.sender.getNonce(this.payload.inputValues); return; } const oldNonce = Number(this._nonce); const newNonce = await this.sender.getNonce(this.payload.inputValues); if (oldNonce !== newNonce) { this._nonce = newNonce; // If the nonce changed, the signers are no longer valid this._signers = {}; // We will also trigger onReset, if it exists if (this._onReset) this._onReset(); } } async _refreshRequiredSignees() { this._requiredSignees = await this.sender.getRequiredSignees( this.payload.inputValues, ); } /** * Given the payload and nonce, use this input to create an ERC191-compatible * message hash */ _refreshMessageHash() { const { payload: { data, destinationAddress, sourceAddress, value }, _nonce, } = this; // Follows ERC191 signature scheme: https://github.com/ethereum/EIPs/issues/191 const addresses = `${sourceAddress.slice(2)}${destinationAddress.slice(2)}`; const paddedValue = padLeft(value.toString(16), 64, '0'); const paddedNonce = padLeft(_nonce.toString(16), 64, '0'); this._messageHash = soliditySha3( `0x${addresses}${paddedValue}${data.slice(2)}${paddedNonce}`, ); } }