diff --git a/src/core/binarycodec/types/currency.rs b/src/core/binarycodec/types/currency.rs index 23fd6927..af3c856a 100644 --- a/src/core/binarycodec/types/currency.rs +++ b/src/core/binarycodec/types/currency.rs @@ -16,6 +16,7 @@ use alloc::vec::Vec; use core::convert::TryFrom; use core::convert::TryInto; use core::fmt::Display; +use exceptions::XRPLUtilsException; use serde::Serializer; use serde::{Deserialize, Serialize}; @@ -28,11 +29,13 @@ pub const NATIVE_CODE: &str = "XRP"; #[serde(try_from = "&str")] pub struct Currency(Hash160); -fn _iso_code_from_hex(value: &[u8]) -> Result, ISOCodeException> { +fn _iso_code_from_hex(value: &[u8]) -> Result, XRPLUtilsException> { let candidate_iso = alloc::str::from_utf8(&value[12..15])?; if candidate_iso == NATIVE_CODE { - Err(ISOCodeException::InvalidXRPBytes) + Err(XRPLUtilsException::ISOCodeError( + ISOCodeException::InvalidXRPBytes, + )) } else if is_iso_code(candidate_iso) { Ok(Some(candidate_iso.to_string())) } else { diff --git a/src/models/flag_collection.rs b/src/models/flag_collection.rs index 2a8b970a..80f4cefc 100644 --- a/src/models/flag_collection.rs +++ b/src/models/flag_collection.rs @@ -66,6 +66,20 @@ where } } +impl TryFrom> for FlagCollection +where + T: IntoEnumIterator + Serialize, +{ + type Error = XRPLModelException; + + fn try_from(flags: Option) -> XRPLModelResult { + match flags { + Some(flags) => FlagCollection::try_from(flags), + None => Ok(FlagCollection::default()), + } + } +} + impl TryFrom> for u32 where T: IntoEnumIterator + Serialize, diff --git a/src/utils/exceptions.rs b/src/utils/exceptions.rs index 776265e3..7efc3031 100644 --- a/src/utils/exceptions.rs +++ b/src/utils/exceptions.rs @@ -23,6 +23,10 @@ pub enum XRPLUtilsException { #[cfg(feature = "models")] #[error("XRPL Model error: {0}")] XRPLModelError(#[from] XRPLModelException), + #[error("XRPL Txn Parser error: {0}")] + XRPLTxnParserError(#[from] XRPLTxnParserException), + #[error("XRPL XChain Claim ID error: {0}")] + XRPLXChainClaimIdError(#[from] XRPLXChainClaimIdException), #[error("ISO Code error: {0}")] ISOCodeError(#[from] ISOCodeException), #[error("Decimal error: {0}")] @@ -35,6 +39,8 @@ pub enum XRPLUtilsException { FromHexError(#[from] hex::FromHexError), #[error("ParseInt error: {0}")] ParseIntError(#[from] core::num::ParseIntError), + #[error("Invalid UTF-8")] + Utf8Error, } #[derive(Debug, Clone, PartialEq, Error)] @@ -80,6 +86,26 @@ pub enum XRPLNFTIdException { InvalidNFTIdLength { expected: usize, found: usize }, } +#[derive(Debug, Clone, PartialEq, Error)] +#[non_exhaustive] +pub enum XRPLXChainClaimIdException { + #[error("No XChainOwnedClaimID created")] + NoXChainOwnedClaimID, +} + +#[cfg(feature = "std")] +impl alloc::error::Error for XRPLXChainClaimIdException {} + +#[derive(Debug, Clone, PartialEq, Error)] +#[non_exhaustive] +pub enum XRPLTxnParserException { + #[error("Missing field: {0}")] + MissingField(&'static str), +} + +#[cfg(feature = "std")] +impl alloc::error::Error for XRPLTxnParserException {} + #[derive(Debug, Clone, PartialEq, Error)] #[non_exhaustive] pub enum ISOCodeException { @@ -91,13 +117,11 @@ pub enum ISOCodeException { InvalidXRPBytes, #[error("Invalid Currency representation")] UnsupportedCurrencyRepresentation, - #[error("Invalid UTF-8")] - Utf8Error, } -impl From for ISOCodeException { +impl From for XRPLUtilsException { fn from(_: core::str::Utf8Error) -> Self { - ISOCodeException::Utf8Error + XRPLUtilsException::Utf8Error } } diff --git a/src/utils/get_xchain_claim_id.rs b/src/utils/get_xchain_claim_id.rs new file mode 100644 index 00000000..333793d8 --- /dev/null +++ b/src/utils/get_xchain_claim_id.rs @@ -0,0 +1,45 @@ +use alloc::{borrow::Cow, vec::Vec}; + +use crate::models::{ + ledger::objects::LedgerEntryType, transactions::metadata::TransactionMetadata, +}; + +use super::exceptions::{XRPLUtilsException, XRPLUtilsResult, XRPLXChainClaimIdException}; +use crate::models::transactions::metadata::AffectedNode; + +pub fn get_xchain_claim_id<'a: 'b, 'b>( + meta: &TransactionMetadata<'a>, +) -> XRPLUtilsResult> { + let affected_nodes: Vec<&AffectedNode> = meta + .affected_nodes + .iter() + .filter(|node| { + // node.is_created_node() && node.created_node().ledger_entry_type == "XChainOwnedClaimID" + match node { + AffectedNode::CreatedNode { + ledger_entry_type, .. + } => ledger_entry_type == &LedgerEntryType::XChainOwnedClaimID, + _ => false, + } + }) + .collect(); + + if affected_nodes.is_empty() { + Err(XRPLXChainClaimIdException::NoXChainOwnedClaimID.into()) + } else { + match affected_nodes[0] { + AffectedNode::CreatedNode { new_fields, .. } => { + if let Some(xchain_claim_id) = new_fields.xchain_claim_id.as_ref() { + Ok(xchain_claim_id.clone()) + } else { + Err(XRPLUtilsException::XRPLXChainClaimIdError( + XRPLXChainClaimIdException::NoXChainOwnedClaimID, + )) + } + } + _ => Err(XRPLUtilsException::XRPLXChainClaimIdError( + XRPLXChainClaimIdException::NoXChainOwnedClaimID, + )), + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index a63283e9..403c4869 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -4,10 +4,15 @@ pub mod exceptions; #[cfg(feature = "models")] pub mod get_nftoken_id; #[cfg(feature = "models")] +pub mod get_xchain_claim_id; +#[cfg(feature = "models")] pub mod parse_nftoken_id; +pub mod str_conversion; pub mod time_conversion; #[cfg(feature = "models")] pub(crate) mod transactions; +#[cfg(feature = "models")] +pub mod txn_parser; pub mod xrpl_conversion; pub use self::time_conversion::*; diff --git a/src/utils/str_conversion.rs b/src/utils/str_conversion.rs new file mode 100644 index 00000000..41d95b4c --- /dev/null +++ b/src/utils/str_conversion.rs @@ -0,0 +1,22 @@ +use alloc::{borrow::Cow, string::String}; + +use super::exceptions::XRPLUtilsResult; + +/// Convert a UTF-8-encoded string into hexadecimal encoding. +/// XRPL uses hex strings as inputs in fields like `domain` +/// in the `AccountSet` transaction. +pub fn str_to_hex<'a: 'b, 'b>(value: Cow<'a, str>) -> XRPLUtilsResult> { + let hex_string = hex::encode(value.as_bytes()); + + Ok(Cow::Owned(hex_string)) +} + +/// Convert a hex string into a human-readable string. +/// XRPL uses hex strings as inputs in fields like `domain` +/// in the `AccountSet` transaction. +pub fn hex_to_str<'a: 'b, 'b>(value: Cow<'a, str>) -> XRPLUtilsResult> { + let bytes = hex::decode(value.as_ref())?; + let string = String::from_utf8(bytes).map_err(|e| e.utf8_error())?; + + Ok(Cow::Owned(string)) +} diff --git a/src/utils/txn_parser/get_balance_changes.rs b/src/utils/txn_parser/get_balance_changes.rs new file mode 100644 index 00000000..18a22bca --- /dev/null +++ b/src/utils/txn_parser/get_balance_changes.rs @@ -0,0 +1,112 @@ +use alloc::vec::Vec; +use bigdecimal::BigDecimal; + +use crate::{ + models::transactions::metadata::TransactionMetadata, + utils::{exceptions::XRPLUtilsResult, txn_parser::utils::parser::get_value}, +}; + +use super::utils::{ + balance_parser::derive_account_balances, nodes::NormalizedNode, AccountBalances, +}; + +/// Parses the balance changes of all accounts affected by a transaction from the transaction metadata. +pub fn get_balance_changes<'a: 'b, 'b>( + meta: &'a TransactionMetadata<'a>, +) -> XRPLUtilsResult>> { + derive_account_balances(meta, compute_balance_change) +} + +/// Get the balance change from a node. +fn compute_balance_change(node: &NormalizedNode) -> XRPLUtilsResult> { + let new_fields = node.new_fields.as_ref(); + let previous_fields = node.previous_fields.as_ref(); + let final_fields = node.final_fields.as_ref(); + + if let Some(new_fields) = new_fields { + if let Some(balance) = &new_fields.balance { + Ok(Some(get_value(&balance.clone().into())?)) + } else { + Ok(None) + } + } else if let (Some(previous_fields), Some(final_fields)) = (previous_fields, final_fields) { + if let (Some(prev_balance), Some(final_balance)) = + (&previous_fields.balance, &final_fields.balance) + { + let prev_value = get_value(&prev_balance.clone().into())?; + let final_value = get_value(&final_balance.clone().into())?; + + Ok(Some(final_value - prev_value)) + } else { + Ok(None) + } + } else { + Ok(None) + } +} + +#[cfg(test)] +mod test { + use core::cell::LazyCell; + + use serde_json::Value; + + use super::*; + use crate::{ + models::transactions::metadata::TransactionMetadata, utils::txn_parser::utils::Balance, + }; + + #[test] + fn test_parse_balance_changes() { + let txn: LazyCell = LazyCell::new(|| { + let txn_value: Value = + serde_json::from_str(include_str!("./test_data/payment_iou.json")).unwrap(); + let txn_meta = txn_value["meta"].clone(); + let txn_meta: TransactionMetadata = serde_json::from_value(txn_meta).unwrap(); + + txn_meta + }); + let expected_balances = Vec::from([ + AccountBalances { + account: "rKmBGxocj9Abgy25J51Mk1iqFzW9aVF9Tc".into(), + balances: Vec::from([ + Balance { + currency: "USD".into(), + value: "-0.01".into(), + issuer: Some("rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q".into()), + }, + Balance { + currency: "XRP".into(), + value: "-0.012".into(), + issuer: None, + }, + ]), + }, + AccountBalances { + account: "rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q".into(), + balances: Vec::from([ + Balance { + currency: "USD".into(), + value: "0.01".into(), + issuer: Some("rKmBGxocj9Abgy25J51Mk1iqFzW9aVF9Tc".into()), + }, + Balance { + currency: "USD".into(), + value: "-0.01".into(), + issuer: Some("rLDYrujdKUfVx28T9vRDAbyJ7G2WVXKo4K".into()), + }, + ]), + }, + AccountBalances { + account: "rLDYrujdKUfVx28T9vRDAbyJ7G2WVXKo4K".into(), + balances: Vec::from([Balance { + currency: "USD".into(), + value: "0.01".into(), + issuer: Some("rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q".into()), + }]), + }, + ]); + let balance_changes = get_balance_changes(&txn).unwrap(); + assert_eq!(expected_balances, balance_changes); + } +} diff --git a/src/utils/txn_parser/get_final_balances.rs b/src/utils/txn_parser/get_final_balances.rs new file mode 100644 index 00000000..2a627389 --- /dev/null +++ b/src/utils/txn_parser/get_final_balances.rs @@ -0,0 +1,99 @@ +use super::utils::{ + balance_parser::derive_account_balances, nodes::NormalizedNode, parser::get_value, + AccountBalances, +}; +use crate::{ + models::transactions::metadata::TransactionMetadata, utils::exceptions::XRPLUtilsResult, +}; +use alloc::vec::Vec; +use bigdecimal::BigDecimal; + +/// Parses the final balances of all accounts affected by a transaction from the transaction metadata. +pub fn get_final_balances<'a: 'b, 'b>( + metadata: &'a TransactionMetadata<'a>, +) -> XRPLUtilsResult>> { + derive_account_balances(metadata, compute_final_balance) +} + +fn compute_final_balance(node: &NormalizedNode) -> XRPLUtilsResult> { + let mut value: BigDecimal = BigDecimal::from(0); + if let Some(new_fields) = &node.new_fields { + if let Some(balance) = &new_fields.balance { + value = get_value(&balance.clone().into())?; + } + } else if let Some(final_fields) = &node.final_fields { + if let Some(balance) = &final_fields.balance { + value = get_value(&balance.clone().into())?; + } + } + if value == BigDecimal::from(0) { + return Ok(None); + } + Ok(Some(value)) +} + +#[cfg(test)] +mod test { + use core::cell::LazyCell; + + use serde_json::Value; + + use super::*; + use crate::{ + models::transactions::metadata::TransactionMetadata, utils::txn_parser::utils::Balance, + }; + + #[test] + fn test_parse_final_balances() { + let txn: LazyCell = LazyCell::new(|| { + let txn_value: Value = + serde_json::from_str(include_str!("./test_data/payment_iou.json")).unwrap(); + let txn_meta = txn_value["meta"].clone(); + let txn_meta: TransactionMetadata = serde_json::from_value(txn_meta).unwrap(); + + txn_meta + }); + let expected_balances = Vec::from([ + AccountBalances { + account: "rKmBGxocj9Abgy25J51Mk1iqFzW9aVF9Tc".into(), + balances: Vec::from([ + Balance { + currency: "USD".into(), + value: "1.525330905250352".into(), + issuer: Some("rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q".into()), + }, + Balance { + currency: "XRP".into(), + value: "-239.555992".into(), + issuer: None, + }, + ]), + }, + AccountBalances { + account: "rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q".into(), + balances: Vec::from([ + Balance { + currency: "USD".into(), + value: "-1.525330905250352".into(), + issuer: Some("rKmBGxocj9Abgy25J51Mk1iqFzW9aVF9Tc".into()), + }, + Balance { + currency: "USD".into(), + value: "-0.02".into(), + issuer: Some("rLDYrujdKUfVx28T9vRDAbyJ7G2WVXKo4K".into()), + }, + ]), + }, + AccountBalances { + account: "rLDYrujdKUfVx28T9vRDAbyJ7G2WVXKo4K".into(), + balances: Vec::from([Balance { + currency: "USD".into(), + value: "0.02".into(), + issuer: Some("rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q".into()), + }]), + }, + ]); + let final_balances = get_final_balances(&txn).unwrap(); + assert_eq!(final_balances, expected_balances); + } +} diff --git a/src/utils/txn_parser/mod.rs b/src/utils/txn_parser/mod.rs new file mode 100644 index 00000000..6d4b8769 --- /dev/null +++ b/src/utils/txn_parser/mod.rs @@ -0,0 +1,3 @@ +pub mod get_balance_changes; +pub mod get_final_balances; +mod utils; diff --git a/src/utils/txn_parser/test_data/offer_created.json b/src/utils/txn_parser/test_data/offer_created.json new file mode 100644 index 00000000..75e5eade --- /dev/null +++ b/src/utils/txn_parser/test_data/offer_created.json @@ -0,0 +1,93 @@ +{ + "Account": "rJHbqhp9Sea4f43RoUanrDE1gW9MymTLp9", + "Expiration": 740218424, + "Fee": "25", + "Flags": 2148007936, + "LastLedgerSequence": 72374322, + "Sequence": 71307620, + "SigningPubKey": "020AF1B1419DB3C80FBB3B2621315F78F03806118094140B6FE535ED809561AC23", + "TakerGets": "44930000", + "TakerPays": { + "currency": "USD", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "14.524821" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "3045022100C1CA7C960C1BD0B2EA9ACCA13BB429C1C5B558AB2B8AFE9FE3815DB075E07806022030152DC46694A3BF8BA1509944F9388723CB30C678E659613D4B3BF8166CCCA5", + "date": 708682430, + "hash": "463E28521ACA7FB5F4081E7E368FA5AEB76FB641FCB3C92CA9E8971A990CE84A", + "inLedger": 72374321, + "ledger_index": 72374321, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "Owner": "rJHbqhp9Sea4f43RoUanrDE1gW9MymTLp9", + "RootIndex": "3418F55643869450792F7047DC92DD661D38E68AC827C378D7C12FE8E017DD2B" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "3418F55643869450792F7047DC92DD661D38E68AC827C378D7C12FE8E017DD2B" + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rJHbqhp9Sea4f43RoUanrDE1gW9MymTLp9", + "Balance": "69932774", + "Flags": 0, + "OwnerCount": 3, + "Sequence": 71307621 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "72D49D4E2ECA5153A90413AA4BCEFBFBE748A2B66A96F5E5611089C095BD666D", + "PreviousFields": { + "Balance": "69932799", + "OwnerCount": 2, + "Sequence": 71307620 + }, + "PreviousTxnID": "70C2D1F863FBF18CA7E9B17D7B35A19BD5ED22C8B703D447A21C746E1F66F311", + "PreviousTxnLgrSeq": 72374313 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "ExchangeRate": "4e0b7c2f29ac3197", + "Flags": 0, + "RootIndex": "DFA3B6DDAB58C7E8E5D944E736DA4B7046C30E4F460FD9DE4E0B7C2F29AC3197", + "TakerGetsCurrency": "0000000000000000000000000000000000000000", + "TakerGetsIssuer": "0000000000000000000000000000000000000000", + "TakerPaysCurrency": "0000000000000000000000005553440000000000", + "TakerPaysIssuer": "0A20B3C85F482532A9578DBB3950B85CA06594D1" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "DFA3B6DDAB58C7E8E5D944E736DA4B7046C30E4F460FD9DE4E0B7C2F29AC3197" + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "DFA50F8A0710C8483D5DEF31E87BFA5DBC617F16045EC187EB21A07B7A2B23DA", + "NewFields": { + "Account": "rJHbqhp9Sea4f43RoUanrDE1gW9MymTLp9", + "BookDirectory": "DFA3B6DDAB58C7E8E5D944E736DA4B7046C30E4F460FD9DE4E0B7C2F29AC3197", + "Expiration": 740218424, + "Flags": 131072, + "Sequence": 71307620, + "TakerGets": "44930000", + "TakerPays": { + "currency": "USD", + "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "value": "14.524821" + } + } + } + } + ], + "TransactionIndex": 37, + "TransactionResult": "tesSUCCESS" + }, + "validated": true +} \ No newline at end of file diff --git a/src/utils/txn_parser/test_data/payment_iou.json b/src/utils/txn_parser/test_data/payment_iou.json new file mode 100644 index 00000000..0721596b --- /dev/null +++ b/src/utils/txn_parser/test_data/payment_iou.json @@ -0,0 +1,123 @@ +{ + "engine_result": "tesSUCCESS", + "engine_result_code": 0, + "engine_result_message": "The transaction was applied.", + "ledger_hash": "F3F1416BF2E813396AB01FAA38E9F1023AC4D2368D94B0D52B2BC603CEEC01C3", + "ledger_index": 10459371, + "status": "closed", + "type": "transaction", + "validated": true, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "USD", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "1.525330905250352" + }, + "Flags": 1114112, + "HighLimit": { + "currency": "USD", + "issuer": "rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q", + "value": "0" + }, + "HighNode": "00000000000001E8", + "LowLimit": { + "currency": "USD", + "issuer": "rKmBGxocj9Abgy25J51Mk1iqFzW9aVF9Tc", + "value": "1000000000" + }, + "LowNode": "0000000000000000" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "2F323020B4288ACD4066CC64C89DAD2E4D5DFC2D44571942A51C005BF79D6E25", + "PreviousFields": { + "Balance": { + "currency": "USD", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "1.535330905250352" + } + }, + "PreviousTxnID": "DC061E6F47B1B6E9A496A31B1AF87194B4CB24B2EBF8A59F35E31E12509238BD", + "PreviousTxnLgrSeq": 10459364 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Balance": { + "currency": "USD", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.02" + }, + "Flags": 1114112, + "HighLimit": { + "currency": "USD", + "issuer": "rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q", + "value": "0" + }, + "HighNode": "00000000000001E8", + "LowLimit": { + "currency": "USD", + "issuer": "rLDYrujdKUfVx28T9vRDAbyJ7G2WVXKo4K", + "value": "1000000000" + }, + "LowNode": "0000000000000000" + }, + "LedgerEntryType": "RippleState", + "LedgerIndex": "AAE13AF5192EFBFD49A8EEE5869595563FEB73228C0B38FED9CC3D20EE74F399", + "PreviousFields": { + "Balance": { + "currency": "USD", + "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", + "value": "0.01" + } + }, + "PreviousTxnID": "DC061E6F47B1B6E9A496A31B1AF87194B4CB24B2EBF8A59F35E31E12509238BD", + "PreviousTxnLgrSeq": 10459364 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rKmBGxocj9Abgy25J51Mk1iqFzW9aVF9Tc", + "Balance": "239555992", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 38 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "E9A39B0BA8703D5FFD05D9EAD01EE6C0E7A15CF33C2C6B7269107BD2BD535818", + "PreviousFields": { + "Balance": "239567992", + "Sequence": 37 + }, + "PreviousTxnID": "DC061E6F47B1B6E9A496A31B1AF87194B4CB24B2EBF8A59F35E31E12509238BD", + "PreviousTxnLgrSeq": 10459364 + } + } + ], + "TransactionIndex": 2, + "TransactionResult": "tesSUCCESS" + }, + "transaction": { + "Account": "rKmBGxocj9Abgy25J51Mk1iqFzW9aVF9Tc", + "Amount": { + "currency": "USD", + "issuer": "rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q", + "value": "0.01" + }, + "Destination": "rLDYrujdKUfVx28T9vRDAbyJ7G2WVXKo4K", + "Fee": "12000", + "Flags": 2147483648, + "LastLedgerSequence": 10459379, + "Sequence": 37, + "SigningPubKey": "03F16A52EBDCA6EBF5D99828E1E6918C64D45E6F136476A8F4757512FE553D18F0", + "TransactionType": "Payment", + "TxnSignature": "3044022031D6AB55CDFD17E06DA0BAD6D6B7DC9B5CA8FFF50405F2FCD3ED8D3893B1835E02200524CC1E7D70AE3F00C9F94405C55EE179C363F534905168AE8B5BA01CF568A0", + "date": 471644150, + "hash": "34671C179737CC89E0F8BBAA83C313885ED1733488FC0F3088BAE16A3D9A5B1B" + } +} \ No newline at end of file diff --git a/src/utils/txn_parser/utils/balance_parser.rs b/src/utils/txn_parser/utils/balance_parser.rs new file mode 100644 index 00000000..89ae94cb --- /dev/null +++ b/src/utils/txn_parser/utils/balance_parser.rs @@ -0,0 +1,184 @@ +use core::str::FromStr; + +use alloc::{ + format, + string::{String, ToString}, + vec::Vec, +}; +use bigdecimal::{num_bigint::Sign, BigDecimal}; + +use crate::{ + models::{ + ledger::objects::LedgerEntryType, transactions::metadata::TransactionMetadata, Amount, + }, + utils::{ + drops_to_xrp, + exceptions::{XRPLTxnParserException, XRPLUtilsResult}, + txn_parser::utils::{negate, nodes::normalize_nodes, Balance}, + }, +}; + +use super::{ + nodes::NormalizedNode, parser::group_balances_by_account as group_account_balances_by_account, + AccountBalance, AccountBalances, +}; + +fn get_xrp_quantity( + node: NormalizedNode, + value: Option, +) -> XRPLUtilsResult> { + if let Some(value) = value { + let absulte_value = value.clone().abs(); + let xrp_value = if value.sign() == Sign::Plus { + let xrp_quantity = drops_to_xrp(absulte_value.to_string().as_str())?; + let mut negated_value_string = String::from("-"); + negated_value_string.push_str(&xrp_quantity); + + BigDecimal::from_str(&negated_value_string)? + } else { + let xrp_value = drops_to_xrp(absulte_value.to_string().as_str())?; + let dec = BigDecimal::from_str(&xrp_value)?; + negate(&dec)? + }; + if let Some(final_fields) = node.final_fields { + if let Some(account) = final_fields.account { + Ok(Some(AccountBalance { + account: account.to_string().into(), + balance: Balance { + currency: "XRP".into(), + value: xrp_value.to_string().into(), + issuer: None, + }, + })) + } else { + Ok(None) + } + } else if let Some(new_fields) = node.new_fields { + if let Some(account) = new_fields.account { + Ok(Some(AccountBalance { + account: account.to_string().into(), + balance: Balance { + currency: "XRP".into(), + value: xrp_value.to_string().into(), + issuer: None, + }, + })) + } else { + Ok(None) + } + } else { + Ok(None) + } + } else { + Ok(None) + } +} + +fn flip_trustline_perspective(account_balance: AccountBalance) -> XRPLUtilsResult { + let balance = account_balance.balance.clone(); + let negated_value = negate(&BigDecimal::from_str(balance.value.as_ref())?)?; + let issuer = balance.issuer.clone(); + + Ok(AccountBalance { + account: issuer.ok_or(XRPLTxnParserException::MissingField("issuer"))?, + balance: Balance { + currency: balance.currency, + value: negated_value.normalized().to_string().into(), + issuer: Some(account_balance.account.clone()), + }, + }) +} +fn get_trustline_quantity<'a>( + node: NormalizedNode, + value: Option, +) -> XRPLUtilsResult>> { + if value.is_none() { + return Ok(Vec::new()); + } + + let fields = if let Some(new_fields) = node.new_fields { + new_fields + } else if let Some(final_fields) = node.final_fields { + final_fields + } else { + return Ok(Vec::new()); + }; + + let low_limit = fields.low_limit.as_ref(); + let balance = fields.balance.as_ref(); + let high_limit = fields.high_limit.as_ref(); + + if let (Some(low_limit), Some(balance), Some(high_limit)) = (low_limit, balance, high_limit) { + let low_limit_issuer = low_limit.issuer.as_ref(); + let balance_currency = match balance { + Amount::IssuedCurrencyAmount(ic) => Some(ic.currency.as_ref()), + _ => Some("XRP"), + }; + let high_limit_issuer = high_limit.issuer.as_ref(); + + if let Some(balance_currency) = balance_currency { + let result = AccountBalance { + account: low_limit_issuer.to_string().into(), + balance: Balance { + currency: balance_currency.to_string().into(), + issuer: Some(high_limit_issuer.to_string().into()), + value: format!("{}", value.unwrap().normalized()).into(), // safe to unwrap because we checked for None above + }, + }; + return Ok([result.clone(), flip_trustline_perspective(result)?].into()); + } + } + + Ok(Vec::new()) +} + +fn get_node_balances<'a: 'b, 'b>( + node: NormalizedNode<'a>, + value: Option, +) -> XRPLUtilsResult>> { + if node.ledger_entry_type == LedgerEntryType::AccountRoot { + let xrp_quantity = get_xrp_quantity(node, value)?; + if let Some(xrp_quantity) = xrp_quantity { + Ok([xrp_quantity].into()) + } else { + Ok(Vec::new()) + } + } else if node.ledger_entry_type == LedgerEntryType::RippleState { + let trustline_quantities = get_trustline_quantity(node, value)?; + Ok(trustline_quantities) + } else { + Ok(Vec::new()) + } +} + +fn group_balances_by_account(account_balances: Vec) -> Vec { + let grouped_balances = group_account_balances_by_account(account_balances); + let mut account_balances_grouped: Vec = Vec::new(); + + for group in grouped_balances.into_iter() { + let account = group.account.clone(); + let balances = group + .account_balances + .into_iter() + .map(|balance| balance.balance) + .collect(); + account_balances_grouped.push(AccountBalances { account, balances }); + } + + account_balances_grouped +} + +pub fn derive_account_balances<'a>( + metadata: &'a TransactionMetadata, + parser_fn: impl Fn(&NormalizedNode) -> XRPLUtilsResult>, +) -> XRPLUtilsResult>> { + let mut account_balances: Vec = Vec::new(); + let nodes = normalize_nodes(metadata); + for node in nodes.into_iter() { + let value = parser_fn(&node)?; + let node_balances = get_node_balances(node, value)?; + account_balances.extend(node_balances); + } + + Ok(group_balances_by_account(account_balances)) +} diff --git a/src/utils/txn_parser/utils/mod.rs b/src/utils/txn_parser/utils/mod.rs new file mode 100644 index 00000000..2f5ed5cc --- /dev/null +++ b/src/utils/txn_parser/utils/mod.rs @@ -0,0 +1,108 @@ +use core::str::FromStr; + +use alloc::{borrow::Cow, string::ToString, vec::Vec}; +use bigdecimal::BigDecimal; + +use crate::{ + models::{transactions::offer_create::OfferCreateFlag, Amount, FlagCollection}, + utils::exceptions::XRPLUtilsResult, +}; + +pub mod balance_parser; +pub mod nodes; +pub mod parser; + +#[derive(Debug, Clone, PartialEq)] +pub struct Balance<'a> { + pub currency: Cow<'a, str>, + pub value: Cow<'a, str>, + pub issuer: Option>, +} + +impl<'a: 'b, 'b> From> for Balance<'b> { + fn from(amount: Amount<'a>) -> Self { + match amount { + Amount::XRPAmount(amount) => Self { + currency: Cow::Borrowed("XRP"), + value: amount.0, + issuer: None, + }, + Amount::IssuedCurrencyAmount(amount) => Self { + currency: amount.currency, + value: amount.value, + issuer: Some(amount.issuer), + }, + } + } +} + +impl<'a> Into> for Balance<'a> { + fn into(self) -> Amount<'a> { + if self.currency == "XRP" { + Amount::XRPAmount(self.value.into()) + } else { + Amount::IssuedCurrencyAmount(crate::models::IssuedCurrencyAmount { + currency: self.currency, + value: self.value, + issuer: self.issuer.unwrap_or("".into()), + }) + } + } +} + +#[derive(Debug, Clone)] +pub struct AccountBalance<'a> { + pub account: Cow<'a, str>, + pub balance: Balance<'a>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AccountBalances<'a> { + pub account: Cow<'a, str>, + pub balances: Vec>, +} + +#[derive(Debug, Clone)] +pub enum OfferStatus { + Created, + PartiallyFilled, + Filled, + Cancelled, +} + +#[derive(Debug, Clone)] +pub struct OfferChange<'a> { + pub flags: FlagCollection, + pub taker_gets: Amount<'a>, + pub taker_pays: Amount<'a>, + pub sequence: u32, + pub status: OfferStatus, + pub maker_exchange_rate: Option, + pub expiration_time: Option, +} + +#[derive(Debug, Clone)] +pub struct AccountOfferChange<'a> { + pub maker_account: Cow<'a, str>, + pub offer_change: OfferChange<'a>, +} + +#[derive(Debug, Clone)] +pub struct AccountOfferChanges<'a> { + pub account: Cow<'a, str>, + pub offer_changes: Vec>, +} + +#[derive(Debug, Clone)] +pub struct AccountObjectGroup<'a> { + pub account: Cow<'a, str>, + pub account_balances: Vec>, + pub account_offer_changes: Vec>, +} + +pub fn negate(value: &BigDecimal) -> XRPLUtilsResult { + let zero = BigDecimal::from_str("0")?; + let working_value = zero - value; + + Ok(BigDecimal::from_str(&working_value.to_string())?) +} diff --git a/src/utils/txn_parser/utils/nodes.rs b/src/utils/txn_parser/utils/nodes.rs new file mode 100644 index 00000000..8c91692b --- /dev/null +++ b/src/utils/txn_parser/utils/nodes.rs @@ -0,0 +1,77 @@ +use alloc::vec::Vec; + +use crate::models::{ + ledger::objects::LedgerEntryType, + requests::LedgerIndex, + transactions::metadata::{AffectedNode, Fields, NodeType, TransactionMetadata}, +}; + +#[derive(Debug)] +pub struct NormalizedNode<'a> { + pub node_type: NodeType, + pub ledger_entry_type: LedgerEntryType, + pub ledger_index: LedgerIndex<'a>, + pub new_fields: Option>, + pub final_fields: Option>, + pub previous_fields: Option>, + pub previous_txn_id: Option<&'a str>, + pub previous_txn_lgr_seq: Option, +} + +fn normalize_node<'a: 'b, 'b>(affected_node: &'a AffectedNode<'_>) -> NormalizedNode<'b> { + match affected_node { + AffectedNode::CreatedNode { + ledger_entry_type, + ledger_index, + new_fields, + } => NormalizedNode { + node_type: NodeType::CreatedNode, + ledger_entry_type: ledger_entry_type.clone(), + ledger_index: ledger_index.clone(), + new_fields: Some(new_fields.clone()), + final_fields: None, + previous_fields: None, + previous_txn_id: None, + previous_txn_lgr_seq: None, + }, + AffectedNode::ModifiedNode { + ledger_entry_type, + ledger_index, + final_fields, + previous_fields, + previous_txn_id, + previous_txn_lgr_seq, + } => NormalizedNode { + node_type: NodeType::ModifiedNode, + ledger_entry_type: ledger_entry_type.clone(), + ledger_index: ledger_index.clone(), + new_fields: None, + final_fields: final_fields.clone(), + previous_fields: previous_fields.clone(), + previous_txn_id: previous_txn_id.as_deref(), + previous_txn_lgr_seq: *previous_txn_lgr_seq, + }, + AffectedNode::DeletedNode { + ledger_entry_type, + ledger_index, + final_fields, + previous_fields, + } => NormalizedNode { + node_type: NodeType::DeletedNode, + ledger_entry_type: ledger_entry_type.clone(), + ledger_index: ledger_index.clone(), + new_fields: None, + final_fields: Some(final_fields.clone()), + previous_fields: previous_fields.clone(), + previous_txn_id: None, + previous_txn_lgr_seq: None, + }, + } +} + +pub fn normalize_nodes<'a: 'b, 'b>(meta: &'a TransactionMetadata<'_>) -> Vec> { + meta.affected_nodes + .iter() + .map(|node| normalize_node(node)) + .collect() +} diff --git a/src/utils/txn_parser/utils/parser.rs b/src/utils/txn_parser/utils/parser.rs new file mode 100644 index 00000000..efc2f286 --- /dev/null +++ b/src/utils/txn_parser/utils/parser.rs @@ -0,0 +1,68 @@ +use core::str::FromStr; + +use alloc::vec::Vec; +use bigdecimal::BigDecimal; + +use crate::utils::exceptions::XRPLUtilsResult; + +use super::{AccountBalance, AccountObjectGroup, AccountOfferChange, Balance}; + +pub fn get_value(balance: &Balance) -> XRPLUtilsResult { + Ok(BigDecimal::from_str(balance.value.as_ref())?) +} + +pub fn group_balances_by_account(account_balances: Vec) -> Vec { + let mut account_object_groups: Vec = Vec::new(); + + for balance in account_balances.iter() { + // Find the account object group with the same account. If it doesn't exist, create a new one. + let account_object_group = account_object_groups + .iter_mut() + .find(|group| group.account == balance.account.as_ref()); + if let Some(group) = account_object_group { + group.account_balances.push(balance.clone()); + } else { + account_object_groups.push(AccountObjectGroup { + account: balance.account.clone(), + account_balances: Vec::new(), + account_offer_changes: Vec::new(), + }); + account_object_groups + .last_mut() + .unwrap() + .account_balances + .push(balance.clone()); + } + } + + account_object_groups +} + +pub fn group_offers_by_account( + account_offer_changes: Vec, +) -> Vec { + let mut account_object_groups: Vec> = Vec::new(); + + for offer_change in account_offer_changes.into_iter() { + // Find the account object group with the same account. If it doesn't exist, create a new one. + let account_object_group = account_object_groups + .iter_mut() + .find(|group| group.account == offer_change.maker_account.as_ref()); + if let Some(group) = account_object_group { + group.account_offer_changes.push(offer_change); + } else { + account_object_groups.push(AccountObjectGroup { + account: offer_change.maker_account.clone(), + account_balances: Vec::new(), + account_offer_changes: Vec::new(), + }); + account_object_groups + .last_mut() + .unwrap() + .account_offer_changes + .push(offer_change); + } + } + + account_object_groups +}