use alloc::{
borrow::Cow,
format,
string::{String, ToString},
vec,
vec::Vec,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use super::exceptions::{XRPLMPTokenMetadataException, XRPLUtilsResult};
pub const MAX_MPT_META_BYTE_LENGTH: usize = 1024;
pub const MPT_META_WARNING_HEADER: &str = "MPTokenMetadata is not properly formatted as JSON as per the XLS-89 standard. \
While adherence to this standard is not mandatory, such non-compliant MPTokens might not be discoverable \
by Explorers and Indexers in the XRPL ecosystem.";
const MPT_META_ALL_FIELDS: [(&str, &str); 9] = [
("ticker", "t"),
("name", "n"),
("icon", "i"),
("asset_class", "ac"),
("issuer_name", "in"),
("desc", "d"),
("asset_subclass", "as"),
("uris", "us"),
("additional_info", "ai"),
];
const MPT_META_URI_FIELDS: [(&str, &str); 3] = [("uri", "u"), ("category", "c"), ("title", "t")];
const MPT_META_ASSET_CLASSES: [&str; 6] = ["rwa", "memes", "wrapped", "gaming", "defi", "other"];
const MPT_META_ASSET_SUB_CLASSES: [&str; 7] = [
"stablecoin",
"commodity",
"real_estate",
"private_credit",
"equity",
"treasury",
"other",
];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MPTokenMetadataUri<'a> {
pub uri: Cow<'a, str>,
pub category: Cow<'a, str>,
pub title: Cow<'a, str>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MPTokenMetadataAdditionalInfo<'a> {
Text(Cow<'a, str>),
Object(Map<String, Value>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MPTokenMetadata<'a> {
pub ticker: Cow<'a, str>,
pub name: Cow<'a, str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub desc: Option<Cow<'a, str>>,
pub icon: Cow<'a, str>,
pub asset_class: Cow<'a, str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_subclass: Option<Cow<'a, str>>,
pub issuer_name: Cow<'a, str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uris: Option<Vec<MPTokenMetadataUri<'a>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additional_info: Option<MPTokenMetadataAdditionalInfo<'a>>,
}
pub fn encode_mptoken_metadata<M>(metadata: &M) -> XRPLUtilsResult<String>
where
M: Serialize + ?Sized,
{
let value = serde_json::to_value(metadata)?;
let obj = value
.as_object()
.ok_or(XRPLMPTokenMetadataException::NotJsonObject)?;
let mut shortened = transform_keys(obj, &MPT_META_ALL_FIELDS, Direction::Shorten);
transform_uri_array(&mut shortened, "uris", Direction::Shorten);
transform_uri_array(&mut shortened, "us", Direction::Shorten);
let json = serde_json::to_string(&Value::Object(shortened))?;
Ok(hex::encode_upper(json.as_bytes()))
}
pub fn decode_mptoken_metadata(input: &str) -> XRPLUtilsResult<Value> {
if !is_hex(input) {
return Err(XRPLMPTokenMetadataException::InvalidHex.into());
}
let bytes = hex::decode(input)?;
let text = String::from_utf8(bytes).map_err(|e| e.utf8_error())?;
let value: Value = serde_json::from_str(&text)?;
let obj = value
.as_object()
.ok_or(XRPLMPTokenMetadataException::NotJsonObject)?;
let mut expanded = transform_keys(obj, &MPT_META_ALL_FIELDS, Direction::Expand);
transform_uri_array(&mut expanded, "uris", Direction::Expand);
transform_uri_array(&mut expanded, "us", Direction::Expand);
Ok(Value::Object(expanded))
}
#[derive(Clone, Copy)]
enum Direction {
Shorten,
Expand,
}
fn transform_keys(
input: &Map<String, Value>,
mappings: &[(&str, &str)],
direction: Direction,
) -> Map<String, Value> {
let mut output = Map::new();
for (key, value) in input {
match mappings
.iter()
.find(|pair| pair.0 == key.as_str() || pair.1 == key.as_str())
{
None => {
output.insert(key.clone(), value.clone());
}
Some(&(long, compact)) => {
if input.contains_key(long) && input.contains_key(compact) {
output.insert(key.clone(), value.clone());
} else {
let renamed = match direction {
Direction::Shorten => compact,
Direction::Expand => long,
};
output.insert(String::from(renamed), value.clone());
}
}
}
}
output
}
fn transform_uri_array(map: &mut Map<String, Value>, key: &str, direction: Direction) {
let transformed = match map.get(key) {
Some(Value::Array(arr)) => arr
.iter()
.map(|elem| match elem.as_object() {
Some(obj) => Value::Object(transform_keys(obj, &MPT_META_URI_FIELDS, direction)),
None => elem.clone(),
})
.collect::<alloc::vec::Vec<Value>>(),
_ => return,
};
map.insert(String::from(key), Value::Array(transformed));
}
fn is_hex(value: &str) -> bool {
!value.is_empty()
&& value.len().is_multiple_of(2)
&& value.bytes().all(|b| b.is_ascii_hexdigit())
}
pub fn validate_mptoken_metadata(input: &str) -> Vec<String> {
let mut messages = Vec::new();
if !is_hex(input) {
messages.push("MPTokenMetadata must be in hex format.".to_string());
return messages;
}
if input.len() / 2 > MAX_MPT_META_BYTE_LENGTH {
messages.push(format!(
"MPTokenMetadata must be max {MAX_MPT_META_BYTE_LENGTH} bytes."
));
return messages;
}
let bytes = hex::decode(input).unwrap_or_default();
let text = match String::from_utf8(bytes) {
Ok(text) => text,
Err(err) => {
messages.push(format!(
"MPTokenMetadata is not properly formatted as JSON - {err}"
));
return messages;
}
};
let value: Value = match serde_json::from_str(&text) {
Ok(value) => value,
Err(err) => {
messages.push(format!(
"MPTokenMetadata is not properly formatted as JSON - {err}"
));
return messages;
}
};
let obj = match value.as_object() {
Some(obj) => obj,
None => {
messages.push(
"MPTokenMetadata is not properly formatted JSON object as per XLS-89.".to_string(),
);
return messages;
}
};
if obj.len() > MPT_META_ALL_FIELDS.len() {
messages.push(format!(
"MPTokenMetadata must not contain more than {} top-level fields (found {}).",
MPT_META_ALL_FIELDS.len(),
obj.len()
));
}
messages.extend(validate_ticker(obj));
messages.extend(validate_non_empty_string(obj, "name", "n"));
messages.extend(validate_non_empty_string(obj, "icon", "i"));
messages.extend(validate_asset_class(obj));
messages.extend(validate_non_empty_string(obj, "issuer_name", "in"));
messages.extend(validate_optional_non_empty_string(obj, "desc", "d"));
messages.extend(validate_asset_subclass(obj));
messages.extend(validate_uris(obj));
messages.extend(validate_additional_info(obj));
messages
}
pub fn mptoken_metadata_warning(input: &str) -> Option<String> {
let messages = validate_mptoken_metadata(input);
if messages.is_empty() {
return None;
}
let mut warning = String::from(MPT_META_WARNING_HEADER);
for message in &messages {
warning.push_str("\n- ");
warning.push_str(message);
}
Some(warning)
}
fn present_non_null(obj: &Map<String, Value>, key: &str) -> bool {
matches!(obj.get(key), Some(value) if !value.is_null())
}
fn has_both_forms(obj: &Map<String, Value>, long: &str, compact: &str) -> bool {
present_non_null(obj, long) && present_non_null(obj, compact)
}
fn neither_form_present(obj: &Map<String, Value>, long: &str, compact: &str) -> bool {
obj.get(long).is_none() && obj.get(compact).is_none()
}
fn coalesce<'a>(obj: &'a Map<String, Value>, long: &str, compact: &str) -> Option<&'a Value> {
match obj.get(long) {
Some(value) if !value.is_null() => Some(value),
_ => obj.get(compact),
}
}
fn is_string(value: Option<&Value>) -> bool {
matches!(value, Some(Value::String(_)))
}
fn is_non_empty_string(value: Option<&Value>) -> bool {
matches!(value, Some(Value::String(s)) if !s.is_empty())
}
fn equals_str(value: Option<&Value>, expected: &str) -> bool {
matches!(value, Some(Value::String(s)) if s == expected)
}
fn both_forms_message(long: &str, compact: &str) -> String {
format!("{long}/{compact}: both long and compact forms present. expected only one.")
}
fn validate_ticker(obj: &Map<String, Value>) -> Vec<String> {
if has_both_forms(obj, "ticker", "t") {
return vec![both_forms_message("ticker", "t")];
}
let valid =
matches!(coalesce(obj, "ticker", "t"), Some(Value::String(s)) if is_valid_ticker(s));
if !valid {
return vec![
"ticker/t: should have uppercase letters (A-Z) and digits (0-9) only. Max 6 characters recommended."
.to_string(),
];
}
Vec::new()
}
fn is_valid_ticker(value: &str) -> bool {
let len = value.chars().count();
(1..=6).contains(&len)
&& value
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
}
fn validate_non_empty_string(obj: &Map<String, Value>, long: &str, compact: &str) -> Vec<String> {
if has_both_forms(obj, long, compact) {
return vec![both_forms_message(long, compact)];
}
if !is_non_empty_string(coalesce(obj, long, compact)) {
return vec![format!("{long}/{compact}: should be a non-empty string.")];
}
Vec::new()
}
fn validate_optional_non_empty_string(
obj: &Map<String, Value>,
long: &str,
compact: &str,
) -> Vec<String> {
if has_both_forms(obj, long, compact) {
return vec![both_forms_message(long, compact)];
}
if neither_form_present(obj, long, compact) {
return Vec::new();
}
if !is_non_empty_string(coalesce(obj, long, compact)) {
return vec![format!("{long}/{compact}: should be a non-empty string.")];
}
Vec::new()
}
fn validate_asset_class(obj: &Map<String, Value>) -> Vec<String> {
if has_both_forms(obj, "asset_class", "ac") {
return vec![both_forms_message("asset_class", "ac")];
}
let value = coalesce(obj, "asset_class", "ac");
let valid =
matches!(value, Some(Value::String(s)) if MPT_META_ASSET_CLASSES.contains(&s.as_str()));
if !valid {
return vec![format!(
"asset_class/ac: should be one of {}.",
MPT_META_ASSET_CLASSES.join(", ")
)];
}
Vec::new()
}
fn validate_asset_subclass(obj: &Map<String, Value>) -> Vec<String> {
if has_both_forms(obj, "asset_subclass", "as") {
return vec![both_forms_message("asset_subclass", "as")];
}
let value = coalesce(obj, "asset_subclass", "as");
let is_rwa = equals_str(obj.get("asset_class"), "rwa") || equals_str(obj.get("ac"), "rwa");
if is_rwa && value.is_none() {
return vec!["asset_subclass/as: required when asset_class is rwa.".to_string()];
}
if neither_form_present(obj, "asset_subclass", "as") {
return Vec::new();
}
let valid =
matches!(value, Some(Value::String(s)) if MPT_META_ASSET_SUB_CLASSES.contains(&s.as_str()));
if !valid {
return vec![format!(
"asset_subclass/as: should be one of {}.",
MPT_META_ASSET_SUB_CLASSES.join(", ")
)];
}
Vec::new()
}
fn validate_uris(obj: &Map<String, Value>) -> Vec<String> {
if has_both_forms(obj, "uris", "us") {
return vec![both_forms_message("uris", "us")];
}
if neither_form_present(obj, "uris", "us") {
return Vec::new();
}
let arr = match coalesce(obj, "uris", "us") {
Some(Value::Array(arr)) if !arr.is_empty() => arr,
_ => return vec!["uris/us: should be a non-empty array.".to_string()],
};
let structure_message =
"uris/us: should be an array of objects each with uri/u, category/c, and title/t properties.";
let mut messages = Vec::new();
for elem in arr {
let uri_obj = match elem.as_object() {
Some(uri_obj) if uri_obj.len() == MPT_META_URI_FIELDS.len() => uri_obj,
_ => {
messages.push(structure_message.to_string());
continue;
}
};
for &(long, compact) in &MPT_META_URI_FIELDS {
if has_both_forms(uri_obj, long, compact) {
messages.push(format!(
"uris/us: should not have both {long} and {compact} fields."
));
break;
}
}
let uri = coalesce(uri_obj, "uri", "u");
let category = coalesce(uri_obj, "category", "c");
let title = coalesce(uri_obj, "title", "t");
if !(is_string(uri) && is_string(category) && is_string(title)) {
messages.push(structure_message.to_string());
}
}
messages
}
fn validate_additional_info(obj: &Map<String, Value>) -> Vec<String> {
if has_both_forms(obj, "additional_info", "ai") {
return vec![both_forms_message("additional_info", "ai")];
}
if neither_form_present(obj, "additional_info", "ai") {
return Vec::new();
}
let value = coalesce(obj, "additional_info", "ai");
if !matches!(value, Some(Value::String(_)) | Some(Value::Object(_))) {
return vec!["additional_info/ai: should be a string or JSON object.".to_string()];
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::{format, string::String, vec::Vec};
use serde::Deserialize;
#[derive(Deserialize)]
struct EncodeDecodeCase {
#[serde(rename = "testName")]
test_name: String,
#[serde(rename = "mptMetadata")]
mpt_metadata: Value,
#[serde(rename = "expectedLongForm")]
expected_long_form: Value,
hex: String,
}
#[test]
fn test_encode_decode_fixtures() {
let data = include_str!("./test_data/mptoken_metadata_encode_decode.json");
let cases: Vec<EncodeDecodeCase> = serde_json::from_str(data).unwrap();
for case in cases {
let encoded = encode_mptoken_metadata(&case.mpt_metadata).unwrap();
assert_eq!(
encoded, case.hex,
"encode mismatch for `{}`",
case.test_name
);
let decoded = decode_mptoken_metadata(&case.hex).unwrap();
assert_eq!(
decoded, case.expected_long_form,
"decode mismatch for `{}`",
case.test_name
);
}
}
#[derive(Deserialize)]
struct ValidationCase {
#[serde(rename = "testName")]
test_name: String,
#[serde(rename = "mptMetadata")]
mpt_metadata: Value,
#[serde(rename = "validationMessages")]
validation_messages: Vec<String>,
}
#[test]
fn test_validation_fixtures() {
const JSON_PARSE_PREFIX: &str = "MPTokenMetadata is not properly formatted as JSON -";
let data = include_str!("./test_data/mptoken_metadata_validation.json");
let cases: Vec<ValidationCase> = serde_json::from_str(data).unwrap();
for case in cases {
let payload = match &case.mpt_metadata {
Value::String(s) => s.clone(),
other => serde_json::to_string(other).unwrap(),
};
let hex = hex::encode_upper(payload.as_bytes());
let actual = validate_mptoken_metadata(&hex);
assert_eq!(
actual.len(),
case.validation_messages.len(),
"message count mismatch for `{}`: {actual:?}",
case.test_name
);
for (got, want) in actual.iter().zip(case.validation_messages.iter()) {
if want.starts_with(JSON_PARSE_PREFIX) {
assert!(
got.starts_with(JSON_PARSE_PREFIX),
"expected JSON-parse message for `{}`, got: {got}",
case.test_name
);
} else {
assert_eq!(got, want, "message mismatch for `{}`", case.test_name);
}
}
}
}
#[test]
fn test_encode_rejects_non_object() {
let err = encode_mptoken_metadata(&Value::String("nope".into())).unwrap_err();
assert_eq!(
err.to_string(),
format!(
"XRPL MPTokenMetadata error: {}",
XRPLMPTokenMetadataException::NotJsonObject
)
);
}
#[test]
fn test_decode_rejects_non_hex() {
let err = decode_mptoken_metadata("not-hex!").unwrap_err();
assert_eq!(
err.to_string(),
format!(
"XRPL MPTokenMetadata error: {}",
XRPLMPTokenMetadataException::InvalidHex
)
);
}
#[test]
fn test_typed_metadata_round_trip() {
let metadata = MPTokenMetadata {
ticker: "TBILL".into(),
name: "T-Bill Yield Token".into(),
desc: Some("A yield-bearing stablecoin backed by U.S. Treasuries.".into()),
icon: "https://example.org/tbill-icon.png".into(),
asset_class: "rwa".into(),
asset_subclass: Some("treasury".into()),
issuer_name: "Example Yield Co.".into(),
uris: Some(vec![MPTokenMetadataUri {
uri: "https://exampleyield.co/tbill".into(),
category: "website".into(),
title: "Product Page".into(),
}]),
additional_info: Some(MPTokenMetadataAdditionalInfo::Object(
serde_json::json!({ "interest_rate": "5.00%", "maturity_date": "2045-06-30" })
.as_object()
.unwrap()
.clone(),
)),
};
let encoded = encode_mptoken_metadata(&metadata).unwrap();
assert!(validate_mptoken_metadata(&encoded).is_empty());
let decoded = decode_mptoken_metadata(&encoded).unwrap();
let round_trip: MPTokenMetadata = serde_json::from_value(decoded).unwrap();
assert_eq!(round_trip, metadata);
}
fn validate_json(value: serde_json::Value) -> Vec<String> {
let hex = hex::encode_upper(serde_json::to_string(&value).unwrap().as_bytes());
validate_mptoken_metadata(&hex)
}
#[test]
fn test_validate_rejects_non_hex_input() {
let expected = vec!["MPTokenMetadata must be in hex format.".to_string()];
assert_eq!(validate_mptoken_metadata("xyz"), expected);
assert_eq!(validate_mptoken_metadata("ABC"), expected);
}
#[test]
fn test_validate_reports_non_utf8_blob() {
let messages = validate_mptoken_metadata("FF");
assert_eq!(messages.len(), 1);
assert!(messages[0].starts_with("MPTokenMetadata is not properly formatted as JSON -"));
}
#[test]
fn test_mptoken_metadata_warning() {
use serde_json::json;
let to_hex = |value: &serde_json::Value| {
hex::encode_upper(serde_json::to_string(value).unwrap().as_bytes())
};
let valid = json!({
"ticker": "TBILL",
"name": "T-Bill Token",
"icon": "https://example.com/icon.png",
"asset_class": "rwa",
"asset_subclass": "treasury",
"issuer_name": "Issuer"
});
assert_eq!(mptoken_metadata_warning(&to_hex(&valid)), None);
let invalid = json!({
"ticker": "TBILL",
"name": "T-Bill Token",
"icon": "https://example.com/icon.png",
"asset_class": "rwa",
"asset_subclass": "treasury",
"issuer_name": "Issuer",
"uris": ["apple"]
});
let warning = mptoken_metadata_warning(&to_hex(&invalid)).expect("expected a warning");
assert!(warning.starts_with(MPT_META_WARNING_HEADER));
assert!(warning.contains(
"\n- uris/us: should be an array of objects each with uri/u, category/c, and title/t properties."
));
}
#[test]
fn test_validate_reports_both_forms_for_every_field() {
use serde_json::json;
let base = || {
json!({
"ticker": "TBILL",
"name": "T-Bill",
"icon": "https://example.org/icon.png",
"asset_class": "rwa",
"asset_subclass": "treasury",
"issuer_name": "Issuer"
})
};
assert!(validate_json(base()).is_empty(), "baseline should be valid");
let single_key_cases = [
("n", json!("dup"), "name/n"),
("i", json!("https://dup"), "icon/i"),
("in", json!("dup"), "issuer_name/in"),
("ac", json!("rwa"), "asset_class/ac"),
("as", json!("treasury"), "asset_subclass/as"),
];
for (key, value, prefix) in single_key_cases {
let mut obj = base();
obj[key] = value;
assert_eq!(
validate_json(obj),
vec![format!(
"{prefix}: both long and compact forms present. expected only one."
)],
"field {prefix}"
);
}
let mut desc = base();
desc["desc"] = json!("a");
desc["d"] = json!("b");
assert_eq!(
validate_json(desc),
vec!["desc/d: both long and compact forms present. expected only one.".to_string()]
);
let mut uris = base();
uris["uris"] = json!([{ "uri": "https://x", "category": "website", "title": "T" }]);
uris["us"] = json!([{ "u": "https://x", "c": "website", "t": "T" }]);
assert_eq!(
validate_json(uris),
vec!["uris/us: both long and compact forms present. expected only one.".to_string()]
);
let mut info = base();
info["additional_info"] = json!("x");
info["ai"] = json!("y");
assert_eq!(
validate_json(info),
vec![
"additional_info/ai: both long and compact forms present. expected only one."
.to_string()
]
);
}
#[test]
fn test_encode_decode_preserves_non_object_uri_elements() {
use serde_json::json;
let value = json!({ "ticker": "TBILL", "uris": [123, "not-an-object"] });
let encoded = encode_mptoken_metadata(&value).unwrap();
let decoded = decode_mptoken_metadata(&encoded).unwrap();
assert_eq!(
decoded,
json!({ "ticker": "TBILL", "uris": [123, "not-an-object"] })
);
}
}