trufflesuite/truffle

View on GitHub
packages/codec/lib/export.ts

Summary

Maintainability
A
0 mins
Test Coverage
import debugModule from "debug";
const debug = debugModule("codec:export");

import OS from "os";
import util from "util";
import * as Format from "@truffle/codec/format";
import type {
  CalldataDecoding,
  LogDecoding,
  ReturndataDecoding,
  AbiArgument,
  CallInterpretationInfo
} from "@truffle/codec/types";
import * as Conversion from "@truffle/codec/conversion";

import {
  ResultInspector,
  unsafeNativize,
  unsafeNativizeVariables,
  InspectOptions,
  ResultInspectorOptions,
  nativizeAccessList,
  stringValueInfoToStringLossy
} from "@truffle/codec/format/utils/inspect";
export {
  ResultInspector,
  ResultInspectorOptions,
  unsafeNativize,
  unsafeNativizeVariables,
  nativizeAccessList,
  stringValueInfoToStringLossy
};

type NumberFormatter = (n: BigInt) => any; //not parameterized since we output any anyway

//HACK; this was going to be parameterized
//but TypeScript didn't like that, so, whatever
interface MixedArray extends Array<any> {
  [key: string]: any;
}

/**
 * Options for the nativize function.
 */
export interface NativizeOptions {
  /**
   * This is a function that is used to describe how to format
   * integer values.  It should take as input the number as a BigInt.
   * By default, it's the identity function (i.e., it formats the numbers
   * as BigInts), but by setting it you could instead format numbers as
   * a BN, BigNumber, string, etc.
   */
  numberFormatter?: NumberFormatter;
  /**
   * The format for the nativized result.  Currently the only supported
   * format is "ethers", which nativizes things in a way compatible with how
   * Ethers decodes values.  This format is quite limited, but more may be
   * added in the future.  There is also the separate function
   * [[Format.Utils.Inspect.unsafeNativize|unsafeNativize]], although that is,
   * as noted, unsafe.
   */
  format?: "ethers";
}

/**
 * This function is similar to
 * [[Format.Utils.Inspect.unsafeNativize|unsafeNativize]], but is intended to
 * be safe, and also allows for different output formats.  The only currently
 * supported format is "ethers", which is intended to match the way that
 * Truffle Contract currently returns values (based on the Ethers decoder).  As
 * such, it only handles ABI types, and in addition does not handle the types
 * fixed, ufixed, or function.  Note that in these cases it returns `undefined`
 * rather than throwing, as we want this function to be used in contexts where
 * it had better not throw.  It also does not handle circularities, for similar
 * reasons.
 *
 * To handle numeric types, this function takes an optional numberFormatter
 * option that tells it how to handle numbers; this function should take a
 * BigInt as input.  By default, this function will be the identity, and so
 * numbers will be represented as BigInts.
 *
 * Note that this function begins by calling abify, so out-of-range enums (that
 * aren't so out-of-range as to be padding errors) will not return `undefined`.
 * Out-of-range booleans similarly will return true rather than `undefined`.
 * However, other range errors may return `undefined`; this may technically be a
 * slight incompatibility with existing behavior, but should not be relevant
 * except in quite unusual cases.
 *
 * In order to match the behavior for tuples, tuples will be transformed into
 * arrays, but named entries will additionally be keyed by name.  Moreover,
 * indexed variables of reference type will be nativized to an undecoded hex
 * string.
 */
export function nativize(
  result: Format.Values.Result,
  options: NativizeOptions = {}
): any {
  const numberFormatter = options.numberFormatter || (x => x);
  const format = options.format || "ethers";
  switch (format) {
    case "ethers":
      return ethersCompatibleNativize(result, numberFormatter);
  }
}

