From 26643a489bbd3673664cc5b51c2cc75fa624c690 Mon Sep 17 00:00:00 2001 From: Lawrence Forman Date: Tue, 2 Apr 2019 17:55:23 -0400 Subject: [PATCH] Add `RichRevertReason` type and utilities to `@0x/utils` --- packages/utils/CHANGELOG.json | 4 + packages/utils/src/index.ts | 5 + packages/utils/src/rich_reverts.ts | 282 +++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 packages/utils/src/rich_reverts.ts diff --git a/packages/utils/CHANGELOG.json b/packages/utils/CHANGELOG.json index cb639a3cde..ef437fe260 100644 --- a/packages/utils/CHANGELOG.json +++ b/packages/utils/CHANGELOG.json @@ -13,6 +13,10 @@ { "note": "More robust normalization of `uint256` types in `sign_typed_data_utils`", "pr": 1742 + }, + { + "note": "Add `RichRevertReason` type and associated utilities", + "pr": TODO } ], "timestamp": 1563006338 diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b757439a2f..51c8864947 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -15,3 +15,8 @@ export { signTypedDataUtils } from './sign_typed_data_utils'; export import AbiEncoder = require('./abi_encoder'); export * from './types'; export { generatePseudoRandom256BitNumber } from './random'; +export { + decodeRichRevertReason, + registerRichRevertReason, + RichRevertReason, +} from './rich_reverts'; diff --git a/packages/utils/src/rich_reverts.ts b/packages/utils/src/rich_reverts.ts new file mode 100644 index 0000000000..e8c792af41 --- /dev/null +++ b/packages/utils/src/rich_reverts.ts @@ -0,0 +1,282 @@ +import { ObjectMap } from '@0x/types'; +import { DataItem, RichRevertAbi } from 'ethereum-types'; +import * as ethUtil from 'ethereumjs-util'; +import * as _ from 'lodash'; +import { inspect } from 'util'; + +import * as AbiEncoder from './abi_encoder'; +import { BigNumber } from './configured_bignumber'; + +type ArgTypes = string | BigNumber | number | boolean; +type ValueMap = ObjectMap; +type RichRevertReasonType = { new(): RichRevertReason }; +type RichRevertReasonDecoder = (hex: string) => ValueMap; + +interface RichRevertReasonRegistryItem { + type: RichRevertReasonType, + decoder: RichRevertReasonDecoder +} + +/** + * Register a RichRevertReason type so that it can be decoded by + * `decodeRevertReason`. + * @param richRevertClass A class that inherits from RichRevertReason. + */ + export function registerRichRevertReason(richRevertClass: RichRevertReasonType) { + RichRevertReason.registerType(richRevertClass); + } + +/** + * Decode an ABI encoded rich revert reason. + * Throws if the data cannot be decoded as a known RichRevertReason type. + * @param bytes The ABI encoded rich revert reason. Either a hex string or a Buffer. + * @return A RichRevertReason object. + */ +export function decodeRichRevertReason(bytes: string | Buffer): RichRevertReason { + return RichRevertReason.decode(bytes); +} + +/** + * Base type for rich revert reasons. + */ +export abstract class RichRevertReason { + + // Map of types registered via `registerType`. + static _typeRegistry: ObjectMap = {}; + + /** + * Decode an ABI encoded rich revert reason. + * Throws if the data cannot be decoded as a known RichRevertReason type. + * @param bytes The ABI encoded rich revert reason. Either a hex string or a Buffer. + * @return A RichRevertReason object. + */ + public static decode(bytes: string | Buffer): RichRevertReason { + let _bytes: string; + if (bytes instanceof Buffer) { + _bytes = ethUtil.bufferToHex(bytes); + } else { + _bytes = bytes; + } + _bytes = ethUtil.addHexPrefix(_bytes); + const selector = _bytes.slice(2, 10); + const { decoder, type } = this._lookupType(selector); + const instance = new type(); + try { + const values = decoder(_bytes); + return _.assign(instance, { values }); + } catch (err) { + throw new Error( + `Bytes ${shortenHex(_bytes)} cannot be decoded as rich revert ${instance.signature}: ${err.message}`, + ); + } + } + + /** + * Register a RichRevertReason type so that it can be decoded by + * `decodeRevertReason`. + * @param richRevertClass A class that inherits from RichRevertReason. + */ + public static registerType(richRevertClass: RichRevertReasonType) { + const instance = new richRevertClass(); + RichRevertReason._typeRegistry[instance.selector] = { + type: richRevertClass, + decoder: createDecoder(instance.abi) + }; + } + + // Ge tthe registry info given a selector. + private static _lookupType(selector: string): RichRevertReasonRegistryItem { + if (selector in RichRevertReason._typeRegistry) { + return RichRevertReason._typeRegistry[selector]; + } + throw new Error( + `Unknown rich revert reason selector "${selector}"` + ); + } + + public abi: RichRevertAbi; + public values: ValueMap = {}; + + /** + * Create a RichRevertReason instance with optional parameter values. + * Parameters that are left undefined will not be tested in equality checks. + * @param declaration Function-style declaration of the rich revert (e.g., StandardError(string message)) + * @param values Optional mapping of parameters to values. + */ + protected constructor(declaration: string, values?: ValueMap) { + this.abi = declarationToAbi(declaration); + if (values !== undefined) { + _.assign(this.values, _.cloneDeep(values)); + } + } + + /** + * Get the ABI name for this reason. + */ + get name(): string { + return this.abi.name; + } + + /** + * Get the hex selector for this reason (without leading '0x'). + */ + get selector(): string { + return toSelector(this.abi); + } + + /** + * Get the signature for this reason: e.g., 'Error(string)'. + */ + get signature(): string { + return toSignature(this.abi); + } + + /** + * Get the ABI arguments for this reason. + */ + get arguments(): DataItem[] { + return this.abi.arguments || []; + } + + private _getArgumentByName(name: string): DataItem { + const arg = _.find(this.arguments, (a: DataItem) => a.name === name); + if (_.isNil(arg)) { + throw new Error(`RichRevertReason ${this.signature} has no argument named ${name}`); + } + return arg; + } + + /** + * Compares this instance with another. + * Fails if instances are not of the same type. + * Only fields/values defined in both instances are compared. + * @param other Either another RichRevertReason instance, hex-encoded bytes, or a Buffer of the ABI encoded reason. + * @return True if both instances match. + */ + public equals(other: RichRevertReason | Buffer | string): boolean { + let _other = other; + if (_other instanceof Buffer) { + _other = ethUtil.bufferToHex(_other); + } + if (typeof _other === 'string') { + _other = RichRevertReason.decode(_other); + } + if (this.constructor !== _other.constructor) { + return false; + } + for (const name of Object.keys(this.values)) { + const a = this.values[name]; + const b = _other.values[name]; + if (a === b) { + continue; + } + if (!_.isNil(a) && !_.isNil(b)) { + const { type } = this._getArgumentByName(name); + if (!checkArgEquality(type, a, b)) { + return false; + } + } + } + return true; + } + + public toString(): string { + const values = _.omitBy(this.values, (v: any) => _.isNil(v)); + const inner = _.isEmpty(values) ? '' : inspect(values); + return `${this.constructor.name}(${inner})`; + } +} + +/** + * Parse a solidity function declaration into a RichRevertAbi object. + * @param declaration Function declaration (e.g., 'foo(uint256 bar)'). + * @return A RichRevertAbi object. + */ +function declarationToAbi(declaration: string): RichRevertAbi { + let m = /^\s*([_a-z][a-z0-9_]*)\((.*)\)\s*$/i.exec(declaration); + if (!m) { + throw new Error(`Invalid Revert Error signature: "${declaration}"`); + } + const [name, args] = m.slice(1); + const argList: string[] = _.filter(args.split(',')); + const argData: DataItem[] = _.map(argList, (a: string) => { + m = /^\s*([_a-z][a-z0-9_]*)\s+([_a-z][a-z0-9_]*)\s*$/i.exec(a); + if (!m) { + throw new Error(`Invalid Revert Error signature: "${declaration}"`); + } + return { + name: m[2], + type: m[1], + }; + }); + const r: RichRevertAbi = { + type: 'error', + name, + arguments: _.isEmpty(argData) ? [] : argData, + }; + return r; +} + +function checkArgEquality(type: string, lhs: ArgTypes, rhs: ArgTypes): boolean { + if (type === 'address') { + return normalizeAddress(lhs as string) === normalizeAddress(rhs as string); + } else if (type.startsWith('bytes')) { + return normalizeBytes(lhs as string) === normalizeBytes(rhs as string); + } else if (type === 'string') { + return lhs === rhs; + } + // tslint:disable-next-line + return new BigNumber((lhs as any) || 0).eq(rhs as any); +} + +function normalizeAddress(addr: string): string { + const ADDRESS_SIZE = 20; + return ethUtil.bufferToHex( + ethUtil.setLengthLeft( + ethUtil.toBuffer(ethUtil.addHexPrefix(addr)), + ADDRESS_SIZE + )); +} + +function normalizeBytes(bytes: string): string { + return ethUtil.addHexPrefix(bytes).toLowerCase(); +} + +function shortenHex(hex: string, maxBytes: number = 4): string { + const _hex = ethUtil.addHexPrefix(hex); + if (_hex.length > maxBytes * 2 + 2) { + const shortened = _hex.slice(0, maxBytes * 2 + 2); + return `${shortened}...`; + } + return _hex; +} + +function createDecoder(abi: RichRevertAbi): (hex: string) => ValueMap { + const encoder = AbiEncoder.createMethod(abi.name, abi.arguments || []); + return (hex: string): ValueMap => { + // tslint:disable-next-line + return encoder.decode(hex) as ValueMap; + }; +} + +function toSignature(abi: RichRevertAbi): string { + const argTypes = _.map(abi.arguments, (a: DataItem) => a.type); + const args = argTypes.join(','); + return `${abi.name}(${args})`; +} + +function toSelector(abi: RichRevertAbi): string { + return ethUtil + .sha3(Buffer.from(toSignature(abi))) + .slice(0, 4) + .toString('hex'); +} + +export class StandardError extends RichRevertReason { + constructor(message?: string) { + super( + 'Error(string message)', + { message } + ); + } +}