xrpl
Version: 
A TypeScript/JavaScript API for interacting with the XRP Ledger in Node.js and the browser
921 lines (821 loc) • 27.4 kB
text/typescript
/* eslint-disable max-lines -- common utility file */
import { HEX_REGEX, hexToString } from '@xrplf/isomorphic/utils'
import { isValidClassicAddress, isValidXAddress } from 'ripple-address-codec'
import { TRANSACTION_TYPES } from 'ripple-binary-codec'
import { ValidationError } from '../../errors'
import {
  Amount,
  AuthorizeCredential,
  ClawbackAmount,
  Currency,
  IssuedCurrency,
  IssuedCurrencyAmount,
  MPTAmount,
  MPTokenMetadata,
  Memo,
  Signer,
  XChainBridge,
} from '../common'
import { isHex, onlyHasFields } from '../utils'
const MEMO_SIZE = 3
export const MAX_AUTHORIZED_CREDENTIALS = 8
const MAX_CREDENTIAL_BYTE_LENGTH = 64
const MAX_CREDENTIAL_TYPE_LENGTH = MAX_CREDENTIAL_BYTE_LENGTH * 2
export const MAX_MPT_META_BYTE_LENGTH = 1024
// Used for Vault transactions
export const VAULT_DATA_MAX_BYTE_LENGTH = 256
// To validate MPTokenMetadata as per XLS-89d
const TICKER_REGEX = /^[A-Z0-9]{1,6}$/u
const MAX_MPT_META_TOP_LEVEL_FIELD_COUNT = 9
const MPT_META_URL_FIELD_COUNT = 3
const MPT_META_REQUIRED_FIELDS = [
  'ticker',
  'name',
  'icon',
  'asset_class',
  'issuer_name',
]
const MPT_META_ASSET_CLASSES = [
  'rwa',
  'memes',
  'wrapped',
  'gaming',
  'defi',
  'other',
]
const MPT_META_ASSET_SUB_CLASSES = [
  'stablecoin',
  'commodity',
  'real_estate',
  'private_credit',
  'equity',
  'treasury',
  'other',
]
export const MPT_META_WARNING_HEADER =
  'MPTokenMetadata is not properly formatted as JSON as per the XLS-89d standard. ' +
  "While adherence to this standard is not mandatory, such non-compliant MPToken's might not be discoverable " +
  'by Explorers and Indexers in the XRPL ecosystem.'
function isMemo(obj: unknown): obj is Memo {
  if (!isRecord(obj)) {
    return false
  }
  const memo = obj.Memo
  if (!isRecord(memo)) {
    return false
  }
  const size = Object.keys(memo).length
  const validData =
    memo.MemoData == null || (isString(memo.MemoData) && isHex(memo.MemoData))
  const validFormat =
    memo.MemoFormat == null ||
    (isString(memo.MemoFormat) && isHex(memo.MemoFormat))
  const validType =
    memo.MemoType == null || (isString(memo.MemoType) && isHex(memo.MemoType))
  return (
    size >= 1 &&
    size <= MEMO_SIZE &&
    validData &&
    validFormat &&
    validType &&
    onlyHasFields(memo, ['MemoFormat', 'MemoData', 'MemoType'])
  )
}
const SIGNER_SIZE = 3
function isSigner(obj: unknown): obj is Signer {
  if (!isRecord(obj)) {
    return false
  }
  const signer = obj.Signer
  if (!isRecord(signer)) {
    return false
  }
  return (
    Object.keys(signer).length === SIGNER_SIZE &&
    isString(signer.Account) &&
    isString(signer.TxnSignature) &&
    isString(signer.SigningPubKey)
  )
}
const XRP_CURRENCY_SIZE = 1
const ISSUE_SIZE = 2
const ISSUED_CURRENCY_SIZE = 3
const XCHAIN_BRIDGE_SIZE = 4
const MPTOKEN_SIZE = 2
const AUTHORIZE_CREDENTIAL_SIZE = 1
/**
 * Verify the form and type of a Record/Object at runtime.
 *
 * @param value - The object to check the form and type of.
 * @returns Whether the Record/Object is properly formed.
 */
