From d31f871edd93dabb678334c835112ea8a6380035 Mon Sep 17 00:00:00 2001 From: Bebeto Nyamwamu Date: Sun, 12 Apr 2026 18:13:21 -0400 Subject: [PATCH 1/6] feat: XLS-66 Lending Protocol transactions --- src/models/requests/account_objects.rs | 2 + .../loan_broker_cover_clawback.rs | 285 ++++ .../transactions/loan_broker_cover_deposit.rs | 143 ++ .../loan_broker_cover_withdraw.rs | 160 +++ src/models/transactions/loan_broker_delete.rs | 137 ++ src/models/transactions/loan_broker_set.rs | 518 +++++++ src/models/transactions/loan_delete.rs | 141 ++ src/models/transactions/loan_manage.rs | 150 ++ src/models/transactions/loan_pay.rs | 155 ++ src/models/transactions/loan_set.rs | 1271 +++++++++++++++++ src/models/transactions/mod.rs | 18 + 11 files changed, 2980 insertions(+) create mode 100644 src/models/transactions/loan_broker_cover_clawback.rs create mode 100644 src/models/transactions/loan_broker_cover_deposit.rs create mode 100644 src/models/transactions/loan_broker_cover_withdraw.rs create mode 100644 src/models/transactions/loan_broker_delete.rs create mode 100644 src/models/transactions/loan_broker_set.rs create mode 100644 src/models/transactions/loan_delete.rs create mode 100644 src/models/transactions/loan_manage.rs create mode 100644 src/models/transactions/loan_pay.rs create mode 100644 src/models/transactions/loan_set.rs diff --git a/src/models/requests/account_objects.rs b/src/models/requests/account_objects.rs index 11060adf..aed97c9c 100644 --- a/src/models/requests/account_objects.rs +++ b/src/models/requests/account_objects.rs @@ -16,6 +16,8 @@ pub enum AccountObjectType { Check, DepositPreauth, Escrow, + Loan, + LoanBroker, Offer, PaymentChannel, SignerList, diff --git a/src/models/transactions/loan_broker_cover_clawback.rs b/src/models/transactions/loan_broker_cover_clawback.rs new file mode 100644 index 00000000..48d980ff --- /dev/null +++ b/src/models/transactions/loan_broker_cover_clawback.rs @@ -0,0 +1,285 @@ +use alloc::borrow::Cow; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::{ + transactions::{CommonTransactionBuilder, Memo, Signer}, + Amount, FlagCollection, IssuedCurrencyAmount, Model, NoFlags, ValidateCurrencies, XRPAmount, + XRPLModelException, XRPLModelResult, +}; + +use super::{CommonFields, Transaction, TransactionType}; + +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct LoanBrokerCoverClawback<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The Loan Broker ID from which to clawback First-Loss Capital. + #[serde(rename = "LoanBrokerID")] + pub loan_broker_id: Option>, + /// The First-Loss Capital amount to clawback. + /// If the amount is 0 or not provided, clawback funds up to LoanBroker.DebtTotal * LoanBroker.CoverRateMinimum. + pub amount: Option>, +} + +impl Model for LoanBrokerCoverClawback<'_> { + fn get_errors(&self) -> crate::models::XRPLModelResult<()> { + self.validate_currencies()?; + + //Amount must not be XRP + if let Some(Amount::XRPAmount(..)) = &self.amount { + return Err(XRPLModelException::InvalidValue { + field: "amount".into(), + expected: "IssuedCurrencyAmount(IOU or MPT)".into(), + found: "XRPAmount".into(), + }); + } + + self.validate_field_requirements() + } +} + +impl<'a> Transaction<'a, NoFlags> for LoanBrokerCoverClawback<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for LoanBrokerCoverClawback<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> LoanBrokerCoverClawback<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + loan_broker_id: Option>, + amount: Option>, + ) -> LoanBrokerCoverClawback<'a> { + LoanBrokerCoverClawback { + common_fields: CommonFields::new( + account, + TransactionType::LoanBrokerCoverClawback, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + loan_broker_id, + amount, + } + } + + /// Set the LoanBrokerID field. + pub fn with_loan_broker_id(mut self, loan_broker_id: Cow<'a, str>) -> Self { + self.loan_broker_id = Some(loan_broker_id); + self + } + + /// Set the Amount field. + pub fn with_amount(mut self, amount: Amount<'a>) -> Self { + self.amount = Some(amount); + self + } + + fn validate_field_requirements(&self) -> XRPLModelResult<()> { + match (&self.loan_broker_id, &self.amount) { + // Amount present without loan_broker_id + (None, Some(_)) => self.validate_amount_without_broker(), + (Some(_), None) => Err(XRPLModelException::FieldRequiresField { + field1: "loan_broker_id".into(), + field2: "amount".into(), + }), + // Neither field is present + (None, None) => Err(XRPLModelException::MissingField( + "'loan_broker_id' and 'amount'".into(), + )), + // Both present + (Some(_), Some(_)) => Ok(()), + } + } + + fn validate_amount_without_broker(&self) -> XRPLModelResult<()> { + match &self.amount { + Some(Amount::IssuedCurrencyAmount(IssuedCurrencyAmount { issuer, .. })) => { + // Issuer must not be the submitter + let issuer_is_submitter = *issuer == self.common_fields.account; + if issuer_is_submitter { + Err(XRPLModelException::InvalidValue { + field: "amount.issuer".into(), + expected: "Issuer account".into(), + found: "submitter account".into(), + }) + } else { + Ok(()) + } + } + // XRP already rejected + _ => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "r9LqNeG6qHxLoanBrokerCoverClawback5weJ9mZgQ"; + const LOAN_BROKER_ID: &str = "rDB303FC1C7611B22C09E773B51044F6BEA02EF9"; + + #[test] + fn test_serde() { + let tx = LoanBrokerCoverClawback { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerCoverClawback, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: Some(LOAN_BROKER_ID.into()), + amount: Some(Amount::XRPAmount(XRPAmount::from("1000000"))), + }; + + let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanBrokerCoverClawback5weJ9mZgQ","TransactionType":"LoanBrokerCoverClawback","Flags":0,"SigningPubKey":"","LoanBrokerID":"rDB303FC1C7611B22C09E773B51044F6BEA02EF9","Amount":"1000000"}"#; + + let default_json_value = serde_json::to_value(default_json_str).unwrap(); + let serialized_tx = serde_json::to_value(&serde_json::to_string(&tx).unwrap()).unwrap(); + + assert_eq!(serialized_tx, default_json_value); + + let deserilized_tx: LoanBrokerCoverClawback = + serde_json::from_str(default_json_str).unwrap(); + + assert_eq!(tx, deserilized_tx); + } + + #[test] + fn test_invalid_no_amount_no_loan_broker_id_specified() { + let tx = LoanBrokerCoverClawback { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerCoverClawback, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: None, + amount: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::MissingField(..)) + )) + } + + #[test] + fn test_invalid_xrp_amount() { + let tx = LoanBrokerCoverClawback { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerCoverClawback, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: Some(LOAN_BROKER_ID.into()), + amount: Some(Amount::XRPAmount(XRPAmount("1000".into()))), + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )) + } + + #[test] + fn test_invalid_same_issuer_same_submitter() { + let tx = LoanBrokerCoverClawback { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerCoverClawback, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: None, + amount: Some(Amount::IssuedCurrencyAmount(IssuedCurrencyAmount { + currency: "USD".into(), + issuer: SOURCE.into(), + value: "1000".into(), + })), + }; + + dbg!(&tx.get_errors().err()); + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )) + } + + #[test] + fn test_valid_loan_broker_cover_clawback() { + let tx = LoanBrokerCoverClawback { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerCoverClawback, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: Some(LOAN_BROKER_ID.into()), + amount: Some(Amount::IssuedCurrencyAmount(IssuedCurrencyAmount { + currency: "USD".into(), + issuer: LOAN_BROKER_ID.into(), + value: "1000".into(), + })), + }; + + assert!(tx.get_errors().is_ok()); + } +} diff --git a/src/models/transactions/loan_broker_cover_deposit.rs b/src/models/transactions/loan_broker_cover_deposit.rs new file mode 100644 index 00000000..88541779 --- /dev/null +++ b/src/models/transactions/loan_broker_cover_deposit.rs @@ -0,0 +1,143 @@ +use alloc::borrow::Cow; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::{ + transactions::{CommonTransactionBuilder, Memo, Signer}, + Amount, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPAmount, +}; + +use super::{CommonFields, Transaction, TransactionType}; + +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct LoanBrokerCoverDeposit<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The Loan Broker ID that the transaction is modifying. + #[serde(rename = "LoanBrokerID")] + pub loan_broker_id: Cow<'a, str>, + /// The Fist-Loss Capital amount to deposit. + pub amount: Amount<'a>, +} + +impl Model for LoanBrokerCoverDeposit<'_> { + fn get_errors(&self) -> crate::models::XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, NoFlags> for LoanBrokerCoverDeposit<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for LoanBrokerCoverDeposit<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> LoanBrokerCoverDeposit<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + loan_broker_id: Cow<'a, str>, + amount: Amount<'a>, + ) -> LoanBrokerCoverDeposit<'a> { + LoanBrokerCoverDeposit { + common_fields: CommonFields::new( + account, + TransactionType::LoanBrokerSet, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + loan_broker_id, + amount, + } + } + + /// Set the Amount field. + pub fn with_amount(mut self, amount: Amount<'a>) -> Self { + self.amount = amount; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "r9LqNeG6qHxLoanCoverDepositterSetter5weJ9mZgQ"; + const LOAN_BROKER_ID: &str = "rDB303FC1C7611B22C09E773B51044F6BEA02EF9"; + + #[test] + fn test_serde() { + let tx = LoanBrokerCoverDeposit { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerDelete, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + }; + + let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanCoverDepositterSetter5weJ9mZgQ","TransactionType":"LoanBrokerDelete","Flags":0,"SigningPubKey":"","LoanBrokerID":"rDB303FC1C7611B22C09E773B51044F6BEA02EF9","Amount":"1000000"}"#; + + let default_json_value = serde_json::to_value(default_json_str).unwrap(); + let serialized_tx = serde_json::to_value(&serde_json::to_string(&tx).unwrap()).unwrap(); + + assert_eq!(serialized_tx, default_json_value); + + let deserilized_tx: LoanBrokerCoverDeposit = + serde_json::from_str(default_json_str).unwrap(); + + assert_eq!(tx, deserilized_tx); + } +} diff --git a/src/models/transactions/loan_broker_cover_withdraw.rs b/src/models/transactions/loan_broker_cover_withdraw.rs new file mode 100644 index 00000000..c890149b --- /dev/null +++ b/src/models/transactions/loan_broker_cover_withdraw.rs @@ -0,0 +1,160 @@ +use alloc::borrow::Cow; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::{ + transactions::{CommonTransactionBuilder, Memo, Signer}, + Amount, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPAmount, +}; + +use super::{CommonFields, Transaction, TransactionType}; + +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct LoanBrokerCoverWithdraw<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The Loan Broker ID that the transaction is modifying. + #[serde(rename = "LoanBrokerID")] + pub loan_broker_id: Cow<'a, str>, + /// The Fist-Loss Capital amount to deposit. + pub amount: Amount<'a>, + /// An account to receive the assets. It must be able to receive the asset. + pub destination: Option>, + /// Arbitrary tag identifying the reason for the transaction to the destination. + pub destination_tag: Option, +} + +impl Model for LoanBrokerCoverWithdraw<'_> { + fn get_errors(&self) -> crate::models::XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, NoFlags> for LoanBrokerCoverWithdraw<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for LoanBrokerCoverWithdraw<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> LoanBrokerCoverWithdraw<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + loan_broker_id: Cow<'a, str>, + amount: Amount<'a>, + destination: Option>, + destination_tag: Option, + ) -> LoanBrokerCoverWithdraw<'a> { + LoanBrokerCoverWithdraw { + common_fields: CommonFields::new( + account, + TransactionType::LoanBrokerSet, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + loan_broker_id, + amount, + destination, + destination_tag, + } + } + + /// Set the Destination field. + pub fn with_destination(mut self, destination: Cow<'a, str>) -> Self { + self.destination = Some(destination); + self + } + + /// Set the DestinationTag field. + pub fn with_destination_tag(mut self, destination_tag: u32) -> Self { + self.destination_tag = Some(destination_tag); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VAULT_ID: &str = "r9LqNeG6qHxVaultIdentity5weJ9mZgQ"; + const TX_ID: &str = "r9LqNeG6qHxLoanBrokerCoverWithdraw5weJ9"; + const LOAN_BROKER_ID: &str = "rDB303FC1C7611B22C09E773B51044F6BEA02EF9"; + + #[test] + fn test_serde() { + let tx = LoanBrokerCoverWithdraw { + common_fields: CommonFields { + account: TX_ID.into(), + transaction_type: TransactionType::LoanBrokerCoverWithdraw, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + destination: Some(VAULT_ID.into()), + destination_tag: Some(32), + }; + + let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanBrokerCoverWithdraw5weJ9","TransactionType":"LoanBrokerCoverWithdraw","Flags":0,"SigningPubKey":"","LoanBrokerID":"rDB303FC1C7611B22C09E773B51044F6BEA02EF9","Amount":"1000000","Destination":"r9LqNeG6qHxVaultIdentity5weJ9mZgQ","DestinationTag":32}"#; + + let default_json_value = serde_json::to_value(default_json_str).unwrap(); + let serialized_tx = serde_json::to_value(&serde_json::to_string(&tx).unwrap()).unwrap(); + + assert_eq!(serialized_tx, default_json_value); + + let deserilized_tx: LoanBrokerCoverWithdraw = + serde_json::from_str(default_json_str).unwrap(); + + assert_eq!(tx, deserilized_tx); + } +} diff --git a/src/models/transactions/loan_broker_delete.rs b/src/models/transactions/loan_broker_delete.rs new file mode 100644 index 00000000..b6dc6175 --- /dev/null +++ b/src/models/transactions/loan_broker_delete.rs @@ -0,0 +1,137 @@ +use alloc::borrow::Cow; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::{ + transactions::{CommonTransactionBuilder, Memo, Signer}, + FlagCollection, Model, NoFlags, ValidateCurrencies, XRPAmount, XRPLModelResult, +}; + +use super::{CommonFields, Transaction, TransactionType}; + +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct LoanBrokerDelete<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The Loan Broker ID that the transaction is deleting. + #[serde(rename = "LoanBrokerID")] + pub loan_broker_id: Cow<'a, str>, +} + +impl Model for LoanBrokerDelete<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, NoFlags> for LoanBrokerDelete<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for LoanBrokerDelete<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> LoanBrokerDelete<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + loan_broker_id: Cow<'a, str>, + ) -> LoanBrokerDelete<'a> { + LoanBrokerDelete { + common_fields: CommonFields::new( + account, + TransactionType::LoanBrokerSet, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + loan_broker_id, + } + } + + /// Set the LoanBroker ID field. + pub fn with_loan_broker_id(mut self, loan_broker_id: Cow<'a, str>) -> Self { + self.loan_broker_id = loan_broker_id; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "r9LqNeG6qHxLoanBrokerDeletter5weJ9mZgQ"; + const LOAN_BROKER_ID: &str = "rDB303FC1C7611B22C09E773B51044F6BEA02EF9"; + + #[test] + fn test_serde() { + let tx = LoanBrokerDelete { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerDelete, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + }; + + let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanBrokerDeletter5weJ9mZgQ","TransactionType":"LoanBrokerDelete","Flags":0,"SigningPubKey":"","LoanBrokerID":"rDB303FC1C7611B22C09E773B51044F6BEA02EF9"}"#; + + let default_json_value = serde_json::to_value(default_json_str).unwrap(); + let serialized_tx = serde_json::to_value(&serde_json::to_string(&tx).unwrap()).unwrap(); + + assert_eq!(serialized_tx, default_json_value); + + let deserilized_tx: LoanBrokerDelete = serde_json::from_str(default_json_str).unwrap(); + + assert_eq!(tx, deserilized_tx); + } +} diff --git a/src/models/transactions/loan_broker_set.rs b/src/models/transactions/loan_broker_set.rs new file mode 100644 index 00000000..ffa6fbad --- /dev/null +++ b/src/models/transactions/loan_broker_set.rs @@ -0,0 +1,518 @@ +use alloc::borrow::Cow; +use bigdecimal::{BigDecimal, Signed}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::{ + transactions::{CommonTransactionBuilder, Memo, Signer}, + FlagCollection, Model, NoFlags, ValidateCurrencies, XRPAmount, XRPLModelException, + XRPLModelResult, +}; + +use super::{CommonFields, Transaction, TransactionType}; + +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct LoanBrokerSet<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + #[serde(rename = "VaultID")] + /// The Vault ID that the Lending Protocol will use to access liquidity. + pub vault_id: Cow<'a, str>, + /// The Loan Broker ID that the transaction is modifying. + #[serde(rename = "LoanBrokerID")] + pub loan_broker_id: Option>, + /// Arbitrary metadata in hex format. The field is limited to 256 bytes. + pub data: Option>, + /// The 1/10th basis point fee charged by the lending protocol owner. + /// Valid values range from 0 to 10000 (inclusive), representing 0% to 10%. + pub management_fee_rate: Option, + /// The maximum amount the protocol can owe the vault. + /// The default value of 0 means there is no limit to the debt. Must be a positive value. + pub debt_maximum: Option>, + /// The 1/10th basis point DebtTotal that the first-loss capital must cover. + /// Valid values range from 0 to 100000 (inclusive), representing 0% to 100%. + pub cover_rate_minimum: Option, + /// The 1/10th basis point of minimum required first-loss capital that is moved to an asset vault to cover a loan default. + /// Valid values range from 0 to 100000 (inclusive), representing 0% to 100%. + pub cover_rate_liquidation: Option, +} + +impl Model for LoanBrokerSet<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies()?; + + if self + .data + .as_ref() + .map_or(false, |s: &Cow<'_, str>| s.len() > 256) + { + return Err(XRPLModelException::ValueTooLong { + field: "data".into(), + max: 256, + found: self.data.as_ref().unwrap().len(), + }); + } + + if self + .data + .as_ref() + .map_or(false, |s: &Cow<'_, str>| s.is_empty()) + { + return Err(XRPLModelException::ValueTooShort { + field: "data".into(), + min: 1, + found: 0, + }); + } + + if let Some(Err(e)) = self.data.as_ref().map(|s| hex::decode(s.as_ref())) { + return Err(XRPLModelException::FromHexError(e)); + } + + if self.management_fee_rate.map_or(false, |v| v > 10_000) { + return Err(XRPLModelException::ValueTooHigh { + field: "management_fee_rate".into(), + max: 10_000, + found: self.management_fee_rate.unwrap() as u32, + }); + } + + if self.cover_rate_minimum.map_or(false, |v| v > 100_000) { + return Err(XRPLModelException::ValueTooHigh { + field: "cover_rate_minimum".into(), + max: 100_000, + found: self.cover_rate_minimum.unwrap() as u32, + }); + } + + if self.cover_rate_liquidation.map_or(false, |v| v > 100_000) { + return Err(XRPLModelException::ValueTooHigh { + field: "cover_rate_liquidation".into(), + max: 100_000, + found: self.cover_rate_liquidation.unwrap() as u32, + }); + } + + if let Some(s) = &self.debt_maximum { + let decimal = s + .parse::() + .map_err(|e| XRPLModelException::BigDecimalError(e))?; + + if decimal.is_negative() { + return Err(XRPLModelException::InvalidValue { + field: "debt_maximum".into(), + expected: "debt_maximum should be at least zero(0)".into(), + found: format!("{}", decimal), + }); + } + } + + if let (Some(crl), Some(crm)) = (self.cover_rate_liquidation, self.cover_rate_minimum) { + if (crl == 0) != (crm == 0) { + return Err(XRPLModelException::InvalidValue { + field: "cover_rate_liquidation and cover_rate_minimum".into(), + expected: "Both should be either None, Zero or Non-Zero".into(), + found: format!( + "cover_rate_liquidation: {}, cover_rate_minimum: {}", + crl, crm + ), + }); + } + } + + Ok(()) + } +} + +impl<'a> Transaction<'a, NoFlags> for LoanBrokerSet<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for LoanBrokerSet<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> LoanBrokerSet<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + data: Option>, + vault_id: Cow<'a, str>, + loan_broker_id: Option>, + management_fee_rate: Option, + debt_maximum: Option>, + cover_rate_minimum: Option, + cover_rate_liquidation: Option, + ) -> LoanBrokerSet<'a> { + LoanBrokerSet { + common_fields: CommonFields::new( + account, + TransactionType::LoanBrokerSet, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + vault_id, + loan_broker_id, + data, + management_fee_rate, + debt_maximum, + cover_rate_minimum, + cover_rate_liquidation, + } + } + + /// Set the data field. + pub fn with_data(mut self, data: Cow<'a, str>) -> Self { + self.data = Some(data); + self + } + + /// Set the LoanBroker ID field. + pub fn with_loan_broker_id(mut self, loan_broker_id: Cow<'a, str>) -> Self { + self.loan_broker_id = Some(loan_broker_id); + self + } + + /// Set the ManagementFeeRate field. + pub fn with_management_fee_rate(mut self, rate: u16) -> Self { + self.management_fee_rate = Some(rate); + self + } + + /// Set the DebtMaximum field. + pub fn with_debt_maximum(mut self, debt_maximum: Cow<'a, str>) -> Self { + self.debt_maximum = Some(debt_maximum); + self + } + /// Set the CoverRateMinimum field. + pub fn with_cover_rate_minimum(mut self, cover_rate_minimum: u32) -> Self { + self.cover_rate_minimum = Some(cover_rate_minimum); + self + } + + /// Set the CoverRateLiquidation field. + pub fn with_cover_rate_liquidation(mut self, cover_rate_liquidation: u32) -> Self { + self.cover_rate_liquidation = Some(cover_rate_liquidation); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "r9LqNeG6qHxLoanBrokerSetter5weJ9mZg"; + const VAULT_ID: &str = "rDB303FC1C7611B22C09E773B51044F6BE"; + + #[test] + fn test_invalid_data_too_long() { + let tx = LoanBrokerSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + loan_broker_id: None, + data: Some("A".repeat(257).into()), + management_fee_rate: None, + debt_maximum: None, + cover_rate_liquidation: None, + cover_rate_minimum: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooLong { .. }) + )); + } + + #[test] + fn test_invalid_data_empty() { + let tx = LoanBrokerSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + loan_broker_id: None, + data: Some("".into()), + management_fee_rate: None, + debt_maximum: None, + cover_rate_liquidation: None, + cover_rate_minimum: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooShort { .. }) + )); + } + + #[test] + fn test_invalid_data_non_hex_string() { + let tx = LoanBrokerSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + loan_broker_id: None, + data: Some("Z".into()), + management_fee_rate: None, + debt_maximum: None, + cover_rate_liquidation: None, + cover_rate_minimum: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::FromHexError(..)) + )); + } + + #[test] + fn test_invalid_management_fee_too_high() { + let tx = LoanBrokerSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + loan_broker_id: None, + data: None, + management_fee_rate: Some(10_001), + debt_maximum: None, + cover_rate_liquidation: None, + cover_rate_minimum: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooHigh { .. }) + )); + } + + #[test] + fn test_invalid_cover_rate_minimum_too_high() { + let tx = LoanBrokerSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + loan_broker_id: None, + data: None, + management_fee_rate: None, + debt_maximum: None, + cover_rate_liquidation: None, + cover_rate_minimum: Some(100_001), + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooHigh { .. }) + )); + } + + #[test] + fn test_in_cover_rate_liquidation_too_high() { + let tx = LoanBrokerSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + loan_broker_id: None, + data: None, + management_fee_rate: None, + debt_maximum: None, + cover_rate_liquidation: Some(100_001), + cover_rate_minimum: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooHigh { .. }) + )); + } + + #[test] + fn test_invalid_debt_maximum_too_low() { + let tx = LoanBrokerSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + loan_broker_id: None, + data: None, + management_fee_rate: None, + debt_maximum: Some("-1".into()), + cover_rate_liquidation: None, + cover_rate_minimum: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_invalid_debt_maximum_empty() { + let tx = LoanBrokerSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + loan_broker_id: None, + data: None, + management_fee_rate: None, + debt_maximum: Some("".into()), + cover_rate_liquidation: None, + cover_rate_minimum: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::BigDecimalError(..)) + )); + } + + #[test] + fn test_cover_rate_minimum_cover_rate_liquidation_mismatch() { + let tx = LoanBrokerSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + loan_broker_id: None, + data: None, + management_fee_rate: None, + debt_maximum: None, + cover_rate_liquidation: Some(0), + cover_rate_minimum: Some(1), + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + + // Swapping values + let updated = tx.with_cover_rate_liquidation(1).with_cover_rate_minimum(0); + + assert!(updated.get_errors().is_err()); + assert!(matches!( + updated.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_serde() { + let tx = LoanBrokerSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + loan_broker_id: None, + data: None, + management_fee_rate: Some(10), + debt_maximum: Some("10000".into()), + cover_rate_liquidation: Some(0), + cover_rate_minimum: Some(0), + }; + + let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanBrokerSetter5weJ9mZg","TransactionType":"LoanBrokerSet","Flags":0,"SigningPubKey":"","VaultID":"rDB303FC1C7611B22C09E773B51044F6BE","ManagementFeeRate":10,"DebtMaximum":"10000","CoverRateMinimum":0,"CoverRateLiquidation":0}"#; + + let default_json_value = serde_json::to_value(default_json_str).unwrap(); + let serialized_tx = serde_json::to_value(&serde_json::to_string(&tx).unwrap()).unwrap(); + + assert_eq!(serialized_tx, default_json_value); + + let deserilized_tx: LoanBrokerSet = serde_json::from_str(default_json_str).unwrap(); + + assert_eq!(tx, deserilized_tx); + } +} diff --git a/src/models/transactions/loan_delete.rs b/src/models/transactions/loan_delete.rs new file mode 100644 index 00000000..c56d1821 --- /dev/null +++ b/src/models/transactions/loan_delete.rs @@ -0,0 +1,141 @@ +use alloc::borrow::Cow; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::{ + transactions::{CommonTransactionBuilder, Memo, Signer}, + FlagCollection, Model, NoFlags, ValidateCurrencies, XRPAmount, XRPLModelResult, +}; + +use super::{CommonFields, Transaction, TransactionType}; + +/// Creates a new Loan ledger entry, representing a loan agreement +/// between a Loan Broker and Borrower. +/// The LoanSet transaction is a mutual agreement between +/// the Loan Broker and Borrower, and must be signed by both parties. +/// The following multi-signature flow can be initiated by either party: +/// 1. The borrower or loan broker creates the transaction with the +/// preagreed terms of the loan. They sign the transaction and +/// set the SigningPubKey, TxnSignature, Signers, Account, +/// Fee, Sequence, and Counterparty fields. +/// 2. The counterparty verifies the loan terms and signature +/// before signing and submitting the transaction. +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct LoanDelete<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + #[serde(rename = "LoanID")] + /// The ID of the Loan object to be deleted. + pub loan_id: Cow<'a, str>, +} + +impl Model for LoanDelete<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, NoFlags> for LoanDelete<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for LoanDelete<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> LoanDelete<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + loan_id: Cow<'a, str>, + ) -> LoanDelete<'a> { + LoanDelete { + common_fields: CommonFields::new( + account, + TransactionType::LoanBrokerSet, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + loan_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "r9LqNeG6qHxLoanDeleter6T5weJ9mZg"; + const LOAN_ID: &str = "rDB303FC1C7611B22C09E773B51044F6BE"; + + #[test] + fn test_invalid_data_too_long() { + let tx = LoanDelete { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanDelete, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_id: LOAN_ID.into(), + }; + + let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanDeleter6T5weJ9mZg","TransactionType":"LoanDelete","Flags":0,"SigningPubKey":"","LoanID":"rDB303FC1C7611B22C09E773B51044F6BE"}"#; + + let default_json_value = serde_json::to_value(default_json_str).unwrap(); + let serialized_tx = serde_json::to_value(&serde_json::to_string(&tx).unwrap()).unwrap(); + + assert_eq!(serialized_tx, default_json_value); + + let deserilized_tx: LoanDelete = serde_json::from_str(default_json_str).unwrap(); + + assert_eq!(tx, deserilized_tx); + } +} diff --git a/src/models/transactions/loan_manage.rs b/src/models/transactions/loan_manage.rs new file mode 100644 index 00000000..b89eb1e8 --- /dev/null +++ b/src/models/transactions/loan_manage.rs @@ -0,0 +1,150 @@ +use alloc::borrow::Cow; +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use serde_with::skip_serializing_none; +use strum_macros::{AsRefStr, Display, EnumIter}; + +use crate::models::{ + transactions::{CommonTransactionBuilder, Memo, Signer}, + FlagCollection, Model, ValidateCurrencies, XRPAmount, XRPLModelResult, +}; + +use super::{CommonFields, Transaction, TransactionType}; + +#[derive( + Debug, Eq, PartialEq, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter, Copy, +)] +#[repr(u32)] +pub enum LoanManageFlag { + /// Indicates the loan should be defaulted. + TfLoanDefault = 0x00010000, + /// Indicates the the loan should be impaired. + TfLoanImpair = 0x00020000, + /// Indicates the the loan should be unimpaired. + TfLoanUnimpair = 0x00040000, +} + +/// Manages the state of a Loan ledger entry, including defaulting, +/// impairing, or unimpairing a loan. +/// Only the LoanBroker ledger entry owner can initiate this transaction. +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct LoanManage<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, LoanManageFlag>, + /// The ID of the Loan ledger entry to manage. + #[serde(rename = "LoanID")] + pub loan_id: Cow<'a, str>, +} + +impl Model for LoanManage<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, LoanManageFlag> for LoanManage<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, LoanManageFlag> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, LoanManageFlag> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, LoanManageFlag> for LoanManage<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, LoanManageFlag> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> LoanManage<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + flags: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + loan_id: Cow<'a, str>, + ) -> LoanManage<'a> { + LoanManage { + common_fields: CommonFields::new( + account, + TransactionType::LoanSet, + account_txn_id, + fee, + flags, + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + loan_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "r9LqNeG6qHxLoanManager6T5weJ9mZg"; + const LOAN_ID: &str = "rDB303FC1C7611B22C09E773B51044F6BE"; + + #[test] + fn test_invalid_data_too_long() { + let tx = LoanManage { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanManage, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_id: LOAN_ID.into(), + }; + + let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanManager6T5weJ9mZg","TransactionType":"LoanManage","Flags":0,"SigningPubKey":"","LoanID":"rDB303FC1C7611B22C09E773B51044F6BE"}"#; + + let default_json_value = serde_json::to_value(default_json_str).unwrap(); + let serialized_tx = serde_json::to_value(&serde_json::to_string(&tx).unwrap()).unwrap(); + + assert_eq!(serialized_tx, default_json_value); + + let deserilized_tx: LoanManage = serde_json::from_str(default_json_str).unwrap(); + + assert_eq!(tx, deserilized_tx); + } +} diff --git a/src/models/transactions/loan_pay.rs b/src/models/transactions/loan_pay.rs new file mode 100644 index 00000000..6bb882ea --- /dev/null +++ b/src/models/transactions/loan_pay.rs @@ -0,0 +1,155 @@ +use alloc::borrow::Cow; +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use serde_with::skip_serializing_none; +use strum_macros::{AsRefStr, Display, EnumIter}; + +use crate::models::{ + transactions::{CommonTransactionBuilder, Memo, Signer}, + Amount, FlagCollection, Model, ValidateCurrencies, XRPAmount, XRPLModelResult, +}; + +use super::{CommonFields, Transaction, TransactionType}; + +#[derive( + Debug, Eq, PartialEq, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter, Copy, +)] +#[repr(u32)] +pub enum LoanPayFlag { + /// Indicates that the remaining payment amount should + /// be treated as an overpayment.. + TfLoanOverpayment = 0x00010000, + /// Indicates that the borrower is making a full early repayment. + TfLoanFullPayment = 0x00020000, + /// Indicates that the borrower is making a late loan payment. + TfLoanLatePayment = 0x00040000, +} +/// Manages the state of a Loan ledger entry, +/// including defaulting, impairing, or unimpairing a loan. +/// Only the LoanBroker ledger entry owner can initiate this transaction. +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct LoanPay<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, LoanPayFlag>, + /// The ID of the Loan ledger entry to repay. + #[serde(rename = "LoanID")] + pub loan_id: Cow<'a, str>, + /// The amount to pay toward the loan. + pub amount: Amount<'a>, +} + +impl Model for LoanPay<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, LoanPayFlag> for LoanPay<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, LoanPayFlag> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, LoanPayFlag> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, LoanPayFlag> for LoanPay<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, LoanPayFlag> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> LoanPay<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + flags: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + loan_id: Cow<'a, str>, + amount: Amount<'a>, + ) -> LoanPay<'a> { + LoanPay { + common_fields: CommonFields::new( + account, + TransactionType::LoanSet, + account_txn_id, + fee, + flags, + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + loan_id, + amount, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "r9LqNeG6qHxLoanPayer6T5weJ9mZg"; + const LOAN_ID: &str = "rDB303FC1C7611B22C09E773B51044F6BE"; + + #[test] + fn test_invalid_data_too_long() { + let tx = LoanPay { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanPay, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_id: LOAN_ID.into(), + amount: Amount::XRPAmount(XRPAmount("1000".into())), + }; + + let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanPayer6T5weJ9mZg","TransactionType":"LoanPay","Flags":0,"SigningPubKey":"","LoanID":"rDB303FC1C7611B22C09E773B51044F6BE","Amount":"1000"}"#; + + let default_json_value = serde_json::to_value(default_json_str).unwrap(); + let serialized_tx = serde_json::to_value(&serde_json::to_string(&tx).unwrap()).unwrap(); + + assert_eq!(serialized_tx, default_json_value); + + let deserilized_tx: LoanPay = serde_json::from_str(default_json_str).unwrap(); + + assert_eq!(tx, deserilized_tx); + } +} diff --git a/src/models/transactions/loan_set.rs b/src/models/transactions/loan_set.rs new file mode 100644 index 00000000..04b25218 --- /dev/null +++ b/src/models/transactions/loan_set.rs @@ -0,0 +1,1271 @@ +use alloc::borrow::Cow; +use bigdecimal::BigDecimal; +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use serde_with::skip_serializing_none; +use strum_macros::{AsRefStr, Display, EnumIter}; + +use crate::models::{ + transactions::{CommonTransactionBuilder, Memo, Signer}, + FlagCollection, Model, ValidateCurrencies, XRPAmount, XRPLModelException, XRPLModelResult, +}; + +use super::{CommonFields, Transaction, TransactionType}; + +#[derive( + Debug, Eq, PartialEq, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter, Copy, +)] +#[repr(u32)] +pub enum LoanSetFlag { + /// Indicates that the loan supports overpayments. + TfLoanOverpayment = 0x00010000, +} + +/// Creates a new Loan ledger entry, representing a loan +/// agreement between a Loan Broker and Borrower. +/// The LoanSet transaction is a mutual agreement between +/// the Loan Broker and Borrower, and must be signed +/// by both parties. The following multi-signature flow +/// can be initiated by either party: +/// 1. The borrower or loan broker creates the transaction +/// with the preagreed terms of the loan. They sign the +/// transaction and set the SigningPubKey, TxnSignature, +/// Signers, Account, Fee, Sequence, and Counterparty fields. +/// 2. The counterparty verifies the loan terms and +/// signature before signing and submitting the transaction. +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct LoanSet<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, LoanSetFlag>, + /// The Loan Broker ID associated with the loan. + #[serde(rename = "LoanBrokerID")] + pub loan_broker_id: Cow<'a, str>, + /// Arbitrary metadata in hex format. The field is limited to 256 bytes. + pub data: Option>, + /// The address of the counterparty of the Loan. + pub counterparty: Option>, + /// The signature of the counterparty over the transaction. + pub counterparty_signature: CounterpartySignature<'a>, + /// A nominal funds amount paid to the LoanBroker.Owner when the Loan is created. + pub loan_origination_fee: Option>, + /// A nominal amount paid to the LoanBroker.Owner with every Loan payment. + pub loan_service_fee: Option>, + /// A nominal funds amount paid to the LoanBroker.Owner when a payment is late. + pub late_payment_fee: Option>, + /// A nominal funds amount paid to the LoanBroker.Owner when an early full repayment is made. + pub close_payment_fee: Option>, + /// A fee charged on overpayments in 1/10th basis points. + /// Valid values are between 0 and 100000 inclusive. (0 - 100%) + pub overpayment_fee: Option, + /// Annualized interest rate of the Loan in in 1/10th basis points. + /// Valid values are between 0 and 100000 inclusive. (0 - 100%) + pub interest_rate: Option, + /// A premium added to the interest rate for late payments in in 1/10th basis points. + /// alid values are between 0 and 100000 inclusive. (0 - 100%) + pub late_interest_rate: Option, + /// A Fee Rate charged for repaying the Loan early in 1/10th basis points. + /// Valid values are between 0 and 100000 inclusive. (0 - 100%) + pub close_interest_rate: Option, + /// An interest rate charged on overpayments in 1/10th basis points. + /// Valid values are between 0 and 100000 inclusive. (0 - 100%) + pub overpayment_interest_rate: Option, + /// The principal amount requested by the Borrower. + pub principal_requested: Cow<'a, str>, + /// The total number of payments to be made against the Loan. + pub payment_total: Option, + /// Number of seconds between Loan payments. + pub payment_interval: Option, + /// The number of seconds after the Loan's Payment Due Date can be Defaulted. + pub grace_period: Option, +} + +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +pub struct CounterpartySignature<'a> { + pub signing_pub_key: Option>, + pub txn_signature: Option>, + pub signers: Option>, +} + +impl Model for LoanSet<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies()?; + + if self + .data + .as_ref() + .map_or(false, |s: &Cow<'_, str>| s.len() > 256) + { + return Err(XRPLModelException::ValueTooLong { + field: "data".into(), + max: 256, + found: self.data.as_ref().unwrap().len(), + }); + } + + if self + .data + .as_ref() + .map_or(false, |s: &Cow<'_, str>| s.is_empty()) + { + return Err(XRPLModelException::ValueTooShort { + field: "data".into(), + min: 1, + found: 0, + }); + } + + if let Some(Err(e)) = self.data.as_ref().map(|s| hex::decode(s.as_ref())) { + return Err(XRPLModelException::FromHexError(e)); + } + + if let Some(lsf) = &self.loan_service_fee { + let lsf_decimal = lsf + .parse::() + .map_err(|e| XRPLModelException::BigDecimalError(e))?; + + if lsf_decimal < 0 { + return Err(XRPLModelException::InvalidValue { + field: "loan_service_fee".into(), + expected: "At least zero(0)".into(), + found: format!("{}", lsf_decimal), + }); + } + } + + if let Some(lpf) = &self.late_payment_fee { + let lpf_decimal = lpf + .parse::() + .map_err(|e| XRPLModelException::BigDecimalError(e))?; + + if lpf_decimal < 0 { + return Err(XRPLModelException::InvalidValue { + field: "late_payment_fee".into(), + expected: "At least zero(0)".into(), + found: format!("{}", lpf_decimal), + }); + } + } + + if let Some(cpf) = &self.close_payment_fee { + let cpf_decimal = cpf + .parse::() + .map_err(|e| XRPLModelException::BigDecimalError(e))?; + + if cpf_decimal < 0 { + return Err(XRPLModelException::InvalidValue { + field: "close_payment_fee".into(), + expected: "At least zero(0)".into(), + found: format!("{}", cpf_decimal), + }); + } + } + + if self.overpayment_fee.map_or(false, |v| v > 100_000) { + return Err(XRPLModelException::ValueTooHigh { + field: "overpayment_fee".into(), + max: 100_000, + found: self.overpayment_fee.unwrap() as u32, + }); + } + + if self.interest_rate.map_or(false, |v| v > 100_000) { + return Err(XRPLModelException::ValueTooHigh { + field: "interest_rate".into(), + max: 100_000, + found: self.interest_rate.unwrap() as u32, + }); + } + + if self.late_interest_rate.map_or(false, |v| v > 100_000) { + return Err(XRPLModelException::ValueTooHigh { + field: "late_interest_rate".into(), + max: 100_000, + found: self.late_interest_rate.unwrap() as u32, + }); + } + + if self.close_interest_rate.map_or(false, |v| v > 100_000) { + return Err(XRPLModelException::ValueTooHigh { + field: "close_interest_rate".into(), + max: 100_000, + found: self.close_interest_rate.unwrap() as u32, + }); + } + + if self + .overpayment_interest_rate + .map_or(false, |v| v > 100_000) + { + return Err(XRPLModelException::ValueTooHigh { + field: "overpayment_interest_rate".into(), + max: 100_000, + found: self.overpayment_interest_rate.unwrap() as u32, + }); + } + + let pr_decimal = &self + .principal_requested + .parse::() + .map_err(|e| XRPLModelException::BigDecimalError(e))?; + + if pr_decimal < 1 { + return Err(XRPLModelException::InvalidValue { + field: "principal_requested".into(), + expected: "At least one(1)".into(), + found: format!("{}", pr_decimal), + }); + } + + if let Some(lof) = &self.loan_origination_fee { + let lof_decimal = lof + .parse::() + .map_err(|e| XRPLModelException::BigDecimalError(e))?; + + if lof_decimal < 0 { + return Err(XRPLModelException::InvalidValue { + field: "loan_origination_fee".into(), + expected: "At least zero(0)".into(), + found: format!("{}", lof_decimal), + }); + } + + if lof_decimal > *pr_decimal { + return Err(XRPLModelException::InvalidValue { + field: "loan_origination_fee and principal_requested".into(), + expected: "loan_origination_fee should be less than principal_requested".into(), + found: format!( + "loan_origination_fee: {}, principal_requested: {}", + lof_decimal, pr_decimal + ), + }); + } + } + + if self.payment_total.map_or(false, |v| v == 0) { + return Err(XRPLModelException::ValueTooLow { + field: "payment_total".into(), + min: 1, + found: 0, + }); + } + + if self.payment_interval.map_or(false, |v| v < 60) { + return Err(XRPLModelException::ValueTooLow { + field: "payment_interval".into(), + min: 60, + found: self.payment_interval.unwrap(), + }); + } + + if self.grace_period.map_or(false, |v| v < 60) { + return Err(XRPLModelException::ValueTooLow { + field: "grace_period".into(), + min: 60, + found: self.grace_period.unwrap(), + }); + } + + if let (Some(gr), Some(pi)) = (self.grace_period, self.payment_interval) { + if gr > pi { + return Err(XRPLModelException::InvalidValue { + field: "grace_period and payment_interval".into(), + expected: "grace_period should be less than payment_interval".into(), + found: format!("grace_period: {}, payment_interval: {}", gr, pi), + }); + } + } + + Ok(()) + } +} + +impl<'a> Transaction<'a, LoanSetFlag> for LoanSet<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, LoanSetFlag> { + &self.common_fields + } + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, LoanSetFlag> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, LoanSetFlag> for LoanSet<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, LoanSetFlag> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> LoanSet<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + flags: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + loan_broker_id: Cow<'a, str>, + data: Option>, + counterparty: Option>, + counterparty_signature: CounterpartySignature<'a>, + loan_origination_fee: Option>, + loan_service_fee: Option>, + late_payment_fee: Option>, + close_payment_fee: Option>, + overpayment_fee: Option, + interest_rate: Option, + late_interest_rate: Option, + close_interest_rate: Option, + overpayment_interest_rate: Option, + principal_requested: Cow<'a, str>, + payment_total: Option, + payment_interval: Option, + grace_period: Option, + ) -> LoanSet<'a> { + LoanSet { + common_fields: CommonFields::new( + account, + TransactionType::LoanSet, + account_txn_id, + fee, + flags, + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + loan_broker_id, + data, + counterparty, + counterparty_signature, + loan_origination_fee, + loan_service_fee, + late_payment_fee, + close_payment_fee, + overpayment_fee, + interest_rate, + late_interest_rate, + close_interest_rate, + overpayment_interest_rate, + principal_requested, + payment_total, + payment_interval, + grace_period, + } + } + + /// Set Data field + pub fn with_data(mut self, data: Cow<'a, str>) -> Self { + self.data = Some(data); + self + } + + /// Set the Counterparty field + pub fn with_counterparty(mut self, counterparty: Cow<'a, str>) -> Self { + self.counterparty = Some(counterparty); + self + } + + /// Set LateOriginationFee + pub fn with_late_origination_fee(mut self, loan_origination_fee: Cow<'a, str>) -> Self { + self.loan_origination_fee = Some(loan_origination_fee); + self + } + + /// Set LoanServiceFee + pub fn with_loan_service_fee(mut self, loan_service_fee: Cow<'a, str>) -> Self { + self.loan_service_fee = Some(loan_service_fee); + self + } + + /// Set LatePaymentFee + pub fn with_late_payment_fee(mut self, late_payment_fee: Cow<'a, str>) -> Self { + self.late_payment_fee = Some(late_payment_fee); + self + } + + /// Set ClosePaymentFee + pub fn with_close_payment_fee(mut self, close_payment_fee: Cow<'a, str>) -> Self { + self.close_payment_fee = Some(close_payment_fee); + self + } + + /// Set OverpaymentFee + pub fn with_overpayment_fee(mut self, overpayment_fee: u32) -> Self { + self.overpayment_fee = Some(overpayment_fee); + self + } + + /// Set InterestRate + pub fn with_interest_rate(mut self, interest_rate: u32) -> Self { + self.interest_rate = Some(interest_rate); + self + } + + /// Set LateInterestRate + pub fn with_late_interest_rate(mut self, late_interest_rate: u32) -> Self { + self.late_interest_rate = Some(late_interest_rate); + self + } + + /// Set CloseInterestRate + pub fn with_close_interest_rate(mut self, close_interest_rate: u32) -> Self { + self.close_interest_rate = Some(close_interest_rate); + self + } + + /// Set OverpaymentInterestRate + pub fn with_overpayment_interest_rate(mut self, overpayment_interest_rate: u32) -> Self { + self.overpayment_interest_rate = Some(overpayment_interest_rate); + self + } + + /// Set PaymentTotal + pub fn with_payment_total(mut self, payment_total: u32) -> Self { + self.payment_total = Some(payment_total); + self + } + + /// Set PaymentInterval + pub fn with_payment_interval(mut self, payment_interval: u32) -> Self { + self.payment_interval = Some(payment_interval); + self + } + + /// Set GracePeriod + pub fn with_grace_period(mut self, grace_period: u32) -> Self { + self.grace_period = Some(grace_period); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "r9LqNeG6qHxLoanSetter5weJ9mZg"; + const VAULT_ID: &str = "rDB303FC1C7611B22C09E773B51044F6BE"; + const LOAN_BROKER_ID: &str = "rDB303FC1C76LOANBROKER09E773B51044F6BE"; + + #[test] + fn test_serde() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanSetter5weJ9mZg","TransactionType":"LoanSet","Flags":0,"SigningPubKey":"","LoanBrokerID":"rDB303FC1C76LOANBROKER09E773B51044F6BE","CounterpartySignature":{},"PrincipalRequested":"1000"}"#; + + let default_json_value = serde_json::to_value(default_json_str).unwrap(); + let serialized_tx = serde_json::to_value(&serde_json::to_string(&tx).unwrap()).unwrap(); + + assert_eq!(serialized_tx, default_json_value); + + let deseriliazed_tx: LoanSet = serde_json::from_str(default_json_str).unwrap(); + + assert_eq!(tx, deseriliazed_tx); + } + + #[test] + fn test_invalid_data_too_long() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: Some("A".repeat(257).into()), + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooLong { .. }) + )); + } + + #[test] + fn test_invalid_data_empty() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: Some("".into()), + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooShort { .. }) + )); + } + + #[test] + fn test_invalid_data_non_hex_string() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: Some("Z".into()), + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::FromHexError(..)) + )); + } + + #[test] + fn test_invalid_interest_rate_too_high() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: Some(100_001), + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooHigh { .. }) + )); + } + + #[test] + fn test_invalid_late_interest_rate_too_high() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: Some(100_001), + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooHigh { .. }) + )); + } + + #[test] + fn test_invalid_close_interest_rate_too_high() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: Some(100_001), + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooHigh { .. }) + )); + } + + #[test] + fn test_invalid_overpayment_interest_rate_too_high() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: Some(100_001), + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooHigh { .. }) + )); + } + + #[test] + fn test_invalid_overpayment_fee_too_high() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: Some(100_001), + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooHigh { .. }) + )); + } + + #[test] + fn test_invalid_payment_interval_shorter_than_grace_period() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: Some(61), + grace_period: Some(62), + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_invalid_payment_interval_too_short() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: Some(59), + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooLow { .. }) + )); + } + + #[test] + fn test_invalid_grace_period_too_short() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: Some(59), + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::ValueTooLow { .. }) + )); + } + + #[test] + fn test_invalid_principal_request() { + let mut tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "0".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + + // Testing negative + tx.principal_requested = "-1".into(); + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_invalid_loan_origination_fee() { + let mut tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: Some("".into()), + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::BigDecimalError(..)) + )); + + // Testing negative + tx.loan_origination_fee = Some("-1".into()); + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_invalid_loan_service_fee() { + let mut tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: Some("".into()), + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::BigDecimalError(..)) + )); + + // Testing negative + tx.loan_service_fee = Some("-1".into()); + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_invalid_late_payment_fee() { + let mut tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: Some("".into()), + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::BigDecimalError(..)) + )); + + // Testing negative + tx.late_payment_fee = Some("-1".into()); + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_invalid_close_payment_fee() { + let mut tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: None, + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: Some("".into()), + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "1000".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::BigDecimalError(..)) + )); + + // Testing negative + tx.close_payment_fee = Some("-1".into()); + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_invalid_principal_requested_shorter_than_loan_origination_fee() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: Some("11".into()), + loan_service_fee: None, + late_payment_fee: None, + close_payment_fee: None, + overpayment_fee: None, + interest_rate: None, + late_interest_rate: None, + close_interest_rate: None, + overpayment_interest_rate: None, + principal_requested: "10".into(), + payment_total: None, + payment_interval: None, + grace_period: None, + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_valid_loan_set() { + let tx = LoanSet { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanBrokerSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + data: None, + counterparty: None, + counterparty_signature: CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: None, + }, + loan_origination_fee: Some("11".into()), + loan_service_fee: Some("11".into()), + late_payment_fee: Some("11".into()), + close_payment_fee: Some("11".into()), + overpayment_fee: Some(1000), + interest_rate: Some(1000), + late_interest_rate: Some(1000), + close_interest_rate: Some(1000), + overpayment_interest_rate: Some(1000), + principal_requested: "1000".into(), + payment_total: Some(12), + payment_interval: Some(61), + grace_period: Some(60), + }; + + assert!(tx.get_errors().is_ok()); + } +} diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 167d56c8..cff5dfa1 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -14,6 +14,15 @@ pub mod escrow_cancel; pub mod escrow_create; pub mod escrow_finish; pub mod exceptions; +pub mod loan_broker_cover_clawback; +pub mod loan_broker_cover_deposit; +pub mod loan_broker_cover_withdraw; +pub mod loan_broker_delete; +pub mod loan_broker_set; +pub mod loan_delete; +pub mod loan_manage; +pub mod loan_pay; +pub mod loan_set; pub mod metadata; pub mod nftoken_accept_offer; pub mod nftoken_burn; @@ -80,6 +89,15 @@ pub enum TransactionType { EscrowCancel, EscrowCreate, EscrowFinish, + LoanBrokerCoverClawback, + LoanBrokerCoverDeposit, + LoanBrokerCoverWithdraw, + LoanBrokerDelete, + LoanBrokerSet, + LoanDelete, + LoanManage, + LoanPay, + LoanSet, NFTokenAcceptOffer, NFTokenBurn, NFTokenCancelOffer, From f0c5f86e40ac32ecfe341cd77436d38c1573045f Mon Sep 17 00:00:00 2001 From: Bebeto Nyamwamu Date: Sun, 12 Apr 2026 21:47:01 -0400 Subject: [PATCH 2/6] test: loanpay flag exclusivity check --- src/models/transactions/loan_pay.rs | 42 ++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/models/transactions/loan_pay.rs b/src/models/transactions/loan_pay.rs index 6bb882ea..65b63d4c 100644 --- a/src/models/transactions/loan_pay.rs +++ b/src/models/transactions/loan_pay.rs @@ -6,7 +6,8 @@ use strum_macros::{AsRefStr, Display, EnumIter}; use crate::models::{ transactions::{CommonTransactionBuilder, Memo, Signer}, - Amount, FlagCollection, Model, ValidateCurrencies, XRPAmount, XRPLModelResult, + Amount, FlagCollection, Model, ValidateCurrencies, XRPAmount, XRPLModelException, + XRPLModelResult, }; use super::{CommonFields, Transaction, TransactionType}; @@ -55,7 +56,18 @@ pub struct LoanPay<'a> { impl Model for LoanPay<'_> { fn get_errors(&self) -> XRPLModelResult<()> { - self.validate_currencies() + self.validate_currencies()?; + + let num_flags = self.common_fields.flags.0.len(); + if num_flags > 1 { + return Err(XRPLModelException::InvalidValue { + field: "flags".into(), + expected: "Only one flag arrowed".into(), + found: format!("{} flags found", num_flags), + }); + } + + Ok(()) } } @@ -129,7 +141,7 @@ mod tests { const LOAN_ID: &str = "rDB303FC1C7611B22C09E773B51044F6BE"; #[test] - fn test_invalid_data_too_long() { + fn test_serde() { let tx = LoanPay { common_fields: CommonFields { account: SOURCE.into(), @@ -152,4 +164,28 @@ mod tests { assert_eq!(tx, deserilized_tx); } + + #[test] + fn test_invalid_flags() { + let tx = LoanPay { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanPay, + signing_pub_key: Some("".into()), + flags: FlagCollection::new(vec![ + LoanPayFlag::TfLoanFullPayment, + LoanPayFlag::TfLoanLatePayment, + ]), + ..Default::default() + }, + loan_id: LOAN_ID.into(), + amount: Amount::XRPAmount(XRPAmount("1000".into())), + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } } From a68bb7010571c48f03145e1cb99282f0e00fa281 Mon Sep 17 00:00:00 2001 From: Bebeto Nyamwamu Date: Sun, 12 Apr 2026 21:47:47 -0400 Subject: [PATCH 3/6] chore: constant renaming --- .../loan_broker_cover_withdraw.rs | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/models/transactions/loan_broker_cover_withdraw.rs b/src/models/transactions/loan_broker_cover_withdraw.rs index c890149b..c8e75133 100644 --- a/src/models/transactions/loan_broker_cover_withdraw.rs +++ b/src/models/transactions/loan_broker_cover_withdraw.rs @@ -126,26 +126,26 @@ impl<'a> LoanBrokerCoverWithdraw<'a> { mod tests { use super::*; - const VAULT_ID: &str = "r9LqNeG6qHxVaultIdentity5weJ9mZgQ"; - const TX_ID: &str = "r9LqNeG6qHxLoanBrokerCoverWithdraw5weJ9"; - const LOAN_BROKER_ID: &str = "rDB303FC1C7611B22C09E773B51044F6BEA02EF9"; + const ACOUNT: &str = "r9LqNeG6qHxLoanBrokerCoverWithdraw5weJ9"; + const LOAN_BROKER_ID: &str = "DB303FC1C7611B22C09E773B51044F6BEA02EF9"; + const DESTINATION: &str = "rf7HPydP4ihkFkSRHWFq34b4SXRc7GvPCR"; #[test] fn test_serde() { let tx = LoanBrokerCoverWithdraw { common_fields: CommonFields { - account: TX_ID.into(), + account: ACOUNT.into(), transaction_type: TransactionType::LoanBrokerCoverWithdraw, signing_pub_key: Some("".into()), ..Default::default() }, loan_broker_id: LOAN_BROKER_ID.into(), amount: Amount::XRPAmount(XRPAmount::from("1000000")), - destination: Some(VAULT_ID.into()), + destination: Some(DESTINATION.into()), destination_tag: Some(32), }; - let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanBrokerCoverWithdraw5weJ9","TransactionType":"LoanBrokerCoverWithdraw","Flags":0,"SigningPubKey":"","LoanBrokerID":"rDB303FC1C7611B22C09E773B51044F6BEA02EF9","Amount":"1000000","Destination":"r9LqNeG6qHxVaultIdentity5weJ9mZgQ","DestinationTag":32}"#; + let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanBrokerCoverWithdraw5weJ9","TransactionType":"LoanBrokerCoverWithdraw","Flags":0,"SigningPubKey":"","LoanBrokerID":"rDB303FC1C7611B22C09E773B51044F6BEA02EF9","Amount":"1000000","Destination":"rf7HPydP4ihkFkSRHWFq34b4SXRc7GvPCR","DestinationTag":32}"#; let default_json_value = serde_json::to_value(default_json_str).unwrap(); let serialized_tx = serde_json::to_value(&serde_json::to_string(&tx).unwrap()).unwrap(); @@ -157,4 +157,22 @@ mod tests { assert_eq!(tx, deserilized_tx); } + + #[test] + fn test_valid() { + let tx = LoanBrokerCoverWithdraw { + common_fields: CommonFields { + account: ACOUNT.into(), + transaction_type: TransactionType::LoanBrokerCoverWithdraw, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + destination: Some(DESTINATION.into()), + destination_tag: Some(32), + }; + + assert!(tx.get_errors().is_ok()) + } } From 840070b6a8ed850a1b52551e042388d3696789be Mon Sep 17 00:00:00 2001 From: Bebeto Nyamwamu Date: Sun, 12 Apr 2026 21:55:08 -0400 Subject: [PATCH 4/6] test: loanmanage flag exclusivity check --- src/models/transactions/loan_manage.rs | 38 ++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/models/transactions/loan_manage.rs b/src/models/transactions/loan_manage.rs index b89eb1e8..303be35d 100644 --- a/src/models/transactions/loan_manage.rs +++ b/src/models/transactions/loan_manage.rs @@ -6,7 +6,7 @@ use strum_macros::{AsRefStr, Display, EnumIter}; use crate::models::{ transactions::{CommonTransactionBuilder, Memo, Signer}, - FlagCollection, Model, ValidateCurrencies, XRPAmount, XRPLModelResult, + FlagCollection, Model, ValidateCurrencies, XRPAmount, XRPLModelException, XRPLModelResult, }; use super::{CommonFields, Transaction, TransactionType}; @@ -53,7 +53,18 @@ pub struct LoanManage<'a> { impl Model for LoanManage<'_> { fn get_errors(&self) -> XRPLModelResult<()> { - self.validate_currencies() + self.validate_currencies()?; + + let num_flags = self.common_fields.flags.0.len(); + if num_flags > 1 { + return Err(XRPLModelException::InvalidValue { + field: "flags".into(), + expected: "Only one flag arrowed".into(), + found: format!("{} flags found", num_flags), + }); + } + + Ok(()) } } @@ -147,4 +158,27 @@ mod tests { assert_eq!(tx, deserilized_tx); } + + #[test] + fn test_invalid_flags() { + let tx = LoanManage { + common_fields: CommonFields { + account: SOURCE.into(), + transaction_type: TransactionType::LoanManage, + signing_pub_key: Some("".into()), + flags: FlagCollection::new(vec![ + LoanManageFlag::TfLoanDefault, + LoanManageFlag::TfLoanImpair, + ]), + ..Default::default() + }, + loan_id: LOAN_ID.into(), + }; + + assert!(tx.get_errors().is_err()); + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } } From 91647d16e2c130ee365fc34f4c5f0aa22ed925c0 Mon Sep 17 00:00:00 2001 From: Bebeto Nyamwamu Date: Sun, 12 Apr 2026 22:52:16 -0400 Subject: [PATCH 5/6] chore: rm r --- src/models/transactions/loan_broker_cover_withdraw.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/transactions/loan_broker_cover_withdraw.rs b/src/models/transactions/loan_broker_cover_withdraw.rs index c8e75133..1cc980c0 100644 --- a/src/models/transactions/loan_broker_cover_withdraw.rs +++ b/src/models/transactions/loan_broker_cover_withdraw.rs @@ -145,7 +145,7 @@ mod tests { destination_tag: Some(32), }; - let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanBrokerCoverWithdraw5weJ9","TransactionType":"LoanBrokerCoverWithdraw","Flags":0,"SigningPubKey":"","LoanBrokerID":"rDB303FC1C7611B22C09E773B51044F6BEA02EF9","Amount":"1000000","Destination":"rf7HPydP4ihkFkSRHWFq34b4SXRc7GvPCR","DestinationTag":32}"#; + let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanBrokerCoverWithdraw5weJ9","TransactionType":"LoanBrokerCoverWithdraw","Flags":0,"SigningPubKey":"","LoanBrokerID":"DB303FC1C7611B22C09E773B51044F6BEA02EF9","Amount":"1000000","Destination":"rf7HPydP4ihkFkSRHWFq34b4SXRc7GvPCR","DestinationTag":32}"#; let default_json_value = serde_json::to_value(default_json_str).unwrap(); let serialized_tx = serde_json::to_value(&serde_json::to_string(&tx).unwrap()).unwrap(); From 008962737b3b4e687387e8efa12889be3a7c31a8 Mon Sep 17 00:00:00 2001 From: Bebeto Nyamwamu Date: Sun, 12 Apr 2026 22:52:54 -0400 Subject: [PATCH 6/6] feat: XLS-66 Lending Protocol ledger entries --- src/models/ledger/objects/loan.rs | 238 +++++++++++++++++++++++ src/models/ledger/objects/loan_broker.rs | 171 ++++++++++++++++ src/models/ledger/objects/mod.rs | 3 + 3 files changed, 412 insertions(+) create mode 100644 src/models/ledger/objects/loan.rs create mode 100644 src/models/ledger/objects/loan_broker.rs diff --git a/src/models/ledger/objects/loan.rs b/src/models/ledger/objects/loan.rs new file mode 100644 index 00000000..9d30bc81 --- /dev/null +++ b/src/models/ledger/objects/loan.rs @@ -0,0 +1,238 @@ +use alloc::borrow::Cow; +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use serde_with::skip_serializing_none; +use strum_macros::{AsRefStr, Display, EnumIter}; + +use crate::models::{ + ledger::objects::{CommonFields, LedgerEntryType, LedgerObject}, + FlagCollection, Model, +}; + +#[derive( + Debug, Eq, PartialEq, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter, +)] +#[repr(u32)] +pub enum LoanFlag { + /// Indicates that the Loan is defaulted + LsfLoanDefault = 0x00010000, + /// Indicates that the Loan is impaired + LsfLoanImpaired = 0x00020000, + /// Indicates that the Loan supports overpayments + LsfLoanOverpayment = 0x00040000, +} + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct Loan<'a> { + /// The base fields for all ledger object models. + /// + /// See Ledger Object Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, LoanFlag>, + #[serde(rename = "PreviousTxnID")] + /// The ID of the transaction that last + /// modified this object. + pub previous_txn_id: Cow<'a, str>, + /// The ledger sequence containing the + /// transaction that last modified this object. + pub previous_txn_lgr_seq: u32, + /// The sequence number of the Loan. + pub loan_sequence: u32, + /// Identifies the page where this item is + /// referenced in the Borrower owner's directory. + pub owner_node: u64, + /// Identifies the page where this item + /// is referenced in the LoanBrokers owner directory. + pub loan_broker_node: u64, + /// The ID of the LoanBroker associated + /// with this Loan Instance. + pub loan_broker_id: Cow<'a, str>, + /// The address of the account that is the borrower. + pub borrower: Cow<'a, str>, + /// A nominal funds amount paid to the + /// LoanBroker.Owner when the Loan is created. + pub loan_origination_fee: Cow<'a, str>, + /// A nominal funds amount paid to the + /// LoanBroker.Owner with every Loan payment. + pub loan_service_fee: Cow<'a, str>, + /// A nominal funds amount paid to the + /// LoanBroker.Owner when a payment is late. + pub late_payment_fee: Cow<'a, str>, + /// A nominal funds amount paid to the + /// LoanBroker.Owner when a full payment is made. + pub close_payment_fee: Cow<'a, str>, + /// A fee charged on overpayments in 1/10th + /// basis points. Valid values are between 0 and 100000 inclusive. (0 - 100%) + pub overpaymnet_fee: Cow<'a, str>, + /// Annualized interest rate of the Loan in 1/10th basis points. + pub interest_rate: u32, + /// A premium is added to the interest rate for + /// late payments in 1/10th basis points. + /// Valid values are between 0 and 100000 inclusive. (0 - 100%) + pub late_interest_rate: u32, + /// An interest rate charged for repaying + /// the Loan early in 1/10th basis points. + /// Valid values are between 0 and 100000 inclusive. (0 - 100%) + pub close_interest_rate: u32, + /// An interest rate charged on overpayments + /// in 1/10th basis points. Valid values are between + /// 0 and 100000 inclusive. (0 - 100%) + pub overpayment_interest_rate: u32, + /// The timestamp of when the Loan started + /// Ripple Epoch.(https://xrpl.org/docs/references/protocol/data-types/basic-data-types/#specifying-time) + pub start_date: u32, + /// Number of seconds between Loan payments. + pub payment_interval: u32, + /// The number of seconds after the Loan's Payment Due Date that the Loan can be Defaulted. + pub grace_period: u32, + /// The timestamp of when the previous payment was made + /// in Ripple Epoch. (https://xrpl.org/docs/references/protocol/data-types/basic-data-types/#specifying-time) + pub previous_payment_due_date: u32, + /// The timestamp of when the next payment is due + /// in Ripple Epoch. (https://xrpl.org/docs/references/protocol/data-types/basic-data-types/#specifying-time) + pub next_payment_due_date: u32, + /// The number of payments remaining on the Loan. + pub payment_remaining: u32, + /// The total outstanding value of the Loan, including all + /// fees and interest. + pub total_value_outstanding: Cow<'a, str>, + /// The principal amount that the Borrower still owes. + pub principal_outstanding: Cow<'a, str>, + /// The remaining Management Fee owed to the LoanBroker. + pub management_fee_outstanding: Cow<'a, str>, + /// The calculated periodic payment amount for each payment interval. + pub periodic_payment: Cow<'a, str>, + /// The scale factor that ensures all computed amounts are + /// rounded to the same number of decimal places. + /// It is determined based on the total loan value at creation time. + pub loan_scale: Option, +} + +impl<'a> Model for Loan<'a> {} + +impl<'a> LedgerObject for Loan<'a> { + fn get_ledger_entry_type(&self) -> LedgerEntryType { + self.common_fields.get_ledger_entry_type() + } +} + +impl<'a> Loan<'a> { + pub fn new( + index: Option>, + ledger_index: Cow<'a, str>, + flags: FlagCollection, + previous_txn_id: Cow<'a, str>, + previous_txn_lgr_seq: u32, + loan_sequence: u32, + owner_node: u64, + loan_broker_node: u64, + loan_broker_id: Cow<'a, str>, + borrower: Cow<'a, str>, + loan_origination_fee: Cow<'a, str>, + loan_service_fee: Cow<'a, str>, + late_payment_fee: Cow<'a, str>, + close_payment_fee: Cow<'a, str>, + overpaymnet_fee: Cow<'a, str>, + interest_rate: u32, + late_interest_rate: u32, + close_interest_rate: u32, + overpayment_interest_rate: u32, + start_date: u32, + payment_interval: u32, + grace_period: u32, + previous_payment_due_date: u32, + next_payment_due_date: u32, + payment_remaining: u32, + total_value_outstanding: Cow<'a, str>, + principal_outstanding: Cow<'a, str>, + management_fee_outstanding: Cow<'a, str>, + periodic_payment: Cow<'a, str>, + loan_scale: Option, + ) -> Self { + Loan { + common_fields: CommonFields { + flags, + ledger_entry_type: LedgerEntryType::LoanBroker, + index, + ledger_index: Some(ledger_index), + }, + previous_txn_id, + previous_txn_lgr_seq, + loan_sequence, + owner_node, + loan_broker_node, + loan_broker_id, + borrower, + loan_origination_fee, + loan_service_fee, + late_payment_fee, + close_payment_fee, + overpaymnet_fee, + interest_rate, + late_interest_rate, + close_interest_rate, + overpayment_interest_rate, + start_date, + payment_interval, + grace_period, + previous_payment_due_date, + next_payment_due_date, + payment_remaining, + total_value_outstanding, + principal_outstanding, + management_fee_outstanding, + periodic_payment, + loan_scale, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::borrow::Cow; + + #[test] + fn test_serde() { + let loan = Loan::new( + None, + Cow::from("ledger_index"), + FlagCollection::new(vec![LoanFlag::LsfLoanDefault]), + "108D5CE7EEAF504B2894B8C674E6D68499076441C483728".into(), + 47636435, + 7446366, + 6363252, + 45372352, + "FA65C9FE1538FD7E398FFFE9D1908DFA4576D8".into(), + "r75E1D753E5B91627516F6D7097".into(), + "1".into(), + "1".into(), + "2".into(), + "1".into(), + "1".into(), + 10, + 12, + 10, + 8, + 177474757, + 86400, + 500, + 1777749474, + 175747473, + 453636, + "100074".into(), + "100000".into(), + "1000".into(), + "500".into(), + Some(5), + ); + + let serialized = serde_json::to_string(&loan).unwrap(); + let deserialized: Loan = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(loan, deserialized); + } +} diff --git a/src/models/ledger/objects/loan_broker.rs b/src/models/ledger/objects/loan_broker.rs new file mode 100644 index 00000000..7bb5ad4e --- /dev/null +++ b/src/models/ledger/objects/loan_broker.rs @@ -0,0 +1,171 @@ +use alloc::borrow::Cow; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::{ledger::objects::LedgerEntryType, FlagCollection, Model, NoFlags}; + +use super::{CommonFields, LedgerObject}; + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct LoanBroker<'a> { + /// The base fields for all ledger object models. + /// + /// See Ledger Object Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + #[serde(rename = "PreviousTxnID")] + /// The ID of the transaction that last modified this object. + pub previous_txn_id: Cow<'a, str>, + /// The sequence of the ledger containing the + /// transaction that last modified this object. + pub previous_txn_lgr_seq: u32, + /// The transaction sequence number that + /// created the LoanBroker. + pub sequence: u32, + /// A sequential identifier for Loan objects, + /// incremented each time a new Loan is + /// created by this LoanBroker instance. + pub loan_sequence: u32, + /// Identifies the page where this item is + /// referenced in the owner's directory. + pub owner_node: u64, + /// Identifies the page where this item is + /// referenced in the Vault's pseudo-account + /// owner's directory. + pub vault_node: u64, + /// The ID of the Vault object associated with this + /// Lending Protocol Instance. + #[serde(rename = "VaultID")] + pub vault_id: Cow<'a, str>, + /// The address of the LoanBroker pseudo-account. + pub account: Cow<'a, str>, + /// The address of the Loan Broker account. + pub owner: Cow<'a, str>, + /// Arbitrary metadata about the LoanBroker. + /// Limited to 256 bytes. + pub data: Option>, + /// The 1/10th basis point fee charged by the Lending Protocol. + /// Valid values are between 0 and 10000 inclusive. + /// A value of 1 is equivalent to 1/10 bps or 0.001% + pub management_fee_rate: Option, + /// The number of active Loans issued by the LoanBroker. + pub owner_count: u32, + /// The total asset amount the protocol owes the + /// Vault, including interest. + pub debt_total: Cow<'a, str>, + /// The maximum amount the protocol can owe the Vault. + /// The default value of 0 means there is no + /// limit to the debt. + pub debt_maximum: Cow<'a, str>, + /// The total amount of first-loss capital + /// deposited into the Lending Protocol. + pub cover_available: Cow<'a, str>, + /// The 1/10th basis point of the DebtTotal that the + /// first-loss capital must cover. Valid values are + /// between 0 and 100000 inclusive. A value of 1 + /// is equivalent to 1/10 bps or 0.001%. + pub cover_rate_minimum: u32, + /// The 1/10th basis point of minimum required + /// first-loss capital that is liquidated to + /// cover a Loan default. Valid values + /// are between 0 and 100000 inclusive. + /// A value of 1 is equivalent to 1/10 bps or 0.001%. + pub cover_rate_liquidation: u32, +} + +impl<'a> Model for LoanBroker<'a> {} + +impl<'a> LedgerObject for LoanBroker<'a> { + fn get_ledger_entry_type(&self) -> LedgerEntryType { + self.common_fields.get_ledger_entry_type() + } +} + +impl<'a> LoanBroker<'a> { + pub fn new( + index: Option>, + ledger_index: Cow<'a, str>, + previous_txn_id: Cow<'a, str>, + previous_txn_lgr_seq: u32, + sequence: u32, + loan_sequence: u32, + owner_node: u64, + vault_node: u64, + vault_id: Cow<'a, str>, + account: Cow<'a, str>, + owner: Cow<'a, str>, + data: Option>, + management_fee_rate: Option, + owner_count: u32, + debt_total: Cow<'a, str>, + debt_maximum: Cow<'a, str>, + cover_available: Cow<'a, str>, + cover_rate_minimum: u32, + cover_rate_liquidation: u32, + ) -> Self { + Self { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::LoanBroker, + index, + ledger_index: Some(ledger_index), + }, + previous_txn_id, + previous_txn_lgr_seq, + sequence, + loan_sequence, + owner_node, + vault_node, + vault_id, + account, + owner, + data, + management_fee_rate, + owner_count, + debt_total, + debt_maximum, + cover_available, + cover_rate_minimum, + cover_rate_liquidation, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::borrow::Cow; + + #[test] + fn test_serde() { + let loan_broker = LoanBroker::new( + None, + Cow::from("1ESDNBCNSGAFDGCFSGXF563BSGVGV8"), + Cow::from(""), + 1734636, + 856363, + 638286, + 325452, + 2534267, + Cow::from(""), + Cow::from("rVALUE463dghsg26473642Ki436ghdghd"), + Cow::from("56ERHJFVGRGFCVSG747YVGW"), + None, + Some(27), + 245, + Cow::from("100000"), + Cow::from("10000"), + Cow::from("7000"), + 10, + 10, + ); + + let serialized = serde_json::to_string(&loan_broker).unwrap(); + let deserialized: LoanBroker = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(loan_broker, deserialized); + } +} diff --git a/src/models/ledger/objects/mod.rs b/src/models/ledger/objects/mod.rs index 45fa941f..face423f 100644 --- a/src/models/ledger/objects/mod.rs +++ b/src/models/ledger/objects/mod.rs @@ -8,6 +8,8 @@ pub mod directory_node; pub mod escrow; pub mod fee_settings; pub mod ledger_hashes; +pub mod loan; +pub mod loan_broker; pub mod negative_unl; pub mod nftoken_offer; pub mod nftoken_page; @@ -62,6 +64,7 @@ pub enum LedgerEntryType { Escrow = 0x0075, FeeSettings = 0x0073, LedgerHashes = 0x0068, + LoanBroker = 0x008, NegativeUNL = 0x004E, NFTokenOffer = 0x0037, NFTokenPage = 0x0050,