Skip to main content

xrpl/utils/
mptoken_metadata.rs

1//! Helpers for encoding and decoding XLS-89 `MPTokenMetadata`.
2//!
3//! On-ledger metadata is stored as a hex blob whose decoded value is a JSON
4//! object. To save space the standard allows every field name to appear in a
5//! short ("compact") form. These helpers convert between the human-readable
6//! ("long") form developers work with and the compact form stored on-ledger,
7//! encoding/decoding the hex blob along the way.
8//!
9//! See <https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0089-multi-purpose-token-metadata-schema>.
10
11use alloc::{
12    borrow::Cow,
13    format,
14    string::{String, ToString},
15    vec,
16    vec::Vec,
17};
18
19use serde::{Deserialize, Serialize};
20use serde_json::{Map, Value};
21
22use super::exceptions::{XRPLMPTokenMetadataException, XRPLUtilsResult};
23
24/// Maximum byte length of the on-ledger `MPTokenMetadata` blob.
25pub const MAX_MPT_META_BYTE_LENGTH: usize = 1024;
26
27/// Warning describing metadata that does not conform to the XLS-89 standard.
28pub const MPT_META_WARNING_HEADER: &str = "MPTokenMetadata is not properly formatted as JSON as per the XLS-89 standard. \
29While adherence to this standard is not mandatory, such non-compliant MPTokens might not be discoverable \
30by Explorers and Indexers in the XRPL ecosystem.";
31
32/// `(long, compact)` field-name pairs for the top-level metadata object.
33const MPT_META_ALL_FIELDS: [(&str, &str); 9] = [
34    ("ticker", "t"),
35    ("name", "n"),
36    ("icon", "i"),
37    ("asset_class", "ac"),
38    ("issuer_name", "in"),
39    ("desc", "d"),
40    ("asset_subclass", "as"),
41    ("uris", "us"),
42    ("additional_info", "ai"),
43];
44
45/// `(long, compact)` field-name pairs for each entry of the `uris` array.
46const MPT_META_URI_FIELDS: [(&str, &str); 3] = [("uri", "u"), ("category", "c"), ("title", "t")];
47
48/// Allowed values for the `asset_class` field.
49const MPT_META_ASSET_CLASSES: [&str; 6] = ["rwa", "memes", "wrapped", "gaming", "defi", "other"];
50
51/// Allowed values for the `asset_subclass` field.
52const MPT_META_ASSET_SUB_CLASSES: [&str; 7] = [
53    "stablecoin",
54    "commodity",
55    "real_estate",
56    "private_credit",
57    "equity",
58    "treasury",
59    "other",
60];
61
62/// A related URI entry within [`MPTokenMetadata`], per XLS-89.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct MPTokenMetadataUri<'a> {
65    /// URI to the related resource (e.g. `https://...` or `ipfs://...`).
66    pub uri: Cow<'a, str>,
67    /// Category of the link: `website`, `social`, `docs`, or `other`.
68    pub category: Cow<'a, str>,
69    /// Human-readable label for the link.
70    pub title: Cow<'a, str>,
71}
72
73/// The freeform `additional_info` field: either UTF-8 text or a JSON object.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75#[serde(untagged)]
76pub enum MPTokenMetadataAdditionalInfo<'a> {
77    /// A UTF-8 string.
78    Text(Cow<'a, str>),
79    /// An arbitrary JSON object.
80    Object(Map<String, Value>),
81}
82
83/// `MPTokenMetadata` as described by the XLS-89 standard.
84///
85/// Build this in long form and pass it to [`encode_mptoken_metadata`] to
86/// produce the compact hex blob stored on-ledger;
87/// [`decode_mptoken_metadata`] produces a JSON value that deserializes back
88/// into this type.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct MPTokenMetadata<'a> {
91    /// Ticker symbol: uppercase letters (A-Z) and digits (0-9), max 6 chars.
92    pub ticker: Cow<'a, str>,
93    /// Display name of the token.
94    pub name: Cow<'a, str>,
95    /// Short description of the token.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub desc: Option<Cow<'a, str>>,
98    /// URI to the token icon.
99    pub icon: Cow<'a, str>,
100    /// Top-level classification: `rwa`, `memes`, `wrapped`, `gaming`, `defi`, or `other`.
101    pub asset_class: Cow<'a, str>,
102    /// Subcategory of the asset class. Required when `asset_class` is `rwa`.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub asset_subclass: Option<Cow<'a, str>>,
105    /// Name of the issuer account.
106    pub issuer_name: Cow<'a, str>,
107    /// Related URIs (site, dashboard, social media, documentation, ...).
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub uris: Option<Vec<MPTokenMetadataUri<'a>>>,
110    /// Freeform field for key token details (interest rate, maturity, ...).
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub additional_info: Option<MPTokenMetadataAdditionalInfo<'a>>,
113}
114
115/// Encodes `MPTokenMetadata` into the compact hex blob stored on-ledger.
116///
117/// The input is serialized to JSON, its known field names are shortened to
118/// their compact form, the object is serialized with deterministically sorted
119/// keys, and the result is hex-encoded. Unknown fields are preserved as-is, and
120/// fields that already carry both the long and compact form are left untouched.
121///
122/// # Errors
123///
124/// Returns an error if `metadata` does not serialize to a JSON object.
125pub fn encode_mptoken_metadata<M>(metadata: &M) -> XRPLUtilsResult<String>
126where
127    M: Serialize + ?Sized,
128{
129    let value = serde_json::to_value(metadata)?;
130    let obj = value
131        .as_object()
132        .ok_or(XRPLMPTokenMetadataException::NotJsonObject)?;
133
134    let mut shortened = transform_keys(obj, &MPT_META_ALL_FIELDS, Direction::Shorten);
135    transform_uri_array(&mut shortened, "uris", Direction::Shorten);
136    transform_uri_array(&mut shortened, "us", Direction::Shorten);
137
138    // `serde_json` (without `preserve_order`) serializes object keys sorted,
139    // giving a deterministic blob regardless of input field ordering.
140    let json = serde_json::to_string(&Value::Object(shortened))?;
141    Ok(hex::encode_upper(json.as_bytes()))
142}
143
144/// Decodes a hex `MPTokenMetadata` blob into a JSON object with long field names.
145///
146/// This is the inverse of [`encode_mptoken_metadata`]: the blob is hex-decoded,
147/// parsed as JSON, and its compact field names are expanded back to their long
148/// form. Unknown fields and fields carrying both forms are preserved as-is.
149///
150/// # Errors
151///
152/// Returns an error if the input is not valid hex, is not valid UTF-8, cannot be
153/// parsed as JSON, or does not decode to a JSON object.
154pub fn decode_mptoken_metadata(input: &str) -> XRPLUtilsResult<Value> {
155    if !is_hex(input) {
156        return Err(XRPLMPTokenMetadataException::InvalidHex.into());
157    }
158
159    let bytes = hex::decode(input)?;
160    let text = String::from_utf8(bytes).map_err(|e| e.utf8_error())?;
161    let value: Value = serde_json::from_str(&text)?;
162    let obj = value
163        .as_object()
164        .ok_or(XRPLMPTokenMetadataException::NotJsonObject)?;
165
166    let mut expanded = transform_keys(obj, &MPT_META_ALL_FIELDS, Direction::Expand);
167    transform_uri_array(&mut expanded, "uris", Direction::Expand);
168    transform_uri_array(&mut expanded, "us", Direction::Expand);
169
170    Ok(Value::Object(expanded))
171}
172
173/// Direction of a long <-> compact field-name conversion.
174#[derive(Clone, Copy)]
175enum Direction {
176    /// Rewrite long field names to their compact form.
177    Shorten,
178    /// Rewrite compact field names to their long form.
179    Expand,
180}
181
182/// Rewrites the keys of `input` between long and compact form per `mappings`.
183///
184/// Keys not present in `mappings` are copied verbatim. When both the long and
185/// compact form of a mapped field are present, both keys are kept as-is to
186/// avoid silently dropping data.
187fn transform_keys(
188    input: &Map<String, Value>,
189    mappings: &[(&str, &str)],
190    direction: Direction,
191) -> Map<String, Value> {
192    let mut output = Map::new();
193
194    for (key, value) in input {
195        match mappings
196            .iter()
197            .find(|pair| pair.0 == key.as_str() || pair.1 == key.as_str())
198        {
199            None => {
200                output.insert(key.clone(), value.clone());
201            }
202            Some(&(long, compact)) => {
203                if input.contains_key(long) && input.contains_key(compact) {
204                    output.insert(key.clone(), value.clone());
205                } else {
206                    let renamed = match direction {
207                        Direction::Shorten => compact,
208                        Direction::Expand => long,
209                    };
210                    output.insert(String::from(renamed), value.clone());
211                }
212            }
213        }
214    }
215
216    output
217}
218
219/// Applies [`transform_keys`] to each object element of the array at `key`.
220fn transform_uri_array(map: &mut Map<String, Value>, key: &str, direction: Direction) {
221    let transformed = match map.get(key) {
222        Some(Value::Array(arr)) => arr
223            .iter()
224            .map(|elem| match elem.as_object() {
225                Some(obj) => Value::Object(transform_keys(obj, &MPT_META_URI_FIELDS, direction)),
226                None => elem.clone(),
227            })
228            .collect::<alloc::vec::Vec<Value>>(),
229        _ => return,
230    };
231
232    map.insert(String::from(key), Value::Array(transformed));
233}
234
235/// Returns `true` if `value` is a non-empty, even-length string of hexadecimal
236/// characters. Hex byte strings always have an even number of characters.
237fn is_hex(value: &str) -> bool {
238    !value.is_empty()
239        && value.len().is_multiple_of(2)
240        && value.bytes().all(|b| b.is_ascii_hexdigit())
241}
242
243/// Validates that a hex `MPTokenMetadata` blob adheres to the XLS-89 standard.
244///
245/// Returns a list of human-readable messages describing every way in which the
246/// blob fails to conform. An empty list means the metadata is valid. Both the
247/// long and compact form of each field are accepted, and unknown fields are
248/// allowed (subject to the top-level field-count limit).
249pub fn validate_mptoken_metadata(input: &str) -> Vec<String> {
250    let mut messages = Vec::new();
251
252    if !is_hex(input) {
253        messages.push("MPTokenMetadata must be in hex format.".to_string());
254        return messages;
255    }
256
257    if input.len() / 2 > MAX_MPT_META_BYTE_LENGTH {
258        messages.push(format!(
259            "MPTokenMetadata must be max {MAX_MPT_META_BYTE_LENGTH} bytes."
260        ));
261        return messages;
262    }
263
264    // `is_hex` guarantees an even-length hex string, so decoding cannot fail; the
265    // decoded bytes may still be invalid UTF-8, which is reported below.
266    let bytes = hex::decode(input).unwrap_or_default();
267    let text = match String::from_utf8(bytes) {
268        Ok(text) => text,
269        Err(err) => {
270            messages.push(format!(
271                "MPTokenMetadata is not properly formatted as JSON - {err}"
272            ));
273            return messages;
274        }
275    };
276
277    let value: Value = match serde_json::from_str(&text) {
278        Ok(value) => value,
279        Err(err) => {
280            messages.push(format!(
281                "MPTokenMetadata is not properly formatted as JSON - {err}"
282            ));
283            return messages;
284        }
285    };
286
287    let obj = match value.as_object() {
288        Some(obj) => obj,
289        None => {
290            messages.push(
291                "MPTokenMetadata is not properly formatted JSON object as per XLS-89.".to_string(),
292            );
293            return messages;
294        }
295    };
296
297    if obj.len() > MPT_META_ALL_FIELDS.len() {
298        messages.push(format!(
299            "MPTokenMetadata must not contain more than {} top-level fields (found {}).",
300            MPT_META_ALL_FIELDS.len(),
301            obj.len()
302        ));
303    }
304
305    // Field order is significant: messages are emitted in this order.
306    messages.extend(validate_ticker(obj));
307    messages.extend(validate_non_empty_string(obj, "name", "n"));
308    messages.extend(validate_non_empty_string(obj, "icon", "i"));
309    messages.extend(validate_asset_class(obj));
310    messages.extend(validate_non_empty_string(obj, "issuer_name", "in"));
311    messages.extend(validate_optional_non_empty_string(obj, "desc", "d"));
312    messages.extend(validate_asset_subclass(obj));
313    messages.extend(validate_uris(obj));
314    messages.extend(validate_additional_info(obj));
315
316    messages
317}
318
319/// Builds the user-facing warning for a non-conformant `MPTokenMetadata` blob.
320///
321/// Returns `None` when `input` conforms to XLS-89, or `Some(message)` — the
322/// [`MPT_META_WARNING_HEADER`] followed by each [`validate_mptoken_metadata`]
323/// message as a bulleted line — when it does not. This mirrors how xrpl.js
324/// surfaces a non-fatal `console.warn` for non-compliant metadata when
325/// validating an `MPTokenIssuanceCreate`: adherence to XLS-89 is not mandatory,
326/// so the metadata is still usable, but callers should warn the user.
327pub fn mptoken_metadata_warning(input: &str) -> Option<String> {
328    let messages = validate_mptoken_metadata(input);
329    if messages.is_empty() {
330        return None;
331    }
332
333    let mut warning = String::from(MPT_META_WARNING_HEADER);
334    for message in &messages {
335        warning.push_str("\n- ");
336        warning.push_str(message);
337    }
338    Some(warning)
339}
340
341/// JS `obj[key] != null`: the key is present and not JSON null.
342fn present_non_null(obj: &Map<String, Value>, key: &str) -> bool {
343    matches!(obj.get(key), Some(value) if !value.is_null())
344}
345
346/// JS `obj[long] != null && obj[compact] != null`.
347fn has_both_forms(obj: &Map<String, Value>, long: &str, compact: &str) -> bool {
348    present_non_null(obj, long) && present_non_null(obj, compact)
349}
350
351/// JS `obj[long] === undefined && obj[compact] === undefined`.
352fn neither_form_present(obj: &Map<String, Value>, long: &str, compact: &str) -> bool {
353    obj.get(long).is_none() && obj.get(compact).is_none()
354}
355
356/// JS `obj[long] ?? obj[compact]`: prefer the long form unless null/absent.
357fn coalesce<'a>(obj: &'a Map<String, Value>, long: &str, compact: &str) -> Option<&'a Value> {
358    match obj.get(long) {
359        Some(value) if !value.is_null() => Some(value),
360        _ => obj.get(compact),
361    }
362}
363
364fn is_string(value: Option<&Value>) -> bool {
365    matches!(value, Some(Value::String(_)))
366}
367
368fn is_non_empty_string(value: Option<&Value>) -> bool {
369    matches!(value, Some(Value::String(s)) if !s.is_empty())
370}
371
372fn equals_str(value: Option<&Value>, expected: &str) -> bool {
373    matches!(value, Some(Value::String(s)) if s == expected)
374}
375
376fn both_forms_message(long: &str, compact: &str) -> String {
377    format!("{long}/{compact}: both long and compact forms present. expected only one.")
378}
379
380fn validate_ticker(obj: &Map<String, Value>) -> Vec<String> {
381    if has_both_forms(obj, "ticker", "t") {
382        return vec![both_forms_message("ticker", "t")];
383    }
384    let valid =
385        matches!(coalesce(obj, "ticker", "t"), Some(Value::String(s)) if is_valid_ticker(s));
386    if !valid {
387        return vec![
388            "ticker/t: should have uppercase letters (A-Z) and digits (0-9) only. Max 6 characters recommended."
389                .to_string(),
390        ];
391    }
392    Vec::new()
393}
394
395fn is_valid_ticker(value: &str) -> bool {
396    let len = value.chars().count();
397    (1..=6).contains(&len)
398        && value
399            .chars()
400            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
401}
402
403fn validate_non_empty_string(obj: &Map<String, Value>, long: &str, compact: &str) -> Vec<String> {
404    if has_both_forms(obj, long, compact) {
405        return vec![both_forms_message(long, compact)];
406    }
407    if !is_non_empty_string(coalesce(obj, long, compact)) {
408        return vec![format!("{long}/{compact}: should be a non-empty string.")];
409    }
410    Vec::new()
411}
412
413fn validate_optional_non_empty_string(
414    obj: &Map<String, Value>,
415    long: &str,
416    compact: &str,
417) -> Vec<String> {
418    if has_both_forms(obj, long, compact) {
419        return vec![both_forms_message(long, compact)];
420    }
421    if neither_form_present(obj, long, compact) {
422        return Vec::new();
423    }
424    if !is_non_empty_string(coalesce(obj, long, compact)) {
425        return vec![format!("{long}/{compact}: should be a non-empty string.")];
426    }
427    Vec::new()
428}
429
430fn validate_asset_class(obj: &Map<String, Value>) -> Vec<String> {
431    if has_both_forms(obj, "asset_class", "ac") {
432        return vec![both_forms_message("asset_class", "ac")];
433    }
434    let value = coalesce(obj, "asset_class", "ac");
435    let valid =
436        matches!(value, Some(Value::String(s)) if MPT_META_ASSET_CLASSES.contains(&s.as_str()));
437    if !valid {
438        return vec![format!(
439            "asset_class/ac: should be one of {}.",
440            MPT_META_ASSET_CLASSES.join(", ")
441        )];
442    }
443    Vec::new()
444}
445
446fn validate_asset_subclass(obj: &Map<String, Value>) -> Vec<String> {
447    if has_both_forms(obj, "asset_subclass", "as") {
448        return vec![both_forms_message("asset_subclass", "as")];
449    }
450    let value = coalesce(obj, "asset_subclass", "as");
451    let is_rwa = equals_str(obj.get("asset_class"), "rwa") || equals_str(obj.get("ac"), "rwa");
452    if is_rwa && value.is_none() {
453        return vec!["asset_subclass/as: required when asset_class is rwa.".to_string()];
454    }
455    if neither_form_present(obj, "asset_subclass", "as") {
456        return Vec::new();
457    }
458    let valid =
459        matches!(value, Some(Value::String(s)) if MPT_META_ASSET_SUB_CLASSES.contains(&s.as_str()));
460    if !valid {
461        return vec![format!(
462            "asset_subclass/as: should be one of {}.",
463            MPT_META_ASSET_SUB_CLASSES.join(", ")
464        )];
465    }
466    Vec::new()
467}
468
469fn validate_uris(obj: &Map<String, Value>) -> Vec<String> {
470    if has_both_forms(obj, "uris", "us") {
471        return vec![both_forms_message("uris", "us")];
472    }
473    if neither_form_present(obj, "uris", "us") {
474        return Vec::new();
475    }
476
477    let arr = match coalesce(obj, "uris", "us") {
478        Some(Value::Array(arr)) if !arr.is_empty() => arr,
479        _ => return vec!["uris/us: should be a non-empty array.".to_string()],
480    };
481
482    let structure_message =
483        "uris/us: should be an array of objects each with uri/u, category/c, and title/t properties.";
484    let mut messages = Vec::new();
485
486    for elem in arr {
487        let uri_obj = match elem.as_object() {
488            Some(uri_obj) if uri_obj.len() == MPT_META_URI_FIELDS.len() => uri_obj,
489            _ => {
490                messages.push(structure_message.to_string());
491                continue;
492            }
493        };
494
495        for &(long, compact) in &MPT_META_URI_FIELDS {
496            if has_both_forms(uri_obj, long, compact) {
497                messages.push(format!(
498                    "uris/us: should not have both {long} and {compact} fields."
499                ));
500                break;
501            }
502        }
503
504        let uri = coalesce(uri_obj, "uri", "u");
505        let category = coalesce(uri_obj, "category", "c");
506        let title = coalesce(uri_obj, "title", "t");
507        if !(is_string(uri) && is_string(category) && is_string(title)) {
508            messages.push(structure_message.to_string());
509        }
510    }
511
512    messages
513}
514
515fn validate_additional_info(obj: &Map<String, Value>) -> Vec<String> {
516    if has_both_forms(obj, "additional_info", "ai") {
517        return vec![both_forms_message("additional_info", "ai")];
518    }
519    if neither_form_present(obj, "additional_info", "ai") {
520        return Vec::new();
521    }
522    let value = coalesce(obj, "additional_info", "ai");
523    if !matches!(value, Some(Value::String(_)) | Some(Value::Object(_))) {
524        return vec!["additional_info/ai: should be a string or JSON object.".to_string()];
525    }
526    Vec::new()
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use alloc::{format, string::String, vec::Vec};
533    use serde::Deserialize;
534
535    #[derive(Deserialize)]
536    struct EncodeDecodeCase {
537        #[serde(rename = "testName")]
538        test_name: String,
539        #[serde(rename = "mptMetadata")]
540        mpt_metadata: Value,
541        #[serde(rename = "expectedLongForm")]
542        expected_long_form: Value,
543        hex: String,
544    }
545
546    #[test]
547    fn test_encode_decode_fixtures() {
548        let data = include_str!("./test_data/mptoken_metadata_encode_decode.json");
549        let cases: Vec<EncodeDecodeCase> = serde_json::from_str(data).unwrap();
550
551        for case in cases {
552            let encoded = encode_mptoken_metadata(&case.mpt_metadata).unwrap();
553            assert_eq!(
554                encoded, case.hex,
555                "encode mismatch for `{}`",
556                case.test_name
557            );
558
559            let decoded = decode_mptoken_metadata(&case.hex).unwrap();
560            assert_eq!(
561                decoded, case.expected_long_form,
562                "decode mismatch for `{}`",
563                case.test_name
564            );
565        }
566    }
567
568    #[derive(Deserialize)]
569    struct ValidationCase {
570        #[serde(rename = "testName")]
571        test_name: String,
572        #[serde(rename = "mptMetadata")]
573        mpt_metadata: Value,
574        #[serde(rename = "validationMessages")]
575        validation_messages: Vec<String>,
576    }
577
578    #[test]
579    fn test_validation_fixtures() {
580        // serde_json's parse-error text differs from V8's, so messages on this
581        // path are matched by prefix only.
582        const JSON_PARSE_PREFIX: &str = "MPTokenMetadata is not properly formatted as JSON -";
583
584        let data = include_str!("./test_data/mptoken_metadata_validation.json");
585        let cases: Vec<ValidationCase> = serde_json::from_str(data).unwrap();
586
587        for case in cases {
588            // The fixture harness uses a raw string value as the blob payload
589            // directly; any other value is serialized to JSON first.
590            let payload = match &case.mpt_metadata {
591                Value::String(s) => s.clone(),
592                other => serde_json::to_string(other).unwrap(),
593            };
594            let hex = hex::encode_upper(payload.as_bytes());
595
596            let actual = validate_mptoken_metadata(&hex);
597            assert_eq!(
598                actual.len(),
599                case.validation_messages.len(),
600                "message count mismatch for `{}`: {actual:?}",
601                case.test_name
602            );
603
604            for (got, want) in actual.iter().zip(case.validation_messages.iter()) {
605                if want.starts_with(JSON_PARSE_PREFIX) {
606                    assert!(
607                        got.starts_with(JSON_PARSE_PREFIX),
608                        "expected JSON-parse message for `{}`, got: {got}",
609                        case.test_name
610                    );
611                } else {
612                    assert_eq!(got, want, "message mismatch for `{}`", case.test_name);
613                }
614            }
615        }
616    }
617
618    #[test]
619    fn test_encode_rejects_non_object() {
620        let err = encode_mptoken_metadata(&Value::String("nope".into())).unwrap_err();
621        assert_eq!(
622            err.to_string(),
623            format!(
624                "XRPL MPTokenMetadata error: {}",
625                XRPLMPTokenMetadataException::NotJsonObject
626            )
627        );
628    }
629
630    #[test]
631    fn test_decode_rejects_non_hex() {
632        let err = decode_mptoken_metadata("not-hex!").unwrap_err();
633        assert_eq!(
634            err.to_string(),
635            format!(
636                "XRPL MPTokenMetadata error: {}",
637                XRPLMPTokenMetadataException::InvalidHex
638            )
639        );
640    }
641
642    #[test]
643    fn test_typed_metadata_round_trip() {
644        let metadata = MPTokenMetadata {
645            ticker: "TBILL".into(),
646            name: "T-Bill Yield Token".into(),
647            desc: Some("A yield-bearing stablecoin backed by U.S. Treasuries.".into()),
648            icon: "https://example.org/tbill-icon.png".into(),
649            asset_class: "rwa".into(),
650            asset_subclass: Some("treasury".into()),
651            issuer_name: "Example Yield Co.".into(),
652            uris: Some(vec![MPTokenMetadataUri {
653                uri: "https://exampleyield.co/tbill".into(),
654                category: "website".into(),
655                title: "Product Page".into(),
656            }]),
657            additional_info: Some(MPTokenMetadataAdditionalInfo::Object(
658                serde_json::json!({ "interest_rate": "5.00%", "maturity_date": "2045-06-30" })
659                    .as_object()
660                    .unwrap()
661                    .clone(),
662            )),
663        };
664
665        let encoded = encode_mptoken_metadata(&metadata).unwrap();
666        assert!(validate_mptoken_metadata(&encoded).is_empty());
667
668        let decoded = decode_mptoken_metadata(&encoded).unwrap();
669        let round_trip: MPTokenMetadata = serde_json::from_value(decoded).unwrap();
670        assert_eq!(round_trip, metadata);
671    }
672
673    /// Hex-encode a JSON value the way the ledger stores it, then validate it.
674    fn validate_json(value: serde_json::Value) -> Vec<String> {
675        let hex = hex::encode_upper(serde_json::to_string(&value).unwrap().as_bytes());
676        validate_mptoken_metadata(&hex)
677    }
678
679    #[test]
680    fn test_validate_rejects_non_hex_input() {
681        let expected = vec!["MPTokenMetadata must be in hex format.".to_string()];
682        // Non-hex characters.
683        assert_eq!(validate_mptoken_metadata("xyz"), expected);
684        // Valid hex characters but an odd length is not a valid hex byte string.
685        assert_eq!(validate_mptoken_metadata("ABC"), expected);
686    }
687
688    #[test]
689    fn test_validate_reports_non_utf8_blob() {
690        // Even-length valid hex that decodes to 0xFF, which is not valid UTF-8.
691        let messages = validate_mptoken_metadata("FF");
692        assert_eq!(messages.len(), 1);
693        assert!(messages[0].starts_with("MPTokenMetadata is not properly formatted as JSON -"));
694    }
695
696    #[test]
697    fn test_mptoken_metadata_warning() {
698        use serde_json::json;
699
700        let to_hex = |value: &serde_json::Value| {
701            hex::encode_upper(serde_json::to_string(value).unwrap().as_bytes())
702        };
703
704        // Conformant metadata produces no warning.
705        let valid = json!({
706            "ticker": "TBILL",
707            "name": "T-Bill Token",
708            "icon": "https://example.com/icon.png",
709            "asset_class": "rwa",
710            "asset_subclass": "treasury",
711            "issuer_name": "Issuer"
712        });
713        assert_eq!(mptoken_metadata_warning(&to_hex(&valid)), None);
714
715        // Non-conformant metadata (invalid `uris`) warns with the header followed
716        // by the specific validation messages — as xrpl.js does on
717        // MPTokenIssuanceCreate.
718        let invalid = json!({
719            "ticker": "TBILL",
720            "name": "T-Bill Token",
721            "icon": "https://example.com/icon.png",
722            "asset_class": "rwa",
723            "asset_subclass": "treasury",
724            "issuer_name": "Issuer",
725            "uris": ["apple"]
726        });
727        let warning = mptoken_metadata_warning(&to_hex(&invalid)).expect("expected a warning");
728        assert!(warning.starts_with(MPT_META_WARNING_HEADER));
729        assert!(warning.contains(
730            "\n- uris/us: should be an array of objects each with uri/u, category/c, and title/t properties."
731        ));
732    }
733
734    #[test]
735    fn test_validate_reports_both_forms_for_every_field() {
736        use serde_json::json;
737
738        let base = || {
739            json!({
740                "ticker": "TBILL",
741                "name": "T-Bill",
742                "icon": "https://example.org/icon.png",
743                "asset_class": "rwa",
744                "asset_subclass": "treasury",
745                "issuer_name": "Issuer"
746            })
747        };
748        assert!(validate_json(base()).is_empty(), "baseline should be valid");
749
750        // Adding a single compact-form key conflicts with its long form.
751        let single_key_cases = [
752            ("n", json!("dup"), "name/n"),
753            ("i", json!("https://dup"), "icon/i"),
754            ("in", json!("dup"), "issuer_name/in"),
755            ("ac", json!("rwa"), "asset_class/ac"),
756            ("as", json!("treasury"), "asset_subclass/as"),
757        ];
758        for (key, value, prefix) in single_key_cases {
759            let mut obj = base();
760            obj[key] = value;
761            assert_eq!(
762                validate_json(obj),
763                vec![format!(
764                    "{prefix}: both long and compact forms present. expected only one."
765                )],
766                "field {prefix}"
767            );
768        }
769
770        // desc / uris / additional_info are optional, so set both forms explicitly.
771        let mut desc = base();
772        desc["desc"] = json!("a");
773        desc["d"] = json!("b");
774        assert_eq!(
775            validate_json(desc),
776            vec!["desc/d: both long and compact forms present. expected only one.".to_string()]
777        );
778
779        let mut uris = base();
780        uris["uris"] = json!([{ "uri": "https://x", "category": "website", "title": "T" }]);
781        uris["us"] = json!([{ "u": "https://x", "c": "website", "t": "T" }]);
782        assert_eq!(
783            validate_json(uris),
784            vec!["uris/us: both long and compact forms present. expected only one.".to_string()]
785        );
786
787        let mut info = base();
788        info["additional_info"] = json!("x");
789        info["ai"] = json!("y");
790        assert_eq!(
791            validate_json(info),
792            vec![
793                "additional_info/ai: both long and compact forms present. expected only one."
794                    .to_string()
795            ]
796        );
797    }
798
799    #[test]
800    fn test_encode_decode_preserves_non_object_uri_elements() {
801        use serde_json::json;
802
803        // Non-object entries in a uris/us array pass through encode/decode unchanged.
804        let value = json!({ "ticker": "TBILL", "uris": [123, "not-an-object"] });
805        let encoded = encode_mptoken_metadata(&value).unwrap();
806        let decoded = decode_mptoken_metadata(&encoded).unwrap();
807        assert_eq!(
808            decoded,
809            json!({ "ticker": "TBILL", "uris": [123, "not-an-object"] })
810        );
811    }
812}