export function isRecord(value: unknown): value is Record<string, unknown> {
  return value !== null && typeof value === 'object' && !Array.isArray(value)
}
/**
 * Verify the form and type of a string at runtime.
 *
 * @param str - The object to check the form and type of.
 * @returns Whether the string is properly formed.
 */
export function isString(str: unknown): str is string {
  return typeof str === 'string'
}
/**
 * Verify the form and type of a number at runtime.
 *
 * @param num - The object to check the form and type of.
 * @returns Whether the number is properly formed.
 */
export function isNumber(num: unknown): num is number {
  return typeof num === 'number'
}
/**
 * Checks whether the given value is a valid XRPL number string.
 * Accepts integer, decimal, or scientific notation strings.
 *
 * Examples of valid input:
 *   - "123"
 *   - "-987.654"
 *   - "+3.14e10"
 *   - "-7.2e-9"
 *
 * @param value - The value to check.
 * @returns True if value is a string that matches the XRPL number format, false otherwise.
 */
export function isXRPLNumber(value: unknown): value is XRPLNumber {
  // Matches optional sign, digits, optional decimal, optional exponent (scientific)
  // Allows leading zeros, but not empty string, lone sign, or missing digits
  return (
    typeof value === 'string' &&
    /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/u.test(value.trim())
  )
}
/**
 * Verify the form and type of a Currency at runtime.
 *
 * @param input - The input to check the form and type of.
 * @returns Whether the Currency is properly formed.
 */
export function isCurrency(input: unknown): input is Currency {
  return isString(input) || isIssuedCurrency(input)
}
/**
 * Verify the form and type of an IssuedCurrency at runtime.
 *
 * @param input - The input to check the form and type of.
 * @returns Whether the IssuedCurrency is properly formed.
 */
export function isIssuedCurrency(input: unknown): input is IssuedCurrency {
  return (
    isRecord(input) &&
    ((Object.keys(input).length === ISSUE_SIZE &&
      isString(input.issuer) &&
      isString(input.currency)) ||
      (Object.keys(input).length === XRP_CURRENCY_SIZE &&
        input.currency === 'XRP'))
  )
}
/**
 * Verify the form and type of an IssuedCurrencyAmount at runtime.
 *
 * @param input - The input to check the form and type of.
 * @returns Whether the IssuedCurrencyAmount is properly formed.
 */
export function isIssuedCurrencyAmount(
  input: unknown,
): input is IssuedCurrencyAmount {
  return (
    isRecord(input) &&
    Object.keys(input).length === ISSUED_CURRENCY_SIZE &&
    isString(input.value) &&
    isString(input.issuer) &&
    isString(input.currency)
  )
}
/**
 * Verify the form and type of an AuthorizeCredential at runtime
 *
 * @param input - The input to check the form and type of
 * @returns Whether the AuthorizeCredential is properly formed
 */
export function isAuthorizeCredential(
  input: unknown,
): input is AuthorizeCredential {
  return (
    isRecord(input) &&
    isRecord(input.Credential) &&
    Object.keys(input).length === AUTHORIZE_CREDENTIAL_SIZE &&
    typeof input.Credential.CredentialType === 'string' &&
    typeof input.Credential.Issuer === 'string'
  )
}
/**
 * Verify the form and type of an MPT at runtime.
 *
 * @param input - The input to check the form and type of.
 * @returns Whether the MPTAmount is properly formed.
 */
export function isMPTAmount(input: unknown): input is MPTAmount {
  return (
    isRecord(input) &&
    Object.keys(input).length === MPTOKEN_SIZE &&
    typeof input.value === 'string' &&
    typeof input.mpt_issuance_id === 'string'
  )
}
/**
 * Type guard to verify if the input is a valid ClawbackAmount.
 *
 * A ClawbackAmount can be either an {@link IssuedCurrencyAmount} or an {@link MPTAmount}.
 * This function checks if the input matches either type.
 *
 * @param input - The value to check for ClawbackAmount structure.
 * @returns True if the input is an IssuedCurrencyAmount or MPTAmount, otherwise false.
 */
