abitype
Version: 
Strict TypeScript types for Ethereum ABIs
411 lines (371 loc) • 12.3 kB
text/typescript
import { z } from 'zod'
import type {
  AbiConstructor as AbiConstructorType,
  AbiEventParameter as AbiEventParameterType,
  AbiFallback as AbiFallbackType,
  AbiFunction as AbiFunctionType,
  AbiParameter as AbiParameterType,
  AbiReceive as AbiReceiveType,
  Address as AddressType,
  TypedData as TTypedData,
} from './abi.js'
import { isSolidityType } from './human-readable/runtime/utils.js'
import { bytesRegex, execTyped, integerRegex } from './regex.js'
const Identifier = z.string().regex(/[a-zA-Z$_][a-zA-Z0-9$_]*/)
export const Address = z.string().transform((val, ctx) => {
  const regex = /^0x[a-fA-F0-9]{40}$/
  if (!regex.test(val)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: `Invalid Address ${val}`,
    })
  }
  return val as AddressType
})
// From https://docs.soliditylang.org/en/latest/abi-spec.html#types
export const SolidityAddress = z.literal('address')
export const SolidityBool = z.literal('bool')
export const SolidityBytes = z.string().regex(bytesRegex)
export const SolidityFunction = z.literal('function')
export const SolidityString = z.literal('string')
export const SolidityTuple = z.literal('tuple')
export const SolidityInt = z.string().regex(integerRegex)
export const SolidityArrayWithoutTuple = z
  .string()
  .regex(
    /^(address|bool|function|string|bytes([1-9]|1[0-9]|2[0-9]|3[0-2])?|u?int(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?)(\[[0-9]{0,}\])+$/,
  )
export const SolidityArrayWithTuple = z
  .string()
  .regex(/^tuple(\[[0-9]{0,}\])+$/)
export const SolidityArray = z.union([
  SolidityArrayWithTuple,
  SolidityArrayWithoutTuple,
])
export const AbiParameter: z.ZodType<AbiParameterType> = z.lazy(() =>
  z.intersection(
    z.object({
      name: z.union([Identifier.optional(), z.literal('')]),
      /** Representation used by Solidity compiler */
      internalType: z.string().optional(),
    }),
    z.union([
      z.object({
        type: z.union([
          SolidityAddress,
          SolidityBool,
          SolidityBytes,
          SolidityFunction,
          SolidityString,
          SolidityInt,
          SolidityArrayWithoutTuple,
        ]),
      }),
      z.object({
        type: z.union([SolidityTuple, SolidityArrayWithTuple]),
        components: z.array(AbiParameter).readonly(),
      }),
    ]),
  ),
)
export const AbiEventParameter: z.ZodType<AbiEventParameterType> =
  z.intersection(AbiParameter, z.object({ indexed: z.boolean().optional() }))
export const AbiStateMutability = z.union([
  z.literal('pure'),
  z.literal('view'),
  z.literal('nonpayable'),
  z.literal('payable'),
])
export const AbiFunction = z.preprocess(
  (val) => {
    const abiFunction = val as unknown as AbiFunctionType
    // Calculate `stateMutability` for deprecated `constant` and `payable` fields
    if (abiFunction.stateMutability === undefined) {
      if (abiFunction.constant) abiFunction.stateMutability = 'view'
      else if (abiFunction.payable) abiFunction.stateMutability = 'payable'
      else abiFunction.stateMutability = 'nonpayable'
    }
    return val
  },
  z.object({
    type: z.literal('function'),
    /**
     * @deprecated use `pure` or `view` from {@link AbiStateMutability} instead
     * https://github.com/ethereum/solidity/issues/992
     */
    constant: z.boolean().optional(),
    /**
     * @deprecated Vyper used to provide gas estimates
     * https://github.com/vyperlang/vyper/issues/2151
     */
    gas: z.number().optional(),
    inputs: z.array(AbiParameter).readonly(),
    name: Identifier,
    outputs: z.array(AbiParameter).readonly(),
    /**
     * @deprecated use `payable` or `nonpayable` from {@link AbiStateMutability} instead
     * https://github.com/ethereum/solidity/issues/992
     */
    payable: z.boolean().optional(),
    stateMutability: AbiStateMutability,
  }),
)
export const AbiConstructor = z.preprocess(
  (val) => {
    const abiFunction = val as unknown as AbiConstructorType
    // Calculate `stateMutability` for deprecated `payable` field
    if (abiFunction.stateMutability === undefined) {
      if (abiFunction.payable) abiFunction.stateMutability = 'payable'
      else abiFunction.stateMutability = 'nonpayable'
    }
    return val
  },
  z.object({
    type: z.literal('constructor'),
    /**
     * @deprecated use `pure` or `view` from {@link AbiStateMutability} instead
     * https://github.com/ethereum/solidity/issues/992
     */
    inputs: z.array(AbiParameter).readonly(),
    /**
     * @deprecated use `payable` or `nonpayable` from {@link AbiStateMutability} instead
     * https://github.com/ethereum/solidity/issues/992
     */
    payable: z.boolean().optional(),
    stateMutability: z.union([z.literal('nonpayable'), z.literal('payable')]),
  }),
)
export const AbiFallback = z.preprocess(
  (val) => {
    const abiFunction = val as unknown as AbiFallbackType
    // Calculate `stateMutability` for deprecated `payable` field
    if (abiFunction.stateMutability === undefined) {
      if (abiFunction.payable) abiFunction.stateMutability = 'payable'
      else abiFunction.stateMutability = 'nonpayable'
    }
    return val
  },
  z.object({
    type: z.literal('fallback'),
    /**
     * @deprecated use `payable` or `nonpayable` from {@link AbiStateMutability} instead
     * https://github.com/ethereum/solidity/issues/992
     */
    payable: z.boolean().optional(),
    stateMutability: z.union([z.literal('nonpayable'), z.literal('payable')]),
  }),
)
export const AbiReceive = z.object({
  type: z.literal('receive'),
  stateMutability: z.literal('payable'),
})
export const AbiEvent = z.object({
  type: z.literal('event'),
  anonymous: z.boolean().optional(),
  inputs: z.array(AbiEventParameter).readonly(),
  name: Identifier,
})
export const AbiError = z.object({
  type: z.literal('error'),
  inputs: z.array(AbiParameter).readonly(),
  name: z.string(),
})
export const AbiItemType = z.union([
  z.literal('constructor'),
  z.literal('event'),
  z.literal('error'),
  z.literal('fallback'),
  z.literal('function'),
  z.literal('receive'),
])
/**
 * Zod Schema for Contract [ABI Specification](https://docs.soliditylang.org/en/latest/abi-spec.html#json)
 *
 * @example
 * const parsedAbi = Abi.parse([…])
 */
export const Abi = z
  .array(
    z.union([
      AbiError,
      AbiEvent,
      // TODO: Replace code below to `z.switch` (https://github.com/colinhacks/zod/issues/2106)
      // Need to redefine `AbiFunction | AbiConstructor | AbiFallback | AbiReceive` since `z.discriminate` doesn't support `z.preprocess` on `options`
      // https://github.com/colinhacks/zod/issues/1490
      z.preprocess(
        (val) => {
          const abiItem = val as
            | AbiConstructorType
            | AbiFallbackType
            | AbiFunctionType
            | AbiReceiveType
          if (abiItem.type === 'receive') return abiItem
          // Calculate `stateMutability` for deprecated fields: `constant` and `payable`
          if (
            (val as { stateMutability: AbiFunctionType['stateMutability'] })
              .stateMutability === undefined
          ) {
            if (
              abiItem.type === 'function' &&
              (abiItem as AbiFunctionType).constant
            )
              abiItem.stateMutability = 'view'
            else if (
              (
                abiItem as
                  | AbiConstructorType
                  | AbiFallbackType
                  | AbiFunctionType
              ).payable
            )
              abiItem.stateMutability = 'payable'
            else abiItem.stateMutability = 'nonpayable'
          }
          return val
        },
        z.intersection(
          z.object({
            /**
             * @deprecated use `pure` or `view` from {@link AbiStateMutability} instead
             * https://github.com/ethereum/solidity/issues/992
             */
            constant: z.boolean().optional(),
            /**
             * @deprecated Vyper used to provide gas estimates
             * https://github.com/vyperlang/vyper/issues/2151
             */
            gas: z.number().optional(),
            /**
             * @deprecated use `payable` or `nonpayable` from {@link AbiStateMutability} instead
             * https://github.com/ethereum/solidity/issues/992
             */
            payable: z.boolean().optional(),
          }),
          z.discriminatedUnion('type', [
            z.object({
              type: z.literal('function'),
              inputs: z.array(AbiParameter).readonly(),
              name: z.string().regex(/[a-zA-Z$_][a-zA-Z0-9$_]*/),
              outputs: z.array(AbiParameter).readonly(),
              stateMutability: AbiStateMutability,
            }),
            z.object({
              type: z.literal('constructor'),
              inputs: z.array(AbiParameter).readonly(),
              stateMutability: z.union([
                z.literal('payable'),
                z.literal('nonpayable'),
              ]),
            }),
            z.object({
              type: z.literal('fallback'),
              inputs: z.tuple([]).optional(),
              stateMutability: z.union([
                z.literal('payable'),
                z.literal('nonpayable'),
              ]),
            }),
            z.object({
              type: z.literal('receive'),
              stateMutability: z.literal('payable'),
            }),
          ]),
        ),
      ),
    ]),
  )
  .readonly()
////////////////////////////////////////////////////////////////////////////////////////////////////
// Typed Data Types
export const TypedDataDomain = z.object({
  chainId: z.union([z.number(), z.bigint()]).optional(),
  name: Identifier.optional(),
  salt: z.string().optional(),
  verifyingContract: Address.optional(),
  version: z.string().optional(),
})
export const TypedDataType = z.union([
  SolidityAddress,
  SolidityBool,
  SolidityBytes,
  SolidityString,
  SolidityInt,
  SolidityArray,
])
export const TypedDataParameter = z.object({
  name: Identifier,
  type: z.string(),
})
export const TypedData = z
  .record(Identifier, z.array(TypedDataParameter))
  .transform((val, ctx) => validateTypedDataKeys(val, ctx))
// Helper Functions.
function validateTypedDataKeys(
  typedData: Record<string, { type: string; name: string }[]>,
  zodContext: z.RefinementCtx,
): TTypedData {
  const keys = Object.keys(typedData)
  for (let i = 0; i < keys.length; i++) {
    if (isSolidityType(keys[i]!)) {
      zodContext.addIssue({
        code: 'custom',
        message: `Invalid key. ${keys[i]} is a solidity type.`,
      })
      return z.NEVER
    }
    validateTypedDataParameters(keys[i]!, typedData, zodContext)
  }
  return typedData as any
}
const typeWithoutTupleRegex =
  /^(?<type>[a-zA-Z$_][a-zA-Z0-9$_]*?)(?<array>(?:\[\d*?\])+?)?$/
function validateTypedDataParameters(
  key: string,
  typedData: Record<string, { type: string; name: string }[]>,
  zodContext: z.RefinementCtx,
  ancestors = new Set<string>(),
) {
  const val = typedData[key] as { type: string; name: string }[]
  const length = val.length
  for (let i = 0; i < length; i++) {
    if (val[i]?.type! === key) {
      zodContext.addIssue({
        code: 'custom',
        message: `Invalid type. ${key} is a self reference.`,
      })
      return z.NEVER
    }
    const match = execTyped<{ array?: string; type: string }>(
      typeWithoutTupleRegex,
      val[i]?.type!,
    )
    if (!match?.type) {
      zodContext.addIssue({
        code: 'custom',
        message: `Invalid type. ${key} does not have a type.`,
      })
      return z.NEVER
    }
    if (match.type in typedData) {
      if (ancestors.has(match.type)) {
        zodContext.addIssue({
          code: 'custom',
          message: `Invalid type. ${match.type} is a circular reference.`,
        })
        return z.NEVER
      }
      validateTypedDataParameters(
        match.type,
        typedData,
        zodContext,
        new Set([...ancestors, match.type]),
      )
    } else if (!isSolidityType(match.type)) {
      zodContext.addIssue({
        code: 'custom',
        message: `Invalid type. ${match.type} is not a valid EIP-712 type.`,
      })
    }
  }
  return
}