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
10// ═══════════════════════════════════════════════════════════════════
11// Binary Codec — Field Encoding
12// ═══════════════════════════════════════════════════════════════════
13
14/// XRPL field type codes.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[repr(u8)]
17pub enum FieldType {
18    /// 16-bit unsigned integer.
19    UInt16 = 1,
20    /// 32-bit unsigned integer.
21    UInt32 = 2,
22    /// 64-bit unsigned amount.
23    Amount = 6,
24    /// Variable-length blob.
25    Blob = 7,
26    /// 160-bit account ID.
27    AccountID = 8,
28    /// 256-bit hash.
29    Hash256 = 5,
30}
31
32/// Encode a field ID for XRPL serialization.
33///
34/// XRPL uses a compact encoding: if both type_code and field_code fit
35/// in 4 bits, they combine into a single byte.
36#[must_use]
37pub fn encode_field_id(type_code: u8, field_code: u8) -> Vec<u8> {
38    if type_code < 16 && field_code < 16 {
39        vec![(type_code << 4) | field_code]
40    } else if type_code < 16 {
41        vec![type_code << 4, field_code]
42    } else if field_code < 16 {
43        vec![field_code, type_code]
44    } else {
45        vec![0, type_code, field_code]
46    }
47}
48
49/// Encode a UInt32 field.
50#[must_use]
51pub fn encode_uint32(type_code: u8, field_code: u8, value: u32) -> Vec<u8> {
52    let mut buf = encode_field_id(type_code, field_code);
53    buf.extend_from_slice(&value.to_be_bytes());
54    buf
55}
56
57/// Encode a UInt16 field.
58#[must_use]
59pub fn encode_uint16(type_code: u8, field_code: u8, value: u16) -> Vec<u8> {
60    let mut buf = encode_field_id(type_code, field_code);
61    buf.extend_from_slice(&value.to_be_bytes());
62    buf
63}
64
65/// Encode an XRP amount (drops) for serialization.
66///
67/// XRP amounts are encoded as 64-bit values with the high bit set
68/// and the second-highest bit indicating positive.
69#[must_use]
70pub fn encode_xrp_amount(drops: u64) -> Vec<u8> {
71    // Set bit 62 (positive) and bit 63 (not IOU)
72    let encoded = drops | 0x4000_0000_0000_0000;
73    encoded.to_be_bytes().to_vec()
74}
75
76/// Encode an AccountID field (20 bytes).
77#[must_use]
78pub fn encode_account_id(type_code: u8, field_code: u8, account: &[u8; 20]) -> Vec<u8> {
79    let mut buf = encode_field_id(type_code, field_code);
80    buf.push(20); // length prefix
81    buf.extend_from_slice(account);
82    buf
83}
84
85/// Encode a variable-length blob.
86#[must_use]
87pub fn encode_blob(type_code: u8, field_code: u8, data: &[u8]) -> Vec<u8> {
88    let mut buf = encode_field_id(type_code, field_code);
89    encode_vl_length(&mut buf, data.len());
90    buf.extend_from_slice(data);
91    buf
92}
93
94fn encode_vl_length(buf: &mut Vec<u8>, len: usize) {
95    if len <= 192 {
96        buf.push(len as u8);
97    } else if len <= 12_480 {
98        let adjusted = len - 193;
99        buf.push((adjusted >> 8) as u8 + 193);
100        buf.push((adjusted & 0xFF) as u8);
101    } else {
102        let adjusted = len - 12_481;
103        buf.push(241u8 + (adjusted >> 16) as u8);
104        buf.push(((adjusted >> 8) & 0xFF) as u8);
105        buf.push((adjusted & 0xFF) as u8);
106    }
107}
108
109// ═══════════════════════════════════════════════════════════════════
110// Payment Transaction
111// ═══════════════════════════════════════════════════════════════════
112
113/// Well-known XRPL field codes for Payment transactions.
114mod fields {
115    // Type 1 (UInt16): TransactionType = field 2
116    pub const TRANSACTION_TYPE: (u8, u8) = (1, 2);
117    // Type 2 (UInt32): Flags = field 2, Sequence = field 4, Fee = field 8
118    pub const FLAGS: (u8, u8) = (2, 2);
119    pub const SEQUENCE: (u8, u8) = (2, 4);
120    pub const LAST_LEDGER_SEQUENCE: (u8, u8) = (2, 27);
121    // Type 6 (Amount): Amount = field 1, Fee = field 8
122    pub const AMOUNT: (u8, u8) = (6, 1);
123    pub const FEE: (u8, u8) = (6, 8);
124    // Type 8 (AccountID): Account = field 1, Destination = field 3
125    pub const ACCOUNT: (u8, u8) = (8, 1);
126    pub const DESTINATION: (u8, u8) = (8, 3);
127}
128
129/// XRPL Transaction types.
130pub const TT_PAYMENT: u16 = 0;
131/// XRPL TrustSet transaction type code.
132pub const TT_TRUST_SET: u16 = 20;
133
134/// Build a serialized XRP Payment transaction (for signing).
135///
136/// # Arguments
137/// - `account` — Sender account ID (20 bytes)
138/// - `destination` — Recipient account ID (20 bytes)
139/// - `amount_drops` — XRP amount in drops
140/// - `fee_drops` — Transaction fee in drops
141/// - `sequence` — Account sequence number
142/// - `last_ledger_sequence` — Maximum ledger for inclusion
143pub fn serialize_payment(
144    account: &[u8; 20],
145    destination: &[u8; 20],
146    amount_drops: u64,
147    fee_drops: u64,
148    sequence: u32,
149    last_ledger_sequence: u32,
150) -> Vec<u8> {
151    let mut buf = Vec::new();
152
153    // Fields must be serialized in canonical order (by type code, then field code)
154    // Type 1: TransactionType
155    buf.extend_from_slice(&encode_uint16(
156        fields::TRANSACTION_TYPE.0,
157        fields::TRANSACTION_TYPE.1,
158        TT_PAYMENT,
159    ));
160    // Type 2: Flags, Sequence, LastLedgerSequence
161    buf.extend_from_slice(&encode_uint32(fields::FLAGS.0, fields::FLAGS.1, 0));
162    buf.extend_from_slice(&encode_uint32(
163        fields::SEQUENCE.0,
164        fields::SEQUENCE.1,
165        sequence,
166    ));
167    buf.extend_from_slice(&encode_uint32(
168        fields::LAST_LEDGER_SEQUENCE.0,
169        fields::LAST_LEDGER_SEQUENCE.1,
170        last_ledger_sequence,
171    ));
172    // Type 6: Amount, Fee
173    let mut amount_field = encode_field_id(fields::AMOUNT.0, fields::AMOUNT.1);
174    amount_field.extend_from_slice(&encode_xrp_amount(amount_drops));
175    buf.extend_from_slice(&amount_field);
176
177    let mut fee_field = encode_field_id(fields::FEE.0, fields::FEE.1);
178    fee_field.extend_from_slice(&encode_xrp_amount(fee_drops));
179    buf.extend_from_slice(&fee_field);
180
181    // Type 8: Account, Destination
182    buf.extend_from_slice(&encode_account_id(
183        fields::ACCOUNT.0,
184        fields::ACCOUNT.1,
185        account,
186    ));
187    buf.extend_from_slice(&encode_account_id(
188        fields::DESTINATION.0,
189        fields::DESTINATION.1,
190        destination,
191    ));
192
193    buf
194}
195
196// ═══════════════════════════════════════════════════════════════════
197// TrustSet Transaction
198// ═══════════════════════════════════════════════════════════════════
199
200/// An XRPL issued currency amount.
201#[derive(Debug, Clone)]
202pub struct IssuedAmount {
203    /// Currency code (3-letter ISO or 20-byte hex).
204    pub currency: [u8; 20],
205    /// Issuer account ID.
206    pub issuer: [u8; 20],
207    /// Value as a string (e.g., "100.5").
208    pub value: String,
209}
210
211/// Build a serialized TrustSet transaction.
212///
213/// # Arguments
214/// - `account` — Account setting the trust line
215/// - `limit_amount` — The trust line limit
216/// - `fee_drops` — Transaction fee in drops
217/// - `sequence` — Account sequence
218/// - `last_ledger_sequence` — Maximum ledger
219pub fn serialize_trust_set(
220    account: &[u8; 20],
221    limit_amount: &IssuedAmount,
222    fee_drops: u64,
223    sequence: u32,
224    last_ledger_sequence: u32,
225) -> Vec<u8> {
226    let mut buf = Vec::new();
227
228    // TransactionType
229    buf.extend_from_slice(&encode_uint16(
230        fields::TRANSACTION_TYPE.0,
231        fields::TRANSACTION_TYPE.1,
232        TT_TRUST_SET,
233    ));
234    // Flags
235    buf.extend_from_slice(&encode_uint32(fields::FLAGS.0, fields::FLAGS.1, 0));
236    // Sequence
237    buf.extend_from_slice(&encode_uint32(
238        fields::SEQUENCE.0,
239        fields::SEQUENCE.1,
240        sequence,
241    ));
242    // LastLedgerSequence
243    buf.extend_from_slice(&encode_uint32(
244        fields::LAST_LEDGER_SEQUENCE.0,
245        fields::LAST_LEDGER_SEQUENCE.1,
246        last_ledger_sequence,
247    ));
248    // Fee
249    let mut fee_field = encode_field_id(fields::FEE.0, fields::FEE.1);
250    fee_field.extend_from_slice(&encode_xrp_amount(fee_drops));
251    buf.extend_from_slice(&fee_field);
252    // Account
253    buf.extend_from_slice(&encode_account_id(
254        fields::ACCOUNT.0,
255        fields::ACCOUNT.1,
256        account,
257    ));
258
259    // LimitAmount is encoded as an object (type 14, field 3)
260    // For simplicity, we encode the currency/issuer/value inline
261    buf.extend_from_slice(&encode_field_id(14, 3)); // LimitAmount object marker
262                                                    // Encode issued amount: 48 bytes (8 value + 20 currency + 20 issuer)
263    let encoded_amount = encode_issued_amount(limit_amount);
264    buf.extend_from_slice(&encoded_amount);
265
266    buf
267}
268
269fn encode_issued_amount(amount: &IssuedAmount) -> Vec<u8> {
270    let mut buf = Vec::new();
271    // Encode value as IOU amount (simplified — sets bit 63 for IOU flag)
272    let value_bytes = encode_iou_value(&amount.value);
273    buf.extend_from_slice(&value_bytes);
274    buf.extend_from_slice(&amount.currency);
275    buf.extend_from_slice(&amount.issuer);
276    buf
277}
278
279fn encode_iou_value(value: &str) -> [u8; 8] {
280    // Simplified IOU encoding: parse as f64, encode as XRPL format
281    // In production this needs precise decimal handling
282    let val: f64 = value.parse().unwrap_or(0.0);
283    if val == 0.0 {
284        return 0x8000_0000_0000_0000u64.to_be_bytes();
285    }
286    // Set IOU flag (bit 63) and positive flag (bit 62)
287    let mut encoded = 0x8000_0000_0000_0000u64;
288    if val > 0.0 {
289        encoded |= 0x4000_0000_0000_0000;
290    }
291    // Store mantissa in lower bits (simplified)
292    let abs_val = val.abs();
293    let mantissa = (abs_val * 1_000_000.0) as u64;
294    encoded |= mantissa & 0x003F_FFFF_FFFF_FFFF;
295    encoded.to_be_bytes()
296}
297
298// ═══════════════════════════════════════════════════════════════════
299// Multisign Helpers
300// ═══════════════════════════════════════════════════════════════════
301
302/// Compute the multisign prefix for XRP multisigning.
303///
304/// XRPL multisign hashes: `SHA-512Half(SIGNER_PREFIX || tx_blob || account_id)`
305///
306/// The `SIGNER_PREFIX` is `0x53545800` ("STX\0").
307pub const MULTISIGN_PREFIX: [u8; 4] = [0x53, 0x54, 0x58, 0x00];
308
309/// Compute the signing hash for XRP multisign.
310///
311/// Returns the hash that each signer should sign.
312pub fn multisign_hash(tx_blob: &[u8], signer_account: &[u8; 20]) -> [u8; 32] {
313    use sha2::{Digest, Sha512};
314    let mut hasher = Sha512::new();
315    hasher.update(MULTISIGN_PREFIX);
316    hasher.update(tx_blob);
317    hasher.update(signer_account);
318    let full = hasher.finalize();
319    let mut out = [0u8; 32];
320    out.copy_from_slice(&full[..32]);
321    out
322}
323
324/// A signer entry for multisign transactions.
325#[derive(Debug, Clone)]
326pub struct SignerEntry {
327    /// Signer's account ID.
328    pub account: [u8; 20],
329    /// Signer's weight.
330    pub weight: u16,
331}
332
333/// Build a SignerListSet transaction payload.
334///
335/// Sets the list of signers and quorum on an account.
336pub fn serialize_signer_list(signers: &[SignerEntry], quorum: u32) -> Vec<u8> {
337    let mut buf = Vec::new();
338    buf.extend_from_slice(&quorum.to_be_bytes());
339    for signer in signers {
340        buf.extend_from_slice(&signer.account);
341        buf.extend_from_slice(&signer.weight.to_be_bytes());
342    }
343    buf
344}
345
346// ═══════════════════════════════════════════════════════════════════
347// Tests
348// ═══════════════════════════════════════════════════════════════════
349
350#[cfg(test)]
351#[allow(clippy::unwrap_used, clippy::expect_used)]
352mod tests {
353    use super::*;
354
355    // ─── Binary Codec Tests ────────────────────────────────────────
356
357    #[test]
358    fn test_field_id_compact() {
359        // type=1, field=2 -> 0x12
360        assert_eq!(encode_field_id(1, 2), vec![0x12]);
361    }
362
363    #[test]
364    fn test_field_id_large_field() {
365        // type=1, field=27 -> [0x10, 27]
366        assert_eq!(encode_field_id(1, 27), vec![0x10, 27]);
367    }
368
369    #[test]
370    fn test_xrp_amount_encoding() {
371        let drops = 1_000_000u64; // 1 XRP
372        let encoded = encode_xrp_amount(drops);
373        assert_eq!(encoded.len(), 8);
374        // Bit 62 should be set (positive)
375        let val = u64::from_be_bytes(encoded.try_into().unwrap());
376        assert!(val & 0x4000_0000_0000_0000 != 0);
377    }
378
379    #[test]
380    fn test_uint32_encoding() {
381        let encoded = encode_uint32(2, 4, 42);
382        assert_eq!(encoded[0], 0x24); // type 2, field 4
383        assert_eq!(&encoded[1..5], &42u32.to_be_bytes());
384    }
385
386    #[test]
387    fn test_account_id_encoding() {
388        let account = [0xAA; 20];
389        let encoded = encode_account_id(8, 1, &account);
390        assert_eq!(encoded[0], 0x81); // type 8, field 1
391        assert_eq!(encoded[1], 20); // length
392        assert_eq!(&encoded[2..22], &account);
393    }
394
395    #[test]
396    fn test_vl_length_small() {
397        let mut buf = Vec::new();
398        encode_vl_length(&mut buf, 10);
399        assert_eq!(buf, vec![10]);
400    }
401
402    #[test]
403    fn test_vl_length_medium() {
404        let mut buf = Vec::new();
405        encode_vl_length(&mut buf, 200);
406        assert_eq!(buf.len(), 2);
407    }
408
409    // ─── Payment Transaction Tests ─────────────────────────────────
410
411    #[test]
412    fn test_payment_serialization() {
413        let from = [0xAA; 20];
414        let to = [0xBB; 20];
415        let blob = serialize_payment(&from, &to, 1_000_000, 12, 1, 100);
416        assert!(!blob.is_empty());
417        // First field should be TransactionType (0x12 = type 1, field 2)
418        assert_eq!(blob[0], 0x12);
419    }
420
421    #[test]
422    fn test_payment_different_amount() {
423        let from = [0xAA; 20];
424        let to = [0xBB; 20];
425        let blob1 = serialize_payment(&from, &to, 1_000, 12, 1, 100);
426        let blob2 = serialize_payment(&from, &to, 2_000, 12, 1, 100);
427        assert_ne!(blob1, blob2);
428    }
429
430    // ─── TrustSet Transaction Tests ────────────────────────────────
431
432    #[test]
433    fn test_trust_set_serialization() {
434        let account = [0xAA; 20];
435        let limit = IssuedAmount {
436            currency: {
437                let mut c = [0u8; 20];
438                c[12..15].copy_from_slice(b"USD");
439                c
440            },
441            issuer: [0xBB; 20],
442            value: "100".to_string(),
443        };
444        let blob = serialize_trust_set(&account, &limit, 12, 1, 100);
445        assert!(!blob.is_empty());
446    }
447
448    // ─── Multisign Tests ───────────────────────────────────────────
449
450    #[test]
451    fn test_multisign_prefix() {
452        assert_eq!(&MULTISIGN_PREFIX, b"STX\0");
453    }
454
455    #[test]
456    fn test_multisign_hash_deterministic() {
457        let tx_blob = vec![0xAA; 100];
458        let account = [0xBB; 20];
459        let h1 = multisign_hash(&tx_blob, &account);
460        let h2 = multisign_hash(&tx_blob, &account);
461        assert_eq!(h1, h2);
462    }
463
464    #[test]
465    fn test_multisign_hash_different_account() {
466        let tx_blob = vec![0xAA; 100];
467        let h1 = multisign_hash(&tx_blob, &[0xBB; 20]);
468        let h2 = multisign_hash(&tx_blob, &[0xCC; 20]);
469        assert_ne!(h1, h2);
470    }
471
472    #[test]
473    fn test_signer_list() {
474        let signers = vec![
475            SignerEntry {
476                account: [0xAA; 20],
477                weight: 1,
478            },
479            SignerEntry {
480                account: [0xBB; 20],
481                weight: 2,
482            },
483        ];
484        let data = serialize_signer_list(&signers, 3);
485        // 4 bytes quorum + 2 * (20 + 2) = 48
486        assert_eq!(data.len(), 48);
487    }
488}