export function isClawbackAmount(input: unknown): input is ClawbackAmount {
  return isIssuedCurrencyAmount(input) || isMPTAmount(input)
}
/**
 * Must be a valid account address
 */
export type Account = string
/**
 * XRPL Number type represented as a string.
 *
 * This string can be an integer (e.g., "123"), a decimal (e.g., "123.45"),
 * or in scientific notation (e.g., "1.23e5", "-4.56e-7").
 * Used for fields that accept arbitrary-precision numbers in XRPL transactions and ledger objects.
 */
export type XRPLNumber = string
/**
 * Verify a string is in fact a valid account address.
 *
 * @param account - The object to check the form and type of.
 * @returns Whether the account is properly formed account for a transaction.
 */
export function isAccount(account: unknown): account is Account {
  return (
    typeof account === 'string' &&
    (isValidClassicAddress(account) || isValidXAddress(account))
  )
}
/**
 * Verify the form and type of an Amount at runtime.
 *
 * @param amount - The object to check the form and type of.
 * @returns Whether the Amount is properly formed.
 */
export function isAmount(amount: unknown): amount is Amount {
  return (
    typeof amount === 'string' ||
    isIssuedCurrencyAmount(amount) ||
    isMPTAmount(amount)
  )
}
/**
 * Verify the form and type of an XChainBridge at runtime.
 *
 * @param input - The input to check the form and type of.
 * @returns Whether the XChainBridge is properly formed.
 */
export function isXChainBridge(input: unknown): input is XChainBridge {
  return (
    isRecord(input) &&
    Object.keys(input).length === XCHAIN_BRIDGE_SIZE &&
    typeof input.LockingChainDoor === 'string' &&
    isIssuedCurrency(input.LockingChainIssue) &&
    typeof input.IssuingChainDoor === 'string' &&
    isIssuedCurrency(input.IssuingChainIssue)
  )
}
/**
 * Verify the form and type of an Array at runtime.
 *
 * @param input - The object to check the form and type of.
 * @returns Whether the Array is properly formed.
 */
export function isArray<T = unknown>(input: unknown): input is T[] {
  return input != null && Array.isArray(input)
}
/* eslint-disable @typescript-eslint/restrict-template-expressions -- tx.TransactionType is checked before any calls */
/**
 * Verify the form and type of a required type for a transaction at runtime.
 *
 * @param tx - The object input to check the form and type of.
 * @param param - The object parameter.
 * @param checkValidity - The function to use to check the type.
 * @param errorOpts - Extra values to make the error message easier to understand.
 * @param errorOpts.txType - The transaction type throwing the error.
 * @param errorOpts.paramName - The name of the parameter in the transaction with the error.
 * @throws ValidationError if the parameter is missing or invalid.
 */
// eslint-disable-next-line max-params -- helper function
export function validateRequiredField<
  T extends Record<string, unknown>,
  K extends keyof T,
  V,
>(
  tx: T,
  param: K,
  checkValidity: (inp: unknown) => inp is V,
  errorOpts: {
    txType?: string
    paramName?: string
  } = {},
): asserts tx is T & { [P in K]: V } {
  const paramNameStr = errorOpts.paramName ?? param
  const txType = errorOpts.txType ?? tx.TransactionType
  if (tx[param] == null) {
    throw new ValidationError(
      `${txType}: missing field ${String(paramNameStr)}`,
    )
  }
  if (!checkValidity(tx[param])) {
    throw new ValidationError(
      `${txType}: invalid field ${String(paramNameStr)}`,
    )
  }
}
/**
 * Verify the form and type of an optional type for a transaction at runtime.
 *
 * @param tx - The transaction input to check the form and type of.
 * @param param - The object parameter.
 * @param checkValidity - The function to use to check the type.
 * @param errorOpts - Extra values to make the error message easier to understand.
 * @param errorOpts.txType - The transaction type throwing the error.
 * @param errorOpts.paramName - The name of the parameter in the transaction with the error.
 * @throws ValidationError if the parameter is invalid.
 */
