11/* eslint-disable max-lines -- common utility file */
2- import { HEX_REGEX } from '@xrplf/isomorphic/utils'
2+ import { HEX_REGEX , hexToString } from '@xrplf/isomorphic/utils'
33import { isValidClassicAddress , isValidXAddress } from 'ripple-address-codec'
44import { TRANSACTION_TYPES } from 'ripple-binary-codec'
55
@@ -12,6 +12,7 @@ import {
1212 IssuedCurrency ,
1313 IssuedCurrencyAmount ,
1414 MPTAmount ,
15+ MPTokenMetadata ,
1516 Memo ,
1617 Signer ,
1718 XChainBridge ,
@@ -22,10 +23,50 @@ const MEMO_SIZE = 3
2223export const MAX_AUTHORIZED_CREDENTIALS = 8
2324const MAX_CREDENTIAL_BYTE_LENGTH = 64
2425const MAX_CREDENTIAL_TYPE_LENGTH = MAX_CREDENTIAL_BYTE_LENGTH * 2
26+ export const MAX_MPT_META_BYTE_LENGTH = 1024
2527
2628// Used for Vault transactions
2729export const VAULT_DATA_MAX_BYTE_LENGTH = 256
2830
31+ // To validate MPTokenMetadata as per XLS-89d
32+ const TICKER_REGEX = / ^ [ A - Z 0 - 9 ] { 1 , 6 } $ / u
33+
34+ const MAX_MPT_META_TOP_LEVEL_FIELD_COUNT = 9
35+
36+ const MPT_META_URL_FIELD_COUNT = 3
37+
38+ const MPT_META_REQUIRED_FIELDS = [
39+ 'ticker' ,
40+ 'name' ,
41+ 'icon' ,
42+ 'asset_class' ,
43+ 'issuer_name' ,
44+ ]
45+
46+ const MPT_META_ASSET_CLASSES = [
47+ 'rwa' ,
48+ 'memes' ,
49+ 'wrapped' ,
50+ 'gaming' ,
51+ 'defi' ,
52+ 'other' ,
53+ ]
54+
55+ const MPT_META_ASSET_SUB_CLASSES = [
56+ 'stablecoin' ,
57+ 'commodity' ,
58+ 'real_estate' ,
59+ 'private_credit' ,
60+ 'equity' ,
61+ 'treasury' ,
62+ 'other' ,
63+ ]
64+
65+ export const MPT_META_WARNING_HEADER =
66+ 'MPTokenMetadata is not properly formatted as JSON as per the XLS-89d standard. ' +
67+ "While adherence to this standard is not mandatory, such non-compliant MPToken's might not be discoverable " +
68+ 'by Explorers and Indexers in the XRPL ecosystem.'
69+
2970function isMemo ( obj : unknown ) : obj is Memo {
3071 if ( ! isRecord ( obj ) ) {
3172 return false
@@ -700,3 +741,180 @@ export function isDomainID(domainID: unknown): domainID is string {
700741 isHex ( domainID )
701742 )
702743}
744+
745+ /* eslint-disable max-lines-per-function -- Required here as structure validation is verbose. */
746+ /* eslint-disable max-statements -- Required here as structure validation is verbose. */
747+
748+ /**
749+ * Validates if MPTokenMetadata adheres to XLS-89d standard.
750+ *
751+ * @param input - Hex encoded MPTokenMetadata.
752+ * @returns Validation messages if MPTokenMetadata does not adheres to XLS-89d standard.
753+ */
754+ export function validateMPTokenMetadata ( input : string ) : string [ ] {
755+ const validationMessages : string [ ] = [ ]
756+
757+ if ( ! isHex ( input ) ) {
758+ validationMessages . push ( `MPTokenMetadata must be in hex format.` )
759+ return validationMessages
760+ }
761+
762+ if ( input . length / 2 > MAX_MPT_META_BYTE_LENGTH ) {
763+ validationMessages . push (
764+ `MPTokenMetadata must be max ${ MAX_MPT_META_BYTE_LENGTH } bytes.` ,
765+ )
766+ return validationMessages
767+ }
768+
769+ let jsonMetaData : unknown
770+
771+ try {
772+ jsonMetaData = JSON . parse ( hexToString ( input ) )
773+ } catch ( err ) {
774+ validationMessages . push (
775+ `MPTokenMetadata is not properly formatted as JSON - ${ String ( err ) } ` ,
776+ )
777+ return validationMessages
778+ }
779+
780+ if (
781+ jsonMetaData == null ||
782+ typeof jsonMetaData !== 'object' ||
783+ Array . isArray ( jsonMetaData )
784+ ) {
785+ validationMessages . push (
786+ 'MPTokenMetadata is not properly formatted as per XLS-89d.' ,
787+ )
788+ return validationMessages
789+ }
790+
791+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- It must be some JSON object.
792+ const obj = jsonMetaData as Record < string , unknown >
793+
794+ // validating structure
795+
796+ // check for maximum number of fields
797+ const fieldCount = Object . keys ( obj ) . length
798+ if ( fieldCount > MAX_MPT_META_TOP_LEVEL_FIELD_COUNT ) {
799+ validationMessages . push (
800+ `MPTokenMetadata must not contain more than ${ MAX_MPT_META_TOP_LEVEL_FIELD_COUNT } top-level fields (found ${ fieldCount } ).` ,
801+ )
802+ return validationMessages
803+ }
804+
805+ const incorrectRequiredFields = MPT_META_REQUIRED_FIELDS . filter (
806+ ( field ) => ! isString ( obj [ field ] ) ,
807+ )
808+
809+ if ( incorrectRequiredFields . length > 0 ) {
810+ incorrectRequiredFields . forEach ( ( field ) =>
811+ validationMessages . push ( `${ field } is required and must be string.` ) ,
812+ )
813+ return validationMessages
814+ }
815+
816+ if ( obj . desc != null && ! isString ( obj . desc ) ) {
817+ validationMessages . push ( `desc must be a string.` )
818+ return validationMessages
819+ }
820+
821+ if ( obj . asset_subclass != null && ! isString ( obj . asset_subclass ) ) {
822+ validationMessages . push ( `asset_subclass must be a string.` )
823+ return validationMessages
824+ }
825+
826+ if (
827+ obj . additional_info != null &&
828+ ! isString ( obj . additional_info ) &&
829+ ! isRecord ( obj . additional_info )
830+ ) {
831+ validationMessages . push ( `additional_info must be a string or JSON object.` )
832+ return validationMessages
833+ }
834+
835+ if ( obj . urls != null ) {
836+ if ( ! Array . isArray ( obj . urls ) ) {
837+ validationMessages . push ( 'urls must be an array as per XLS-89d.' )
838+ return validationMessages
839+ }
840+ if ( ! obj . urls . every ( isValidMPTokenMetadataUrlStructure ) ) {
841+ validationMessages . push (
842+ 'One or more urls are not structured per XLS-89d.' ,
843+ )
844+ return validationMessages
845+ }
846+ }
847+
848+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required here.
849+ const mptMPTokenMetadata = obj as unknown as MPTokenMetadata
850+
851+ // validating content
852+ if ( ! TICKER_REGEX . test ( mptMPTokenMetadata . ticker ) ) {
853+ validationMessages . push (
854+ `ticker should have uppercase letters (A-Z) and digits (0-9) only. Max 6 characters recommended.` ,
855+ )
856+ }
857+
858+ if ( ! mptMPTokenMetadata . icon . startsWith ( 'https://' ) ) {
859+ validationMessages . push ( `icon should be a valid https url.` )
860+ }
861+
862+ if (
863+ ! MPT_META_ASSET_CLASSES . includes (
864+ mptMPTokenMetadata . asset_class . toLowerCase ( ) ,
865+ )
866+ ) {
867+ validationMessages . push (
868+ `asset_class should be one of ${ MPT_META_ASSET_CLASSES . join ( ', ' ) } .` ,
869+ )
870+ }
871+
872+ if (
873+ mptMPTokenMetadata . asset_subclass != null &&
874+ ! MPT_META_ASSET_SUB_CLASSES . includes (
875+ mptMPTokenMetadata . asset_subclass . toLowerCase ( ) ,
876+ )
877+ ) {
878+ validationMessages . push (
879+ `asset_subclass should be one of ${ MPT_META_ASSET_SUB_CLASSES . join (
880+ ', ' ,
881+ ) } .`,
882+ )
883+ }
884+
885+ if (
886+ mptMPTokenMetadata . asset_class . toLowerCase ( ) === 'rwa' &&
887+ mptMPTokenMetadata . asset_subclass == null
888+ ) {
889+ validationMessages . push (
890+ `asset_subclass is required when asset_class is rwa.` ,
891+ )
892+ }
893+
894+ if (
895+ mptMPTokenMetadata . urls != null &&
896+ ! mptMPTokenMetadata . urls . every ( ( ele ) => ele . url . startsWith ( 'https://' ) )
897+ ) {
898+ validationMessages . push ( `url should be a valid https url.` )
899+ }
900+
901+ return validationMessages
902+ }
903+ /* eslint-enable max-lines-per-function */
904+ /* eslint-enable max-statements */
905+
906+ function isValidMPTokenMetadataUrlStructure ( input : unknown ) : boolean {
907+ if ( input == null ) {
908+ return false
909+ }
910+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Required here.
911+ const obj = input as Record < string , unknown >
912+
913+ return (
914+ typeof obj === 'object' &&
915+ isString ( obj . url ) &&
916+ isString ( obj . type ) &&
917+ isString ( obj . title ) &&
918+ Object . keys ( obj ) . length === MPT_META_URL_FIELD_COUNT
919+ )
920+ }
0 commit comments