Skip to main content

xrpl/core/binarycodec/
mod.rs

1//! Functions for encoding objects into the XRP Ledger's
2//! canonical binary format and decoding them.
3//!
4//! This module is the public API entry point.
5//! Internal serialization/deserialization logic lives in `binary_wrappers`
6
7pub mod definitions;
8pub mod types;
9
10use types::AccountId;
11
12use alloc::borrow::Cow;
13use alloc::string::String;
14use core::convert::TryFrom;
15use serde::Serialize;
16use serde_json::Value;
17
18pub mod binary_wrappers;
19pub mod exceptions;
20pub(crate) mod test_cases;
21pub mod utils;
22
23pub use binary_wrappers::*;
24
25use self::binary_wrappers::{
26    decode_ledger_data_inner, decode_st_object, serialize_json, BATCH_PREFIX,
27    PAYMENT_CHANNEL_CLAIM_PREFIX, TRANSACTION_MULTISIG_PREFIX, TRANSACTION_SIGNATURE_PREFIX,
28};
29
30use super::exceptions::XRPLCoreResult;
31
32/// Encode a transaction (or any XRPL object) to hex-encoded binary.
33pub fn encode<T>(signed_transaction: &T) -> XRPLCoreResult<String>
34where
35    T: Serialize,
36{
37    serialize_json(signed_transaction, None, None, false)
38}
39
40/// Encode a transaction for signing (prepends the signing prefix).
41pub fn encode_for_signing<T>(prepared_transaction: &T) -> XRPLCoreResult<String>
42where
43    T: Serialize,
44{
45    serialize_json(
46        prepared_transaction,
47        Some(TRANSACTION_SIGNATURE_PREFIX.to_be_bytes().as_ref()),
48        None,
49        true,
50    )
51}
52
53/// Encode a transaction for multi-signing (prepends multi-sign prefix,
54/// appends the signing account ID).
55pub fn encode_for_multisigning<T>(
56    prepared_transaction: &T,
57    signing_account: Cow<'_, str>,
58) -> XRPLCoreResult<String>
59where
60    T: Serialize,
61{
62    let signing_account_id = AccountId::try_from(signing_account.as_ref()).unwrap();
63
64    serialize_json(
65        prepared_transaction,
66        Some(TRANSACTION_MULTISIG_PREFIX.as_ref()),
67        Some(signing_account_id.as_ref()),
68        true,
69    )
70}
71
72/// Encode a payment channel claim for signing.
73///
74/// This produces the serialized data that must be signed to authorize
75/// a claim against a payment channel. The format is:
76/// - 4 bytes: HashPrefix `0x434C4D00` ("CLM\0")
77/// - 32 bytes: channel ID (Hash256)
78/// - 8 bytes: amount in drops (UInt64, big-endian)
79///
80/// See Payment Channel Claim:
81/// `<https://xrpl.org/docs/references/protocol/transactions/types/paymentchannelclaim>`
82pub fn encode_for_signing_claim(channel: &str, amount: &str) -> XRPLCoreResult<String> {
83    let channel_bytes = hex::decode(channel).map_err(|_| {
84        super::exceptions::XRPLCoreException::XRPLBinaryCodecError(
85            exceptions::XRPLBinaryCodecException::InvalidHashLength {
86                expected: 64,
87                found: channel.len(),
88            },
89        )
90    })?;
91    if channel_bytes.len() != 32 {
92        return Err(super::exceptions::XRPLCoreException::XRPLBinaryCodecError(
93            exceptions::XRPLBinaryCodecException::InvalidHashLength {
94                expected: 32,
95                found: channel_bytes.len(),
96            },
97        ));
98    }
99    let amount_val: u64 = amount.parse().map_err(|e| {
100        super::exceptions::XRPLCoreException::XRPLBinaryCodecError(
101            exceptions::XRPLBinaryCodecException::ParseIntError(e),
102        )
103    })?;
104
105    let mut buf = alloc::vec::Vec::with_capacity(44);
106    buf.extend_from_slice(&PAYMENT_CHANNEL_CLAIM_PREFIX);
107    buf.extend_from_slice(&channel_bytes);
108    buf.extend_from_slice(&amount_val.to_be_bytes());
109    Ok(hex::encode_upper(&buf))
110}
111
112/// Encode a Batch transaction for signing.
113///
114/// This produces the serialized data that must be signed to authorize
115/// a batch transaction. The format is:
116/// - 4 bytes: HashPrefix `0x42434800` ("BCH\0")
117/// - 4 bytes: flags (UInt32, big-endian)
118/// - 4 bytes: number of txIDs (UInt32, big-endian)
119/// - N × 32 bytes: each txID (Hash256)
120///
121/// See Batch Transaction:
122/// `<https://xrpl.org/docs/references/protocol/transactions/types/batch>`
123pub fn encode_for_signing_batch(flags: u32, tx_ids: &[&str]) -> XRPLCoreResult<String> {
124    let mut buf = alloc::vec::Vec::with_capacity(4 + 4 + 4 + tx_ids.len() * 32);
125    buf.extend_from_slice(&BATCH_PREFIX);
126    buf.extend_from_slice(&flags.to_be_bytes());
127    buf.extend_from_slice(&(tx_ids.len() as u32).to_be_bytes());
128    for tx_id in tx_ids {
129        let id_bytes = hex::decode(tx_id).map_err(|_| {
130            super::exceptions::XRPLCoreException::XRPLBinaryCodecError(
131                exceptions::XRPLBinaryCodecException::InvalidHashLength {
132                    expected: 64,
133                    found: tx_id.len(),
134                },
135            )
136        })?;
137        if id_bytes.len() != 32 {
138            return Err(super::exceptions::XRPLCoreException::XRPLBinaryCodecError(
139                exceptions::XRPLBinaryCodecException::InvalidHashLength {
140                    expected: 32,
141                    found: id_bytes.len(),
142                },
143            ));
144        }
145        buf.extend_from_slice(&id_bytes);
146    }
147    Ok(hex::encode_upper(&buf))
148}
149
150/// Decode a hex-encoded XRPL binary blob into a JSON object.
151///
152/// This is the inverse of `encode`. It takes a hex string representing
153/// a serialized XRPL transaction (or other object) and returns its
154/// JSON representation as a `serde_json::Value`.
155pub fn decode(hex_string: &str) -> XRPLCoreResult<Value> {
156    let mut parser = BinaryParser::try_from(hex_string)?;
157    decode_st_object(&mut parser)
158}
159
160/// Decode a serialized ledger header from hex into JSON.
161///
162/// Ledger headers use a fixed-length format (not field-prefixed like STObject):
163/// - 4 bytes: ledger_index (UInt32)
164/// - 8 bytes: total_coins (UInt64, as base-10 string)
165/// - 32 bytes: parent_hash (Hash256)
166/// - 32 bytes: transaction_hash (Hash256)
167/// - 32 bytes: account_hash (Hash256)
168/// - 4 bytes: parent_close_time (UInt32)
169/// - 4 bytes: close_time (UInt32)
170/// - 1 byte: close_time_resolution (UInt8)
171/// - 1 byte: close_flags (UInt8)
172pub fn decode_ledger_data(hex_string: &str) -> XRPLCoreResult<Value> {
173    decode_ledger_data_inner(hex_string)
174}
175
176#[cfg(all(test, feature = "std"))]
177mod test {
178    use alloc::vec;
179
180    use super::*;
181    use crate::utils::testing::test_constants::*;
182
183    #[path = "binary_json_tests.rs"]
184    mod binary_json_tests;
185    #[path = "binary_serializer_tests.rs"]
186    mod binary_serializer_tests;
187    #[path = "tx_encode_decode_tests.rs"]
188    mod tx_encode_decode_tests;
189    #[path = "x_address_tests.rs"]
190    mod x_address_tests;
191
192    use crate::core::binarycodec::definitions::{
193        get_field_instance, get_ledger_entry_type_code, get_transaction_type_code,
194    };
195    use crate::core::binarycodec::utils::{decode_field_name, encode_field_name};
196    use crate::models::transactions::{
197        mptoken_authorize::MPTokenAuthorize,
198        mptoken_issuance_create::{MPTokenIssuanceCreate, MPTokenIssuanceCreateFlag},
199        mptoken_issuance_destroy::MPTokenIssuanceDestroy,
200        mptoken_issuance_set::{MPTokenIssuanceSet, MPTokenIssuanceSetFlag},
201        CommonFields, TransactionType,
202    };
203
204    // ── Field encoding / decoding ──────────────────────────────────────
205
206    #[test]
207    fn test_mpt_field_name_encoding() {
208        // (field_name, expected_hex)
209        // Hash192 type_code=21 (>=16), AccountID type_code=8 (<16),
210        // UInt8 type_code=16 (>=16), UInt64 type_code=3, Blob type_code=7
211        let cases = [
212            ("MPTokenIssuanceID", "0115"), // Hash192(21), nth 1 → byte1=0x01, byte2=0x15
213            ("ShareMPTID", "0215"),        // Hash192(21), nth 2 → byte1=0x02, byte2=0x15
214            ("Holder", "8B"),              // AccountID(8), nth 11 → (8<<4)|11 = 0x8B
215            ("AssetScale", "0510"),        // UInt8(16), nth 5 → byte1=0x05, byte2=0x10
216            ("MaximumAmount", "3018"),     // UInt64(3), nth 24 → (3<<4)=0x30, 0x18
217            ("MPTAmount", "301A"),         // UInt64(3), nth 26 → (3<<4)=0x30, 0x1A
218            ("MPTokenMetadata", "701E"),   // Blob(7), nth 30 → (7<<4)=0x70, 0x1E
219        ];
220
221        for (field_name, expected_hex) in &cases {
222            let encoded = encode_field_name(field_name)
223                .unwrap_or_else(|e| panic!("failed to encode field {}: {:?}", field_name, e));
224            let hex = hex::encode_upper(encoded);
225            assert_eq!(
226                &hex, expected_hex,
227                "encode mismatch for field {}",
228                field_name
229            );
230
231            let decoded = decode_field_name(expected_hex)
232                .unwrap_or_else(|e| panic!("failed to decode hex {}: {:?}", expected_hex, e));
233            assert_eq!(
234                decoded, *field_name,
235                "decode mismatch for hex {}",
236                expected_hex
237            );
238        }
239    }
240
241    // ── Type code resolution ───────────────────────────────────────────
242
243    #[test]
244    fn test_mpt_transaction_type_codes() {
245        assert_eq!(
246            get_transaction_type_code("MPTokenIssuanceCreate"),
247            Some(&54)
248        );
249        assert_eq!(
250            get_transaction_type_code("MPTokenIssuanceDestroy"),
251            Some(&55)
252        );
253        assert_eq!(get_transaction_type_code("MPTokenIssuanceSet"), Some(&56));
254        assert_eq!(get_transaction_type_code("MPTokenAuthorize"), Some(&57));
255    }
256
257    #[test]
258    fn test_mpt_ledger_entry_type_codes() {
259        assert_eq!(get_ledger_entry_type_code("MPTokenIssuance"), Some(&126));
260        assert_eq!(get_ledger_entry_type_code("MPToken"), Some(&127));
261    }
262
263    // ── Field instance metadata ────────────────────────────────────────
264
265    #[test]
266    fn test_mpt_field_instances() {
267        let fi = get_field_instance("MPTokenIssuanceID").expect("MPTokenIssuanceID not found");
268        assert_eq!(fi.associated_type, "Hash192");
269        assert_eq!(fi.nth, 1);
270        assert!(fi.is_serialized);
271        assert!(fi.is_signing);
272
273        let fi = get_field_instance("Holder").expect("Holder not found");
274        assert_eq!(fi.associated_type, "AccountID");
275        assert_eq!(fi.nth, 11);
276        assert!(fi.is_vl_encoded);
277
278        let fi = get_field_instance("MPTAmount").expect("MPTAmount not found");
279        assert_eq!(fi.associated_type, "UInt64");
280        assert_eq!(fi.nth, 26);
281
282        let fi = get_field_instance("MPTokenMetadata").expect("MPTokenMetadata not found");
283        assert_eq!(fi.associated_type, "Blob");
284        assert_eq!(fi.nth, 30);
285        assert!(fi.is_vl_encoded);
286
287        let fi = get_field_instance("AssetScale").expect("AssetScale not found");
288        assert_eq!(fi.associated_type, "UInt8");
289        assert_eq!(fi.nth, 5);
290
291        let fi = get_field_instance("MaximumAmount").expect("MaximumAmount not found");
292        assert_eq!(fi.associated_type, "UInt64");
293        assert_eq!(fi.nth, 24);
294
295        let fi = get_field_instance("ShareMPTID").expect("ShareMPTID not found");
296        assert_eq!(fi.associated_type, "Hash192");
297        assert_eq!(fi.nth, 2);
298    }
299
300    // ── Full transaction encoding ──────────────────────────────────────
301
302    /// TransactionType is always the first serialized field (lowest ordinal).
303    /// For MPTokenIssuanceCreate (code 54 = 0x0036), the hex starts with
304    /// field ID 0x12 (UInt16, nth 2) followed by the 2-byte type code.
305    #[test]
306    fn test_encode_mptoken_issuance_create() {
307        let txn = MPTokenIssuanceCreate {
308            common_fields: CommonFields {
309                account: ACCOUNT_GENESIS.into(),
310                transaction_type: TransactionType::MPTokenIssuanceCreate,
311                fee: Some("10".into()),
312                sequence: Some(1),
313                flags: vec![MPTokenIssuanceCreateFlag::TfMPTCanTransfer].into(),
314                ..Default::default()
315            },
316            asset_scale: Some(2),
317            maximum_amount: Some("1000000".into()),
318            transfer_fee: Some(314),
319            mptoken_metadata: Some("CAFEBABE".into()),
320            ..Default::default()
321        };
322
323        let hex = encode(&txn).expect("encode MPTokenIssuanceCreate failed");
324
325        // TransactionType field: 0x12 + 0x0036 (54)
326        assert!(
327            hex.starts_with("120036"),
328            "expected hex to start with 120036 (MPTokenIssuanceCreate), got: {}",
329            &hex[..core::cmp::min(20, hex.len())]
330        );
331
332        // TransferFee field: 0x14 + 0x013A (314)
333        assert!(
334            hex.contains("14013A"),
335            "expected TransferFee 314 (14013A) in hex"
336        );
337
338        // Flags = TfMPTCanTransfer (0x20): 0x22 + 0x00000020
339        assert!(
340            hex.contains("2200000020"),
341            "expected Flags TfMPTCanTransfer (2200000020) in hex"
342        );
343
344        // Sequence = 1: 0x24 + 0x00000001
345        assert!(
346            hex.contains("2400000001"),
347            "expected Sequence 1 (2400000001) in hex"
348        );
349
350        // AssetScale = 2: field ID 0x0510 + 0x02
351        assert!(
352            hex.contains("051002"),
353            "expected AssetScale 2 (051002) in hex"
354        );
355
356        // MPTokenMetadata (Blob): field ID 0x701E + length prefix + CAFEBABE
357        assert!(
358            hex.contains("CAFEBABE"),
359            "expected MPTokenMetadata hex payload in encoded output"
360        );
361    }
362
363    #[test]
364    fn test_encode_mptoken_issuance_create_with_flags() {
365        let txn = MPTokenIssuanceCreate {
366            common_fields: CommonFields {
367                account: ACCOUNT_GENESIS.into(),
368                transaction_type: TransactionType::MPTokenIssuanceCreate,
369                fee: Some("12".into()),
370                sequence: Some(5),
371                flags: vec![
372                    MPTokenIssuanceCreateFlag::TfMPTCanTransfer,
373                    MPTokenIssuanceCreateFlag::TfMPTCanLock,
374                ]
375                .into(),
376                ..Default::default()
377            },
378            ..Default::default()
379        };
380
381        let hex = encode(&txn).expect("encode MPTokenIssuanceCreate with flags failed");
382
383        assert!(hex.starts_with("120036"), "wrong transaction type");
384
385        // Flags = TfMPTCanTransfer (0x20) | TfMPTCanLock (0x02) = 0x22
386        assert!(
387            hex.contains("2200000022"),
388            "expected Flags 0x22 (2200000022) in hex, got: {}",
389            hex
390        );
391    }
392
393    /// MPTokenIssuanceDestroy (code 55 = 0x0037) exercises Hash192
394    /// serialization through the MPTokenIssuanceID field.
395    #[test]
396    fn test_encode_mptoken_issuance_destroy() {
397        let txn = MPTokenIssuanceDestroy {
398            common_fields: CommonFields {
399                account: ACCOUNT_GENESIS.into(),
400                transaction_type: TransactionType::MPTokenIssuanceDestroy,
401                fee: Some("10".into()),
402                sequence: Some(1),
403                ..Default::default()
404            },
405            mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344".into(),
406        };
407
408        let hex = encode(&txn).expect("encode MPTokenIssuanceDestroy failed");
409
410        // TransactionType = 55 = 0x0037
411        assert!(
412            hex.starts_with("120037"),
413            "expected hex to start with 120037 (MPTokenIssuanceDestroy), got: {}",
414            &hex[..core::cmp::min(20, hex.len())]
415        );
416
417        // The Hash192 value should appear verbatim in the encoded hex
418        // (Hash192 is a fixed-length 24-byte field, no length prefix)
419        assert!(
420            hex.contains("00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344"),
421            "expected MPTokenIssuanceID hash in encoded output"
422        );
423    }
424
425    /// MPTokenIssuanceSet (code 56 = 0x0038) with the TfMPTLock flag
426    /// and Holder field (AccountID, nth 11).
427    #[test]
428    fn test_encode_mptoken_issuance_set() {
429        let txn = MPTokenIssuanceSet {
430            common_fields: CommonFields {
431                account: ACCOUNT_GENESIS.into(),
432                transaction_type: TransactionType::MPTokenIssuanceSet,
433                fee: Some("10".into()),
434                sequence: Some(1),
435                flags: vec![MPTokenIssuanceSetFlag::TfMPTLock].into(),
436                ..Default::default()
437            },
438            mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344".into(),
439            holder: Some(ACCOUNT_ALT.into()),
440            ..Default::default()
441        };
442
443        let hex = encode(&txn).expect("encode MPTokenIssuanceSet failed");
444
445        // TransactionType = 56 = 0x0038
446        assert!(
447            hex.starts_with("120038"),
448            "expected hex to start with 120038 (MPTokenIssuanceSet), got: {}",
449            &hex[..core::cmp::min(20, hex.len())]
450        );
451
452        // TfMPTLock = 0x00000001
453        assert!(
454            hex.contains("2200000001"),
455            "expected Flags TfMPTLock (2200000001) in hex"
456        );
457
458        // Hash192 value in output
459        assert!(
460            hex.contains("00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344"),
461            "expected MPTokenIssuanceID in encoded output"
462        );
463
464        // Holder field (AccountID, 0x8B) should be present
465        assert!(hex.contains("8B"), "expected Holder field ID (8B) in hex");
466    }
467
468    /// MPTokenAuthorize (code 57 = 0x0039) with holder opt-in (no holder field).
469    #[test]
470    fn test_encode_mptoken_authorize() {
471        let txn = MPTokenAuthorize {
472            common_fields: CommonFields {
473                account: ACCOUNT_GENESIS.into(),
474                transaction_type: TransactionType::MPTokenAuthorize,
475                fee: Some("10".into()),
476                sequence: Some(1),
477                ..Default::default()
478            },
479            mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344".into(),
480            ..Default::default()
481        };
482
483        let hex = encode(&txn).expect("encode MPTokenAuthorize failed");
484
485        // TransactionType = 57 = 0x0039
486        assert!(
487            hex.starts_with("120039"),
488            "expected hex to start with 120039 (MPTokenAuthorize), got: {}",
489            &hex[..core::cmp::min(20, hex.len())]
490        );
491
492        // Hash192 value
493        assert!(
494            hex.contains("00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344"),
495            "expected MPTokenIssuanceID in encoded output"
496        );
497    }
498
499    /// Verify that encode_for_signing adds the signing prefix and
500    /// excludes non-signing fields for MPT transactions.
501    #[test]
502    fn test_encode_for_signing_mptoken_issuance_create() {
503        let txn = MPTokenIssuanceCreate {
504            common_fields: CommonFields {
505                account: ACCOUNT_GENESIS.into(),
506                transaction_type: TransactionType::MPTokenIssuanceCreate,
507                fee: Some("10".into()),
508                sequence: Some(1),
509                ..Default::default()
510            },
511            ..Default::default()
512        };
513
514        let hex = encode_for_signing(&txn).expect("encode_for_signing failed");
515
516        // Signing prefix: 0x53545800
517        assert!(
518            hex.starts_with("53545800"),
519            "expected signing prefix 53545800, got: {}",
520            &hex[..core::cmp::min(20, hex.len())]
521        );
522
523        // TransactionType follows the prefix
524        assert!(
525            hex[8..].starts_with("120036"),
526            "expected TransactionType after signing prefix"
527        );
528    }
529
530    /// Verify that encode_for_multisigning adds the multisign prefix,
531    /// the signing account suffix, and excludes non-signing fields.
532    #[test]
533    fn test_encode_for_multisigning_mptoken_authorize() {
534        let txn = MPTokenAuthorize {
535            common_fields: CommonFields {
536                account: ACCOUNT_GENESIS.into(),
537                transaction_type: TransactionType::MPTokenAuthorize,
538                fee: Some("10".into()),
539                sequence: Some(1),
540                ..Default::default()
541            },
542            mptoken_issuance_id: "00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344".into(),
543            ..Default::default()
544        };
545
546        let hex = encode_for_multisigning(&txn, ACCOUNT_ALT.into())
547            .expect("encode_for_multisigning failed");
548
549        // Multisign prefix: 0x534D5400
550        assert!(
551            hex.starts_with("534D5400"),
552            "expected multisign prefix 534D5400, got: {}",
553            &hex[..core::cmp::min(20, hex.len())]
554        );
555
556        // TransactionType follows the prefix
557        assert!(
558            hex[8..].starts_with("120039"),
559            "expected MPTokenAuthorize type after multisign prefix"
560        );
561
562        // The signing account ID should appear at the end as a suffix
563        // (account ID is 20 bytes = 40 hex chars at end of encoded output)
564        assert!(
565            hex.len() > 40,
566            "encoded output too short for multisign suffix"
567        );
568    }
569
570    /// Encode the same transaction twice and verify deterministic output.
571    #[test]
572    fn test_encode_deterministic() {
573        let txn = MPTokenIssuanceDestroy {
574            common_fields: CommonFields {
575                account: ACCOUNT_GENESIS.into(),
576                transaction_type: TransactionType::MPTokenIssuanceDestroy,
577                fee: Some("10".into()),
578                sequence: Some(42),
579                ..Default::default()
580            },
581            mptoken_issuance_id: "AABBCCDD11223344AABBCCDD11223344AABBCCDD11223344".into(),
582        };
583
584        let hex1 = encode(&txn).expect("first encode failed");
585        let hex2 = encode(&txn).expect("second encode failed");
586        assert_eq!(hex1, hex2, "encoding should be deterministic");
587    }
588
589    // ── Hash192 round-trip ─────────────────────────────────────────────
590
591    /// Encode a transaction that includes a Hash192 field (MPTokenIssuanceID),
592    /// then decode the result and verify the field value survives the round-trip.
593    #[test]
594    fn test_hash192_encode_decode_roundtrip() {
595        use crate::core::binarycodec::{decode, encode};
596
597        let issuance_id = "00000001A407AF5856CEFBF81F3D4A00AABBCCDD11223344";
598        let txn = MPTokenIssuanceDestroy {
599            common_fields: CommonFields {
600                account: ACCOUNT_GENESIS.into(),
601                transaction_type: TransactionType::MPTokenIssuanceDestroy,
602                fee: Some("10".into()),
603                sequence: Some(1),
604                ..Default::default()
605            },
606            mptoken_issuance_id: issuance_id.into(),
607        };
608
609        let encoded = encode(&txn).expect("encode MPTokenIssuanceDestroy failed");
610        let decoded = decode(&encoded).expect("decode failed");
611
612        let decoded_id = decoded
613            .get("MPTokenIssuanceID")
614            .expect("MPTokenIssuanceID missing from decoded output")
615            .as_str()
616            .expect("MPTokenIssuanceID is not a string");
617
618        assert_eq!(
619            decoded_id.to_uppercase(),
620            issuance_id.to_uppercase(),
621            "Hash192 value did not survive encode->decode round-trip"
622        );
623    }
624
625    /// Exact-match encode/decode against authoritative vectors from xrpl.js
626    /// ripple-binary-codec uint.test.ts ("UInt64 is parsed as base 10 for MPT amounts").
627    ///
628    /// These vectors were produced by the reference implementation and cover:
629    ///   - MPTokenIssuance ledger entry (Hash192, UInt64 MPT amounts, Blob metadata)
630    ///   - MPToken ledger entry (Hash192 MPTokenIssuanceID, UInt64 MPTAmount)
631    #[test]
632    fn test_mpt_ledger_entry_exact_vectors() {
633        use super::{decode, serialize_json};
634        use serde_json::json;
635
636        // ── MPTokenIssuance ──────────────────────────────────────────────────
637        // Source: xrpl.js ripple-binary-codec/test/uint.test.ts
638        // `mptIssuanceEntryBinary` / `mptIssuanceEntryJson`
639        let mpt_issuance_binary = concat!(
640            "11007E220000006224000002DF25000002E434000000000000000030187FFFFFFFFFFFFFFF",
641            "30190000000000000064552E78C1FFBDDAEE077253CEB12CFEA83689AA0899F94762190A357208DADC76FE",
642            "701EC1EC7B226E616D65223A2255532054726561737572792042696C6C20546F6B656E222C2273796D626F",
643            "6C223A225553544254222C22646563696D616C73223A322C22746F74616C537570706C79223A313030303030",
644            "302C22697373756572223A225553205472656173757279222C22697373756544617465223A22323032342D30",
645            "332D3235222C226D6174757269747944617465223A22323032352D30332D3235222C226661636556616C7565",
646            "223A2231303030222C22696E74657265737452617465223A22322E35222C22696E7465726573744672657175",
647            "656E6379223A22517561727465726C79222C22636F6C6C61746572616C223A22555320476F7665726E6D656E",
648            "74222C226A7572697364696374696F6E223A22556E6974656420537461746573222C22726567756C61746F72",
649            "79436F6D706C69616E6365223A2253454320526567756C6174696F6E73222C22736563757269747954797065",
650            "223A2254726561737572792042696C6C222C2265787465726E616C5F75726C223A2268747470733A2F2F6578",
651            "616D706C652E636F6D2F742D62696C6C2D746F6B656E2D6D657461646174612E6A736F6E227D",
652            "8414A4D893CFBC4DC6AE877EB585F90A3B47528B958D051003"
653        );
654
655        let decoded_issuance = decode(mpt_issuance_binary).expect("decode MPTokenIssuance failed");
656        assert_eq!(
657            decoded_issuance["LedgerEntryType"],
658            json!("MPTokenIssuance")
659        );
660        assert_eq!(decoded_issuance["AssetScale"], json!(3));
661        assert_eq!(decoded_issuance["Flags"], json!(98));
662        assert_eq!(decoded_issuance["Sequence"], json!(735));
663        assert_eq!(
664            decoded_issuance["MaximumAmount"],
665            json!("9223372036854775807")
666        );
667        assert_eq!(decoded_issuance["OutstandingAmount"], json!("100"));
668        assert_eq!(
669            decoded_issuance["Issuer"],
670            json!("rGpdGXDV2RFPeLEfWS9RFo5Nh9cpVDToZa")
671        );
672        assert_eq!(
673            decoded_issuance["PreviousTxnID"],
674            json!("2E78C1FFBDDAEE077253CEB12CFEA83689AA0899F94762190A357208DADC76FE")
675        );
676        assert_eq!(decoded_issuance["PreviousTxnLgrSeq"], json!(740));
677
678        // Re-encode and confirm byte-for-byte match
679        let re_encoded = serialize_json(&decoded_issuance, None, None, false)
680            .expect("re-encode MPTokenIssuance failed");
681        assert_eq!(
682            re_encoded.to_uppercase(),
683            mpt_issuance_binary.to_uppercase(),
684            "MPTokenIssuance re-encode does not match authoritative vector"
685        );
686
687        // ── MPToken ──────────────────────────────────────────────────────────
688        // Source: xrpl.js ripple-binary-codec/test/uint.test.ts
689        // `mptokenEntryBinary` / `mptokenEntryJson`
690        let mptoken_binary = concat!(
691            "11007F220000000025000002E5340000000000000000",
692            "301A00000000000000645522",
693            "2EF3C7E82D8A44984A66E2B8E357CB536EC2547359CCF70E56E14BC4C284C8",
694            "81143930DB9A74C26D96CB58ADFFD7E8BB78BCFE623",
695            "40115000002DF71CAE59C9B7E56587FFF74D4EA5830D9BE3CE0CC"
696        );
697
698        let decoded_token = decode(mptoken_binary).expect("decode MPToken failed");
699        assert_eq!(decoded_token["LedgerEntryType"], json!("MPToken"));
700        assert_eq!(decoded_token["Flags"], json!(0));
701        assert_eq!(decoded_token["MPTAmount"], json!("100"));
702        assert_eq!(
703            decoded_token["MPTokenIssuanceID"],
704            json!("000002DF71CAE59C9B7E56587FFF74D4EA5830D9BE3CE0CC")
705        );
706        assert_eq!(
707            decoded_token["Account"],
708            json!("raDQsd1s8rqGjL476g59a9vVNi1rSwrC44")
709        );
710        assert_eq!(
711            decoded_token["PreviousTxnID"],
712            json!("222EF3C7E82D8A44984A66E2B8E357CB536EC2547359CCF70E56E14BC4C284C8")
713        );
714        assert_eq!(decoded_token["PreviousTxnLgrSeq"], json!(741));
715
716        // Re-encode and confirm byte-for-byte match
717        let re_encoded_token =
718            serialize_json(&decoded_token, None, None, false).expect("re-encode MPToken failed");
719        assert_eq!(
720            re_encoded_token.to_uppercase(),
721            mptoken_binary.to_uppercase(),
722            "MPToken re-encode does not match authoritative vector"
723        );
724    }
725}