Skip to content

Commit 7799528

Browse files
authored
Add warnings for MPTokenMetadata - XLS-89d (#3041)
* add console warnings for incorrect MPTokenMetadata * fix subscribe stream IT due to newly introduced network_id field
1 parent 06071a4 commit 7799528

File tree

12 files changed

+891
-23
lines changed

12 files changed

+891
-23
lines changed

packages/xrpl/HISTORY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr
1111
* Adds `XRPLNumber` amount type used in Vault transactions. This supports integer, decimal, or scientific notation strings.
1212
* Adds `ClawbackAmount` amount type used in transactions related to Clawback.
1313
* Fixed minified `build/xrpl-latest-min.js` to have all the latest xrpl package changes.
14+
* Add warning messages to `MPTokenIssuanceCreate` transaction as per [XLS-89d](https://github.com/XRPLF/XRPL-Standards/pull/293).
1415

1516
### Fixed
1617
* Fix `AccountRoot` ledger object to correctly parse `FirstNFTokenSequence` field

packages/xrpl/src/models/common/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,27 @@ export interface PriceData {
218218
Scale?: number
219219
}
220220
}
221+
222+
/**
223+
* MPTokenMetadata object as per the XLS-89d standard.
224+
*/
225+
export interface MPTokenMetadata {
226+
ticker: string
227+
name: string
228+
icon: string
229+
asset_class: string
230+
issuer_name: string
231+
desc?: string
232+
asset_subclass?: string
233+
urls?: MPTokenMetadataUrl[]
234+
additional_info?: string
235+
}
236+
237+
/**
238+
* MPTokenMetadataUrl object as per the XLS-89d standard.
239+
*/
240+
export interface MPTokenMetadataUrl {
241+
url: string
242+
type: string
243+
title: string
244+
}

packages/xrpl/src/models/methods/subscribe.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ export interface LedgerStream extends BaseStream {
142142
* connected but has not yet obtained a ledger from the network.
143143
*/
144144
validated_ledgers?: string
145+
146+
/**
147+
* The network from which the ledger stream is received.
148+
*/
149+
network_id?: number
145150
}
146151

147152
/**
@@ -181,6 +186,11 @@ export interface LedgerStreamResponse {
181186
* connected but has not yet obtained a ledger from the network.
182187
*/
183188
validated_ledgers?: string
189+
190+
/**
191+
* The network from which the ledger stream is received.
192+
*/
193+
network_id?: number
184194
}
185195

186196
/**
@@ -265,6 +275,11 @@ export interface ValidationStream extends BaseStream {
265275
* validator is using a token, this is an ephemeral public key.
266276
*/
267277
validation_public_key: string
278+
279+
/**
280+
* The network from which the validations stream is received.
281+
*/
282+
network_id?: number
268283
}
269284

270285
/**

packages/xrpl/src/models/transactions/MPTokenIssuanceCreate.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
validateOptionalField,
99
isString,
1010
isNumber,
11+
MAX_MPT_META_BYTE_LENGTH,
12+
MPT_META_WARNING_HEADER,
13+
validateMPTokenMetadata,
1114
} from './common'
1215
import type { TransactionMetadataBase } from './metadata'
1316

@@ -104,10 +107,18 @@ export interface MPTokenIssuanceCreate extends BaseTransaction {
104107
* The field must NOT be present if the `tfMPTCanTransfer` flag is not set.
105108
*/
106109
TransferFee?: number
110+
107111
/**
108-
* Arbitrary metadata about this issuance, in hex format.
112+
* Optional arbitrary metadata about this issuance, encoded as a hex string and limited to 1024 bytes.
113+
*
114+
* The decoded value must be a UTF-8 encoded JSON object that adheres to the
115+
* XLS-89d MPTokenMetadata standard.
116+
*
117+
* While adherence to the XLS-89d format is not mandatory, non-compliant metadata
118+
* may not be discoverable by ecosystem tools such as explorers and indexers.
109119
*/
110-
MPTokenMetadata?: string | null
120+
MPTokenMetadata?: string
121+
111122
Flags?: number | MPTokenIssuanceCreateFlagsInterface
112123
}
113124

@@ -131,15 +142,13 @@ export function validateMPTokenIssuanceCreate(
131142
validateOptionalField(tx, 'TransferFee', isNumber)
132143
validateOptionalField(tx, 'AssetScale', isNumber)
133144

134-
if (typeof tx.MPTokenMetadata === 'string' && tx.MPTokenMetadata === '') {
145+
if (
146+
typeof tx.MPTokenMetadata === 'string' &&
147+
(!isHex(tx.MPTokenMetadata) ||
148+
tx.MPTokenMetadata.length / 2 > MAX_MPT_META_BYTE_LENGTH)
149+
) {
135150
throw new ValidationError(
136-
'MPTokenIssuanceCreate: MPTokenMetadata must not be empty string',
137-
)
138-
}
139-
140-
if (typeof tx.MPTokenMetadata === 'string' && !isHex(tx.MPTokenMetadata)) {
141-
throw new ValidationError(
142-
'MPTokenIssuanceCreate: MPTokenMetadata must be in hex format',
151+
`MPTokenIssuanceCreate: MPTokenMetadata (hex format) must be non-empty and no more than ${MAX_MPT_META_BYTE_LENGTH} bytes.`,
143152
)
144153
}
145154

@@ -178,5 +187,19 @@ export function validateMPTokenIssuanceCreate(
178187
)
179188
}
180189
}
190+
191+
if (tx.MPTokenMetadata != null) {
192+
const validationMessages = validateMPTokenMetadata(tx.MPTokenMetadata)
193+
194+
if (validationMessages.length > 0) {
195+
const message = [
196+
MPT_META_WARNING_HEADER,
197+
...validationMessages.map((msg) => `- ${msg}`),
198+
].join('\n')
199+
200+
// eslint-disable-next-line no-console -- Required here.
201+
console.warn(message)
202+
}
203+
}
181204
}
182205
/* eslint-enable max-lines-per-function */

packages/xrpl/src/models/transactions/common.ts

Lines changed: 219 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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'
33
import { isValidClassicAddress, isValidXAddress } from 'ripple-address-codec'
44
import { 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
2223
export const MAX_AUTHORIZED_CREDENTIALS = 8
2324
const MAX_CREDENTIAL_BYTE_LENGTH = 64
2425
const MAX_CREDENTIAL_TYPE_LENGTH = MAX_CREDENTIAL_BYTE_LENGTH * 2
26+
export const MAX_MPT_META_BYTE_LENGTH = 1024
2527

2628
// Used for Vault transactions
2729
export const VAULT_DATA_MAX_BYTE_LENGTH = 256
2830

31+
// To validate MPTokenMetadata as per XLS-89d
32+
const TICKER_REGEX = /^[A-Z0-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+
2970
function 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+
}

packages/xrpl/src/models/transactions/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { BaseTransaction, isMPTAmount } from './common'
1+
export { BaseTransaction, isMPTAmount, validateMPTokenMetadata } from './common'
22
export {
33
validate,
44
PseudoTransaction,

0 commit comments

Comments
 (0)