// eslint-disable-next-line max-params -- helper function
export function validateOptionalField<
  T extends Record<string, unknown>,
  K extends keyof T,
  V,
>(
  tx: T,
  param: K,
  checkValidity: (inp: unknown) => inp is V,
  errorOpts: {
    txType?: string
    paramName?: string
  } = {},
): asserts tx is T & { [P in K]: V | undefined } {
  const paramNameStr = errorOpts.paramName ?? param
  const txType = errorOpts.txType ?? tx.TransactionType
  if (tx[param] !== undefined && !checkValidity(tx[param])) {
    throw new ValidationError(
      `${txType}: invalid field ${String(paramNameStr)}`,
    )
  }
}
/* eslint-enable @typescript-eslint/restrict-template-expressions -- checked before */
export enum GlobalFlags {
  tfInnerBatchTxn = 0x40000000,
}
export interface GlobalFlagsInterface {
  tfInnerBatchTxn?: boolean
}
/**
 * Every transaction has the same set of common fields.
 */
export interface BaseTransaction extends Record<string, unknown> {
  /** The unique address of the transaction sender. */
  Account: Account
  /**
   * The type of transaction. Valid types include: `Payment`, `OfferCreate`,
   * `TrustSet`, and many others.
   */
  TransactionType: string
  /**
   * Integer amount of XRP, in drops, to be destroyed as a cost for
   * distributing this transaction to the network. Some transaction types have
   * different minimum requirements.
   */
  Fee?: string
  /**
   * The sequence number of the account sending the transaction. A transaction
   * is only valid if the Sequence number is exactly 1 greater than the previous
   * transaction from the same account. The special case 0 means the transaction
   * is using a Ticket instead.
   */
  Sequence?: number
  /**
   * Hash value identifying another transaction. If provided, this transaction
   * is only valid if the sending account's previously-sent transaction matches
   * the provided hash.
   */
  AccountTxnID?: string
  /** Set of bit-flags for this transaction. */
  Flags?: number | GlobalFlagsInterface
  /**
   * Highest ledger index this transaction can appear in. Specifying this field
   * places a strict upper limit on how long the transaction can wait to be
   * validated or rejected.
   */
  LastLedgerSequence?: number
  /**
   * Additional arbitrary information used to identify this transaction.
   */
  Memos?: Memo[]
  /**
   * Array of objects that represent a multi-signature which authorizes this
   * transaction.
   */
  Signers?: Signer[]
  /**
   * Arbitrary integer used to identify the reason for this payment, or a sender
   * on whose behalf this transaction is made. Conventionally, a refund should
   * specify the initial payment's SourceTag as the refund payment's
   * DestinationTag.
   */
  SourceTag?: number
  /**
   * Hex representation of the public key that corresponds to the private key
   * used to sign this transaction. If an empty string, indicates a
   * multi-signature is present in the Signers field instead.
   */
  SigningPubKey?: string
  /**
   * The sequence number of the ticket to use in place of a Sequence number. If
   * this is provided, Sequence must be 0. Cannot be used with AccountTxnID.
   */
  TicketSequence?: number
  /**
   * The signature that verifies this transaction as originating from the
   * account it says it is from.
   */
  TxnSignature?: string
  /**
   * The network id of the transaction.
   */
  NetworkID?: number
  /**
   * The delegate account that is sending the transaction.
   */
  Delegate?: Account
}
/**
 * Verify the common fields of a transaction. The validate functionality will be
 * optional, and will check transaction form at runtime. This should be called
 * any time a transaction will be verified.
 *
 * @param common - An interface w/ common transaction fields.
 * @throws When the common param is malformed.
 */
