UNPKG

@colony/colony-js-contract-client

Version:

Method-like interface for Smart Contracts

304 lines (259 loc) 9.24 kB
/* @flow */ /* eslint-disable import/no-cycle */ import flatMap from 'lodash.flatmap'; import type { Query } from '@colony/colony-js-contract-loader'; import type { IAdapter, IContract, Log, LogFilter, TransactionOptions, TransactionReceipt, TransactionRequest, } from '@colony/colony-js-adapter'; import ContractEvent from './ContractEvent'; import ContractMethod from './ContractMethod'; import ContractMethodCaller from './ContractMethodCaller'; import ContractMethodSender from './ContractMethodSender'; import ContractMethodMultisigSender from './ContractMethodMultisigSender'; import type { ContractMethodDef, ContractClientConstructorArgs, Params, } from '../flowtypes'; export default class ContractClient { // The adapter used to communicate with the blockchain adapter: IAdapter; // The contract interface (as provided by the adapter) _contract: IContract; // The contract loading query the class was constructed with _query: Query; // The contract event subscription methods events = {}; // Mapping of event topics to ContractEvents eventSignatures = {}; // Show additional logs verbose: ?boolean; // Static getters used in lieu of named exports; this package only has // one export. static get Caller(): typeof ContractMethodCaller { return ContractMethodCaller; } static get Sender(): typeof ContractMethodSender { return ContractMethodSender; } static get MultisigSender(): typeof ContractMethodMultisigSender { return ContractMethodMultisigSender; } static get Event(): typeof ContractEvent { return ContractEvent; } static get defaultQuery(): Query { // eslint-disable-next-line no-console console.warn( 'No query defined; defaultQuery is designed to be ' + 'defined in a derived class', ); return {}; } constructor({ adapter, query, verbose }: ContractClientConstructorArgs) { this.adapter = adapter; this._query = Object.assign({}, this.constructor.defaultQuery, query); this.verbose = verbose; } get contract() { if (!this._contract) throw new Error('Contract not loaded; did you forget to call `.init()`?'); return this._contract; } get network() { // eslint-disable-next-line no-underscore-dangle return this.adapter.loader._network || this._query.network; } async init() { if (this._contract) throw new Error('ContractClient already initialized'); this._contract = await this.adapter.getContract(this._query); this.initializeContractMethods(); return this; } /** * Designed to be overridden in a derived class; called in the constructor. */ // eslint-disable-next-line class-methods-use-this initializeContractMethods(): void {} /** * Low-level method to call a named contract function with an array of * arguments that have been properly validated for that function. */ async call(functionName: string, args: Array<any>) { return this.contract.callConstant(functionName, args); } async callTransaction(transaction: TransactionRequest) { return this.adapter.callTransaction(transaction); } /** * Low-level method to estimate the gas cost of sending a transaction to * call a contract function with an array of arguments that have been * properly validated for that function. */ async estimate(functionName: string, args: Array<any>) { return this.contract.callEstimate(functionName, args); } /** * Low-level method to send a transaction to a named contract function * with an array of arguments that have been properly validated for that * function, and optional transaction options. */ async send( functionName: string, args: Array<any>, options: TransactionOptions, ) { return this.contract.sendTransaction(functionName, args, options); } /** * Get logs with filter, and return parsed event logs. */ async getEvents( filter?: LogFilter & { eventNames?: Array<string> } = {}, ): Promise<Array<Object>> { const logs = await this.getLogs(filter); return this.parseLogs(logs); } /** * Get logs from the contract with filter. * * The filter `topics` is an array, where each element is a string, array of * strings, or null. In each position, these represent a filter which matches * events including that single topic, one of any of the array of topics, or * any topic in that position respectively. * * - String: match only this topic in this position * - Array: match any of these topics in this position * - Null: match any topic in this position * * The returned logs will match these filters in each position. Trailing null * values will require a topic in that position (e.g. [null, null] will only * match logs with at least two topics). */ async getLogs( filter?: LogFilter & { eventNames?: Array<string> } = {}, ): Promise<Array<Object>> { const { eventNames = [], topics = [] } = filter; // Get topics for the given eventNames const extraTopics: Array<string> = flatMap(eventNames, eventName => { if (!this.events[eventName]) throw new Error('Cannot get logs for unknown event'); return this.events[eventName].interface.topics; }); // Combine any existing topics with the extra ones if (!topics.length) { topics.push(extraTopics); } else if (Array.isArray(topics[0])) { topics[0].push(...extraTopics); } else { topics[0] = [topics[0], ...extraTopics]; } // Fetch the logs and parse return this.adapter.provider.getLogs({ ...filter, topics, }); } /** * Given a transaction receipt, decode the event logs with the contract * interface, then use the corresponding ContractEvents to collect event data. */ getReceiptEventData({ logs = [] }: TransactionReceipt) { const { events } = this.contract.interface; const eventNames = Object.keys(events); return ( logs // Find matching event info by the topic .map(log => { const eventName = eventNames // Filter out events not supported by the interface (e.g. from BYOT) .filter(name => !!events[name]) .find(name => // The first topic should be the hashed event signature log.topics.includes(events[name].topics[0]), ); return eventName ? { ...log, eventInfo: events[eventName] } : null; }) // Filter out logs we couldn't find on the interface as events .filter(Boolean) // Parse the event data and add it to a resulting object .reduce((acc, { eventInfo, topics, data }) => { const args = eventInfo.parse(topics, data); const event = this.events[eventInfo.name]; if (!event) throw new Error(`Event ${eventInfo.name} not found`); // Add the event data both at the top level and under the event name const eventData = event.parse(args); return Object.assign(acc, { ...eventData, [eventInfo.name]: eventData, }); }, {}) ); } createTransactionData(functionName: string, args: Array<any>): string { return this.contract.createTransactionData(functionName, args); } parseLogs(logs: Log[]): Array<Object> { return logs .filter(({ topics: [topic] }) => this.eventSignatures[topic]) .map(log => ({ ...this.eventSignatures[log.topics[0]].parseLog(log), eventName: this.eventSignatures[log.topics[0]].eventName, })); } addMethod( Method: typeof ContractMethod.constructor, name: string, def: ContractMethodDef<*>, ): void { if (Reflect.has(this, name)) throw new Error(`A ContractMethod named "${name}" already exists`); Object.assign(this, { [name]: new Method({ name, functionName: name, client: this, ...def }), }); } addCaller(name: string, def: Object): void { this.addMethod(this.constructor.Caller, name, def); } addSender(name: string, def: Object): void { this.addMethod(this.constructor.Sender, name, def); } addMultisigSender(name: string, def: Object): void { this.addMethod(this.constructor.MultisigSender, name, def); } /** * Add event subscription functionality for a particular event of this * contract to the given ContractClient instance. */ addEvent(eventName: string, argsDef: Params): void { if (Reflect.has(this.events, eventName)) { throw new Error(`An event named "${eventName}" already exists`); } // Allow initialising of clients where some events may be missing in the // ABI, due to changing of events on the contract and then log the error // as a warning if the client is initialized in verbose mode. try { const event = new ContractEvent({ eventName, client: this, argsDef, }); Object.assign(this.events, { [eventName]: event, }); Object.assign(this.eventSignatures, { [event.interface.topics[0]]: event, }); } catch (error) { if (this.verbose) { console.warn(`WARNING: ${error.message}`); } } } }