diff options
Diffstat (limited to 'packages/utils/src')
-rw-r--r-- | packages/utils/src/abi_decoder.ts | 57 | ||||
-rw-r--r-- | packages/utils/src/abi_utils.ts | 224 | ||||
-rw-r--r-- | packages/utils/src/address_utils.ts | 16 | ||||
-rw-r--r-- | packages/utils/src/configured_bignumber.ts | 9 | ||||
-rw-r--r-- | packages/utils/src/constants.ts | 1 | ||||
-rw-r--r-- | packages/utils/src/error_utils.ts | 5 | ||||
-rw-r--r-- | packages/utils/src/fetchAsync.ts | 40 | ||||
-rw-r--r-- | packages/utils/src/index.ts | 4 | ||||
-rw-r--r-- | packages/utils/src/interval_utils.ts | 18 | ||||
-rw-r--r-- | packages/utils/src/log_utils.ts | 3 |
10 files changed, 336 insertions, 41 deletions
diff --git a/packages/utils/src/abi_decoder.ts b/packages/utils/src/abi_decoder.ts index d49906cfb..7f93e746e 100644 --- a/packages/utils/src/abi_decoder.ts +++ b/packages/utils/src/abi_decoder.ts @@ -8,53 +8,55 @@ import { LogWithDecodedArgs, RawLog, SolidityTypes, -} from '@0xproject/types'; -import * as ethersContracts from 'ethers-contracts'; +} from 'ethereum-types'; +import * as ethers from 'ethers'; import * as _ from 'lodash'; +import { addressUtils } from './address_utils'; import { BigNumber } from './configured_bignumber'; export class AbiDecoder { - private _savedABIs: AbiDefinition[] = []; - private _methodIds: { [signatureHash: string]: EventAbi } = {}; - private static _padZeros(address: string) { - let formatted = address; - if (_.startsWith(formatted, '0x')) { - formatted = formatted.slice(2); - } - - formatted = _.padStart(formatted, 40, '0'); - return `0x${formatted}`; - } + private readonly _methodIds: { [signatureHash: string]: EventAbi } = {}; constructor(abiArrays: AbiDefinition[][]) { - _.forEach(abiArrays, this._addABI.bind(this)); + _.forEach(abiArrays, this.addABI.bind(this)); } // This method can only decode logs from the 0x & ERC20 smart contracts - public tryToDecodeLogOrNoop<ArgsType>(log: LogEntry): LogWithDecodedArgs<ArgsType> | RawLog { + public tryToDecodeLogOrNoop<ArgsType extends DecodedLogArgs>(log: LogEntry): LogWithDecodedArgs<ArgsType> | RawLog { const methodId = log.topics[0]; const event = this._methodIds[methodId]; if (_.isUndefined(event)) { return log; } - const ethersInterface = new ethersContracts.Interface([event]); - const logData = log.data; + const ethersInterface = new ethers.Interface([event]); const decodedParams: DecodedLogArgs = {}; let topicsIndex = 1; - const nonIndexedInputs = _.filter(event.inputs, input => !input.indexed); - const dataTypes = _.map(nonIndexedInputs, input => input.type); - const decodedData = ethersInterface.events[event.name].parse(log.data); + let decodedData: any[]; + try { + decodedData = ethersInterface.events[event.name].parse(log.data); + } catch (error) { + if (error.code === ethers.errors.INVALID_ARGUMENT) { + // Because we index events by Method ID, and Method IDs are derived from the method + // name and the input parameters, it's possible that the return value of the event + // does not match our ABI. If that's the case, then ethers will throw an error + // when we try to parse the event. We handle that case here by returning the log rather + // than throwing an error. + return log; + } + throw error; + } - let failedToDecode = false; + let didFailToDecode = false; _.forEach(event.inputs, (param: EventParameter, i: number) => { // Indexed parameters are stored in topics. Non-indexed ones in decodedData let value: BigNumber | string | number = param.indexed ? log.topics[topicsIndex++] : decodedData[i]; if (_.isUndefined(value)) { - failedToDecode = true; + didFailToDecode = true; return; } if (param.type === SolidityTypes.Address) { - value = AbiDecoder._padZeros(new BigNumber(value).toString(16)); + const baseHex = 16; + value = addressUtils.padZeros(new BigNumber(value).toString(baseHex)); } else if (param.type === SolidityTypes.Uint256 || param.type === SolidityTypes.Uint) { value = new BigNumber(value); } else if (param.type === SolidityTypes.Uint8) { @@ -63,7 +65,7 @@ export class AbiDecoder { decodedParams[param.name] = value; }); - if (failedToDecode) { + if (didFailToDecode) { return log; } else { return { @@ -73,17 +75,16 @@ export class AbiDecoder { }; } } - private _addABI(abiArray: AbiDefinition[]): void { + public addABI(abiArray: AbiDefinition[]): void { if (_.isUndefined(abiArray)) { return; } - const ethersInterface = new ethersContracts.Interface(abiArray); + const ethersInterface = new ethers.Interface(abiArray); _.map(abiArray, (abi: AbiDefinition) => { if (abi.type === AbiType.Event) { - const topic = ethersInterface.events[abi.name].topic; + const topic = ethersInterface.events[abi.name].topics[0]; this._methodIds[topic] = abi; } }); - this._savedABIs = this._savedABIs.concat(abiArray); } } diff --git a/packages/utils/src/abi_utils.ts b/packages/utils/src/abi_utils.ts new file mode 100644 index 000000000..c9b70966c --- /dev/null +++ b/packages/utils/src/abi_utils.ts @@ -0,0 +1,224 @@ +import { AbiDefinition, AbiType, ContractAbi, DataItem, MethodAbi } from 'ethereum-types'; +import * as ethers from 'ethers'; +import * as _ from 'lodash'; + +import { BigNumber } from './configured_bignumber'; + +// Note(albrow): This function is unexported in ethers.js. Copying it here for +// now. +// Source: https://github.com/ethers-io/ethers.js/blob/884593ab76004a808bf8097e9753fb5f8dcc3067/contracts/interface.js#L30 +function parseEthersParams(params: DataItem[]): { names: ethers.ParamName[]; types: string[] } { + const names: ethers.ParamName[] = []; + const types: string[] = []; + + params.forEach((param: DataItem) => { + if (param.components != null) { + let suffix = ''; + const arrayBracket = param.type.indexOf('['); + if (arrayBracket >= 0) { + suffix = param.type.substring(arrayBracket); + } + + const result = parseEthersParams(param.components); + names.push({ name: param.name || null, names: result.names }); + types.push('tuple(' + result.types.join(',') + ')' + suffix); + } else { + names.push(param.name || null); + types.push(param.type); + } + }); + + return { + names, + types, + }; +} + +// returns true if x is equal to y and false otherwise. Performs some minimal +// type conversion and data massaging for x and y, depending on type. name and +// type should typically be derived from parseEthersParams. +function isAbiDataEqual(name: ethers.ParamName, type: string, x: any, y: any): boolean { + if (_.isUndefined(x) && _.isUndefined(y)) { + return true; + } else if (_.isUndefined(x) && !_.isUndefined(y)) { + return false; + } else if (!_.isUndefined(x) && _.isUndefined(y)) { + return false; + } + if (_.endsWith(type, '[]')) { + // For array types, we iterate through the elements and check each one + // individually. Strangely, name does not need to be changed in this + // case. + if (x.length !== y.length) { + return false; + } + const newType = _.trimEnd(type, '[]'); + for (let i = 0; i < x.length; i++) { + if (!isAbiDataEqual(name, newType, x[i], y[i])) { + return false; + } + } + return true; + } + if (_.startsWith(type, 'tuple(')) { + if (_.isString(name)) { + throw new Error('Internal error: type was tuple but names was a string'); + } else if (_.isNull(name)) { + throw new Error('Internal error: type was tuple but names was null'); + } + // For tuples, we iterate through the underlying values and check each + // one individually. + const types = splitTupleTypes(type); + if (types.length !== name.names.length) { + throw new Error( + `Internal error: parameter types/names length mismatch (${types.length} != ${name.names.length})`, + ); + } + for (let i = 0; i < types.length; i++) { + // For tuples, name is an object with a names property that is an + // array. As an example, for orders, name looks like: + // + // { + // name: 'orders', + // names: [ + // 'makerAddress', + // // ... + // 'takerAssetData' + // ] + // } + // + const nestedName = _.isString(name.names[i]) + ? (name.names[i] as string) + : ((name.names[i] as ethers.NestedParamName).name as string); + if (!isAbiDataEqual(name.names[i], types[i], x[nestedName], y[nestedName])) { + return false; + } + } + return true; + } else if (type === 'address' || type === 'bytes') { + // HACK(albrow): ethers.js returns the checksummed address even when + // initially passed in a non-checksummed address. To account for that, + // we convert to lowercase before comparing. + return _.isEqual(_.toLower(x), _.toLower(y)); + } else if (_.startsWith(type, 'uint') || _.startsWith(type, 'int')) { + return new BigNumber(x).eq(new BigNumber(y)); + } + return _.isEqual(x, y); +} + +// splitTupleTypes splits a tuple type string (of the form `tuple(X)` where X is +// any other type or list of types) into its component types. It works with +// nested tuples, so, e.g., `tuple(tuple(uint256,address),bytes32)` will yield: +// `['tuple(uint256,address)', 'bytes32']`. It expects exactly one tuple type as +// an argument (not an array). +function splitTupleTypes(type: string): string[] { + if (_.endsWith(type, '[]')) { + throw new Error('Internal error: array types are not supported'); + } else if (!_.startsWith(type, 'tuple(')) { + throw new Error('Internal error: expected tuple type but got non-tuple type: ' + type); + } + // Trim the outtermost tuple(). + const trimmedType = type.substring('tuple('.length, type.length - 1); + const types: string[] = []; + let currToken = ''; + let parenCount = 0; + // Tokenize the type string while keeping track of parentheses. + for (const char of trimmedType) { + switch (char) { + case '(': + parenCount += 1; + currToken += char; + break; + case ')': + parenCount -= 1; + currToken += char; + break; + case ',': + if (parenCount === 0) { + types.push(currToken); + currToken = ''; + break; + } else { + currToken += char; + break; + } + default: + currToken += char; + break; + } + } + types.push(currToken); + return types; +} + +export const abiUtils = { + parseEthersParams, + isAbiDataEqual, + splitTupleTypes, + parseFunctionParam(param: DataItem): string { + if (param.type === 'tuple') { + // Parse out tuple types into {type_1, type_2, ..., type_N} + const tupleComponents = param.components; + const paramString = _.map(tupleComponents, component => abiUtils.parseFunctionParam(component)); + const tupleParamString = `{${paramString}}`; + return tupleParamString; + } + return param.type; + }, + getFunctionSignature(methodAbi: MethodAbi): string { + const functionName = methodAbi.name; + const parameterTypeList = _.map(methodAbi.inputs, (param: DataItem) => abiUtils.parseFunctionParam(param)); + const functionSignature = `${functionName}(${parameterTypeList})`; + return functionSignature; + }, + /** + * Solidity supports function overloading whereas TypeScript does not. + * See: https://solidity.readthedocs.io/en/v0.4.21/contracts.html?highlight=overload#function-overloading + * In order to support overloaded functions, we suffix overloaded function names with an index. + * This index should be deterministic, regardless of function ordering within the smart contract. To do so, + * we assign indexes based on the alphabetical order of function signatures. + * + * E.g + * ['f(uint)', 'f(uint,byte32)'] + * Should always be renamed to: + * ['f1(uint)', 'f2(uint,byte32)'] + * Regardless of the order in which these these overloaded functions are declared within the contract ABI. + */ + renameOverloadedMethods(inputContractAbi: ContractAbi): ContractAbi { + const contractAbi = _.cloneDeep(inputContractAbi); + const methodAbis = contractAbi.filter((abi: AbiDefinition) => abi.type === AbiType.Function) as MethodAbi[]; + // Sort method Abis into alphabetical order, by function signature + const methodAbisOrdered = _.sortBy(methodAbis, [ + (methodAbi: MethodAbi) => { + const functionSignature = abiUtils.getFunctionSignature(methodAbi); + return functionSignature; + }, + ]); + // Group method Abis by name (overloaded methods will be grouped together, in alphabetical order) + const methodAbisByName: { [key: string]: MethodAbi[] } = {}; + _.each(methodAbisOrdered, methodAbi => { + (methodAbisByName[methodAbi.name] || (methodAbisByName[methodAbi.name] = [])).push(methodAbi); + }); + // Rename overloaded methods to overloadedMethodName1, overloadedMethodName2, ... + _.each(methodAbisByName, methodAbisWithSameName => { + _.each(methodAbisWithSameName, (methodAbi, i: number) => { + if (methodAbisWithSameName.length > 1) { + const overloadedMethodId = i + 1; + const sanitizedMethodName = `${methodAbi.name}${overloadedMethodId}`; + const indexOfExistingAbiWithSanitizedMethodNameIfExists = _.findIndex( + methodAbis, + currentMethodAbi => currentMethodAbi.name === sanitizedMethodName, + ); + if (indexOfExistingAbiWithSanitizedMethodNameIfExists >= 0) { + const methodName = methodAbi.name; + throw new Error( + `Failed to rename overloaded method '${methodName}' to '${sanitizedMethodName}'. A method with this name already exists.`, + ); + } + methodAbi.name = sanitizedMethodName; + } + }); + }); + return contractAbi; + }, +}; diff --git a/packages/utils/src/address_utils.ts b/packages/utils/src/address_utils.ts index f94985441..1fc960408 100644 --- a/packages/utils/src/address_utils.ts +++ b/packages/utils/src/address_utils.ts @@ -1,7 +1,10 @@ +import { addHexPrefix, stripHexPrefix } from 'ethereumjs-util'; import * as jsSHA3 from 'js-sha3'; +import * as _ from 'lodash'; const BASIC_ADDRESS_REGEX = /^(0x)?[0-9a-f]{40}$/i; const SAME_CASE_ADDRESS_REGEX = /^(0x)?([0-9a-f]{40}|[0-9A-F]{40})$/; +const ADDRESS_LENGTH = 40; export const addressUtils = { isChecksumAddress(address: string): boolean { @@ -9,11 +12,15 @@ export const addressUtils = { const unprefixedAddress = address.replace('0x', ''); const addressHash = jsSHA3.keccak256(unprefixedAddress.toLowerCase()); - for (let i = 0; i < 40; i++) { + for (let i = 0; i < ADDRESS_LENGTH; i++) { // The nth letter should be uppercase if the nth digit of casemap is 1 + const hexBase = 16; + const lowercaseRange = 7; if ( - (parseInt(addressHash[i], 16) > 7 && unprefixedAddress[i].toUpperCase() !== unprefixedAddress[i]) || - (parseInt(addressHash[i], 16) <= 7 && unprefixedAddress[i].toLowerCase() !== unprefixedAddress[i]) + (parseInt(addressHash[i], hexBase) > lowercaseRange && + unprefixedAddress[i].toUpperCase() !== unprefixedAddress[i]) || + (parseInt(addressHash[i], hexBase) <= lowercaseRange && + unprefixedAddress[i].toLowerCase() !== unprefixedAddress[i]) ) { return false; } @@ -33,4 +40,7 @@ export const addressUtils = { return isValidChecksummedAddress; } }, + padZeros(address: string): string { + return addHexPrefix(_.padStart(stripHexPrefix(address), ADDRESS_LENGTH, '0')); + }, }; diff --git a/packages/utils/src/configured_bignumber.ts b/packages/utils/src/configured_bignumber.ts index e44c062c2..2b22b6938 100644 --- a/packages/utils/src/configured_bignumber.ts +++ b/packages/utils/src/configured_bignumber.ts @@ -1,9 +1,14 @@ import { BigNumber } from 'bignumber.js'; -// By default BigNumber's `toString` method converts to exponential notation if the value has -// more then 20 digits. We want to avoid this behavior, so we set EXPONENTIAL_AT to a high number BigNumber.config({ + // By default BigNumber's `toString` method converts to exponential notation if the value has + // more then 20 digits. We want to avoid this behavior, so we set EXPONENTIAL_AT to a high number EXPONENTIAL_AT: 1000, + // Note(albrow): This is the lowest value for which + // `x.div(y).floor() === x.divToInt(y)` + // for all values of x and y <= MAX_UINT256, where MAX_UINT256 is the + // maximum number represented by the uint256 type in Solidity (2^256-1). + DECIMAL_PLACES: 78, }); export { BigNumber }; diff --git a/packages/utils/src/constants.ts b/packages/utils/src/constants.ts new file mode 100644 index 000000000..2894d4747 --- /dev/null +++ b/packages/utils/src/constants.ts @@ -0,0 +1 @@ +export const NULL_BYTES = '0x'; diff --git a/packages/utils/src/error_utils.ts b/packages/utils/src/error_utils.ts new file mode 100644 index 000000000..735d3940b --- /dev/null +++ b/packages/utils/src/error_utils.ts @@ -0,0 +1,5 @@ +export const errorUtils = { + spawnSwitchErr(name: string, value: any): Error { + return new Error(`Unexpected switch value: ${value} encountered for ${name}`); + }, +}; diff --git a/packages/utils/src/fetchAsync.ts b/packages/utils/src/fetchAsync.ts new file mode 100644 index 000000000..b4c85718d --- /dev/null +++ b/packages/utils/src/fetchAsync.ts @@ -0,0 +1,40 @@ +import isNode = require('detect-node'); +import 'isomorphic-fetch'; +// WARNING: This needs to be imported after isomorphic-fetch: https://github.com/mo/abortcontroller-polyfill#using-it-on-browsers-without-fetch +// tslint:disable-next-line:ordered-imports +import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'; + +export const fetchAsync = async ( + endpoint: string, + options: RequestInit = {}, + timeoutMs: number = 20000, +): Promise<Response> => { + if (options.signal || (options as any).timeout) { + throw new Error( + 'Cannot call fetchAsync with options.signal or options.timeout. To set a timeout, please use the supplied "timeoutMs" parameter.', + ); + } + let optionsWithAbortParam; + if (!isNode) { + const controller = new AbortController(); + const signal = controller.signal; + setTimeout(() => { + controller.abort(); + }, timeoutMs); + optionsWithAbortParam = { + signal, + ...options, + }; + } else { + // HACK: the `timeout` param only exists in `node-fetch`, and not on the `isomorphic-fetch` + // `RequestInit` type. Since `isomorphic-fetch` conditionally wraps `node-fetch` when the + // execution environment is `Node.js`, we need to cast it to `any` in that scenario. + optionsWithAbortParam = { + timeout: timeoutMs, + ...options, + } as any; + } + + const response = await fetch(endpoint, optionsWithAbortParam); + return response; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index debcce746..b8e0b1775 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -5,3 +5,7 @@ export { intervalUtils } from './interval_utils'; export { BigNumber } from './configured_bignumber'; export { AbiDecoder } from './abi_decoder'; export { logUtils } from './log_utils'; +export { abiUtils } from './abi_utils'; +export { NULL_BYTES } from './constants'; +export { errorUtils } from './error_utils'; +export { fetchAsync } from './fetchAsync'; diff --git a/packages/utils/src/interval_utils.ts b/packages/utils/src/interval_utils.ts index ebecc7015..3d0561cd2 100644 --- a/packages/utils/src/interval_utils.ts +++ b/packages/utils/src/interval_utils.ts @@ -1,19 +1,21 @@ -import * as _ from 'lodash'; - export const intervalUtils = { - setAsyncExcludingInterval(fn: () => Promise<void>, intervalMs: number, onError: (err: Error) => void) { - let locked = false; + setAsyncExcludingInterval( + fn: () => Promise<void>, + intervalMs: number, + onError: (err: Error) => void, + ): NodeJS.Timer { + let isLocked = false; const intervalId = setInterval(async () => { - if (locked) { + if (isLocked) { return; } else { - locked = true; + isLocked = true; try { await fn(); } catch (err) { onError(err); } - locked = false; + isLocked = false; } }, intervalMs); return intervalId; @@ -21,7 +23,7 @@ export const intervalUtils = { clearAsyncExcludingInterval(intervalId: NodeJS.Timer): void { clearInterval(intervalId); }, - setInterval(fn: () => void, intervalMs: number, onError: (err: Error) => void) { + setInterval(fn: () => void, intervalMs: number, onError: (err: Error) => void): NodeJS.Timer { const intervalId = setInterval(() => { try { fn(); diff --git a/packages/utils/src/log_utils.ts b/packages/utils/src/log_utils.ts index d0f0e34c9..87f8479b5 100644 --- a/packages/utils/src/log_utils.ts +++ b/packages/utils/src/log_utils.ts @@ -2,4 +2,7 @@ export const logUtils = { log(...args: any[]): void { console.log(...args); // tslint:disable-line:no-console }, + warn(...args: any[]): void { + console.warn(...args); // tslint:disable-line:no-console + }, }; |