// eslint-disable-next-line max-statements, max-lines-per-function -- lines required for validation
export function validateBaseTransaction(
  common: unknown,
): asserts common is BaseTransaction {
  if (!isRecord(common)) {
    throw new ValidationError(
      'BaseTransaction: invalid, expected a valid object',
    )
  }
  if (common.TransactionType === undefined) {
    throw new ValidationError('BaseTransaction: missing field TransactionType')
  }
  if (typeof common.TransactionType !== 'string') {
    throw new ValidationError('BaseTransaction: TransactionType not string')
  }
  if (!TRANSACTION_TYPES.includes(common.TransactionType)) {
    throw new ValidationError(
      `BaseTransaction: Unknown TransactionType ${common.TransactionType}`,
    )
  }
  validateRequiredField(common, 'Account', isString)
  validateOptionalField(common, 'Fee', isString)
  validateOptionalField(common, 'Sequence', isNumber)
  validateOptionalField(common, 'AccountTxnID', isString)
  validateOptionalField(common, 'LastLedgerSequence', isNumber)
  const memos = common.Memos
  if (memos != null && (!isArray(memos) || !memos.every(isMemo))) {
    throw new ValidationError('BaseTransaction: invalid Memos')
  }
  const signers = common.Signers
  if (
    signers != null &&
    (!isArray(signers) || signers.length === 0 || !signers.every(isSigner))
  ) {
    throw new ValidationError('BaseTransaction: invalid Signers')
  }
  validateOptionalField(common, 'SourceTag', isNumber)
  validateOptionalField(common, 'SigningPubKey', isString)
  validateOptionalField(common, 'TicketSequence', isNumber)
  validateOptionalField(common, 'TxnSignature', isString)
  validateOptionalField(common, 'NetworkID', isNumber)
  validateOptionalField(common, 'Delegate', isAccount)
  const delegate = common.Delegate
  if (delegate != null && delegate === common.Account) {
    throw new ValidationError(
      'BaseTransaction: Account and Delegate addresses cannot be the same',
    )
  }
}
/**
 * Parse the value of an amount, expressed either in XRP or as an Issued Currency, into a number.
 *
 * @param amount - An Amount to parse for its value.
 * @returns The parsed amount value, or NaN if the amount count not be parsed.
 */
export function parseAmountValue(amount: unknown): number {
  if (!isAmount(amount)) {
    return NaN
  }
  if (typeof amount === 'string') {
    return parseFloat(amount)
  }
  return parseFloat(amount.value)
}
/**
 * Verify the form and type of a CredentialType at runtime.
 *
 * @param tx A CredentialType Transaction.
 * @throws when the CredentialType is malformed.
 */
export function validateCredentialType<
  T extends BaseTransaction & Record<string, unknown>,
>(tx: T): void {
  if (typeof tx.TransactionType !== 'string') {
    throw new ValidationError('Invalid TransactionType')
  }
  if (tx.CredentialType === undefined) {
    throw new ValidationError(
      `${tx.TransactionType}: missing field CredentialType`,
    )
  }
  if (!isString(tx.CredentialType)) {
    throw new ValidationError(
      `${tx.TransactionType}: CredentialType must be a string`,
    )
  }
  if (tx.CredentialType.length === 0) {
    throw new ValidationError(
      `${tx.TransactionType}: CredentialType cannot be an empty string`,
    )
  } else if (tx.CredentialType.length > MAX_CREDENTIAL_TYPE_LENGTH) {
    throw new ValidationError(
      `${tx.TransactionType}: CredentialType length cannot be > ${MAX_CREDENTIAL_TYPE_LENGTH}`,
    )
  }
  if (!HEX_REGEX.test(tx.CredentialType)) {
    throw new ValidationError(
      `${tx.TransactionType}: CredentialType must be encoded in hex`,
    )
  }
}
/**
 * Check a CredentialAuthorize array for parameter errors
 *
 * @param credentials An array of credential IDs to check for errors
 * @param transactionType The transaction type to include in error messages
 * @param isStringID Toggle for if array contains IDs instead of AuthorizeCredential objects
 * @param maxCredentials The maximum length of the credentials array.
 *        PermissionedDomainSet transaction uses 10, other transactions use 8.
 * @throws Validation Error if the formatting is incorrect
 */