function ethersCompatibleNativize(
  result: Format.Values.Result,
  numberFormatter: NumberFormatter = x => x
): any {
  //note: the original version of this function began by calling abify,
  //but we don't do that here because abify requires a userDefinedTypes
  //parameter and we don't want that.
  //However, it only needs that to handle getting the types right.  Since
  //we don't care about that here, we instead do away with abify and handle
  //such matters ourselves (which is less convenient, yeah).
  switch (result.kind) {
    case "error":
      switch (result.error.kind) {
        case "IndexedReferenceTypeError":
          //strictly speaking for arrays ethers will fail to decode
          //rather than do this, but, eh
          return result.error.raw;
        case "EnumOutOfRangeError":
          return numberFormatter(Conversion.toBigInt(result.error.rawAsBN));
        default:
          return undefined;
      }
    case "value":
      switch (result.type.typeClass) {
        case "uint":
        case "int":
          const asBN = (<Format.Values.UintValue | Format.Values.IntValue>(
            result
          )).value.asBN;
          return numberFormatter(Conversion.toBigInt(asBN));
        case "enum":
          const numericAsBN = (<Format.Values.EnumValue>result).value
            .numericAsBN;
          return numberFormatter(Conversion.toBigInt(numericAsBN));
        case "bool":
          return (<Format.Values.BoolValue>result).value.asBoolean;
        case "bytes":
          const asHex = (<Format.Values.BytesValue>result).value.asHex;
          return asHex !== "0x" ? asHex : null;
        case "address":
          return (<Format.Values.AddressValue>result).value.asAddress;
        case "contract":
          return (<Format.Values.ContractValue>result).value.address;
        case "string":
          return stringValueInfoToStringLossy(
            (result as Format.Values.StringValue).value
          );
        case "userDefinedValueType":
          return ethersCompatibleNativize(
            (<Format.Values.UserDefinedValueTypeValue>result).value,
            numberFormatter
          );
        case "array":
          return (<Format.Values.ArrayValue>result).value.map(value =>
            ethersCompatibleNativize(value, numberFormatter)
          );
        case "tuple":
        case "struct":
          //in this case, we need the result to be an array, but also
          //to have the field names (where extant) as keys
          const nativized: MixedArray = [];
          const pairs = (<Format.Values.TupleValue | Format.Values.StructValue>(
            result
          )).value;
          for (const { name, value } of pairs) {
            const nativizedValue = ethersCompatibleNativize(
              value,
              numberFormatter
            );
            nativized.push(nativizedValue);
            if (name) {
              nativized[name] = nativizedValue;
            }
          }
          return nativized;
        case "function":
          switch (result.type.visibility) {
            case "external":
              const coercedResult = <Format.Values.FunctionExternalValue>result;
              //ethers per se doesn't handle this, but web3's hacked version will
              //sometimes decode these as just a bytes24, so let's do that
              return (
                coercedResult.value.contract.address.toLowerCase() +
                coercedResult.value.selector.slice(2)
              );
            case "internal":
              return undefined;
          }
        case "fixed":
        case "ufixed":
        default:
          return undefined;
      }
  }
}

/**
 * This function is similar to [[nativize]], but takes
 * a [[ReturndataDecoding]].  If there's only one returned value, it
 * will be run through compatibleNativize but otherwise unaltered;
 * otherwise the results will be put in an object.
 *
 * Note that if the ReturndataDecoding is not a [[ReturnDecoding]],
 * this will just return `undefined`.
 */
export function nativizeReturn(
  decoding: ReturndataDecoding,
  options: NativizeOptions = {}
): any {
  const numberFormatter = options.numberFormatter || (x => x);
  const format = options.format || "ethers";
  switch (format) {
    case "ethers":
      return ethersCompatibleNativizeReturn(decoding, numberFormatter);
  }
}

function ethersCompatibleNativizeReturn(
  decoding: ReturndataDecoding,
  numberFormatter: NumberFormatter = x => x
): any {
  if (decoding.kind !== "return") {
    return undefined;
  }
  if (decoding.arguments.length === 1) {
    return ethersCompatibleNativize(
      decoding.arguments[0].value,
      numberFormatter
    );
  }
  const result: any = {};
  for (let i = 0; i < decoding.arguments.length; i++) {
    const { name, value } = decoding.arguments[i];
    const nativized = ethersCompatibleNativize(value, numberFormatter);
    result[i] = nativized;
    if (name) {
      result[name] = nativized;
    }
  }
  return result;
}

/**
 * This function is similar to [[compatibleNativize]], but takes
 * a [[LogDecoding]], and puts the results in an object.  Note
 * that this does not return the entire event info, but just the
 * `args` for the event.
 */
export function nativizeEventArgs(
  decoding: LogDecoding,
  options: NativizeOptions = {}
): any {
  const numberFormatter = options.numberFormatter || (x => x);
  const format = options.format || "ethers";
  switch (format) {
    case "ethers":
      return ethersCompatibleNativizeEventArgs(decoding, numberFormatter);
  }
}

function ethersCompatibleNativizeEventArgs(
  decoding: LogDecoding,
  numberFormatter: NumberFormatter = x => x
): any {
  const result: any = {};
  for (let i = 0; i < decoding.arguments.length; i++) {
    const { name, value } = decoding.arguments[i];
    const nativized = ethersCompatibleNativize(value, numberFormatter);
    result[i] = nativized;
    if (name) {
      result[name] = nativized;
    }
  }
  //note: if you have an argument named __length__, what ethers
  //actually does is... weird.  we're just going to do this instead,
  //which is simpler and probably more useful, even if it's not strictly
  //the same (I *seriously* doubt anyone was relying on the old behavior,
  //because it's, uh, not very useful)
  result.__length__ = decoding.arguments.length;
  return result;
}

