UNPKG

@gnosis.pm/pm-js

Version:

A javascript library for building applications on top of Gnosis, the Ethereum prediction market platform

367 lines (300 loc) 12.5 kB
import _ from 'lodash' import DecimalJS from 'decimal.js' function makeWeb3Compatible(value, type, argName) { if(type == null) { throw new Error(`type must be specified for argument ${argName}`) } let match = /^(.*)\[(\d*)\]$/.exec(type) if(match != null) { if(!_.isArray(value)) { throw new Error(`expected ${value} to be convertable to ${type} ${argName}`) } if(match[2] !== '' && value.length !== Number(match[2])) { throw new Error(`${value} has ${value.length} items but should be ${type} ${argName}`) } return value.map((v) => makeWeb3Compatible(v, match[1], argName)) } if(type === 'address') { // if it quacks like a TruffleContract if (_.has(value, 'address')) { value = value.address } if(!_.isString(value)) { throw new Error(`${value} must be string for ${type} ${argName}`) } if(!/^(0x)?[0-9a-f]{40}$/i.test(value)) { throw new Error(`${value} has wrong format for ${type} ${argName}`) } return value } if(type === 'bool') { if(!_.isBoolean(value)) { throw new Error(`expected ${value} to be a bool for ${type} ${argName}`) } return value } if(type === 'bytes' || type === 'string') { if(_.isString(value)) { return value } throw new Error(`could not format ${value} for ${type} ${argName}`) } match = /^bytes(\d+)$/.exec(type) if(match != null) { let bytesLength = Number(match[1]) if(bytesLength > 32 || bytesLength === 0 || match[1].startsWith('0')) { throw new Error(`invalid type ${type} specified for ${argName}`) } if(_.isString(value)) { // TODO: refine this check to account for things like '\uACDC'.length if(value.length > bytesLength) { throw new Error(`value ${value} too long for ${type} ${argName}`) } return value } throw new Error(`could not format ${value} for ${type} ${argName}`) } match = /^(u?)int(\d+)$/.exec(type) if(match != null) { let signed = match[1] === '' let numBits = Number(match[2]) if(numBits % 8 !== 0) { throw new Error(`number of bits for ${type} ${argName} not divisible by 8`) } if(numBits > 256) { throw new Error(`number of bits for ${type} ${argName} is too large`) } value = value.valueOf() if(_.isString(value) && /^-?(0x[\da-f]+|\d+)$/i.test(value) || _.isNumber(value)) { if( _.isString(value) && value.startsWith('0x') && value.slice(2) === Number(value).toString(16) || value == Number(value).toString() ) { value = Number(value) } if(!signed && value.toString().startsWith('-')) { throw new Error(`cannot pass negative value ${value} for ${type} ${argName}`) } return value } throw new Error(`could not normalize ${value} for ${type} ${argName}`) } throw new Error(`unsupported type ${type} for ${argName}`) } function getOptsFromArgs(args) { return typeof args[args.length - 1] === 'object' ? args[args.length - 1] : {} } function getTruffleArgsWhileMutatingOptions (argInfo, opts, argAliases) { opts = opts == null ? {} : opts if(argAliases != null) { _.forOwn(argAliases, (name, alias) => { if(_.has(opts, alias)) { if(_.has(opts, name)) { throw new Error(`both name ${name} and its alias ${alias} specified in ${opts}`) } opts[name] = opts[alias] delete opts[alias] } }) } return argInfo.map(({ name, type }) => { if (!_.has(opts, name)) { throw new Error(`missing argument ${name}`) } const ret = makeWeb3Compatible(opts[name], type, name) delete opts[name] return ret }) } export let Decimal = DecimalJS.clone({ precision: 80, toExpPos: 9999 }) export function normalizeWeb3Args(args, opts) { let { functionInputs, methodName, argAliases, defaults } = opts // Format arguments in a way that web3 likes let methodArgs, methodOpts if(functionInputs.length === 1 && args.length === 1) { // if there is one input, user could have supplied either the argument with no options // or the argument inside of an options object if(typeof args[0] === 'object' && _.has(args[0], functionInputs[0].name)) { // we consider argument to be an options object if it has the parameter name as a key on it methodOpts = _.defaults(_.clone(args[0]), defaults) methodArgs = getTruffleArgsWhileMutatingOptions(functionInputs, methodOpts, argAliases) } else { methodOpts = null methodArgs = functionInputs.map(({ name, type }, i) => makeWeb3Compatible(args[i], type, name)) } } else if(functionInputs.length === args.length) { methodOpts = null methodArgs = functionInputs.map(({ name, type }, i) => makeWeb3Compatible(args[i], type, name)) } else if(functionInputs.length + 1 === args.length && typeof args[functionInputs.length] === 'object') { methodOpts = args[args.length - 1] // this map should not hit the last element of args methodArgs = functionInputs.map(({ name, type }, i) => makeWeb3Compatible(args[i], type, name)) } else if(args.length === 1 && typeof args[0] === 'object') { methodOpts = _.defaults(_.clone(args[0]), defaults) methodArgs = getTruffleArgsWhileMutatingOptions(functionInputs, methodOpts, argAliases) } else { throw new Error(`${methodName}(${ functionInputs.map(({ name, type }) => `${type} ${name}`).join(', ') }) can't be called with args (${args.join(', ')})`) } return [methodArgs, methodOpts] } function getWeb3CallMetadata(args, opts, speccedOpts) { let { callerContract, callerABI, methodName, eventName, eventArgName, resultContract, argAliases, validators } = speccedOpts if(callerABI == null) { callerABI = callerContract.abi } const functionCandidates = callerABI.filter(({ name }) => name === methodName) if(functionCandidates.length === 0) { throw new Error(`could not find function ${methodName} in abi ${callerABI}`) } else if(functionCandidates.length > 1) { // eslint-disable-next-line no-console console.warn(`function ${methodName} has multiple candidates in abi ${callerABI} -- using last candidate`) } const functionInputs = functionCandidates.pop().inputs let [methodArgs, methodOpts] = normalizeWeb3Args(args, { functionInputs, methodName, argAliases }) if(validators != null) { validators.forEach((validator) => { validator(methodArgs) }) } // Pass extra options down to the web3 layer if(methodOpts != null) { methodArgs.push(methodOpts) } return { callerContract, methodName, methodArgs, eventName, eventArgName, resultContract, } } export function wrapWeb3Function(spec) { const wrappedFn = async function() { const opts = getOptsFromArgs(arguments) const speccedOpts = spec(this, opts) const callMetadata = getWeb3CallMetadata(arguments, opts, speccedOpts) callMetadata.log = this.log return await sendTransactionAndGetResult(callMetadata) } wrappedFn.estimateGas = async function() { const opts = getOptsFromArgs(arguments) const speccedOpts = spec(this, opts) const { callerContract, methodName } = speccedOpts if(opts.using === 'stats') { return callerContract.gasStats[methodName].averageGasUsed } if(opts.using === 'rpc') { const { methodArgs } = getWeb3CallMetadata(arguments, opts, speccedOpts) let caller = callerContract if (_.has(caller, 'deployed')) { caller = await caller.deployed() } return await caller[methodName].estimateGas(...methodArgs) } throw new Error(`unsupported gas estimation source ${opts.using}`) } return wrappedFn } /** * Looks for a single event in the logs of a transaction result. If no such events or multiple matching events are found, throws an error. Otherwise returns the matching event log. * * @param {Transaction} result Result of sending a transaction * @param {string} eventName Name of the event * @return {Object} The matching event log found * @alias Gnosis.requireEventFromTXResult */ export function requireEventFromTXResult (result, eventName) { let matchingLogs = _.filter(result.logs, (l) => l.event === eventName) if (matchingLogs.length < 1) { throw new Error(`could not find any logs in transaction ${result.tx} corresponding to event ${eventName}`) } else if (matchingLogs.length > 1) { throw new Error(`found too many logs in transaction ${result.tx} corresponding to event ${eventName}`) } return matchingLogs[0] } export function formatCallSignature(opts) { return `${ opts.caller.constructor.contractName }(${opts.caller.address.slice(0, 6)}..${opts.caller.address.slice(-4)}).${opts.methodName}(${ opts.methodArgs.map(v => { try { return JSON.stringify(v) } catch(e) { return v } }).join(', ') })` } export class TransactionError extends Error { constructor(opts) { super(`${formatCallSignature(opts)}${opts.txHash == null ? '' : ` with transaction hash ${opts.txHash}`} failed with ${opts.subError}`) Object.assign(this, opts) this.name = 'TransactionError' } } export async function sendTransactionAndGetResult (opts) { opts = opts || {} let caller, txHash, txResult, matchingLog, contractInstance try { caller = opts.callerContract if (_.has(caller, 'deployed')) { caller = await caller.deployed() } txHash = await caller[opts.methodName].sendTransaction(...opts.methodArgs) if(opts.log != null) { opts.log(`got tx hash ${txHash} for call ${ formatCallSignature({ caller, methodName: opts.methodName, methodArgs: opts.methodArgs }) }`) } txResult = await caller.constructor.syncTransaction(txHash) matchingLog = requireEventFromTXResult(txResult, opts.eventName) if(opts.resultContract == null) { return matchingLog.args[opts.eventArgName] } else { opts.log(`tx hash ${txHash.slice(0, 6)}..${txHash.slice(-4)} returned ${opts.resultContract.contractName}(${matchingLog.args[opts.eventArgName]})`) contractInstance = await opts.resultContract.at(matchingLog.args[opts.eventArgName]) // Set the resulting transaction hash on the contract instance contractInstance.transactionHash = txHash return contractInstance } } catch(err) { throw new TransactionError(Object.assign({ caller, txHash, txResult, matchingLog, subError: err, }, opts)) } } // I know bluebird does this, but it's heavy // Also, as of Node v8.5.0, `util.promisify` doesn't call the function with the same `this` export function promisify (fn) { return new Proxy(fn, { apply: (target, thisArg, args) => { return new Promise((resolve, reject) => { let newArgs = Array.from(args) newArgs.push((err, result) => { if (err != null) { reject(new Error(`${err}${result == null ? '' : ` (${result})`}`)) } else { resolve(result) } }) target.apply(thisArg, newArgs) }) } }) } export function promisifyAll (obj) { _.functionsIn(obj).forEach((fnName) => { let asyncFnName = fnName + 'Async' if (!_.has(obj, asyncFnName)) { obj[asyncFnName] = promisify(obj[fnName]) } }) return obj }