1use 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
24pub const MAX_MPT_META_BYTE_LENGTH: usize = 1024;
26
27pub 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
32const 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
45const MPT_META_URI_FIELDS: [(&str, &str); 3] = [("uri", "u"), ("category", "c"), ("title", "t")];
47
48const MPT_META_ASSET_CLASSES: [&str; 6] = ["rwa", "memes", "wrapped", "gaming", "defi", "other"];
50
51const 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct MPTokenMetadataUri<'a> {
65 pub uri: Cow<'a, str>,
67 pub category: Cow<'a, str>,
69 pub title: Cow<'a, str>,
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75#[serde(untagged)]
76pub enum MPTokenMetadataAdditionalInfo<'a> {
77 Text(Cow<'a, str>),
79 Object(Map<String, Value>),
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct MPTokenMetadata<'a> {
91 pub ticker: Cow<'a, str>,
93 pub name: Cow<'a, str>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub desc: Option<Cow<'a, str>>,
98 pub icon: Cow<'a, str>,
100 pub asset_class: Cow<'a, str>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub asset_subclass: Option<Cow<'a, str>>,
105 pub issuer_name: Cow<'a, str>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub uris: Option<Vec<MPTokenMetadataUri<'a>>>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub additional_info: Option<MPTokenMetadataAdditionalInfo<'a>>,
113}
114
115pub 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 let json = serde_json::to_string(&Value::Object(shortened))?;
141 Ok(hex::encode_upper(json.as_bytes()))
142}
143
144pub 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#[derive(Clone, Copy)]
175enum Direction {
176 Shorten,
178 Expand,
180}
181
182fn 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
219fn 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
235fn 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
243pub 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 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 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
319pub 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
341fn present_non_null(obj: &Map<String, Value>, key: &str) -> bool {
343 matches!(obj.get(key), Some(value) if !value.is_null())
344}
345
346fn 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
351fn 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
356fn 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 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 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 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 assert_eq!(validate_mptoken_metadata("xyz"), expected);
684 assert_eq!(validate_mptoken_metadata("ABC"), expected);
686 }
687
688 #[test]
689 fn test_validate_reports_non_utf8_blob() {
690 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 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 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 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 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 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}