/**
 * Similar to [[ResultInspector]], but for a [[CalldataDecoding]].
 * See [[ResultInspector]] for more information.
 */
export class CalldataDecodingInspector {
  decoding: CalldataDecoding;
  options: ResultInspectorOptions;

  constructor(decoding: CalldataDecoding, options?: ResultInspectorOptions) {
    this.decoding = decoding;
    this.options = options || {};
  }
  /**
   * @dev non-standard alternative interface name used by browser-util-inspect
   *      package
   */
  inspect(depth: number | null, options: InspectOptions): string {
    return this[util.inspect.custom].bind(this)(depth, options);
  }
  [util.inspect.custom](depth: number | null, options: InspectOptions): string {
    switch (this.decoding.kind) {
      case "function":
        const fullName = `${this.decoding.class.typeName}.${this.decoding.abi.name}`;
        if (this.decoding.interpretations.multicall) {
          return formatMulticall(
            fullName,
            this.decoding.interpretations.multicall,
            options,
            this.options
          );
        } else if (this.decoding.interpretations.aggregate) {
          return formatAggregate(
            fullName,
            this.decoding.interpretations.aggregate,
            options,
            this.options
          );
        } else if (this.decoding.interpretations.tryAggregate) {
          const { requireSuccess, calls } =
            this.decoding.interpretations.tryAggregate;
          return formatAggregate(
            fullName,
            calls,
            options,
            this.options,
            "requireSuccess",
            options.stylize(requireSuccess.toString(), "number"),
            true //including try here would be redundant, so suppress it
          );
        } else if (this.decoding.interpretations.tryAggregate) {
          const { requireSuccess, calls } =
            this.decoding.interpretations.tryAggregate;
          return formatAggregate(
            fullName,
            calls,
            options,
            this.options,
            "requireSuccess",
            options.stylize(requireSuccess.toString(), "number")
          );
        } else if (this.decoding.interpretations.deadlinedMulticall) {
          const { deadline, calls: decodings } =
            this.decoding.interpretations.deadlinedMulticall;
          return formatMulticall(
            fullName,
            decodings,
            options,
            this.options,
            "deadline",
            options.stylize(deadline.toString(), "number")
          );
        } else if (this.decoding.interpretations.specifiedBlockhashMulticall) {
          const { specifiedBlockhash, calls: decodings } =
            this.decoding.interpretations.specifiedBlockhashMulticall;
          return formatMulticall(
            fullName,
            decodings,
            options,
            this.options,
            "previousBlockhash",
            options.stylize(specifiedBlockhash, "number")
          );
        }
        return formatFunctionLike(
          fullName,
          this.decoding.arguments,
          options,
          this.options
        );
      case "constructor":
        return formatFunctionLike(
          `new ${this.decoding.class.typeName}`,
          this.decoding.arguments,
          options,
          this.options
        );
      case "message":
        const { data, abi } = this.decoding;
        //we'll set up a value and inspect that :)
        const codecValue: Format.Values.BytesDynamicValue = {
          kind: "value" as const,
          type: {
            typeClass: "bytes" as const,
            kind: "dynamic" as const
          },
          value: {
            asHex: data
          },
          interpretations: {}
        };
        if (abi) {
          return formatFunctionLike(
            `${this.decoding.class.typeName}.${abi.type}`,
            [{ value: codecValue }],
            options,
            this.options,
            true // we don't need to see the type here!
          );
        } else {
          return `Sent raw data to ${
            this.decoding.class.typeName
          }: ${util.inspect(
            new ResultInspector(codecValue, this.options),
            options
          )}`;
        }
      case "unknown":
        return "Receiving contract could not be identified.";
      case "create":
        return "Created contract could not be identified.";
    }
  }
}

export function containsDeliberateReadError(
  result: Format.Values.Result
): boolean {
  switch (result.kind) {
    case "value":
      switch (result.type.typeClass) {
        case "struct":
          //this is currently only intended for use with storage variables, so I
          //won't bother with handling tuple, magic, options
          return (result as Format.Values.StructValue).value.some(({ value }) =>
            containsDeliberateReadError(value)
          );
        case "array":
          return (result as Format.Values.ArrayValue).value.some(
            containsDeliberateReadError
          );
        case "mapping":
          return (result as Format.Values.MappingValue).value.some(
            ({ value }) => containsDeliberateReadError(value)
          );
        default:
          return false;
      }
    case "error":
      switch (result.error.kind) {
        case "StorageNotSuppliedError":
        case "CodeNotSuppliedError":
          return true;
        default:
          return false;
      }
  }
}