// eslint-disable-next-line max-params, max-lines-per-function -- separating logic further will add unnecessary complexity
export function validateCredentialsList(
  credentials: unknown,
  transactionType: string,
  isStringID: boolean,
  maxCredentials: number,
): void {
  if (credentials == null) {
    return
  }
  if (!isArray(credentials)) {
    throw new ValidationError(
      `${transactionType}: Credentials must be an array`,
    )
  }
  if (credentials.length > maxCredentials) {
    throw new ValidationError(
      `${transactionType}: Credentials length cannot exceed ${maxCredentials} elements`,
    )
  } else if (credentials.length === 0) {
    throw new ValidationError(
      `${transactionType}: Credentials cannot be an empty array`,
    )
  }
  credentials.forEach((credential) => {
    if (isStringID) {
      if (!isString(credential)) {
        throw new ValidationError(
          `${transactionType}: Invalid Credentials ID list format`,
        )
      }
    } else if (!isAuthorizeCredential(credential)) {
      throw new ValidationError(
        `${transactionType}: Invalid Credentials format`,
      )
    }
  })
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- checked above
  if (containsDuplicates(credentials as string[] | AuthorizeCredential[])) {
    throw new ValidationError(
      `${transactionType}: Credentials cannot contain duplicate elements`,
    )
  }
}
// Type guard to ensure we're working with AuthorizeCredential[]
// Note: This is not a rigorous type-guard. A more thorough solution would be to iterate over the array and check each item.
function isAuthorizeCredentialArray(
  list: AuthorizeCredential[] | string[],
): list is AuthorizeCredential[] {
  return typeof list[0] !== 'string'
}
/**
 * Check if an array of objects contains any duplicates.
 *
 * @param objectList - Array of objects to check for duplicates
 * @returns True if duplicates exist, false otherwise
 */
export function containsDuplicates(
  objectList: AuthorizeCredential[] | string[],
): boolean {
  // Case-1: Process a list of string-IDs
  if (typeof objectList[0] === 'string') {
    const objSet = new Set(objectList.map((obj) => JSON.stringify(obj)))
    return objSet.size !== objectList.length
  }
  // Case-2: Process a list of nested objects
  const seen = new Set<string>()
  if (isAuthorizeCredentialArray(objectList)) {
    for (const item of objectList) {
      const key = `${item.Credential.Issuer}-${item.Credential.CredentialType}`
      if (seen.has(key)) {
        return true
      }
      seen.add(key)
    }
  }
  return false
}
const _DOMAIN_ID_LENGTH = 64
/**
 * Utility method used across OfferCreate and Payment transactions to validate the DomainID.
 *
 * @param domainID - The domainID is a 64-character string that is used to identify a domain.
 *
 * @returns true if the domainID is a valid 64-character string, false otherwise
 */
export function isDomainID(domainID: unknown): domainID is string {
  return (
    isString(domainID) &&
    domainID.length === _DOMAIN_ID_LENGTH &&
    isHex(domainID)
  )
}
/* eslint-disable max-lines-per-function -- Required here as structure validation is verbose. */
/* eslint-disable max-statements -- Required here as structure validation is verbose. */
/**
 * Validates if MPTokenMetadata adheres to XLS-89d standard.
 *
 * @param input - Hex encoded MPTokenMetadata.
 * @returns Validation messages if MPTokenMetadata does not adheres to XLS-89d standard.
 */
