Skip to main content

chains_sdk/xrp/
transaction.rs

1//! XRP transaction serialization, Payment/TrustSet encoding, and multisign.
2//!
3//! Implements the XRP Ledger binary codec for transaction types:
4//! - **Payment**: XRP-to-XRP and issued currency transfers
5//! - **TrustSet**: Trust line management
6//! - **Multisign**: Multi-signature helpers
7//!
8//! Field encoding follows the XRPL serialization format specification.
9
10use crate::error::SignerError;
11
12// ═══════════════════════════════════════════════════════════════════
13// Binary Codec — Field Encoding
14// ═══════════════════════════════════════════════════════════════════
15
16/// XRPL field type codes.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[repr(u8)]
19pub enum FieldType {
20    /// 16-bit unsigned integer.
21    UInt16 = 1,
22    /// 32-bit unsigned integer.
23    UInt32 = 2,
24    /// 64-bit unsigned amount.
25    Amount = 6,
26    /// Variable-length blob.
27    Blob = 7,
28    /// 160-bit account ID.
29    AccountID = 8,
30    /// 256-bit hash.
31    Hash256 = 5,
32}
33
34/// Encode a field ID for XRPL serialization.
35///
36/// XRPL uses a compact encoding: if both type_code and field_code fit
37/// in 4 bits, they combine into a single byte.
38#[must_use]
39pub fn encode_field_id(type_code: u8, field_code: u8) -> Vec<u8> {
40    if type_code < 16 && field_code < 16 {
41        vec![(type_code << 4) | field_code]
42    } else if type_code < 16 {
43        vec![type_code << 4, field_code]
44    } else if field_code < 16 {
45        vec![field_code, type_code]
46    } else {
47        vec![0, type_code, field_code]
48    }
49}
50
51/// Encode a UInt32 field.
52#[must_use]
53pub fn encode_uint32(type_code: u8, field_code: u8, value: u32) -> Vec<u8> {
54    let mut buf = encode_field_id(type_code, field_code);
55    buf.extend_from_slice(&value.to_be_bytes());
56    buf
57}
58
59/// Encode a UInt16 field.
60#[must_use]
61pub fn encode_uint16(type_code: u8, field_code: u8, value: u16) -> Vec<u8> {
62    let mut buf = encode_field_id(type_code, field_code);
63    buf.extend_from_slice(&value.to_be_bytes());
64    buf
65}
66
67/// Encode an XRP amount (drops) for serialization.
68///
69/// XRP amounts are encoded as 64-bit values with the high bit set
70/// and the second-highest bit indicating positive.
71#[must_use]
72pub fn encode_xrp_amount(drops: u64) -> Vec<u8> {
73    // Set bit 62 (positive) and bit 63 (not IOU)
74    let encoded = drops | 0x4000_0000_0000_0000;
75    encoded.to_be_bytes().to_vec()
76}
77
78/// Encode an AccountID field (20 bytes).
79#[must_use]
80pub fn encode_account_id(type_code: u8, field_code: u8, account: &[u8; 20]) -> Vec<u8> {
81    let mut buf = encode_field_id(type_code, field_code);
82    buf.push(20); // length prefix
83    buf.extend_from_slice(account);
84    buf
85}
86
87/// Encode a variable-length blob.
88#[must_use]
89pub fn encode_blob(type_code: u8, field_code: u8, data: &[u8]) -> Vec<u8> {
90    let mut buf = encode_field_id(type_code, field_code);
91    encode_vl_length(&mut buf, data.len());
92    buf.extend_from_slice(data);
93    buf
94}
95
96fn encode_vl_length(buf: &mut Vec<u8>, len: usize) {
97    if len <= 192 {
98        buf.push(len as u8);
99    } else if len <= 12_480 {
100        let adjusted = len - 193;
101        buf.push((adjusted >> 8) as u8 + 193);
102        buf.push((adjusted & 0xFF) as u8);
103    } else {
104        let adjusted = len - 12_481;
105        buf.push(241u8 + (adjusted >> 16) as u8);
106        buf.push(((adjusted >> 8) & 0xFF) as u8);
107        buf.push((adjusted & 0xFF) as u8);
108    }
109}
110
111// ═══════════════════════════════════════════════════════════════════
112// Payment Transaction
113// ═══════════════════════════════════════════════════════════════════
114
115/// Well-known XRPL field codes for Payment transactions.
116mod fields {
117    // Type 1 (UInt16): TransactionType = field 2
118    pub const TRANSACTION_TYPE: (u8, u8) = (1, 2);
119    // Type 2 (UInt32): Flags = field 2, Sequence = field 4, Fee = field 8
120    pub const FLAGS: (u8, u8) = (2, 2);
121    pub const SEQUENCE: (u8, u8) = (2, 4);
122    pub const LAST_LEDGER_SEQUENCE: (u8, u8) = (2, 27);
123    // Type 6 (Amount): Amount = field 1, Fee = field 8
124    pub const AMOUNT: (u8, u8) = (6, 1);
125    pub const FEE: (u8, u8) = (6, 8);
126    // Type 8 (AccountID): Account = field 1, Destination = field 3
127    pub const ACCOUNT: (u8, u8) = (8, 1);
128    pub const DESTINATION: (u8, u8) = (8, 3);
129}
130
131/// XRPL Transaction types.
132pub const TT_PAYMENT: u16 = 0;
133/// XRPL TrustSet transaction type code.
134pub const TT_TRUST_SET: u16 = 20;
135
136/// Build a serialized XRP Payment transaction (for signing).
137///
138/// # Arguments
139/// - `account` — Sender account ID (20 bytes)
140/// - `destination` — Recipient account ID (20 bytes)
141/// - `amount_drops` — XRP amount in drops
142/// - `fee_drops` — Transaction fee in drops
143/// - `sequence` — Account sequence number
144/// - `last_ledger_sequence` — Maximum ledger for inclusion
145pub fn serialize_payment(
146    account: &[u8; 20],
147    destination: &[u8; 20],
148    amount_drops: u64,
149    fee_drops: u64,
150    sequence: u32,
151    last_ledger_sequence: u32,
152) -> Vec<u8> {
153    let mut buf = Vec::new();
154
155    // Fields must be serialized in canonical order (by type code, then field code)
156    // Type 1: TransactionType
157    buf.extend_from_slice(&encode_uint16(
158        fields::TRANSACTION_TYPE.0,
159        fields::TRANSACTION_TYPE.1,
160        TT_PAYMENT,
161    ));
162    // Type 2: Flags, Sequence, LastLedgerSequence
163    buf.extend_from_slice(&encode_uint32(fields::FLAGS.0, fields::FLAGS.1, 0));
164    buf.extend_from_slice(&encode_uint32(
165        fields::SEQUENCE.0,
166        fields::SEQUENCE.1,
167        sequence,
168    ));
169    buf.extend_from_slice(&encode_uint32(
170        fields::LAST_LEDGER_SEQUENCE.0,
171        fields::LAST_LEDGER_SEQUENCE.1,
172        last_ledger_sequence,
173    ));
174    // Type 6: Amount, Fee
175    let mut amount_field = encode_field_id(fields::AMOUNT.0, fields::AMOUNT.1);
176    amount_field.extend_from_slice(&encode_xrp_amount(amount_drops));
177    buf.extend_from_slice(&amount_field);
178
179    let mut fee_field = encode_field_id(fields::FEE.0, fields::FEE.1);
180    fee_field.extend_from_slice(&encode_xrp_amount(fee_drops));
181    buf.extend_from_slice(&fee_field);
182
183    // Type 8: Account, Destination
184    buf.extend_from_slice(&encode_account_id(
185        fields::ACCOUNT.0,
186        fields::ACCOUNT.1,
187        account,
188    ));
189    buf.extend_from_slice(&encode_account_id(
190        fields::DESTINATION.0,
191        fields::DESTINATION.1,
192        destination,
193    ));
194
195    buf
196}
197
198// ═══════════════════════════════════════════════════════════════════
199// TrustSet Transaction
200// ═══════════════════════════════════════════════════════════════════
201
202/// An XRPL issued currency amount.
203#[derive(Debug, Clone)]
204pub struct IssuedAmount {
205    /// Currency code (3-letter ISO or 20-byte hex).
206    pub currency: [u8; 20],
207    /// Issuer account ID.
208    pub issuer: [u8; 20],
209    /// Value as a string (e.g., "100.5").
210    pub value: String,
211}
212
213/// Build a serialized TrustSet transaction.
214///
215/// # Arguments
216/// - `account` — Account setting the trust line
217/// - `limit_amount` — The trust line limit
218/// - `fee_drops` — Transaction fee in drops
219/// - `sequence` — Account sequence
220/// - `last_ledger_sequence` — Maximum ledger
221pub fn serialize_trust_set(
222    account: &[u8; 20],
223    limit_amount: &IssuedAmount,
224    fee_drops: u64,
225    sequence: u32,
226    last_ledger_sequence: u32,
227) -> Result<Vec<u8>, SignerError> {
228    let mut buf = Vec::new();
229
230    // TransactionType
231    buf.extend_from_slice(&encode_uint16(
232        fields::TRANSACTION_TYPE.0,
233        fields::TRANSACTION_TYPE.1,
234        TT_TRUST_SET,
235    ));
236    // Flags
237    buf.extend_from_slice(&encode_uint32(fields::FLAGS.0, fields::FLAGS.1, 0));
238    // Sequence
239    buf.extend_from_slice(&encode_uint32(
240        fields::SEQUENCE.0,
241        fields::SEQUENCE.1,
242        sequence,
243    ));
244    // LastLedgerSequence
245    buf.extend_from_slice(&encode_uint32(
246        fields::LAST_LEDGER_SEQUENCE.0,
247        fields::LAST_LEDGER_SEQUENCE.1,
248        last_ledger_sequence,
249    ));
250    // Fee
251    let mut fee_field = encode_field_id(fields::FEE.0, fields::FEE.1);
252    fee_field.extend_from_slice(&encode_xrp_amount(fee_drops));
253    buf.extend_from_slice(&fee_field);
254    // Account
255    buf.extend_from_slice(&encode_account_id(
256        fields::ACCOUNT.0,
257        fields::ACCOUNT.1,
258        account,
259    ));
260
261    // LimitAmount is encoded as an object (type 14, field 3)
262    // For simplicity, we encode the currency/issuer/value inline
263    buf.extend_from_slice(&encode_field_id(14, 3)); // LimitAmount object marker
264                                                    // Encode issued amount: 48 bytes (8 value + 20 currency + 20 issuer)
265    let encoded_amount = encode_issued_amount(limit_amount)?;
266    buf.extend_from_slice(&encoded_amount);
267
268    Ok(buf)
269}
270
271fn encode_issued_amount(amount: &IssuedAmount) -> Result<Vec<u8>, SignerError> {
272    let mut buf = Vec::new();
273    // Encode value as IOU amount per XRPL serialization spec
274    let value_bytes = encode_iou_value(&amount.value)?;
275    buf.extend_from_slice(&value_bytes);
276    buf.extend_from_slice(&amount.currency);
277    buf.extend_from_slice(&amount.issuer);
278    Ok(buf)
279}
280
281/// Parse a decimal string into (is_negative, mantissa, exponent) per XRPL spec.
282///
283/// XRPL IOU amounts are canonical when mantissa is in [1e15, 1e16).
284/// Exponent range is [-96, 80].
285fn parse_xrpl_decimal(value: &str) -> Result<(bool, u64, i8), SignerError> {
286    let s = value.trim();
287    if s.is_empty() {
288        return Err(SignerError::ParseError("empty IOU value".into()));
289    }
290
291    let (negative, abs_str) = if let Some(rest) = s.strip_prefix('-') {
292        (true, rest)
293    } else {
294        (false, s)
295    };
296
297    // Split into integer and fractional parts
298    let (int_part, frac_part) = if let Some((i, f)) = abs_str.split_once('.') {
299        (i, f)
300    } else {
301        (abs_str, "")
302    };
303
304    // Validate all characters are digits
305    if !int_part.chars().all(|c| c.is_ascii_digit())
306        || !frac_part.chars().all(|c| c.is_ascii_digit())
307    {
308        return Err(SignerError::ParseError(format!(
309            "invalid IOU value: {value}"
310        )));
311    }
312    if int_part.is_empty() && frac_part.is_empty() {
313        return Err(SignerError::ParseError(format!(
314            "invalid IOU value: {value}"
315        )));
316    }
317
318    // Concatenate digits to form the full integer and track the exponent offset
319    let combined = format!("{int_part}{frac_part}");
320    let frac_len = frac_part.len() as i32;
321
322    // Strip leading zeros
323    let stripped = combined.trim_start_matches('0');
324    if stripped.is_empty() {
325        // Value is zero
326        return Ok((false, 0, 0));
327    }
328
329    // Parse the stripped digits as mantissa
330    let mut mantissa: u64 = stripped
331        .parse()
332        .map_err(|_| SignerError::ParseError(format!("IOU value too large: {value}")))?;
333    let mut exponent: i32 = -(frac_len) + (combined.len() as i32 - stripped.len() as i32);
334
335    // Normalize: mantissa must be in [1e15, 1e16)
336    const MIN_MANTISSA: u64 = 1_000_000_000_000_000;
337    const MAX_MANTISSA: u64 = 10_000_000_000_000_000;
338
339    while mantissa < MIN_MANTISSA {
340        mantissa *= 10;
341        exponent -= 1;
342    }
343    while mantissa >= MAX_MANTISSA {
344        mantissa /= 10;
345        exponent += 1;
346    }
347
348    // Validate exponent range
349    if !(-96..=80).contains(&exponent) {
350        return Err(SignerError::ParseError(format!(
351            "IOU exponent {exponent} out of range [-96, 80]"
352        )));
353    }
354
355    Ok((negative, mantissa, exponent as i8))
356}
357
358fn encode_iou_value(value: &str) -> Result<[u8; 8], SignerError> {
359    let (negative, mantissa, exponent) = parse_xrpl_decimal(value)?;
360
361    if mantissa == 0 {
362        return Ok(0x8000_0000_0000_0000u64.to_be_bytes());
363    }
364
365    // Bit 63: always 1 (IOU flag)
366    // Bit 62: 1 if positive, 0 if negative
367    // Bits 54–61: exponent + 97 (biased, 8 bits)
368    // Bits 0–53: mantissa (54 bits)
369    let mut encoded: u64 = 0x8000_0000_0000_0000; // IOU flag
370    if !negative {
371        encoded |= 0x4000_0000_0000_0000; // positive flag
372    }
373    let biased_exp = (exponent as i32 + 97) as u64;
374    encoded |= (biased_exp & 0xFF) << 54;
375    encoded |= mantissa & 0x003F_FFFF_FFFF_FFFF;
376
377    Ok(encoded.to_be_bytes())
378}
379
380// ═══════════════════════════════════════════════════════════════════
381// Multisign Helpers
382// ═══════════════════════════════════════════════════════════════════
383
384/// Compute the multisign prefix for XRP multisigning.
385///
386/// XRPL multisign hashes: `SHA-512Half(SIGNER_PREFIX || tx_blob || account_id)`
387///
388/// The `SIGNER_PREFIX` is `0x53545800` ("STX\0").
389pub const MULTISIGN_PREFIX: [u8; 4] = [0x53, 0x54, 0x58, 0x00];
390
391/// Compute the signing hash for XRP multisign.
392///
393/// Returns the hash that each signer should sign.
394pub fn multisign_hash(tx_blob: &[u8], signer_account: &[u8; 20]) -> [u8; 32] {
395    use sha2::{Digest, Sha512};
396    let mut hasher = Sha512::new();
397    hasher.update(MULTISIGN_PREFIX);
398    hasher.update(tx_blob);
399    hasher.update(signer_account);
400    let full = hasher.finalize();
401    let mut out = [0u8; 32];
402    out.copy_from_slice(&full[..32]);
403    out
404}
405
406/// A signer entry for multisign transactions.
407#[derive(Debug, Clone)]
408pub struct SignerEntry {
409    /// Signer's account ID.
410    pub account: [u8; 20],
411    /// Signer's weight.
412    pub weight: u16,
413}
414
415/// Build a SignerListSet transaction payload.
416///
417/// Sets the list of signers and quorum on an account.
418pub fn serialize_signer_list(signers: &[SignerEntry], quorum: u32) -> Vec<u8> {
419    let mut buf = Vec::new();
420    buf.extend_from_slice(&quorum.to_be_bytes());
421    for signer in signers {
422        buf.extend_from_slice(&signer.account);
423        buf.extend_from_slice(&signer.weight.to_be_bytes());
424    }
425    buf
426}
427
428// ═══════════════════════════════════════════════════════════════════
429// Tests
430// ═══════════════════════════════════════════════════════════════════
431
432#[cfg(test)]
433#[allow(clippy::unwrap_used, clippy::expect_used)]
434mod tests {
435    use super::*;
436
437    // ─── Binary Codec Tests ────────────────────────────────────────
438
439    #[test]
440    fn test_field_id_compact() {
441        // type=1, field=2 -> 0x12
442        assert_eq!(encode_field_id(1, 2), vec![0x12]);
443    }
444
445    #[test]
446    fn test_field_id_large_field() {
447        // type=1, field=27 -> [0x10, 27]
448        assert_eq!(encode_field_id(1, 27), vec![0x10, 27]);
449    }
450
451    #[test]
452    fn test_xrp_amount_encoding() {
453        let drops = 1_000_000u64; // 1 XRP
454        let encoded = encode_xrp_amount(drops);
455        assert_eq!(encoded.len(), 8);
456        // Bit 62 should be set (positive)
457        let val = u64::from_be_bytes(encoded.try_into().unwrap());
458        assert!(val & 0x4000_0000_0000_0000 != 0);
459    }
460
461    #[test]
462    fn test_uint32_encoding() {
463        let encoded = encode_uint32(2, 4, 42);
464        assert_eq!(encoded[0], 0x24); // type 2, field 4
465        assert_eq!(&encoded[1..5], &42u32.to_be_bytes());
466    }
467
468    #[test]
469    fn test_account_id_encoding() {
470        let account = [0xAA; 20];
471        let encoded = encode_account_id(8, 1, &account);
472        assert_eq!(encoded[0], 0x81); // type 8, field 1
473        assert_eq!(encoded[1], 20); // length
474        assert_eq!(&encoded[2..22], &account);
475    }
476
477    #[test]
478    fn test_vl_length_small() {
479        let mut buf = Vec::new();
480        encode_vl_length(&mut buf, 10);
481        assert_eq!(buf, vec![10]);
482    }
483
484    #[test]
485    fn test_vl_length_medium() {
486        let mut buf = Vec::new();
487        encode_vl_length(&mut buf, 200);
488        assert_eq!(buf.len(), 2);
489    }
490
491    // ─── Payment Transaction Tests ─────────────────────────────────
492
493    #[test]
494    fn test_payment_serialization() {
495        let from = [0xAA; 20];
496        let to = [0xBB; 20];
497        let blob = serialize_payment(&from, &to, 1_000_000, 12, 1, 100);
498        assert!(!blob.is_empty());
499        // First field should be TransactionType (0x12 = type 1, field 2)
500        assert_eq!(blob[0], 0x12);
501    }
502
503    #[test]
504    fn test_payment_different_amount() {
505        let from = [0xAA; 20];
506        let to = [0xBB; 20];
507        let blob1 = serialize_payment(&from, &to, 1_000, 12, 1, 100);
508        let blob2 = serialize_payment(&from, &to, 2_000, 12, 1, 100);
509        assert_ne!(blob1, blob2);
510    }
511
512    // ─── TrustSet Transaction Tests ────────────────────────────────
513
514    #[test]
515    fn test_trust_set_serialization() {
516        let account = [0xAA; 20];
517        let limit = IssuedAmount {
518            currency: {
519                let mut c = [0u8; 20];
520                c[12..15].copy_from_slice(b"USD");
521                c
522            },
523            issuer: [0xBB; 20],
524            value: "100".to_string(),
525        };
526        let blob = serialize_trust_set(&account, &limit, 12, 1, 100).unwrap();
527        assert!(!blob.is_empty());
528    }
529
530    #[test]
531    fn test_iou_zero_encoding() {
532        let result = encode_iou_value("0").unwrap();
533        assert_eq!(result, 0x8000_0000_0000_0000u64.to_be_bytes());
534    }
535
536    #[test]
537    fn test_iou_positive_value() {
538        let result = encode_iou_value("100").unwrap();
539        let val = u64::from_be_bytes(result);
540        // Must have IOU flag (bit 63) and positive flag (bit 62)
541        assert!(val & 0x8000_0000_0000_0000 != 0);
542        assert!(val & 0x4000_0000_0000_0000 != 0);
543    }
544
545    #[test]
546    fn test_iou_negative_value() {
547        let result = encode_iou_value("-50.5").unwrap();
548        let val = u64::from_be_bytes(result);
549        // Must have IOU flag but NOT positive flag
550        assert!(val & 0x8000_0000_0000_0000 != 0);
551        assert!(val & 0x4000_0000_0000_0000 == 0);
552    }
553
554    #[test]
555    fn test_iou_invalid_value_rejected() {
556        assert!(encode_iou_value("abc").is_err());
557        assert!(encode_iou_value("").is_err());
558    }
559
560    #[test]
561    fn test_iou_decimal_precision() {
562        // "0.001" should not silently become 0
563        let result = encode_iou_value("0.001").unwrap();
564        assert_ne!(result, 0x8000_0000_0000_0000u64.to_be_bytes());
565    }
566
567    // ─── Multisign Tests ───────────────────────────────────────────
568
569    #[test]
570    fn test_multisign_prefix() {
571        assert_eq!(&MULTISIGN_PREFIX, b"STX\0");
572    }
573
574    #[test]
575    fn test_multisign_hash_deterministic() {
576        let tx_blob = vec![0xAA; 100];
577        let account = [0xBB; 20];
578        let h1 = multisign_hash(&tx_blob, &account);
579        let h2 = multisign_hash(&tx_blob, &account);
580        assert_eq!(h1, h2);
581    }
582
583    #[test]
584    fn test_multisign_hash_different_account() {
585        let tx_blob = vec![0xAA; 100];
586        let h1 = multisign_hash(&tx_blob, &[0xBB; 20]);
587        let h2 = multisign_hash(&tx_blob, &[0xCC; 20]);
588        assert_ne!(h1, h2);
589    }
590
591    #[test]
592    fn test_signer_list() {
593        let signers = vec![
594            SignerEntry {
595                account: [0xAA; 20],
596                weight: 1,
597            },
598            SignerEntry {
599                account: [0xBB; 20],
600                weight: 2,
601            },
602        ];
603        let data = serialize_signer_list(&signers, 3);
604        // 4 bytes quorum + 2 * (20 + 2) = 48
605        assert_eq!(data.len(), 48);
606    }
607}