/**
 * Similar to [[ResultInspector]], but for a [[LogDecoding]].
 * See [[ResultInspector]] for more information.
 */
export class LogDecodingInspector {
  decoding: LogDecoding;
  options: ResultInspectorOptions;
  constructor(decoding: LogDecoding, options?: ResultInspectorOptions) {
    this.decoding = decoding;
    this.options = options || {};
  }
  /**
   * @dev non-standard alternative interface name used by browser-util-inspect
   *      package
   */
  inspect(depth: number | null, options: InspectOptions): string {
    return this[util.inspect.custom].bind(this)(depth, options);
  }
  [util.inspect.custom](depth: number | null, options: InspectOptions): string {
    const eventName = this.decoding.abi.name;
    let fullName: string;
    if (this.decoding.definedIn) {
      fullName = `${this.decoding.definedIn}.${eventName}`;
    } else if (this.decoding.definedIn === null) {
      //file-level event
      fullName = eventName;
    } else {
      //event of unknown definition location
      fullName = `${this.decoding.class.typeName}.${eventName}`;
    }
    switch (this.decoding.kind) {
      case "event":
        return formatFunctionLike(
          fullName,
          this.decoding.arguments,
          options,
          this.options
        );
      case "anonymous":
        return formatFunctionLike(
          `<anonymous> ${fullName}`,
          this.decoding.arguments,
          options,
          this.options
        );
    }
  }
}

/**
 * Similar to [[ResultInspector]], but for a [[ReturndataDecoding]].
 * See [[ResultInspector]] for more information.
 */
export class ReturndataDecodingInspector {
  decoding: ReturndataDecoding;
  options: ResultInspectorOptions;
  constructor(decoding: ReturndataDecoding, options?: ResultInspectorOptions) {
    this.decoding = decoding;
    this.options = options || {};
  }
  /**
   * @dev non-standard alternative interface name used by browser-util-inspect
   *      package
   */
  inspect(depth: number | null, options: InspectOptions): string {
    return this[util.inspect.custom].bind(this)(depth, options);
  }
  [util.inspect.custom](depth: number | null, options: InspectOptions): string {
    switch (this.decoding.kind) {
      case "return":
        return formatFunctionLike(
          "Returned values: ",
          this.decoding.arguments,
          options,
          this.options
        );
      case "returnmessage":
        const { data } = this.decoding;
        //we'll just set up a value and inspect that :)
        const codecValue: Format.Values.BytesDynamicValue = {
          kind: "value" as const,
          type: {
            typeClass: "bytes" as const,
            kind: "dynamic" as const
          },
          value: {
            asHex: data
          },
          interpretations: {}
        };
        const dataString = util.inspect(
          new ResultInspector(codecValue, this.options),
          options
        );
        return `Returned raw data: ${dataString}`;
      case "selfdestruct":
        return "The contract self-destructed.";
      case "failure":
        return "The transaction reverted without a message.";
      case "revert":
        const name = this.decoding.definedIn
          ? `${this.decoding.definedIn.typeName}.${this.decoding.abi.name}`
          : this.decoding.abi.name;
        return formatFunctionLike(
          `Error thrown:${OS.EOL}${name}`,
          this.decoding.arguments,
          options,
          this.options
        );
      case "bytecode":
        //this one gets custom handling :P
        const contractKind = this.decoding.class.contractKind || "contract";
        const firstLine =
          this.decoding.address !== undefined
            ? `Returned bytecode for a ${contractKind} ${this.decoding.class.typeName} at ${this.decoding.address}.`
            : `Returned bytecode for a ${contractKind} ${this.decoding.class.typeName}.`;
        if (this.decoding.immutables && this.decoding.immutables.length > 0) {
          const prefixes = this.decoding.immutables.map(
            ({ name, class: { typeName } }) => `${typeName}.${name}: `
          );
          const maxLength = Math.max(...prefixes.map(prefix => prefix.length));
          const paddedPrefixes = prefixes.map(prefix =>
            prefix.padStart(maxLength)
          );
          const formattedValues = this.decoding.immutables.map(
            (value, index) => {
              const prefix = paddedPrefixes[index];
              const formatted = indentExcludingFirstLine(
                util.inspect(
                  new ResultInspector(value.value, this.options),
                  options
                ),
                maxLength
              );
              return prefix + formatted;
            }
          );
          return `Immutable values:${OS.EOL}${formattedValues.join(OS.EOL)}`;
        } else {
          return firstLine;
        }
      case "unknownbytecode":
        return "Bytecode was returned, but it could not be identified.";
    }
  }
}