export function validateMPTokenMetadata(input: string): string[] {
  const validationMessages: string[] = []
  if (!isHex(input)) {
    validationMessages.push(`MPTokenMetadata must be in hex format.`)
    return validationMessages
  }
  if (input.length / 2 > MAX_MPT_META_BYTE_LENGTH) {
    validationMessages.push(
      `MPTokenMetadata must be max ${MAX_MPT_META_BYTE_LENGTH} bytes.`,
    )
    return validationMessages
  }
  let jsonMetaData: unknown
  try {
    jsonMetaData = JSON.parse(hexToString(input))
  } catch (err) {
    validationMessages.push(
      `MPTokenMetadata is not properly formatted as JSON - ${String(err)}`,
    )
    return validationMessages
  }
  if (
    jsonMetaData == null ||
    typeof jsonMetaData !== 'object' ||
    Array.isArray(jsonMetaData)
  ) {
    validationMessages.push(
      'MPTokenMetadata is not properly formatted as per XLS-89d.',
    )
    return validationMessages
  }
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- It must be some JSON object.
  const obj = jsonMetaData as Record<string, unknown>
  // validating structure
  // check for maximum number of fields
  const fieldCount = Object.keys(obj).length
  if (fieldCount > MAX_MPT_META_TOP_LEVEL_FIELD_COUNT) {
    validationMessages.push(
      `MPTokenMetadata must not contain more than ${MAX_MPT_META_TOP_LEVEL_FIELD_COUNT} top-level fields (found ${fieldCount}).`,
    )
    return validationMessages
  }
  const incorrectRequiredFields = MPT_META_REQUIRED_FIELDS.filter(
    (field) => !isString(obj[field]),
  )
  if (incorrectRequiredFields.length > 0) {
    incorrectRequiredFields.forEach((field) =>
      validationMessages.push(`${field} is required and must be string.`),
    )
    return validationMessages
  }
  if (obj.desc != null && !isString(obj.desc)) {
    validationMessages.push(`desc must be a string.`)
    return validationMessages
  }
  if (obj.asset_subclass != null && !isString(obj.asset_subclass)) {
    validationMessages.push(`asset_subclass must be a string.`)
    return validationMessages
  }
  if (
    obj.additional_info != null &&
    !isString(obj.additional_info) &&
    !isRecord(obj.additional_info)
  ) {
    validationMessages.push(`additional_info must be a string or JSON object.`)
    return validationMessages
  }
  if (obj.urls != null) {
    if (!Array.isArray(obj.urls)) {
      validationMessages.push('urls must be an array as per XLS-89d.')
      return validationMessages
    }
    if (!obj.urls.every(isValidMPTokenMetadataUrlStructure)) {
      validationMessages.push(
        'One or more urls are not structured per XLS-89d.',
      )
      return validationMessages
    }
  }
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required here.
  const mptMPTokenMetadata = obj as unknown as MPTokenMetadata
  // validating content
  if (!TICKER_REGEX.test(mptMPTokenMetadata.ticker)) {
    validationMessages.push(
      `ticker should have uppercase letters (A-Z) and digits (0-9) only. Max 6 characters recommended.`,
    )
  }
  if (!mptMPTokenMetadata.icon.startsWith('https://')) {
    validationMessages.push(`icon should be a valid https url.`)
  }
  if (
    !MPT_META_ASSET_CLASSES.includes(
      mptMPTokenMetadata.asset_class.toLowerCase(),
    )
  ) {
    validationMessages.push(
      `asset_class should be one of ${MPT_META_ASSET_CLASSES.join(', ')}.`,
    )
  }
  if (
    mptMPTokenMetadata.asset_subclass != null &&
    !MPT_META_ASSET_SUB_CLASSES.includes(
      mptMPTokenMetadata.asset_subclass.toLowerCase(),
    )
  ) {
    validationMessages.push(
      `asset_subclass should be one of ${MPT_META_ASSET_SUB_CLASSES.join(
        ', ',
      )}.`,
    )
  }
  if (
    mptMPTokenMetadata.asset_class.toLowerCase() === 'rwa' &&
    mptMPTokenMetadata.asset_subclass == null
  ) {
    validationMessages.push(
      `asset_subclass is required when asset_class is rwa.`,
    )
  }
  if (
    mptMPTokenMetadata.urls != null &&
    !mptMPTokenMetadata.urls.every((ele) => ele.url.startsWith('https://'))
  ) {
    validationMessages.push(`url should be a valid https url.`)
  }
  return validationMessages
}
/* eslint-enable max-lines-per-function */
/* eslint-enable max-statements */
function isValidMPTokenMetadataUrlStructure(input: unknown): boolean {
  if (input == null) {
    return false
  }
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required here.
  const obj = input as Record<string, unknown>
  return (
    typeof obj === 'object' &&
    isString(obj.url) &&
    isString(obj.type) &&
    isString(obj.title) &&
    Object.keys(obj).length === MPT_META_URL_FIELD_COUNT
  )
}