UNPKG

xrpl

Version:

A TypeScript/JavaScript API for interacting with the XRP Ledger in Node.js and the browser

530 lines (469 loc) 15.1 kB
/* eslint-disable max-lines -- utility file */ /* eslint-disable no-continue -- makes logic easier to write and read in this case */ import { hexToString, stringToHex } from '@xrplf/isomorphic/utils' import stableStringify from 'fast-json-stable-stringify' import type { MPTokenMetadata } from '../common' import { isRecord, isString } from '../transactions/common' import { isHex } from '.' export const MAX_MPT_META_BYTE_LENGTH = 1024 export const MPT_META_WARNING_HEADER = 'MPTokenMetadata is not properly formatted as JSON as per the XLS-89 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.' const MPT_META_URI_FIELDS = [ { long: 'uri', compact: 'u', }, { long: 'category', compact: 'c', }, { long: 'title', compact: 't', }, ] const MPT_META_ALL_FIELDS = [ { long: 'ticker', compact: 't', validate(obj: Record<string, unknown>): string[] { if (obj[this.long] != null && obj[this.compact] != null) { return [ `${this.long}/${this.compact}: both long and compact forms present. expected only one.`, ] } const value = obj[this.long] ?? obj[this.compact] if (!isString(value) || !/^[A-Z0-9]{1,6}$/u.test(value)) { return [ `${this.long}/${this.compact}: should have uppercase letters (A-Z) and digits (0-9) only. Max 6 characters recommended.`, ] } return [] }, }, { long: 'name', compact: 'n', validate(obj: Record<string, unknown>): string[] { if (obj[this.long] != null && obj[this.compact] != null) { return [ `${this.long}/${this.compact}: both long and compact forms present. expected only one.`, ] } const value = obj[this.long] ?? obj[this.compact] if (!isString(value) || value.length === 0) { return [`${this.long}/${this.compact}: should be a non-empty string.`] } return [] }, }, { long: 'icon', compact: 'i', validate(obj: Record<string, unknown>): string[] { if (obj[this.long] != null && obj[this.compact] != null) { return [ `${this.long}/${this.compact}: both long and compact forms present. expected only one.`, ] } const value = obj[this.long] ?? obj[this.compact] if (!isString(value) || value.length === 0) { return [`${this.long}/${this.compact}: should be a non-empty string.`] } return [] }, }, { long: 'asset_class', compact: 'ac', validate(obj: Record<string, unknown>): string[] { if (obj[this.long] != null && obj[this.compact] != null) { return [ `${this.long}/${this.compact}: both long and compact forms present. expected only one.`, ] } const value = obj[this.long] ?? obj[this.compact] const MPT_META_ASSET_CLASSES = [ 'rwa', 'memes', 'wrapped', 'gaming', 'defi', 'other', ] if (!isString(value) || !MPT_META_ASSET_CLASSES.includes(value)) { return [ `${this.long}/${this.compact}: should be one of ${MPT_META_ASSET_CLASSES.join( ', ', )}.`, ] } return [] }, }, { long: 'issuer_name', compact: 'in', validate(obj: Record<string, unknown>): string[] { if (obj[this.long] != null && obj[this.compact] != null) { return [ `${this.long}/${this.compact}: both long and compact forms present. expected only one.`, ] } const value = obj[this.long] ?? obj[this.compact] if (!isString(value) || value.length === 0) { return [`${this.long}/${this.compact}: should be a non-empty string.`] } return [] }, }, { long: 'desc', compact: 'd', validate(obj: Record<string, unknown>): string[] { if (obj[this.long] != null && obj[this.compact] != null) { return [ `${this.long}/${this.compact}: both long and compact forms present. expected only one.`, ] } if (obj[this.long] === undefined && obj[this.compact] === undefined) { return [] } const value = obj[this.long] ?? obj[this.compact] if (!isString(value) || value.length === 0) { return [`${this.long}/${this.compact}: should be a non-empty string.`] } return [] }, }, { long: 'asset_subclass', compact: 'as', required: false, validate(obj: Record<string, unknown>): string[] { if (obj[this.long] != null && obj[this.compact] != null) { return [ `${this.long}/${this.compact}: both long and compact forms present. expected only one.`, ] } const value = obj[this.long] ?? obj[this.compact] if ( (obj.asset_class === 'rwa' || obj.ac === 'rwa') && value === undefined ) { return [ `${this.long}/${this.compact}: required when asset_class is rwa.`, ] } if (obj[this.long] === undefined && obj[this.compact] === undefined) { return [] } const MPT_META_ASSET_SUB_CLASSES = [ 'stablecoin', 'commodity', 'real_estate', 'private_credit', 'equity', 'treasury', 'other', ] if (!isString(value) || !MPT_META_ASSET_SUB_CLASSES.includes(value)) { return [ `${this.long}/${this.compact}: should be one of ${MPT_META_ASSET_SUB_CLASSES.join( ', ', )}.`, ] } return [] }, }, { long: 'uris', compact: 'us', required: false, // eslint-disable-next-line max-lines-per-function -- required for validation validate(obj: Record<string, unknown>): string[] { if (obj[this.long] != null && obj[this.compact] != null) { return [ `${this.long}/${this.compact}: both long and compact forms present. expected only one.`, ] } if (obj[this.long] === undefined && obj[this.compact] === undefined) { return [] } const value = obj[this.long] ?? obj[this.compact] if (!Array.isArray(value) || value.length === 0) { return [`${this.long}/${this.compact}: should be a non-empty array.`] } const messages: string[] = [] for (const uriObj of value) { if ( !isRecord(uriObj) || Object.keys(uriObj).length !== MPT_META_URI_FIELDS.length ) { messages.push( `${this.long}/${this.compact}: should be an array of objects each with uri/u, category/c, and title/t properties.`, ) continue } // Check for both long and compact forms in the same URI object for (const uriField of MPT_META_URI_FIELDS) { if ( uriObj[uriField.long] != null && uriObj[uriField.compact] != null ) { messages.push( `${this.long}/${this.compact}: should not have both ${uriField.long} and ${uriField.compact} fields.`, ) break } } const uri = uriObj.uri ?? uriObj.u const category = uriObj.category ?? uriObj.c const title = uriObj.title ?? uriObj.t if (!isString(uri) || !isString(category) || !isString(title)) { messages.push( `${this.long}/${this.compact}: should be an array of objects each with uri/u, category/c, and title/t properties.`, ) } } return messages }, }, { long: 'additional_info', compact: 'ai', required: false, validate(obj: Record<string, unknown>): string[] { if (obj[this.long] != null && obj[this.compact] != null) { return [ `${this.long}/${this.compact}: both long and compact forms present. expected only one.`, ] } if (obj[this.long] === undefined && obj[this.compact] === undefined) { return [] } const value = obj[this.long] ?? obj[this.compact] if (!isString(value) && !isRecord(value)) { return [ `${this.long}/${this.compact}: should be a string or JSON object.`, ] } return [] }, }, ] /** * Shortens long field names to their compact form equivalents. * Reverse operation of {@link expandKeys}. * * @param input - Object with potentially long field names. * @param mappings - Array of field mappings with long and compact names. * @returns Object with shortened compact field names. */ function shortenKeys( input: Record<string, unknown>, mappings: Array<{ long: string; compact: string }>, ): Record<string, unknown> { const output: Record<string, unknown> = {} for (const [key, value] of Object.entries(input)) { const mapping = mappings.find( ({ long, compact }) => long === key || compact === key, ) // Extra keys stays there if (mapping === undefined) { output[key] = value continue } // Both long and compact forms are present if ( input[mapping.long] !== undefined && input[mapping.compact] !== undefined ) { output[key] = value continue } output[mapping.compact] = value } return output } /** * Encodes {@link MPTokenMetadata} object to a hex string. * Steps: * 1. Shorten long field names to their compact form equivalents. * 2. Sort the fields alphabetically for deterministic encoding. * 3. Stringify the object. * 4. Convert to hex. * * @param mptokenMetadata - {@link MPTokenMetadata} to encode. * @returns Hex encoded {@link MPTokenMetadata}. * @throws Error if input is not a JSON object. * @category Utilities */ export function encodeMPTokenMetadata( mptokenMetadata: MPTokenMetadata, ): string { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required here to implement type-guard let input = mptokenMetadata as unknown as Record<string, unknown> if (!isRecord(input)) { throw new Error('MPTokenMetadata must be JSON object.') } input = shortenKeys(input, MPT_META_ALL_FIELDS) if (Array.isArray(input.uris)) { input.uris = input.uris.map( (uri: Record<string, unknown>): Record<string, unknown> => { if (isRecord(uri)) { return shortenKeys(uri, MPT_META_URI_FIELDS) } return uri }, ) } if (Array.isArray(input.us)) { input.us = input.us.map( (uri: Record<string, unknown>): Record<string, unknown> => { if (isRecord(uri)) { return shortenKeys(uri, MPT_META_URI_FIELDS) } return uri }, ) } return stringToHex(stableStringify(input)).toUpperCase() } /** * Expands compact field names to their long form equivalents. * Reverse operation of {@link shortenKeys}. * * @param input - Object with potentially compact field names. * @param mappings - Array of field mappings with long and compact names. * @returns Object with expanded long field names. */ function expandKeys( input: Record<string, unknown>, mappings: Array<{ long: string; compact: string }>, ): Record<string, unknown> { const output: Record<string, unknown> = {} for (const [key, value] of Object.entries(input)) { const mapping = mappings.find( ({ long, compact }) => long === key || compact === key, ) // Extra keys stays there if (mapping === undefined) { output[key] = value continue } // Both long and compact forms are present if ( input[mapping.long] !== undefined && input[mapping.compact] !== undefined ) { output[key] = value continue } output[mapping.long] = value } return output } /** * Decodes hex-encoded {@link MPTokenMetadata} into a JSON object. * Converts compact field names to their corresponding long-form equivalents. * * @param input - Hex encoded {@link MPTokenMetadata}. * @returns Decoded {@link MPTokenMetadata} object with long field names. * @throws Error if input is not valid hex or cannot be parsed as JSON. * @category Utilities */ export function decodeMPTokenMetadata(input: string): MPTokenMetadata { if (!isHex(input)) { throw new Error('MPTokenMetadata must be in hex format.') } let jsonMetaData: unknown try { jsonMetaData = JSON.parse(hexToString(input)) } catch (err) { throw new Error( `MPTokenMetadata is not properly formatted as JSON - ${String(err)}`, ) } if (!isRecord(jsonMetaData)) { throw new Error('MPTokenMetadata must be a JSON object.') } let output = jsonMetaData output = expandKeys(output, MPT_META_ALL_FIELDS) if (Array.isArray(output.uris)) { output.uris = output.uris.map( (uri: Record<string, unknown>): Record<string, unknown> => { if (isRecord(uri)) { return expandKeys(uri, MPT_META_URI_FIELDS) } return uri }, ) } if (Array.isArray(output.us)) { output.us = output.us.map( (uri: Record<string, unknown>): Record<string, unknown> => { if (isRecord(uri)) { return expandKeys(uri, MPT_META_URI_FIELDS) } return uri }, ) } // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required here as output is now properly formatted return output as unknown as MPTokenMetadata } /** * Validates {@link MPTokenMetadata} adheres to XLS-89 standard. * * @param input - Hex encoded {@link MPTokenMetadata}. * @returns Validation messages if {@link MPTokenMetadata} does not adheres to XLS-89 standard. * @category Utilities */ export function validateMPTokenMetadata(input: string): string[] { const validationMessages: string[] = [] // Validate hex format if (!isHex(input)) { validationMessages.push(`MPTokenMetadata must be in hex format.`) return validationMessages } // Validate byte length if (input.length / 2 > MAX_MPT_META_BYTE_LENGTH) { validationMessages.push( `MPTokenMetadata must be max ${MAX_MPT_META_BYTE_LENGTH} bytes.`, ) return validationMessages } // Parse JSON let jsonMetaData: unknown try { jsonMetaData = JSON.parse(hexToString(input)) } catch (err) { validationMessages.push( `MPTokenMetadata is not properly formatted as JSON - ${String(err)}`, ) return validationMessages } // Validate JSON structure if (!isRecord(jsonMetaData)) { validationMessages.push( 'MPTokenMetadata is not properly formatted JSON object as per XLS-89.', ) return validationMessages } if (Object.keys(jsonMetaData).length > MPT_META_ALL_FIELDS.length) { validationMessages.push( `MPTokenMetadata must not contain more than ${MPT_META_ALL_FIELDS.length} top-level fields (found ${ Object.keys(jsonMetaData).length }).`, ) } const obj = jsonMetaData for (const property of MPT_META_ALL_FIELDS) { validationMessages.push(...property.validate(obj)) } return validationMessages }