//copied from TestRunner, but simplified for our purposes :)
function indentArray(input: string[], indentation: number): string[] {
  return input.map(line => " ".repeat(indentation) + line);
}

function indentExcludingFirstLine(input: string, indentation: number): string {
  const lines = input.split(/\r?\n/);
  return [lines[0], ...indentArray(lines.slice(1), indentation)].join(OS.EOL);
}

function indentMiddleLines(input: string, indentation: number): string {
  const lines = input.split(/\r?\n/);
  if (lines.length < 2) {
    return input;
  }
  return [
    lines[0],
    ...indentArray(lines.slice(1, -1), indentation),
    lines[lines.length - 1]
  ].join(OS.EOL);
}

//used for formatting things that look like function calls:
//events (including anonymous events), identifiable transactions,
//and revert messages
//"header" param should include everything before the initial parenthesis
/**
 * @hidden
 */
export function formatFunctionLike(
  header: string,
  values: AbiArgument[],
  options: InspectOptions,
  inspectorOptions?: ResultInspectorOptions,
  suppressType: boolean = false,
  indent: number = 2 //for use by debug-utils
): string {
  if (values.length === 0) {
    return `${header}()`;
  }
  let formattedValues = values.map(({ name, indexed, value }, index) => {
    const namePrefix = name ? `${name}: ` : "";
    const indexedPrefix = indexed ? "<indexed> " : "";
    const prefix = namePrefix + indexedPrefix;
    const displayValue = util.inspect(
      new ResultInspector(value, inspectorOptions),
      options
    );
    const typeString = suppressType
      ? ""
      : ` (type: ${Format.Types.typeStringWithoutLocation(value.type)})`;
    return indentMiddleLines(
      prefix +
        displayValue +
        typeString +
        (index < values.length - 1 ? "," : ""),
      indent
    );
  });
  return indentMiddleLines(
    `${header}(${OS.EOL}${formattedValues.join(OS.EOL)}${OS.EOL})`,
    indent
  );
}

function formatMulticall(
  fullName: string,
  decodings: (CalldataDecoding | null)[],
  options: InspectOptions,
  inspectorOptions?: ResultInspectorOptions,
  additionalParameterName?: string,
  additionalParameterValue?: string
): string {
  if (decodings.length === 0) {
    return `${fullName}()`;
  }
  const indent = 2;
  let formattedDecodings = decodings.map((decoding, index) => {
    const formattedDecoding =
      decoding === null
        ? "<decoding error>"
        : util.inspect(
            new CalldataDecodingInspector(decoding, inspectorOptions),
            options
          );
    return formattedDecoding + (index < decodings.length - 1 ? "," : "");
  });
  if (additionalParameterName) {
    formattedDecodings.unshift(
      `${additionalParameterName}: ${additionalParameterValue},`
    );
  }
  return indentMiddleLines(
    `${fullName}(${OS.EOL}${formattedDecodings.join(OS.EOL)}${OS.EOL})`,
    indent
  );
}

function formatAggregate(
  fullName: string,
  calls: CallInterpretationInfo[],
  options: InspectOptions,
  inspectorOptions?: ResultInspectorOptions,
  additionalParameterName?: string,
  additionalParameterValue?: string,
  suppressTry: boolean = false
): string {
  if (calls.length === 0) {
    return `${fullName}()`;
  }
  const indent = 2;
  let formattedCalls = calls.map(
    ({ address, allowFailure, value, decoding }, index) => {
      let formattedCall =
        decoding === null
          ? "<decoding error>"
          : util.inspect(
              new CalldataDecodingInspector(decoding, inspectorOptions),
              options
            );
      if (value !== null && value.gtn(0)) {
        formattedCall = formattedCall.replace(
          ".",
          `{value: ${options.stylize(value.toString(), "number")}}.` //HACK: splice in the value
        );
      }
      if (address !== null) {
        formattedCall = formattedCall.replace(
          ".",
          `(${options.stylize(address, "number")}).`
        ); //HACK: splice in the address
      }
      if (allowFailure && !suppressTry && decoding !== null) {
        formattedCall = "[try] " + formattedCall;
      }
      return formattedCall + (index < calls.length - 1 ? "," : "");
    }
  );
  if (additionalParameterName) {
    formattedCalls.unshift(
      `${additionalParameterName}: ${additionalParameterValue},`
    );
  }
  return indentMiddleLines(
    `${fullName}(${OS.EOL}${formattedCalls.join(OS.EOL)}${OS.EOL})`,
    indent
  );
}