From 373fb5296395fe9fa72b256111fdaa31db153ccc Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 8 Sep 2025 14:19:16 -0700 Subject: [PATCH 01/21] Models and unit tests for LP amendment; TODO: Integ tests are remaining; LoanDraw transaction has nt been implemented --- .ci-config/rippled.cfg | 1 + .../test_loan_broker_cover_clawback.py | 52 +++ .../unit/models/transactions/test_loan_set.py | 50 +++ .../binarycodec/definitions/definitions.json | 328 ++++++++++++++++++ xrpl/models/transactions/__init__.py | 18 + .../loan_broker_cover_clawback.py | 63 ++++ .../transactions/loan_broker_cover_deposit.py | 32 ++ .../loan_broker_cover_withdraw.py | 38 ++ .../models/transactions/loan_broker_delete.py | 27 ++ xrpl/models/transactions/loan_broker_set.py | 62 ++++ xrpl/models/transactions/loan_delete.py | 27 ++ xrpl/models/transactions/loan_manage.py | 63 ++++ xrpl/models/transactions/loan_pay.py | 33 ++ xrpl/models/transactions/loan_set.py | 177 ++++++++++ .../transactions/types/transaction_type.py | 9 + 15 files changed, 980 insertions(+) create mode 100644 tests/unit/models/transactions/test_loan_broker_cover_clawback.py create mode 100644 tests/unit/models/transactions/test_loan_set.py create mode 100644 xrpl/models/transactions/loan_broker_cover_clawback.py create mode 100644 xrpl/models/transactions/loan_broker_cover_deposit.py create mode 100644 xrpl/models/transactions/loan_broker_cover_withdraw.py create mode 100644 xrpl/models/transactions/loan_broker_delete.py create mode 100644 xrpl/models/transactions/loan_broker_set.py create mode 100644 xrpl/models/transactions/loan_delete.py create mode 100644 xrpl/models/transactions/loan_manage.py create mode 100644 xrpl/models/transactions/loan_pay.py create mode 100644 xrpl/models/transactions/loan_set.py diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index b6b907c8d..2c642dcab 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -206,6 +206,7 @@ PermissionDelegation PermissionedDEX Batch TokenEscrow +LendingProtocol # This section can be used to simulate various FeeSettings scenarios for rippled node in standalone mode [voting] diff --git a/tests/unit/models/transactions/test_loan_broker_cover_clawback.py b/tests/unit/models/transactions/test_loan_broker_cover_clawback.py new file mode 100644 index 000000000..cb71ab215 --- /dev/null +++ b/tests/unit/models/transactions/test_loan_broker_cover_clawback.py @@ -0,0 +1,52 @@ +from unittest import TestCase + +from xrpl.models.amounts import IssuedCurrencyAmount, MPTAmount +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions import LoanBrokerCoverClawback + +_SOURCE = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" +_ISSUER = "rHxTJLqdVUxjJuZEZvajXYYQJ7q8p4DhHy" + + +class TestLoanBrokerCoverClawback(TestCase): + def test_invalid_no_amount_nor_loan_broker_id_specified(self): + with self.assertRaises(XRPLModelException) as error: + LoanBrokerCoverClawback(account=_SOURCE) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerCoverClawback': 'No amount or loan broker ID specified.'}", + ) + + def test_invalid_xrp_amount(self): + with self.assertRaises(XRPLModelException) as error: + LoanBrokerCoverClawback(account=_SOURCE, amount="10.20") + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerCoverClawback:Amount': 'Amount cannot be XRP.'}", + ) + + def test_invalid_negative_amount(self): + with self.assertRaises(XRPLModelException) as error: + LoanBrokerCoverClawback( + account=_SOURCE, + amount=IssuedCurrencyAmount( + issuer=_ISSUER, + currency="USD", + value="-10", + ), + ) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerCoverClawback:Amount': 'Amount must be greater than 0.'}", + ) + + def test_valid_loan_broker_cover_clawback(self): + tx = LoanBrokerCoverClawback( + account=_SOURCE, + amount=MPTAmount( + mpt_issuance_id=_ISSUER, + value="10.20", + ), + loan_broker_id=_ISSUER, + ) + self.assertTrue(tx.is_valid()) diff --git a/tests/unit/models/transactions/test_loan_set.py b/tests/unit/models/transactions/test_loan_set.py new file mode 100644 index 000000000..ed070595a --- /dev/null +++ b/tests/unit/models/transactions/test_loan_set.py @@ -0,0 +1,50 @@ +import datetime +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions import LoanSet + +_SOURCE = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" +_ISSUER = "rHxTJLqdVUxjJuZEZvajXYYQJ7q8p4DhHy" + + +class TestLoanSet(TestCase): + def test_invalid_payment_interval_shorter_than_grace_period(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested=100000000, + start_date=int(datetime.datetime.now().timestamp()), + payment_interval=65, + grace_period=70, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:GracePeriod': 'Grace period must be less than the payment " + + "interval.'}", + ) + + def test_invalid_payment_interval_too_short(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested=100000000, + start_date=int(datetime.datetime.now().timestamp()), + payment_interval=59, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:PaymentInterval': 'Payment interval must be at least 60 seconds." + + "'}", + ) + + def test_valid_loan_set(self): + tx = LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested=100000000, + start_date=int(datetime.datetime.now().timestamp()), + ) + self.assertTrue(tx.is_valid()) diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 9fdd5ff6a..956f2616b 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -180,6 +180,16 @@ "type": "UInt16" } ], + [ + "ManagementFeeRate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 22, + "type": "UInt16" + } + ], [ "NetworkID", { @@ -680,6 +690,156 @@ "type": "UInt32" } ], + [ + "StartDate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 53, + "type": "UInt32" + } + ], + [ + "PaymentInterval", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 54, + "type": "UInt32" + } + ], + [ + "GracePeriod", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 55, + "type": "UInt32" + } + ], + [ + "PreviousPaymentDate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 56, + "type": "UInt32" + } + ], + [ + "NextPaymentDueDate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 57, + "type": "UInt32" + } + ], + [ + "PaymentRemaining", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 58, + "type": "UInt32" + } + ], + [ + "PaymentTotal", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 59, + "type": "UInt32" + } + ], + [ + "LoanSequence", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 60, + "type": "UInt32" + } + ], + [ + "CoverRateMinimum", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 61, + "type": "UInt32" + } + ], + [ + "CoverRateLiquidation", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 62, + "type": "UInt32" + } + ], + [ + "OverpaymentFee", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 63, + "type": "UInt32" + } + ], + [ + "InterestRate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 64, + "type": "UInt32" + } + ], + [ + "LateInterestRate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 65, + "type": "UInt32" + } + ], + [ + "CloseInterestRate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 66, + "type": "UInt32" + } + ], + [ + "OverpaymentInterestRate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 67, + "type": "UInt32" + } + ], [ "IndexNext", { @@ -950,6 +1110,26 @@ "type": "UInt64" } ], + [ + "VaultNode", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 30, + "type": "UInt64" + } + ], + [ + "LoanBrokerNode", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 31, + "type": "UInt64" + } + ], [ "EmailHash", { @@ -1300,6 +1480,26 @@ "type": "Hash256" } ], + [ + "LoanBrokerID", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 37, + "type": "Hash256" + } + ], + [ + "LoanID", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 38, + "type": "Hash256" + } + ], [ "hash", { @@ -2080,6 +2280,26 @@ "type": "AccountID" } ], + [ + "Borrower", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 25, + "type": "AccountID" + } + ], + [ + "Counterparty", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 26, + "type": "AccountID" + } + ], [ "Number", { @@ -2130,6 +2350,95 @@ "type": "Number" } ], + [ + "DebtTotal", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 6, + "type": "Number" + } + ], + [ + "DebtMaximum", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 7, + "type": "Number" + } + ], + [ + "CoverAvailable", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 8, + "type": "Number" + } + ], + [ + "LoanOriginationFee", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 9, + "type": "Number" + } + ], + [ + "LoanServiceFee", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 10, + "type": "Number" + } + ], + [ + "LatePaymentFee", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 11, + "type": "Number" + } + ], + [ + "ClosePaymentFee", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 12, + "type": "Number" + } + ], [ + "PrincipalOutstanding", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 13, + "type": "Number" + } + ], + [ + "PrincipalRequested", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 14, + "type": "Number" + } + ], [ "TransactionMetaData", { @@ -2470,6 +2779,16 @@ "type": "STObject" } ], + [ + "CounterpartySignature", + { + "isSerialized": true, + "isSigningField": false, + "isVLEncoded": false, + "nth": 37, + "type": "STObject" + } + ], [ "Signers", { @@ -3316,6 +3635,15 @@ "EscrowFinish": 2, "Invalid": -1, "LedgerStateFix": 53, + "LoanBrokerSet": 74, + "LoanBrokerDelete": 75, + "LoanBrokerCoverDeposit": 76, + "LoanBrokerCoverWithdraw": 77, + "LoanBrokerCoverClawback": 78, + "LoanSet": 80, + "LoanDelete": 81, + "LoanManage": 82, + "LoanPay": 84, "MPTokenAuthorize": 57, "MPTokenIssuanceCreate": 54, "MPTokenIssuanceDestroy": 55, diff --git a/xrpl/models/transactions/__init__.py b/xrpl/models/transactions/__init__.py index 112a0ca75..ba41c9ed9 100644 --- a/xrpl/models/transactions/__init__.py +++ b/xrpl/models/transactions/__init__.py @@ -40,6 +40,15 @@ from xrpl.models.transactions.escrow_cancel import EscrowCancel from xrpl.models.transactions.escrow_create import EscrowCreate from xrpl.models.transactions.escrow_finish import EscrowFinish +from xrpl.models.transactions.loan_broker_cover_clawback import LoanBrokerCoverClawback +from xrpl.models.transactions.loan_broker_cover_deposit import LoanBrokerCoverDeposit +from xrpl.models.transactions.loan_broker_cover_withdraw import LoanBrokerCoverWithdraw +from xrpl.models.transactions.loan_broker_delete import LoanBrokerDelete +from xrpl.models.transactions.loan_broker_set import LoanBrokerSet +from xrpl.models.transactions.loan_delete import LoanDelete +from xrpl.models.transactions.loan_manage import LoanManage +from xrpl.models.transactions.loan_pay import LoanPay +from xrpl.models.transactions.loan_set import LoanSet from xrpl.models.transactions.metadata import TransactionMetadata from xrpl.models.transactions.mptoken_authorize import ( MPTokenAuthorize, @@ -165,6 +174,15 @@ "EscrowCreate", "EscrowFinish", "GranularPermission", + "LoanBrokerCoverClawback", + "LoanBrokerCoverDeposit", + "LoanBrokerCoverWithdraw", + "LoanBrokerDelete", + "LoanBrokerSet", + "LoanDelete", + "LoanManage", + "LoanPay", + "LoanSet", "Memo", "MPTokenAuthorize", "MPTokenAuthorizeFlag", diff --git a/xrpl/models/transactions/loan_broker_cover_clawback.py b/xrpl/models/transactions/loan_broker_cover_clawback.py new file mode 100644 index 000000000..b04118735 --- /dev/null +++ b/xrpl/models/transactions/loan_broker_cover_clawback.py @@ -0,0 +1,63 @@ +"""Model for LoanBrokerCoverClawback transaction type.""" + +from __future__ import annotations # Requires Python 3.7+ + +from dataclasses import dataclass, field +from typing import Dict, Optional + +from typing_extensions import Self + +from xrpl.models.amounts import Amount, get_amount_value, is_xrp +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class LoanBrokerCoverClawback(Transaction): + """This transaction withdraws First Loss Capital from a Loan Broker""" + + loan_broker_id: Optional[str] = None + """ + The Loan Broker ID from which to withdraw First-Loss Capital. Must be provided if + the Amount is an MPT, or Amount is an IOU and issuer is specified as the Account + submitting the transaction. + """ + + amount: Optional[Amount] = None + """ + The First-Loss Capital amount to clawback. If the amount is 0 or not provided, + clawback funds up to LoanBroker.DebtTotal * LoanBroker.CoverRateMinimum. + """ + + transaction_type: TransactionType = field( + default=TransactionType.LOAN_BROKER_COVER_CLAWBACK, + init=False, + ) + + def _get_errors(self: Self) -> Dict[str, str]: + parent_class_errors = { + key: value + for key, value in { + **super()._get_errors(), + }.items() + if value is not None + } + + if self.loan_broker_id is None and self.amount is None: + parent_class_errors["LoanBrokerCoverClawback"] = ( + "No amount or loan broker ID specified." + ) + + if self.amount is not None: + if is_xrp(self.amount): + parent_class_errors["LoanBrokerCoverClawback:Amount"] = ( + "Amount cannot be XRP." + ) + elif get_amount_value(self.amount) < 0: + parent_class_errors["LoanBrokerCoverClawback:Amount"] = ( + "Amount must be greater than 0." + ) + + return parent_class_errors diff --git a/xrpl/models/transactions/loan_broker_cover_deposit.py b/xrpl/models/transactions/loan_broker_cover_deposit.py new file mode 100644 index 000000000..15b8b7397 --- /dev/null +++ b/xrpl/models/transactions/loan_broker_cover_deposit.py @@ -0,0 +1,32 @@ +"""Model for LoanBrokerCoverDeposit transaction type.""" + +from __future__ import annotations # Requires Python 3.7+ + +from dataclasses import dataclass, field + +from xrpl.models.amounts import Amount +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class LoanBrokerCoverDeposit(Transaction): + """This transaction deposits First Loss Capital into a Loan Broker""" + + loan_broker_id: str = REQUIRED # type: ignore + """ + The Loan Broker ID to deposit First-Loss Capital. + """ + + amount: Amount = REQUIRED # type: ignore + """ + The Fist-Loss Capital amount to deposit. + """ + + transaction_type: TransactionType = field( + default=TransactionType.LOAN_BROKER_COVER_DEPOSIT, + init=False, + ) diff --git a/xrpl/models/transactions/loan_broker_cover_withdraw.py b/xrpl/models/transactions/loan_broker_cover_withdraw.py new file mode 100644 index 000000000..a8a0f8410 --- /dev/null +++ b/xrpl/models/transactions/loan_broker_cover_withdraw.py @@ -0,0 +1,38 @@ +"""Model for LoanBrokerCoverWithdraw transaction type.""" + +from __future__ import annotations # Requires Python 3.7+ + +from dataclasses import dataclass, field +from typing import Optional + +from xrpl.models.amounts import Amount +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class LoanBrokerCoverWithdraw(Transaction): + """This transaction withdraws First Loss Capital from a Loan Broker""" + + loan_broker_id: str = REQUIRED # type: ignore + """ + The Loan Broker ID from which to withdraw First-Loss Capital. + """ + + amount: Amount = REQUIRED # type: ignore + """ + The Fist-Loss Capital amount to withdraw. + """ + + destination: Optional[str] = None + """ + An account to receive the assets. It must be able to receive the asset. + """ + + transaction_type: TransactionType = field( + default=TransactionType.LOAN_BROKER_COVER_WITHDRAW, + init=False, + ) diff --git a/xrpl/models/transactions/loan_broker_delete.py b/xrpl/models/transactions/loan_broker_delete.py new file mode 100644 index 000000000..f63b0f8d7 --- /dev/null +++ b/xrpl/models/transactions/loan_broker_delete.py @@ -0,0 +1,27 @@ +"""Model for LoanBrokerDelete transaction type.""" + +from __future__ import annotations # Requires Python 3.7+ + +from dataclasses import dataclass, field + +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class LoanBrokerDelete(Transaction): + """This transaction deletes a Loan Broker""" + + loan_broker_id: str = REQUIRED # type: ignore + """ + The Loan Broker ID that the transaction is deleting. + This field is required. + """ + + transaction_type: TransactionType = field( + default=TransactionType.LOAN_BROKER_DELETE, + init=False, + ) diff --git a/xrpl/models/transactions/loan_broker_set.py b/xrpl/models/transactions/loan_broker_set.py new file mode 100644 index 000000000..ae59f0786 --- /dev/null +++ b/xrpl/models/transactions/loan_broker_set.py @@ -0,0 +1,62 @@ +"""Model for LoanBrokerSet transaction type.""" + +from __future__ import annotations # Requires Python 3.7+ + +from dataclasses import dataclass, field +from typing import Optional + +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class LoanBrokerSet(Transaction): + """This transaction creates and updates a Loan Broker""" + + vault_id: str = REQUIRED # type: ignore + """ + The Vault ID that the Lending Protocol will use to access liquidity. + This field is required. + """ + + loan_broker_id: Optional[str] = None + """ + The Loan Broker ID that the transaction is modifying. + """ + + data: Optional[str] = None + """ + Arbitrary metadata in hex format. The field is limited to 256 bytes. + """ + + management_fee_rate: Optional[int] = None + """ + The 1/10th basis point fee charged by the Lending Protocol Owner. + Valid values are between 0 and 10000 inclusive. + """ + + debt_maximum: Optional[int] = None + """ + The maximum amount the protocol can owe the Vault. + The default value of 0 means there is no limit to the debt. Must not be negative. + """ + + cover_rate_minimum: Optional[int] = None + """ + The 1/10th basis point DebtTotal that the first loss capital must cover. + Valid values are between 0 and 100000 inclusive. + """ + + cover_rate_liquidation: Optional[int] = None + """ + The 1/10th basis point of minimum required first loss capital liquidated to cover a + Loan default. Valid values are between 0 and 100000 inclusive. + """ + + transaction_type: TransactionType = field( + default=TransactionType.LOAN_BROKER_SET, + init=False, + ) diff --git a/xrpl/models/transactions/loan_delete.py b/xrpl/models/transactions/loan_delete.py new file mode 100644 index 000000000..308309a6e --- /dev/null +++ b/xrpl/models/transactions/loan_delete.py @@ -0,0 +1,27 @@ +"""Model for LoanDelete transaction type.""" + +from __future__ import annotations # Requires Python 3.7+ + +from dataclasses import dataclass, field + +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class LoanDelete(Transaction): + """The transaction deletes an existing Loan object.""" + + loan_id: str = REQUIRED # type: ignore + """ + The ID of the Loan object to be deleted. + This field is required. + """ + + transaction_type: TransactionType = field( + default=TransactionType.LOAN_DELETE, + init=False, + ) diff --git a/xrpl/models/transactions/loan_manage.py b/xrpl/models/transactions/loan_manage.py new file mode 100644 index 000000000..4a553bdd7 --- /dev/null +++ b/xrpl/models/transactions/loan_manage.py @@ -0,0 +1,63 @@ +"""Model for LoanManage transaction type.""" + +from __future__ import annotations # Requires Python 3.7+ + +from dataclasses import dataclass, field +from enum import Enum + +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction, TransactionFlagInterface +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +class LoanManageFlag(int, Enum): + """ + Enum for LoanManage Transaction Flags. + + Transactions of the LoanManage type support additional values in the Flags field. + This enum represents those options. + """ + + TF_LOAN_DEFAULT = 0x00010000 + """ + Indicates that the Loan should be defaulted. + """ + + TF_LOAN_IMPAIR = 0x00020000 + """ + Indicates that the Loan should be impaired. + """ + + TF_LOAN_UNIMPAIR = 0x00040000 + """ + Indicates that the Loan should be unimpaired. + """ + + +class LoanManageFlagInterface(TransactionFlagInterface): + """ + Transactions of the LoanManage type support additional values in the Flags field. + This TypedDict represents those options. + """ + + TF_LOAN_DEFAULT: bool + TF_LOAN_IMPAIR: bool + TF_LOAN_UNIMPAIR: bool + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class LoanManage(Transaction): + """The transaction updates an existing Loan object.""" + + loan_id: str = REQUIRED # type: ignore + """ + The ID of the Loan object to be updated. + This field is required. + """ + + transaction_type: TransactionType = field( + default=TransactionType.LOAN_MANAGE, + init=False, + ) diff --git a/xrpl/models/transactions/loan_pay.py b/xrpl/models/transactions/loan_pay.py new file mode 100644 index 000000000..b2a1cb8ef --- /dev/null +++ b/xrpl/models/transactions/loan_pay.py @@ -0,0 +1,33 @@ +"""Model for LoanPay transaction type.""" + +# from __future__ import annotations # Requires Python 3.7+ + +from dataclasses import dataclass, field + +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class LoanPay(Transaction): + """The Borrower submits a LoanPay transaction to make a Payment on the Loan.""" + + loan_id: str = REQUIRED # type: ignore + """ + The ID of the Loan object to be paid to. + This field is required. + """ + + amount: int = REQUIRED # type: ignore + """ + The amount of funds to pay. + This field is required. + """ + + transaction_type: TransactionType = field( + default=TransactionType.LOAN_PAY, + init=False, + ) diff --git a/xrpl/models/transactions/loan_set.py b/xrpl/models/transactions/loan_set.py new file mode 100644 index 000000000..2a2974d5c --- /dev/null +++ b/xrpl/models/transactions/loan_set.py @@ -0,0 +1,177 @@ +"""Model for LoanSet transaction type.""" + +from __future__ import annotations # Requires Python 3.7+ + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional + +from typing_extensions import Self + +from xrpl.models.nested_model import NestedModel +from xrpl.models.required import REQUIRED +from xrpl.models.transactions.transaction import Transaction, TransactionFlagInterface +from xrpl.models.transactions.types import TransactionType +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class CounterpartySignature(NestedModel): + """ + An inner object that contains the signature of the Lender over the transaction. + The fields contained in this object are: + """ + + signing_pub_key: Optional[str] = None + txn_signature: Optional[str] = None + signers: Optional[List[str]] = None + + +class LoanSetFlag(int, Enum): + """ + Enum for LoanSet Transaction Flags. + + Transactions of the LoanSet type support additional values in the Flags field. + This enum represents those options. + """ + + TF_LOAN_OVER_PAYMENT = 0x00010000 + """ + Indicates that the loan supports overpayments. + """ + + +class LoanSetFlagInterface(TransactionFlagInterface): + """ + Transactions of the LoanSet type support additional values in the Flags field. + This TypedDict represents those options. + """ + + TF_LOAN_OVER_PAYMENT: bool + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class LoanSet(Transaction): + """This transaction creates a Loan""" + + loan_broker_id: str = REQUIRED # type: ignore + """ + The Loan Broker ID associated with the loan. + """ + + data: Optional[str] = None + """ + Arbitrary metadata in hex format. The field is limited to 256 bytes. + """ + + counterparty: Optional[str] = None + """The address of the counterparty of the Loan.""" + + counterparty_signature: Optional[CounterpartySignature] = None + """ + The signature of the counterparty over the transaction. + """ + + loan_origination_fee: Optional[int] = None + """ + A nominal funds amount paid to the LoanBroker.Owner when the Loan is created. + """ + + loan_service_fee: Optional[int] = None + """ + A nominal amount paid to the LoanBroker.Owner with every Loan payment. + """ + + late_payment_fee: Optional[int] = None + """ + A nominal funds amount paid to the LoanBroker.Owner when a payment is late. + """ + + close_payment_fee: Optional[int] = None + """ + A nominal funds amount paid to the LoanBroker.Owner when an early full repayment is + made. + """ + + overpayment_fee: Optional[int] = None + """ + A fee charged on overpayments in 1/10th basis points. Valid values are between 0 + and 100000 inclusive. (0 - 100%) + """ + + interest_rate: Optional[int] = None + """ + Annualized interest rate of the Loan in in 1/10th basis points. Valid values are + between 0 and 100000 inclusive. (0 - 100%) + """ + + late_interest_rate: Optional[int] = None + """ + A premium added to the interest rate for late payments in in 1/10th basis points. + Valid values are between 0 and 100000 inclusive. (0 - 100%) + """ + + close_interest_rate: Optional[int] = None + """ + A Fee Rate charged for repaying the Loan early in 1/10th basis points. Valid values + are between 0 and 100000 inclusive. (0 - 100%) + """ + + overpayment_interest_rate: Optional[int] = None + """ + An interest rate charged on overpayments in 1/10th basis points. Valid values are + between 0 and 100000 inclusive. (0 - 100%) + """ + + principal_requested: int = REQUIRED # type: ignore + """ + The principal amount requested by the Borrower. + """ + + start_date: int = REQUIRED # type: ignore + payment_total: Optional[int] = None + """ + The total number of payments to be made against the Loan. + """ + + payment_interval: Optional[int] = None + """ + Number of seconds between Loan payments. + """ + + grace_period: Optional[int] = None + """ + The number of seconds after the Loan's Payment Due Date can be Defaulted. + """ + + transaction_type: TransactionType = field( + default=TransactionType.LOAN_SET, + init=False, + ) + + def _get_errors(self: Self) -> Dict[str, str]: + parent_class_errors = { + key: value + for key, value in { + **super()._get_errors(), + }.items() + if value is not None + } + + if self.payment_interval is not None and self.payment_interval < 60: + parent_class_errors["LoanSet:PaymentInterval"] = ( + "Payment interval must be at least 60 seconds." + ) + + if ( + self.grace_period is not None + and self.payment_interval is not None + and self.grace_period > self.payment_interval + ): + parent_class_errors["LoanSet:GracePeriod"] = ( + "Grace period must be less than the payment interval." + ) + + return parent_class_errors diff --git a/xrpl/models/transactions/types/transaction_type.py b/xrpl/models/transactions/types/transaction_type.py index 5389c90e3..309d72837 100644 --- a/xrpl/models/transactions/types/transaction_type.py +++ b/xrpl/models/transactions/types/transaction_type.py @@ -30,6 +30,15 @@ class TransactionType(str, Enum): ESCROW_CANCEL = "EscrowCancel" ESCROW_CREATE = "EscrowCreate" ESCROW_FINISH = "EscrowFinish" + LOAN_BROKER_COVER_CLAWBACK = "LoanBrokerCoverClawback" + LOAN_BROKER_COVER_DEPOSIT = "LoanBrokerCoverDeposit" + LOAN_BROKER_COVER_WITHDRAW = "LoanBrokerCoverWithdraw" + LOAN_BROKER_DELETE = "LoanBrokerDelete" + LOAN_BROKER_SET = "LoanBrokerSet" + LOAN_DELETE = "LoanDelete" + LOAN_MANAGE = "LoanManage" + LOAN_PAY = "LoanPay" + LOAN_SET = "LoanSet" MPTOKEN_AUTHORIZE = "MPTokenAuthorize" MPTOKEN_ISSUANCE_CREATE = "MPTokenIssuanceCreate" MPTOKEN_ISSUANCE_DESTROY = "MPTokenIssuanceDestroy" From aadadbe5eb174bfd280fd0577f208170a485fcb5 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Fri, 12 Sep 2025 15:36:19 -0700 Subject: [PATCH 02/21] update integration test with LoanSet transaction --- .../transactions/test_lending_protocol.py | 140 ++++++++++++++++++ .../unit/models/transactions/test_loan_set.py | 6 +- xrpl/asyncio/transaction/main.py | 20 +++ xrpl/models/requests/account_objects.py | 1 + xrpl/models/transactions/loan_set.py | 6 +- 5 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 tests/integration/transactions/test_lending_protocol.py diff --git a/tests/integration/transactions/test_lending_protocol.py b/tests/integration/transactions/test_lending_protocol.py new file mode 100644 index 000000000..01ebc10ac --- /dev/null +++ b/tests/integration/transactions/test_lending_protocol.py @@ -0,0 +1,140 @@ +import datetime + +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import ( + fund_wallet_async, + sign_and_reliable_submission_async, + test_async_and_sync, +) +from xrpl.asyncio.transaction import autofill_and_sign, submit +from xrpl.core.binarycodec import encode_for_signing +from xrpl.core.keypairs.main import sign +from xrpl.models import ( + AccountObjects, + AccountSet, + AccountSetAsfFlag, + LoanBrokerSet, + LoanSet, + Transaction, + VaultCreate, + VaultDeposit, +) +from xrpl.models.currencies.xrp import XRP +from xrpl.models.requests.account_objects import AccountObjectType +from xrpl.models.response import ResponseStatus +from xrpl.models.transactions.loan_set import CounterpartySignature +from xrpl.models.transactions.vault_create import WithdrawalPolicy +from xrpl.wallet import Wallet + + +class TestLendingProtocolLifecycle(IntegrationTestCase): + @test_async_and_sync( + globals(), ["xrpl.transaction.autofill_and_sign", "xrpl.transaction.submit"] + ) + async def test_lending_protocol_lifecycle(self, client): + + loan_issuer = Wallet.create() + await fund_wallet_async(loan_issuer) + + depositor_wallet = Wallet.create() + await fund_wallet_async(depositor_wallet) + borrower_wallet = Wallet.create() + await fund_wallet_async(borrower_wallet) + + # Step-0: Set up the relevant flags on the loan_issuer account -- This is + # a pre-requisite for a Vault to hold the Issued Currency Asset + response = await sign_and_reliable_submission_async( + AccountSet( + account=loan_issuer.classic_address, + set_flag=AccountSetAsfFlag.ASF_DEFAULT_RIPPLE, + ), + loan_issuer, + ) + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-1: Create a vault + tx = VaultCreate( + account=loan_issuer.address, + asset=XRP(), + assets_maximum="1000", + withdrawal_policy=WithdrawalPolicy.VAULT_STRATEGY_FIRST_COME_FIRST_SERVE, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + account_objects_response = await client.request( + AccountObjects(account=loan_issuer.address, type=AccountObjectType.VAULT) + ) + self.assertEqual(len(account_objects_response.result["account_objects"]), 1) + VAULT_ID = account_objects_response.result["account_objects"][0]["index"] + + # Step-2: Create a loan broker + tx = LoanBrokerSet( + account=loan_issuer.address, + vault_id=VAULT_ID, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-2.1: Verify that the LoanBroker was successfully created + response = await client.request( + AccountObjects( + account=loan_issuer.address, type=AccountObjectType.LOAN_BROKER + ) + ) + self.assertEqual(len(response.result["account_objects"]), 1) + LOAN_BROKER_ID = response.result["account_objects"][0]["index"] + + # Step-3: Deposit funds into the vault + tx = VaultDeposit( + account=depositor_wallet.address, + vault_id=VAULT_ID, + amount="100", + ) + response = await sign_and_reliable_submission_async( + tx, depositor_wallet, client + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-5: The Loan Broker and Borrower create a Loan object with a LoanSet + # transaction and the requested principal (excluding fees) is transered to + # the Borrower. + + loan_issuer_signed_txn = await autofill_and_sign( + LoanSet( + account=loan_issuer.address, + loan_broker_id=LOAN_BROKER_ID, + principal_requested="100", + start_date=int(datetime.datetime.now().timestamp()), + counterparty=borrower_wallet.address, + ), + client, + loan_issuer, + ) + + # borrower agrees to the terms of the loan + borrower_txn_signature = sign( + encode_for_signing(loan_issuer_signed_txn.to_xrpl()), + borrower_wallet.private_key, + ) + + loan_issuer_and_borrower_signature = loan_issuer_signed_txn.to_dict() + loan_issuer_and_borrower_signature["counterparty_signature"] = ( + CounterpartySignature( + signing_pub_key=borrower_wallet.public_key, + txn_signature=borrower_txn_signature, + ) + ) + + response = await submit( + Transaction.from_dict(loan_issuer_and_borrower_signature), + client, + fail_hard=True, + ) + + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") diff --git a/tests/unit/models/transactions/test_loan_set.py b/tests/unit/models/transactions/test_loan_set.py index ed070595a..e9ea5bb27 100644 --- a/tests/unit/models/transactions/test_loan_set.py +++ b/tests/unit/models/transactions/test_loan_set.py @@ -14,7 +14,7 @@ def test_invalid_payment_interval_shorter_than_grace_period(self): LoanSet( account=_SOURCE, loan_broker_id=_ISSUER, - principal_requested=100000000, + principal_requested="100000000", start_date=int(datetime.datetime.now().timestamp()), payment_interval=65, grace_period=70, @@ -30,7 +30,7 @@ def test_invalid_payment_interval_too_short(self): LoanSet( account=_SOURCE, loan_broker_id=_ISSUER, - principal_requested=100000000, + principal_requested="100000000", start_date=int(datetime.datetime.now().timestamp()), payment_interval=59, ) @@ -44,7 +44,7 @@ def test_valid_loan_set(self): tx = LoanSet( account=_SOURCE, loan_broker_id=_ISSUER, - principal_requested=100000000, + principal_requested="100000000", start_date=int(datetime.datetime.now().timestamp()), ) self.assertTrue(tx.is_valid()) diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index 4ec1dda28..d5fe67820 100644 --- a/xrpl/asyncio/transaction/main.py +++ b/xrpl/asyncio/transaction/main.py @@ -16,6 +16,7 @@ from xrpl.models import ( Batch, EscrowFinish, + LoanSet, Response, ServerState, Simulate, @@ -516,6 +517,25 @@ async def _calculate_fee_per_transaction_type( for raw_txn in batch.raw_transactions ] ) + elif transaction.transaction_type == TransactionType.LOAN_SET: + # Compute the additional cost of each signature in the + # CounterpartySignature, whether a single signature or a multisignature + loan_set = cast(LoanSet, transaction) + if loan_set.counterparty_signature is not None: + signer_count = ( + len(loan_set.counterparty_signature.signers) + if loan_set.counterparty_signature.signers is not None + else 1 + ) + base_fee += net_fee * signer_count + else: + # Note: Due to lack of information, the client-library assumes that + # there is only one signer. However, the LoanIssuer and Borrower need to + # communicate the number of CounterpartySignature.signers + # (or the appropriate transaction-fee) + # with each other off-chain. This helps with efficient fee-calculation for + # the LoanSet transaction. + base_fee += net_fee # Multi-signed/Multi-Account Batch Transactions # BaseFee × (1 + Number of Signatures Provided) diff --git a/xrpl/models/requests/account_objects.py b/xrpl/models/requests/account_objects.py index 8caa58aba..53254a2d7 100644 --- a/xrpl/models/requests/account_objects.py +++ b/xrpl/models/requests/account_objects.py @@ -27,6 +27,7 @@ class AccountObjectType(str, Enum): DELEGATE = "delegate" DID = "did" ESCROW = "escrow" + LOAN_BROKER = "loan_broker" MPT_ISSUANCE = "mpt_issuance" MPTOKEN = "mptoken" NFT_OFFER = "nft_offer" diff --git a/xrpl/models/transactions/loan_set.py b/xrpl/models/transactions/loan_set.py index 2a2974d5c..45c605875 100644 --- a/xrpl/models/transactions/loan_set.py +++ b/xrpl/models/transactions/loan_set.py @@ -8,7 +8,7 @@ from typing_extensions import Self -from xrpl.models.nested_model import NestedModel +from xrpl.models.base_model import BaseModel from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction, TransactionFlagInterface from xrpl.models.transactions.types import TransactionType @@ -17,7 +17,7 @@ @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) -class CounterpartySignature(NestedModel): +class CounterpartySignature(BaseModel): """ An inner object that contains the signature of the Lender over the transaction. The fields contained in this object are: @@ -125,7 +125,7 @@ class LoanSet(Transaction): between 0 and 100000 inclusive. (0 - 100%) """ - principal_requested: int = REQUIRED # type: ignore + principal_requested: str = REQUIRED # type: ignore """ The principal amount requested by the Borrower. """ From 1db1744adbb284c64dc5b6b7928db83c4ce11b0c Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Fri, 12 Sep 2025 17:34:26 -0700 Subject: [PATCH 03/21] add integ tests for loan-crud operations; update changelog --- CHANGELOG.md | 3 ++ .../transactions/test_lending_protocol.py | 42 +++++++++++++++++++ xrpl/models/requests/account_objects.py | 1 + xrpl/models/transactions/loan_pay.py | 2 +- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b832c0b1..9b297164c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [[Unreleased]] +### Added +- Support for the Lending Protocol (XLS-66d) + ### Fixed - Removed snippets files from the xrpl-py code repository. Updated the README file to point to the correct location on XRPL.org. diff --git a/tests/integration/transactions/test_lending_protocol.py b/tests/integration/transactions/test_lending_protocol.py index 01ebc10ac..9142305d3 100644 --- a/tests/integration/transactions/test_lending_protocol.py +++ b/tests/integration/transactions/test_lending_protocol.py @@ -14,6 +14,9 @@ AccountSet, AccountSetAsfFlag, LoanBrokerSet, + LoanDelete, + LoanManage, + LoanPay, LoanSet, Transaction, VaultCreate, @@ -22,6 +25,7 @@ from xrpl.models.currencies.xrp import XRP from xrpl.models.requests.account_objects import AccountObjectType from xrpl.models.response import ResponseStatus +from xrpl.models.transactions.loan_manage import LoanManageFlag from xrpl.models.transactions.loan_set import CounterpartySignature from xrpl.models.transactions.vault_create import WithdrawalPolicy from xrpl.wallet import Wallet @@ -138,3 +142,41 @@ async def test_lending_protocol_lifecycle(self, client): self.assertEqual(response.status, ResponseStatus.SUCCESS) self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # fetch the Loan object + response = await client.request( + AccountObjects(account=borrower_wallet.address, type=AccountObjectType.LOAN) + ) + self.assertEqual(len(response.result["account_objects"]), 1) + LOAN_ID = response.result["account_objects"][0]["index"] + + # Delete the Loan object + tx = LoanDelete( + account=loan_issuer.address, + loan_id=LOAN_ID, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + # Loan cannot be deleted until all the remaining payments are completed + self.assertEqual(response.result["engine_result"], "tecHAS_OBLIGATIONS") + + # Test the LoanManage transaction + tx = LoanManage( + account=loan_issuer.address, + loan_id=LOAN_ID, + flags=LoanManageFlag.TF_LOAN_IMPAIR, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Test the LoanPay transaction + tx = LoanPay( + account=borrower_wallet.address, + loan_id=LOAN_ID, + amount="100", + ) + response = await sign_and_reliable_submission_async(tx, borrower_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + # The borrower cannot pay the loan before the start date + self.assertEqual(response.result["engine_result"], "tecTOO_SOON") diff --git a/xrpl/models/requests/account_objects.py b/xrpl/models/requests/account_objects.py index 53254a2d7..5b4e80202 100644 --- a/xrpl/models/requests/account_objects.py +++ b/xrpl/models/requests/account_objects.py @@ -27,6 +27,7 @@ class AccountObjectType(str, Enum): DELEGATE = "delegate" DID = "did" ESCROW = "escrow" + LOAN = "loan" LOAN_BROKER = "loan_broker" MPT_ISSUANCE = "mpt_issuance" MPTOKEN = "mptoken" diff --git a/xrpl/models/transactions/loan_pay.py b/xrpl/models/transactions/loan_pay.py index b2a1cb8ef..e1709a15f 100644 --- a/xrpl/models/transactions/loan_pay.py +++ b/xrpl/models/transactions/loan_pay.py @@ -21,7 +21,7 @@ class LoanPay(Transaction): This field is required. """ - amount: int = REQUIRED # type: ignore + amount: str = REQUIRED # type: ignore """ The amount of funds to pay. This field is required. From b062bc37d16fb1f4724b7536d1fef48db99a7f5c Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 15 Sep 2025 08:30:47 -0700 Subject: [PATCH 04/21] address first batch of coderabbit AI suggestions --- tests/integration/transactions/test_lending_protocol.py | 4 ++++ xrpl/core/binarycodec/definitions/definitions.json | 2 ++ xrpl/models/transactions/loan_broker_cover_deposit.py | 4 ++-- xrpl/models/transactions/loan_broker_cover_withdraw.py | 4 ++-- xrpl/models/transactions/loan_pay.py | 3 ++- xrpl/models/transactions/loan_set.py | 8 ++++++-- 6 files changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/integration/transactions/test_lending_protocol.py b/tests/integration/transactions/test_lending_protocol.py index 9142305d3..f2028325d 100644 --- a/tests/integration/transactions/test_lending_protocol.py +++ b/tests/integration/transactions/test_lending_protocol.py @@ -2,6 +2,7 @@ from tests.integration.integration_test_case import IntegrationTestCase from tests.integration.it_utils import ( + LEDGER_ACCEPT_REQUEST, fund_wallet_async, sign_and_reliable_submission_async, test_async_and_sync, @@ -143,6 +144,9 @@ async def test_lending_protocol_lifecycle(self, client): self.assertEqual(response.status, ResponseStatus.SUCCESS) self.assertEqual(response.result["engine_result"], "tesSUCCESS") + # Wait for the validation of the latest ledger + await client.request(LEDGER_ACCEPT_REQUEST) + # fetch the Loan object response = await client.request( AccountObjects(account=borrower_wallet.address, type=AccountObjectType.LOAN) diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 956f2616b..3a72bbbe0 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -3395,6 +3395,8 @@ "FeeSettings": 115, "Invalid": -1, "LedgerHashes": 104, + "Loan": 137, + "LoanBroker": 136, "MPToken": 127, "MPTokenIssuance": 126, "NFTokenOffer": 55, diff --git a/xrpl/models/transactions/loan_broker_cover_deposit.py b/xrpl/models/transactions/loan_broker_cover_deposit.py index 15b8b7397..01c9ebecf 100644 --- a/xrpl/models/transactions/loan_broker_cover_deposit.py +++ b/xrpl/models/transactions/loan_broker_cover_deposit.py @@ -14,7 +14,7 @@ @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class LoanBrokerCoverDeposit(Transaction): - """This transaction deposits First Loss Capital into a Loan Broker""" + """This transaction deposits First-Loss Capital into a Loan Broker""" loan_broker_id: str = REQUIRED # type: ignore """ @@ -23,7 +23,7 @@ class LoanBrokerCoverDeposit(Transaction): amount: Amount = REQUIRED # type: ignore """ - The Fist-Loss Capital amount to deposit. + The First-Loss Capital amount to deposit. """ transaction_type: TransactionType = field( diff --git a/xrpl/models/transactions/loan_broker_cover_withdraw.py b/xrpl/models/transactions/loan_broker_cover_withdraw.py index a8a0f8410..c34b762dc 100644 --- a/xrpl/models/transactions/loan_broker_cover_withdraw.py +++ b/xrpl/models/transactions/loan_broker_cover_withdraw.py @@ -15,7 +15,7 @@ @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class LoanBrokerCoverWithdraw(Transaction): - """This transaction withdraws First Loss Capital from a Loan Broker""" + """This transaction withdraws First-Loss Capital from a Loan Broker""" loan_broker_id: str = REQUIRED # type: ignore """ @@ -24,7 +24,7 @@ class LoanBrokerCoverWithdraw(Transaction): amount: Amount = REQUIRED # type: ignore """ - The Fist-Loss Capital amount to withdraw. + The First-Loss Capital amount to withdraw. """ destination: Optional[str] = None diff --git a/xrpl/models/transactions/loan_pay.py b/xrpl/models/transactions/loan_pay.py index e1709a15f..21d1e32f9 100644 --- a/xrpl/models/transactions/loan_pay.py +++ b/xrpl/models/transactions/loan_pay.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field +from xrpl.models.amounts import Amount from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction from xrpl.models.transactions.types import TransactionType @@ -21,7 +22,7 @@ class LoanPay(Transaction): This field is required. """ - amount: str = REQUIRED # type: ignore + amount: Amount = REQUIRED # type: ignore """ The amount of funds to pay. This field is required. diff --git a/xrpl/models/transactions/loan_set.py b/xrpl/models/transactions/loan_set.py index 45c605875..56599f6b7 100644 --- a/xrpl/models/transactions/loan_set.py +++ b/xrpl/models/transactions/loan_set.py @@ -10,7 +10,11 @@ from xrpl.models.base_model import BaseModel from xrpl.models.required import REQUIRED -from xrpl.models.transactions.transaction import Transaction, TransactionFlagInterface +from xrpl.models.transactions.transaction import ( + Signer, + Transaction, + TransactionFlagInterface, +) from xrpl.models.transactions.types import TransactionType from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init @@ -25,7 +29,7 @@ class CounterpartySignature(BaseModel): signing_pub_key: Optional[str] = None txn_signature: Optional[str] = None - signers: Optional[List[str]] = None + signers: Optional[List[Signer]] = None class LoanSetFlag(int, Enum): From 9083a846c2d08d2bc7a5a80a7b238b9e443ce153 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 16 Sep 2025 14:05:23 -0700 Subject: [PATCH 05/21] fix linter errors --- xrpl/models/transactions/loan_broker_cover_deposit.py | 4 ++-- xrpl/models/transactions/loan_broker_cover_withdraw.py | 4 ++-- xrpl/models/transactions/loan_broker_delete.py | 2 +- xrpl/models/transactions/loan_broker_set.py | 2 +- xrpl/models/transactions/loan_delete.py | 2 +- xrpl/models/transactions/loan_manage.py | 2 +- xrpl/models/transactions/loan_pay.py | 4 ++-- xrpl/models/transactions/loan_set.py | 6 +++--- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/xrpl/models/transactions/loan_broker_cover_deposit.py b/xrpl/models/transactions/loan_broker_cover_deposit.py index 01c9ebecf..6a4db5ba7 100644 --- a/xrpl/models/transactions/loan_broker_cover_deposit.py +++ b/xrpl/models/transactions/loan_broker_cover_deposit.py @@ -16,12 +16,12 @@ class LoanBrokerCoverDeposit(Transaction): """This transaction deposits First-Loss Capital into a Loan Broker""" - loan_broker_id: str = REQUIRED # type: ignore + loan_broker_id: str = REQUIRED """ The Loan Broker ID to deposit First-Loss Capital. """ - amount: Amount = REQUIRED # type: ignore + amount: Amount = REQUIRED """ The First-Loss Capital amount to deposit. """ diff --git a/xrpl/models/transactions/loan_broker_cover_withdraw.py b/xrpl/models/transactions/loan_broker_cover_withdraw.py index c34b762dc..b34cd7a00 100644 --- a/xrpl/models/transactions/loan_broker_cover_withdraw.py +++ b/xrpl/models/transactions/loan_broker_cover_withdraw.py @@ -17,12 +17,12 @@ class LoanBrokerCoverWithdraw(Transaction): """This transaction withdraws First-Loss Capital from a Loan Broker""" - loan_broker_id: str = REQUIRED # type: ignore + loan_broker_id: str = REQUIRED """ The Loan Broker ID from which to withdraw First-Loss Capital. """ - amount: Amount = REQUIRED # type: ignore + amount: Amount = REQUIRED """ The First-Loss Capital amount to withdraw. """ diff --git a/xrpl/models/transactions/loan_broker_delete.py b/xrpl/models/transactions/loan_broker_delete.py index f63b0f8d7..4e3eeb770 100644 --- a/xrpl/models/transactions/loan_broker_delete.py +++ b/xrpl/models/transactions/loan_broker_delete.py @@ -15,7 +15,7 @@ class LoanBrokerDelete(Transaction): """This transaction deletes a Loan Broker""" - loan_broker_id: str = REQUIRED # type: ignore + loan_broker_id: str = REQUIRED """ The Loan Broker ID that the transaction is deleting. This field is required. diff --git a/xrpl/models/transactions/loan_broker_set.py b/xrpl/models/transactions/loan_broker_set.py index ae59f0786..732ed44b5 100644 --- a/xrpl/models/transactions/loan_broker_set.py +++ b/xrpl/models/transactions/loan_broker_set.py @@ -16,7 +16,7 @@ class LoanBrokerSet(Transaction): """This transaction creates and updates a Loan Broker""" - vault_id: str = REQUIRED # type: ignore + vault_id: str = REQUIRED """ The Vault ID that the Lending Protocol will use to access liquidity. This field is required. diff --git a/xrpl/models/transactions/loan_delete.py b/xrpl/models/transactions/loan_delete.py index 308309a6e..b6dcc0b8e 100644 --- a/xrpl/models/transactions/loan_delete.py +++ b/xrpl/models/transactions/loan_delete.py @@ -15,7 +15,7 @@ class LoanDelete(Transaction): """The transaction deletes an existing Loan object.""" - loan_id: str = REQUIRED # type: ignore + loan_id: str = REQUIRED """ The ID of the Loan object to be deleted. This field is required. diff --git a/xrpl/models/transactions/loan_manage.py b/xrpl/models/transactions/loan_manage.py index 4a553bdd7..e3067bd83 100644 --- a/xrpl/models/transactions/loan_manage.py +++ b/xrpl/models/transactions/loan_manage.py @@ -51,7 +51,7 @@ class LoanManageFlagInterface(TransactionFlagInterface): class LoanManage(Transaction): """The transaction updates an existing Loan object.""" - loan_id: str = REQUIRED # type: ignore + loan_id: str = REQUIRED """ The ID of the Loan object to be updated. This field is required. diff --git a/xrpl/models/transactions/loan_pay.py b/xrpl/models/transactions/loan_pay.py index 21d1e32f9..61e5dd86e 100644 --- a/xrpl/models/transactions/loan_pay.py +++ b/xrpl/models/transactions/loan_pay.py @@ -16,13 +16,13 @@ class LoanPay(Transaction): """The Borrower submits a LoanPay transaction to make a Payment on the Loan.""" - loan_id: str = REQUIRED # type: ignore + loan_id: str = REQUIRED """ The ID of the Loan object to be paid to. This field is required. """ - amount: Amount = REQUIRED # type: ignore + amount: Amount = REQUIRED """ The amount of funds to pay. This field is required. diff --git a/xrpl/models/transactions/loan_set.py b/xrpl/models/transactions/loan_set.py index 56599f6b7..f3ec4a2eb 100644 --- a/xrpl/models/transactions/loan_set.py +++ b/xrpl/models/transactions/loan_set.py @@ -60,7 +60,7 @@ class LoanSetFlagInterface(TransactionFlagInterface): class LoanSet(Transaction): """This transaction creates a Loan""" - loan_broker_id: str = REQUIRED # type: ignore + loan_broker_id: str = REQUIRED """ The Loan Broker ID associated with the loan. """ @@ -129,12 +129,12 @@ class LoanSet(Transaction): between 0 and 100000 inclusive. (0 - 100%) """ - principal_requested: str = REQUIRED # type: ignore + principal_requested: str = REQUIRED """ The principal amount requested by the Borrower. """ - start_date: int = REQUIRED # type: ignore + start_date: int = REQUIRED payment_total: Optional[int] = None """ The total number of payments to be made against the Loan. From c6720156f0ccf753fa9da61d03a8592d1f173e5c Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 17 Sep 2025 16:29:58 -0700 Subject: [PATCH 06/21] update Number internal rippled type into str JSON type --- .../test_loan_broker_cover_clawback.py | 5 ++++- .../transactions/loan_broker_cover_clawback.py | 15 ++++++++++----- xrpl/models/transactions/loan_broker_set.py | 2 +- xrpl/models/transactions/loan_set.py | 8 ++++---- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/tests/unit/models/transactions/test_loan_broker_cover_clawback.py b/tests/unit/models/transactions/test_loan_broker_cover_clawback.py index cb71ab215..22ed86361 100644 --- a/tests/unit/models/transactions/test_loan_broker_cover_clawback.py +++ b/tests/unit/models/transactions/test_loan_broker_cover_clawback.py @@ -22,7 +22,10 @@ def test_invalid_xrp_amount(self): LoanBrokerCoverClawback(account=_SOURCE, amount="10.20") self.assertEqual( error.exception.args[0], - "{'LoanBrokerCoverClawback:Amount': 'Amount cannot be XRP.'}", + "{'amount': \"amount is , expected " + + "typing.Union[xrpl.models.amounts.issued_currency_amount" + + ".IssuedCurrencyAmount, xrpl.models.amounts.mpt_amount.MPTAmount, " + + "NoneType]\", 'LoanBrokerCoverClawback:Amount': 'Amount cannot be XRP.'}", ) def test_invalid_negative_amount(self): diff --git a/xrpl/models/transactions/loan_broker_cover_clawback.py b/xrpl/models/transactions/loan_broker_cover_clawback.py index b04118735..9606e602f 100644 --- a/xrpl/models/transactions/loan_broker_cover_clawback.py +++ b/xrpl/models/transactions/loan_broker_cover_clawback.py @@ -3,11 +3,16 @@ from __future__ import annotations # Requires Python 3.7+ from dataclasses import dataclass, field -from typing import Dict, Optional +from typing import Dict, Optional, Union from typing_extensions import Self -from xrpl.models.amounts import Amount, get_amount_value, is_xrp +from xrpl.models.amounts import ( + IssuedCurrencyAmount, + MPTAmount, + get_amount_value, + is_xrp, +) from xrpl.models.transactions.transaction import Transaction from xrpl.models.transactions.types import TransactionType from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init @@ -16,16 +21,16 @@ @require_kwargs_on_init @dataclass(frozen=True, **KW_ONLY_DATACLASS) class LoanBrokerCoverClawback(Transaction): - """This transaction withdraws First Loss Capital from a Loan Broker""" + """This transaction claws back First-Loss Capital from a Loan Broker""" loan_broker_id: Optional[str] = None """ - The Loan Broker ID from which to withdraw First-Loss Capital. Must be provided if + The Loan Broker ID from which to claw back First-Loss Capital. Must be provided if the Amount is an MPT, or Amount is an IOU and issuer is specified as the Account submitting the transaction. """ - amount: Optional[Amount] = None + amount: Optional[Union[IssuedCurrencyAmount, MPTAmount]] = None """ The First-Loss Capital amount to clawback. If the amount is 0 or not provided, clawback funds up to LoanBroker.DebtTotal * LoanBroker.CoverRateMinimum. diff --git a/xrpl/models/transactions/loan_broker_set.py b/xrpl/models/transactions/loan_broker_set.py index 732ed44b5..437c530a5 100644 --- a/xrpl/models/transactions/loan_broker_set.py +++ b/xrpl/models/transactions/loan_broker_set.py @@ -38,7 +38,7 @@ class LoanBrokerSet(Transaction): Valid values are between 0 and 10000 inclusive. """ - debt_maximum: Optional[int] = None + debt_maximum: Optional[str] = None """ The maximum amount the protocol can owe the Vault. The default value of 0 means there is no limit to the debt. Must not be negative. diff --git a/xrpl/models/transactions/loan_set.py b/xrpl/models/transactions/loan_set.py index f3ec4a2eb..4351d642a 100644 --- a/xrpl/models/transactions/loan_set.py +++ b/xrpl/models/transactions/loan_set.py @@ -78,22 +78,22 @@ class LoanSet(Transaction): The signature of the counterparty over the transaction. """ - loan_origination_fee: Optional[int] = None + loan_origination_fee: Optional[str] = None """ A nominal funds amount paid to the LoanBroker.Owner when the Loan is created. """ - loan_service_fee: Optional[int] = None + loan_service_fee: Optional[str] = None """ A nominal amount paid to the LoanBroker.Owner with every Loan payment. """ - late_payment_fee: Optional[int] = None + late_payment_fee: Optional[str] = None """ A nominal funds amount paid to the LoanBroker.Owner when a payment is late. """ - close_payment_fee: Optional[int] = None + close_payment_fee: Optional[str] = None """ A nominal funds amount paid to the LoanBroker.Owner when an early full repayment is made. From 029d65c275fe3f36acfc0cf4b43c19f3a35906e5 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 17 Sep 2025 17:01:23 -0700 Subject: [PATCH 07/21] add unit tests and validation for loan_broker_set txn --- .../transactions/test_loan_broker_set.py | 135 ++++++++++++++++++ xrpl/models/transactions/loan_broker_set.py | 48 ++++++- 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 tests/unit/models/transactions/test_loan_broker_set.py diff --git a/tests/unit/models/transactions/test_loan_broker_set.py b/tests/unit/models/transactions/test_loan_broker_set.py new file mode 100644 index 000000000..1a2f154d8 --- /dev/null +++ b/tests/unit/models/transactions/test_loan_broker_set.py @@ -0,0 +1,135 @@ +from unittest import TestCase + +from xrpl.models.exceptions import XRPLModelException +from xrpl.models.transactions import LoanBrokerSet + +_SOURCE = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ" +_VAULT_ID = "DB303FC1C7611B22C09E773B51044F6BEA02EF917DF59A2E2860871E167066A5" + + +class TestLoanBrokerSet(TestCase): + + def test_invalid_data_too_long(self): + with self.assertRaises(XRPLModelException) as error: + LoanBrokerSet( + account=_SOURCE, + vault_id=_VAULT_ID, + data="A" * 257 * 2, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerSet:data': 'Data must be less than 256 bytes.'}", + ) + + def test_invalid_management_fee_rate_too_low(self): + with self.assertRaises(XRPLModelException) as error: + LoanBrokerSet( + account=_SOURCE, + vault_id=_VAULT_ID, + management_fee_rate=-1, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerSet:management_fee_rate': 'Management fee rate must be between" + + " 0 and 10_000 inclusive.'}", + ) + + def test_invalid_management_fee_rate_too_high(self): + + with self.assertRaises(XRPLModelException) as error: + LoanBrokerSet( + account=_SOURCE, + vault_id=_VAULT_ID, + management_fee_rate=10001, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerSet:management_fee_rate': 'Management fee rate must be between" + + " 0 and 10_000 inclusive.'}", + ) + + def test_invalid_cover_rate_minimum_too_low(self): + with self.assertRaises(XRPLModelException) as error: + LoanBrokerSet( + account=_SOURCE, + vault_id=_VAULT_ID, + cover_rate_minimum=-1, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerSet:cover_rate_minimum': 'Cover rate minimum must be between 0" + + " and 100_000 inclusive.'}", + ) + + def test_invalid_cover_rate_minimum_too_high(self): + with self.assertRaises(XRPLModelException) as error: + LoanBrokerSet( + account=_SOURCE, + vault_id=_VAULT_ID, + cover_rate_minimum=100001, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerSet:cover_rate_minimum': 'Cover rate minimum must be between 0" + + " and 100_000 inclusive.'}", + ) + + def test_invalid_cover_rate_liquidation_too_low(self): + with self.assertRaises(XRPLModelException) as error: + LoanBrokerSet( + account=_SOURCE, + vault_id=_VAULT_ID, + cover_rate_liquidation=-1, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerSet:cover_rate_liquidation': 'Cover rate liquidation must be" + + " between 0 and 100_000 inclusive.'}", + ) + + def test_invalid_cover_rate_liquidation_too_high(self): + with self.assertRaises(XRPLModelException) as error: + LoanBrokerSet( + account=_SOURCE, + vault_id=_VAULT_ID, + cover_rate_liquidation=100001, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerSet:cover_rate_liquidation': 'Cover rate liquidation must be" + + " between 0 and 100_000 inclusive.'}", + ) + + def test_invalid_debt_maximum_too_low(self): + with self.assertRaises(XRPLModelException) as error: + LoanBrokerSet( + account=_SOURCE, + vault_id=_VAULT_ID, + debt_maximum="-1", + ) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerSet:debt_maximum': 'Debt maximum must not be negative or" + + " greater than 9223372036854775807.'}", + ) + + def test_invalid_debt_maximum_too_high(self): + + with self.assertRaises(XRPLModelException) as error: + LoanBrokerSet( + account=_SOURCE, + vault_id=_VAULT_ID, + debt_maximum="9223372036854775808", + ) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerSet:debt_maximum': 'Debt maximum must not be negative or" + + " greater than 9223372036854775807.'}", + ) + + def test_valid_loan_broker_set(self): + tx = LoanBrokerSet( + account=_SOURCE, + vault_id=_VAULT_ID, + ) + self.assertTrue(tx.is_valid()) diff --git a/xrpl/models/transactions/loan_broker_set.py b/xrpl/models/transactions/loan_broker_set.py index 437c530a5..a1a928bd2 100644 --- a/xrpl/models/transactions/loan_broker_set.py +++ b/xrpl/models/transactions/loan_broker_set.py @@ -3,7 +3,9 @@ from __future__ import annotations # Requires Python 3.7+ from dataclasses import dataclass, field -from typing import Optional +from typing import Dict, Optional + +from typing_extensions import Self from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction @@ -60,3 +62,47 @@ class LoanBrokerSet(Transaction): default=TransactionType.LOAN_BROKER_SET, init=False, ) + + MAX_DATA_PAYLOAD_LENGTH = 256 * 2 + MAX_MANAGEMENT_FEE_RATE = 10_000 + MAX_COVER_RATE_MINIMUM = 100_000 + MAX_COVER_RATE_LIQUIDATION = 100_000 + MAX_DEBT_MAXIMUM = 9223372036854775807 + + def _get_errors(self: Self) -> Dict[str, str]: + errors = super()._get_errors() + + if self.data is not None and len(self.data) > self.MAX_DATA_PAYLOAD_LENGTH: + errors["LoanBrokerSet:data"] = "Data must be less than 256 bytes." + + if self.management_fee_rate is not None and ( + self.management_fee_rate < 0 + or self.management_fee_rate > self.MAX_MANAGEMENT_FEE_RATE + ): + errors["LoanBrokerSet:management_fee_rate"] = ( + "Management fee rate must be between 0 and 10_000 inclusive." + ) + + if self.cover_rate_minimum is not None and ( + self.cover_rate_minimum < 0 + or self.cover_rate_minimum > self.MAX_COVER_RATE_MINIMUM + ): + errors["LoanBrokerSet:cover_rate_minimum"] = ( + "Cover rate minimum must be between 0 and 100_000 inclusive." + ) + + if self.cover_rate_liquidation is not None and ( + self.cover_rate_liquidation < 0 + or self.cover_rate_liquidation > self.MAX_COVER_RATE_LIQUIDATION + ): + errors["LoanBrokerSet:cover_rate_liquidation"] = ( + "Cover rate liquidation must be between 0 and 100_000 inclusive." + ) + if self.debt_maximum is not None and ( + int(self.debt_maximum) < 0 or int(self.debt_maximum) > self.MAX_DEBT_MAXIMUM + ): + errors["LoanBrokerSet:debt_maximum"] = ( + "Debt maximum must not be negative or greater than 9223372036854775807." + ) + + return errors From 1ddf3484cd5226dfc2776638df364cef44870f64 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 17 Sep 2025 17:09:40 -0700 Subject: [PATCH 08/21] loan_set validation and unit tests --- .../transactions/test_loan_broker_set.py | 2 +- .../unit/models/transactions/test_loan_set.py | 164 ++++++++++++++++++ xrpl/models/transactions/loan_broker_set.py | 2 +- xrpl/models/transactions/loan_set.py | 55 +++++- 4 files changed, 220 insertions(+), 3 deletions(-) diff --git a/tests/unit/models/transactions/test_loan_broker_set.py b/tests/unit/models/transactions/test_loan_broker_set.py index 1a2f154d8..216df71ac 100644 --- a/tests/unit/models/transactions/test_loan_broker_set.py +++ b/tests/unit/models/transactions/test_loan_broker_set.py @@ -14,7 +14,7 @@ def test_invalid_data_too_long(self): LoanBrokerSet( account=_SOURCE, vault_id=_VAULT_ID, - data="A" * 257 * 2, + data="A" * 257, ) self.assertEqual( error.exception.args[0], diff --git a/tests/unit/models/transactions/test_loan_set.py b/tests/unit/models/transactions/test_loan_set.py index e9ea5bb27..14df4a8da 100644 --- a/tests/unit/models/transactions/test_loan_set.py +++ b/tests/unit/models/transactions/test_loan_set.py @@ -9,6 +9,170 @@ class TestLoanSet(TestCase): + def test_invalid_data_too_long(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + data="A" * 257, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:data': 'Data must be less than 512 bytes.'}", + ) + + def test_invalid_overpayment_fee_too_low(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + overpayment_fee=-1, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:overpayment_fee': 'Overpayment fee must be between 0 and 100000" + + " inclusive.'}", + ) + + def test_invalid_interest_rate_too_low(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + interest_rate=-1, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:interest_rate': 'Interest rate must be between 0 and 100000" + + " inclusive.'}", + ) + + def test_invalid_interest_rate_too_high(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + interest_rate=100001, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:interest_rate': 'Interest rate must be between 0 and 100000" + + " inclusive.'}", + ) + + def test_invalid_late_interest_rate_too_low(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + late_interest_rate=-1, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:late_interest_rate': 'Late interest rate must be between 0 and" + + " 100000 inclusive.'}", + ) + + def test_invalid_late_interest_rate_too_high(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + late_interest_rate=100001, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:late_interest_rate': 'Late interest rate must be between 0 and" + + " 100000 inclusive.'}", + ) + + def test_invalid_close_interest_rate_too_low(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + close_interest_rate=-1, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:close_interest_rate': 'Close interest rate must be between 0 and" + + " 100000 inclusive.'}", + ) + + def test_invalid_close_interest_rate_too_high(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + close_interest_rate=100001, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:close_interest_rate': 'Close interest rate must be between 0 and" + + " 100000 inclusive.'}", + ) + + def test_invalid_overpayment_interest_rate_too_low(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + overpayment_interest_rate=-1, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:overpayment_interest_rate': 'Overpayment interest rate must be" + + " between 0 and 100000 inclusive.'}", + ) + + def test_invalid_overpayment_interest_rate_too_high(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + overpayment_interest_rate=100001, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:overpayment_interest_rate': 'Overpayment interest rate must be" + + " between 0 and 100000 inclusive.'}", + ) + + def test_invalid_overpayment_fee_too_high(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + overpayment_fee=100001, + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:overpayment_fee': 'Overpayment fee must be between 0 and 100000" + + " inclusive.'}", + ) + def test_invalid_payment_interval_shorter_than_grace_period(self): with self.assertRaises(XRPLModelException) as error: LoanSet( diff --git a/xrpl/models/transactions/loan_broker_set.py b/xrpl/models/transactions/loan_broker_set.py index a1a928bd2..f70124f1d 100644 --- a/xrpl/models/transactions/loan_broker_set.py +++ b/xrpl/models/transactions/loan_broker_set.py @@ -63,7 +63,7 @@ class LoanBrokerSet(Transaction): init=False, ) - MAX_DATA_PAYLOAD_LENGTH = 256 * 2 + MAX_DATA_PAYLOAD_LENGTH = 256 MAX_MANAGEMENT_FEE_RATE = 10_000 MAX_COVER_RATE_MINIMUM = 100_000 MAX_COVER_RATE_LIQUIDATION = 100_000 diff --git a/xrpl/models/transactions/loan_set.py b/xrpl/models/transactions/loan_set.py index 4351d642a..86432e2f9 100644 --- a/xrpl/models/transactions/loan_set.py +++ b/xrpl/models/transactions/loan_set.py @@ -155,6 +155,14 @@ class LoanSet(Transaction): init=False, ) + MAX_DATA_LENGTH = 256 + MAX_OVER_PAYMENT_FEE_RATE = 100_000 + MAX_INTEREST_RATE = 100_000 + MAX_LATE_INTEREST_RATE = 100_000 + MAX_CLOSE_INTEREST_RATE = 100_000 + MAX_OVER_PAYMENT_INTEREST_RATE = 100_000 + MIN_PAYMENT_INTERVAL = 60 + def _get_errors(self: Self) -> Dict[str, str]: parent_class_errors = { key: value @@ -164,7 +172,52 @@ def _get_errors(self: Self) -> Dict[str, str]: if value is not None } - if self.payment_interval is not None and self.payment_interval < 60: + if self.data is not None and len(self.data) > self.MAX_DATA_LENGTH: + parent_class_errors["LoanSet:data"] = "Data must be less than 512 bytes." + + if self.overpayment_fee is not None and ( + self.overpayment_fee < 0 + or self.overpayment_fee > self.MAX_OVER_PAYMENT_FEE_RATE + ): + parent_class_errors["LoanSet:overpayment_fee"] = ( + "Overpayment fee must be between 0 and 100000 inclusive." + ) + + if self.interest_rate is not None and ( + self.interest_rate < 0 or self.interest_rate > self.MAX_INTEREST_RATE + ): + parent_class_errors["LoanSet:interest_rate"] = ( + "Interest rate must be between 0 and 100000 inclusive." + ) + + if self.late_interest_rate is not None and ( + self.late_interest_rate < 0 + or self.late_interest_rate > self.MAX_LATE_INTEREST_RATE + ): + parent_class_errors["LoanSet:late_interest_rate"] = ( + "Late interest rate must be between 0 and 100000 inclusive." + ) + + if self.close_interest_rate is not None and ( + self.close_interest_rate < 0 + or self.close_interest_rate > self.MAX_CLOSE_INTEREST_RATE + ): + parent_class_errors["LoanSet:close_interest_rate"] = ( + "Close interest rate must be between 0 and 100000 inclusive." + ) + + if self.overpayment_interest_rate is not None and ( + self.overpayment_interest_rate < 0 + or self.overpayment_interest_rate > self.MAX_OVER_PAYMENT_INTEREST_RATE + ): + parent_class_errors["LoanSet:overpayment_interest_rate"] = ( + "Overpayment interest rate must be between 0 and 100000 inclusive." + ) + + if ( + self.payment_interval is not None + and self.payment_interval < self.MIN_PAYMENT_INTERVAL + ): parent_class_errors["LoanSet:PaymentInterval"] = ( "Payment interval must be at least 60 seconds." ) From 4e5cf35397163a6fe855da7b7f954e06d25d35f8 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 18 Sep 2025 11:13:09 -0700 Subject: [PATCH 09/21] add hex validation for data field --- tests/unit/core/binarycodec/types/test_blob.py | 4 ++++ .../transactions/test_loan_broker_set.py | 14 +++++++++++++- .../unit/models/transactions/test_loan_set.py | 18 ++++++++++++++++-- xrpl/models/transactions/loan_broker_set.py | 6 +++++- xrpl/models/transactions/loan_set.py | 8 ++++++-- 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/tests/unit/core/binarycodec/types/test_blob.py b/tests/unit/core/binarycodec/types/test_blob.py index 515c5f5c0..8255b2646 100644 --- a/tests/unit/core/binarycodec/types/test_blob.py +++ b/tests/unit/core/binarycodec/types/test_blob.py @@ -17,3 +17,7 @@ def test_from_value(self): def test_raises_invalid_value_type(self): invalid_value = [1, 2, 3] self.assertRaises(XRPLBinaryCodecException, Blob.from_value, invalid_value) + + def test_raises_invalid_non_hex_input(self): + invalid_value = "Z" + self.assertRaises(ValueError, Blob.from_value, invalid_value) diff --git a/tests/unit/models/transactions/test_loan_broker_set.py b/tests/unit/models/transactions/test_loan_broker_set.py index 216df71ac..e055863e3 100644 --- a/tests/unit/models/transactions/test_loan_broker_set.py +++ b/tests/unit/models/transactions/test_loan_broker_set.py @@ -14,13 +14,25 @@ def test_invalid_data_too_long(self): LoanBrokerSet( account=_SOURCE, vault_id=_VAULT_ID, - data="A" * 257, + data="A" * 257 * 2, ) self.assertEqual( error.exception.args[0], "{'LoanBrokerSet:data': 'Data must be less than 256 bytes.'}", ) + def test_invalid_data_non_hex_string(self): + with self.assertRaises(XRPLModelException) as error: + LoanBrokerSet( + account=_SOURCE, + vault_id=_VAULT_ID, + data="Z", + ) + self.assertEqual( + error.exception.args[0], + "{'LoanBrokerSet:data': 'Data must be a valid hex string.'}", + ) + def test_invalid_management_fee_rate_too_low(self): with self.assertRaises(XRPLModelException) as error: LoanBrokerSet( diff --git a/tests/unit/models/transactions/test_loan_set.py b/tests/unit/models/transactions/test_loan_set.py index 14df4a8da..e6f53077d 100644 --- a/tests/unit/models/transactions/test_loan_set.py +++ b/tests/unit/models/transactions/test_loan_set.py @@ -16,11 +16,25 @@ def test_invalid_data_too_long(self): loan_broker_id=_ISSUER, principal_requested="100000000", start_date=int(datetime.datetime.now().timestamp()), - data="A" * 257, + data="A" * 257 * 2, ) self.assertEqual( error.exception.args[0], - "{'LoanSet:data': 'Data must be less than 512 bytes.'}", + "{'LoanSet:data': 'Data must be less than 256 bytes.'}", + ) + + def test_invalid_data_non_hex_string(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + start_date=int(datetime.datetime.now().timestamp()), + data="Z", + ) + self.assertEqual( + error.exception.args[0], + "{'LoanSet:data': 'Data must be a valid hex string.'}", ) def test_invalid_overpayment_fee_too_low(self): diff --git a/xrpl/models/transactions/loan_broker_set.py b/xrpl/models/transactions/loan_broker_set.py index f70124f1d..5fcd03b2e 100644 --- a/xrpl/models/transactions/loan_broker_set.py +++ b/xrpl/models/transactions/loan_broker_set.py @@ -7,6 +7,7 @@ from typing_extensions import Self +from xrpl.constants import HEX_REGEX from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction from xrpl.models.transactions.types import TransactionType @@ -63,7 +64,7 @@ class LoanBrokerSet(Transaction): init=False, ) - MAX_DATA_PAYLOAD_LENGTH = 256 + MAX_DATA_PAYLOAD_LENGTH = 256 * 2 MAX_MANAGEMENT_FEE_RATE = 10_000 MAX_COVER_RATE_MINIMUM = 100_000 MAX_COVER_RATE_LIQUIDATION = 100_000 @@ -75,6 +76,9 @@ def _get_errors(self: Self) -> Dict[str, str]: if self.data is not None and len(self.data) > self.MAX_DATA_PAYLOAD_LENGTH: errors["LoanBrokerSet:data"] = "Data must be less than 256 bytes." + if self.data is not None and not HEX_REGEX.fullmatch(self.data): + errors["LoanBrokerSet:data"] = "Data must be a valid hex string." + if self.management_fee_rate is not None and ( self.management_fee_rate < 0 or self.management_fee_rate > self.MAX_MANAGEMENT_FEE_RATE diff --git a/xrpl/models/transactions/loan_set.py b/xrpl/models/transactions/loan_set.py index 86432e2f9..18ee6c682 100644 --- a/xrpl/models/transactions/loan_set.py +++ b/xrpl/models/transactions/loan_set.py @@ -8,6 +8,7 @@ from typing_extensions import Self +from xrpl.constants import HEX_REGEX from xrpl.models.base_model import BaseModel from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import ( @@ -155,7 +156,7 @@ class LoanSet(Transaction): init=False, ) - MAX_DATA_LENGTH = 256 + MAX_DATA_LENGTH = 256 * 2 MAX_OVER_PAYMENT_FEE_RATE = 100_000 MAX_INTEREST_RATE = 100_000 MAX_LATE_INTEREST_RATE = 100_000 @@ -173,7 +174,10 @@ def _get_errors(self: Self) -> Dict[str, str]: } if self.data is not None and len(self.data) > self.MAX_DATA_LENGTH: - parent_class_errors["LoanSet:data"] = "Data must be less than 512 bytes." + parent_class_errors["LoanSet:data"] = "Data must be less than 256 bytes." + + if self.data is not None and not HEX_REGEX.fullmatch(self.data): + parent_class_errors["LoanSet:data"] = "Data must be a valid hex string." if self.overpayment_fee is not None and ( self.overpayment_fee < 0 From 388553d78d11972fd5666357e1990fd029a030a1 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 18 Sep 2025 13:18:28 -0700 Subject: [PATCH 10/21] update tests for LoanSet txn; remove start_date field --- .../transactions/test_lending_protocol.py | 20 +------------ .../unit/models/transactions/test_loan_set.py | 16 ---------- xrpl/asyncio/transaction/main.py | 2 ++ .../binarycodec/definitions/definitions.json | 30 +++++++++---------- xrpl/models/transactions/loan_set.py | 1 - 5 files changed, 18 insertions(+), 51 deletions(-) diff --git a/tests/integration/transactions/test_lending_protocol.py b/tests/integration/transactions/test_lending_protocol.py index f2028325d..fcfb85840 100644 --- a/tests/integration/transactions/test_lending_protocol.py +++ b/tests/integration/transactions/test_lending_protocol.py @@ -1,5 +1,3 @@ -import datetime - from tests.integration.integration_test_case import IntegrationTestCase from tests.integration.it_utils import ( LEDGER_ACCEPT_REQUEST, @@ -12,8 +10,6 @@ from xrpl.core.keypairs.main import sign from xrpl.models import ( AccountObjects, - AccountSet, - AccountSetAsfFlag, LoanBrokerSet, LoanDelete, LoanManage, @@ -46,18 +42,6 @@ async def test_lending_protocol_lifecycle(self, client): borrower_wallet = Wallet.create() await fund_wallet_async(borrower_wallet) - # Step-0: Set up the relevant flags on the loan_issuer account -- This is - # a pre-requisite for a Vault to hold the Issued Currency Asset - response = await sign_and_reliable_submission_async( - AccountSet( - account=loan_issuer.classic_address, - set_flag=AccountSetAsfFlag.ASF_DEFAULT_RIPPLE, - ), - loan_issuer, - ) - self.assertTrue(response.is_successful()) - self.assertEqual(response.result["engine_result"], "tesSUCCESS") - # Step-1: Create a vault tx = VaultCreate( account=loan_issuer.address, @@ -114,7 +98,6 @@ async def test_lending_protocol_lifecycle(self, client): account=loan_issuer.address, loan_broker_id=LOAN_BROKER_ID, principal_requested="100", - start_date=int(datetime.datetime.now().timestamp()), counterparty=borrower_wallet.address, ), client, @@ -182,5 +165,4 @@ async def test_lending_protocol_lifecycle(self, client): ) response = await sign_and_reliable_submission_async(tx, borrower_wallet, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) - # The borrower cannot pay the loan before the start date - self.assertEqual(response.result["engine_result"], "tecTOO_SOON") + self.assertEqual(response.result["engine_result"], "tesSUCCESS") diff --git a/tests/unit/models/transactions/test_loan_set.py b/tests/unit/models/transactions/test_loan_set.py index e6f53077d..f66ca3408 100644 --- a/tests/unit/models/transactions/test_loan_set.py +++ b/tests/unit/models/transactions/test_loan_set.py @@ -1,4 +1,3 @@ -import datetime from unittest import TestCase from xrpl.models.exceptions import XRPLModelException @@ -15,7 +14,6 @@ def test_invalid_data_too_long(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), data="A" * 257 * 2, ) self.assertEqual( @@ -29,7 +27,6 @@ def test_invalid_data_non_hex_string(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), data="Z", ) self.assertEqual( @@ -43,7 +40,6 @@ def test_invalid_overpayment_fee_too_low(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), overpayment_fee=-1, ) self.assertEqual( @@ -58,7 +54,6 @@ def test_invalid_interest_rate_too_low(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), interest_rate=-1, ) self.assertEqual( @@ -73,7 +68,6 @@ def test_invalid_interest_rate_too_high(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), interest_rate=100001, ) self.assertEqual( @@ -88,7 +82,6 @@ def test_invalid_late_interest_rate_too_low(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), late_interest_rate=-1, ) self.assertEqual( @@ -103,7 +96,6 @@ def test_invalid_late_interest_rate_too_high(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), late_interest_rate=100001, ) self.assertEqual( @@ -118,7 +110,6 @@ def test_invalid_close_interest_rate_too_low(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), close_interest_rate=-1, ) self.assertEqual( @@ -133,7 +124,6 @@ def test_invalid_close_interest_rate_too_high(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), close_interest_rate=100001, ) self.assertEqual( @@ -148,7 +138,6 @@ def test_invalid_overpayment_interest_rate_too_low(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), overpayment_interest_rate=-1, ) self.assertEqual( @@ -163,7 +152,6 @@ def test_invalid_overpayment_interest_rate_too_high(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), overpayment_interest_rate=100001, ) self.assertEqual( @@ -178,7 +166,6 @@ def test_invalid_overpayment_fee_too_high(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), overpayment_fee=100001, ) self.assertEqual( @@ -193,7 +180,6 @@ def test_invalid_payment_interval_shorter_than_grace_period(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), payment_interval=65, grace_period=70, ) @@ -209,7 +195,6 @@ def test_invalid_payment_interval_too_short(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), payment_interval=59, ) self.assertEqual( @@ -223,6 +208,5 @@ def test_valid_loan_set(self): account=_SOURCE, loan_broker_id=_ISSUER, principal_requested="100000000", - start_date=int(datetime.datetime.now().timestamp()), ) self.assertTrue(tx.is_valid()) diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index d5fe67820..a76427a14 100644 --- a/xrpl/asyncio/transaction/main.py +++ b/xrpl/asyncio/transaction/main.py @@ -182,6 +182,8 @@ async def submit( if response.is_successful(): return response + print("input txn: ", transaction) + print(response.result) raise XRPLRequestFailureException(response.result) diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 3a72bbbe0..a08372e54 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -696,7 +696,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 53, + "nth": 54, "type": "UInt32" } ], @@ -706,7 +706,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 54, + "nth": 55, "type": "UInt32" } ], @@ -716,7 +716,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 55, + "nth": 56, "type": "UInt32" } ], @@ -726,7 +726,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 56, + "nth": 57, "type": "UInt32" } ], @@ -736,7 +736,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 57, + "nth": 58, "type": "UInt32" } ], @@ -746,7 +746,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 58, + "nth": 59, "type": "UInt32" } ], @@ -756,7 +756,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 59, + "nth": 60, "type": "UInt32" } ], @@ -766,7 +766,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 60, + "nth": 61, "type": "UInt32" } ], @@ -776,7 +776,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 61, + "nth": 62, "type": "UInt32" } ], @@ -786,7 +786,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 62, + "nth": 63, "type": "UInt32" } ], @@ -796,7 +796,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 63, + "nth": 64, "type": "UInt32" } ], @@ -806,7 +806,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 64, + "nth": 65, "type": "UInt32" } ], @@ -816,7 +816,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 65, + "nth": 66, "type": "UInt32" } ], @@ -826,7 +826,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 66, + "nth": 67, "type": "UInt32" } ], @@ -836,7 +836,7 @@ "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 67, + "nth": 68, "type": "UInt32" } ], diff --git a/xrpl/models/transactions/loan_set.py b/xrpl/models/transactions/loan_set.py index 18ee6c682..0c47cd18d 100644 --- a/xrpl/models/transactions/loan_set.py +++ b/xrpl/models/transactions/loan_set.py @@ -135,7 +135,6 @@ class LoanSet(Transaction): The principal amount requested by the Borrower. """ - start_date: int = REQUIRED payment_total: Optional[int] = None """ The total number of payments to be made against the Loan. From 31b699e1438a527a11a5b1f6881d60c6cce814e2 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 18 Sep 2025 15:53:41 -0700 Subject: [PATCH 11/21] integ test for Lending Protocol with IOU --- .../transactions/test_lending_protocol.py | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/tests/integration/transactions/test_lending_protocol.py b/tests/integration/transactions/test_lending_protocol.py index fcfb85840..6fe966f4c 100644 --- a/tests/integration/transactions/test_lending_protocol.py +++ b/tests/integration/transactions/test_lending_protocol.py @@ -10,15 +10,21 @@ from xrpl.core.keypairs.main import sign from xrpl.models import ( AccountObjects, + AccountSet, + AccountSetAsfFlag, LoanBrokerSet, LoanDelete, LoanManage, LoanPay, LoanSet, + Payment, Transaction, + TrustSet, VaultCreate, VaultDeposit, ) +from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.models.currencies.issued_currency import IssuedCurrency from xrpl.models.currencies.xrp import XRP from xrpl.models.requests.account_objects import AccountObjectType from xrpl.models.response import ResponseStatus @@ -166,3 +172,201 @@ async def test_lending_protocol_lifecycle(self, client): response = await sign_and_reliable_submission_async(tx, borrower_wallet, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + @test_async_and_sync( + globals(), ["xrpl.transaction.autofill_and_sign", "xrpl.transaction.submit"] + ) + async def test_lending_protocol_lifecycle_with_iou_asset(self, client): + loan_issuer = Wallet.create() + await fund_wallet_async(loan_issuer) + + depositor_wallet = Wallet.create() + await fund_wallet_async(depositor_wallet) + borrower_wallet = Wallet.create() + await fund_wallet_async(borrower_wallet) + + # Step-0: Set up the relevant flags on the loan_issuer account -- This is + # a pre-requisite for a Vault to hold the Issued Currency Asset + response = await sign_and_reliable_submission_async( + AccountSet( + account=loan_issuer.classic_address, + set_flag=AccountSetAsfFlag.ASF_DEFAULT_RIPPLE, + ), + loan_issuer, + ) + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step 0.1: Set up trustlines required for the transferring the IOU token + tx = TrustSet( + account=depositor_wallet.address, + limit_amount=IssuedCurrencyAmount( + currency="USD", issuer=loan_issuer.address, value="1000" + ), + ) + response = await sign_and_reliable_submission_async( + tx, depositor_wallet, client + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + tx = TrustSet( + account=borrower_wallet.address, + limit_amount=IssuedCurrencyAmount( + currency="USD", issuer=loan_issuer.address, value="1000" + ), + ) + response = await sign_and_reliable_submission_async(tx, borrower_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step 0.2: Transfer the `USD` IOU to depositor_wallet and borrower_wallet + tx = Payment( + account=loan_issuer.address, + destination=depositor_wallet.address, + amount=IssuedCurrencyAmount( + currency="USD", issuer=loan_issuer.address, value="1000" + ), + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + tx = Payment( + account=loan_issuer.address, + destination=borrower_wallet.address, + amount=IssuedCurrencyAmount( + currency="USD", issuer=loan_issuer.address, value="1000" + ), + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-1: Create a vault + tx = VaultCreate( + account=loan_issuer.address, + asset=IssuedCurrency(currency="USD", issuer=loan_issuer.address), + assets_maximum="1000", + withdrawal_policy=WithdrawalPolicy.VAULT_STRATEGY_FIRST_COME_FIRST_SERVE, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + account_objects_response = await client.request( + AccountObjects(account=loan_issuer.address, type=AccountObjectType.VAULT) + ) + self.assertEqual(len(account_objects_response.result["account_objects"]), 1) + VAULT_ID = account_objects_response.result["account_objects"][0]["index"] + + # Step-2: Create a loan broker + tx = LoanBrokerSet( + account=loan_issuer.address, + vault_id=VAULT_ID, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-2.1: Verify that the LoanBroker was successfully created + response = await client.request( + AccountObjects( + account=loan_issuer.address, type=AccountObjectType.LOAN_BROKER + ) + ) + self.assertEqual(len(response.result["account_objects"]), 1) + LOAN_BROKER_ID = response.result["account_objects"][0]["index"] + + # Step-3: Deposit funds into the vault + tx = VaultDeposit( + account=depositor_wallet.address, + vault_id=VAULT_ID, + amount=IssuedCurrencyAmount( + currency="USD", issuer=loan_issuer.address, value="1000" + ), + ) + response = await sign_and_reliable_submission_async( + tx, depositor_wallet, client + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-5: The Loan Broker and Borrower create a Loan object with a LoanSet + # transaction and the requested principal (excluding fees) is transered to + # the Borrower. + loan_issuer_signed_txn = await autofill_and_sign( + LoanSet( + account=loan_issuer.address, + loan_broker_id=LOAN_BROKER_ID, + principal_requested="100", + counterparty=borrower_wallet.address, + ), + client, + loan_issuer, + ) + + # borrower agrees to the terms of the loan + borrower_txn_signature = sign( + encode_for_signing(loan_issuer_signed_txn.to_xrpl()), + borrower_wallet.private_key, + ) + + loan_issuer_and_borrower_signature = loan_issuer_signed_txn.to_dict() + loan_issuer_and_borrower_signature["counterparty_signature"] = ( + CounterpartySignature( + signing_pub_key=borrower_wallet.public_key, + txn_signature=borrower_txn_signature, + ) + ) + + response = await submit( + Transaction.from_dict(loan_issuer_and_borrower_signature), + client, + fail_hard=True, + ) + + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Wait for the validation of the latest ledger + await client.request(LEDGER_ACCEPT_REQUEST) + + # fetch the Loan object + response = await client.request( + AccountObjects(account=borrower_wallet.address, type=AccountObjectType.LOAN) + ) + self.assertEqual(len(response.result["account_objects"]), 1) + LOAN_ID = response.result["account_objects"][0]["index"] + + # Delete the Loan object + tx = LoanDelete( + account=loan_issuer.address, + loan_id=LOAN_ID, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + # Loan cannot be deleted until all the remaining payments are completed + self.assertEqual(response.result["engine_result"], "tecHAS_OBLIGATIONS") + + # Test the LoanManage transaction + tx = LoanManage( + account=loan_issuer.address, + loan_id=LOAN_ID, + flags=LoanManageFlag.TF_LOAN_IMPAIR, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Test the LoanPay transaction + tx = LoanPay( + account=borrower_wallet.address, + loan_id=LOAN_ID, + amount=IssuedCurrencyAmount( + currency="USD", issuer=loan_issuer.address, value="100" + ), + ) + response = await sign_and_reliable_submission_async(tx, borrower_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") From e39509f7c488e6c0dd7b8a742f9f2baf82ba4cef Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 18 Sep 2025 21:10:37 -0700 Subject: [PATCH 12/21] fix the errors in STIssue codec --- .../core/binarycodec/types/test_account_id.py | 22 ++++++ .../unit/core/binarycodec/types/test_issue.py | 29 +++++++- xrpl/core/binarycodec/types/issue.py | 68 +++++++++++++++---- 3 files changed, 101 insertions(+), 18 deletions(-) diff --git a/tests/unit/core/binarycodec/types/test_account_id.py b/tests/unit/core/binarycodec/types/test_account_id.py index aab9694a7..cdd3260a6 100644 --- a/tests/unit/core/binarycodec/types/test_account_id.py +++ b/tests/unit/core/binarycodec/types/test_account_id.py @@ -22,3 +22,25 @@ def test_from_value_base58(self): def test_raises_invalid_value_type(self): invalid_value = 30 self.assertRaises(XRPLBinaryCodecException, AccountID.from_value, invalid_value) + + def test_special_account_ACCOUNT_ONE(self): + self.assertEqual( + AccountID.from_value("0000000000000000000000000000000000000001").to_json(), + "rrrrrrrrrrrrrrrrrrrrBZbvji", + ) + + self.assertEqual( + AccountID.from_value("rrrrrrrrrrrrrrrrrrrrBZbvji").to_hex(), + AccountID.from_value("0000000000000000000000000000000000000001").to_hex(), + ) + + def test_special_account_ACCOUNT_ZERO(self): + self.assertEqual( + AccountID.from_value("0000000000000000000000000000000000000000").to_json(), + "rrrrrrrrrrrrrrrrrrrrrhoLvTp", + ) + + self.assertEqual( + AccountID.from_value("rrrrrrrrrrrrrrrrrrrrrhoLvTp").to_hex(), + AccountID.from_value("0000000000000000000000000000000000000000").to_hex(), + ) diff --git a/tests/unit/core/binarycodec/types/test_issue.py b/tests/unit/core/binarycodec/types/test_issue.py index 95725e014..9d339a883 100644 --- a/tests/unit/core/binarycodec/types/test_issue.py +++ b/tests/unit/core/binarycodec/types/test_issue.py @@ -40,6 +40,30 @@ def test_from_value_mpt(self): } self.assertEqual(issue_obj.to_json(), expected) + def test_short_mpt_issuance_id(self): + # A valid mpt_issuance_id is 192 bits long (or 48 characters in hex). + test_input = { + "mpt_issuance_id": "A" * 47, + } + self.assertRaises(XRPLBinaryCodecException, Issue.from_value, test_input) + + def test_binary_representation_of_mpt_issuance_id(self): + # The issuer_account is represented by `A` and Sequence number + # (of the MPTokenIssuanceCreate transaction) is represented by `B`. + mpt_issuance_id_in_hex = "A" * 40 + "B" * 8 + test_input = { + "mpt_issuance_id": mpt_issuance_id_in_hex, + } + issue_obj = Issue.from_value(test_input) + self.assertEqual( + issue_obj.to_hex(), + mpt_issuance_id_in_hex[:40] + # the below line is the hex representation of the + # black-hole-account-id (ACCOUNT_ONE) + + "0000000000000000000000000000000000000001" + mpt_issuance_id_in_hex[40:], + ) + self.assertEqual(issue_obj.to_json(), test_input) + def test_from_parser_xrp(self): # Test round-trip: serialize an XRP Issue and then parse it back. test_input = {"currency": "XRP"} @@ -79,10 +103,9 @@ def test_from_parser_mpt(self): "mpt_issuance_id": "BAADF00DBAADF00DBAADF00DBAADF00DBAADF00DBAADF00D", } issue_obj = Issue.from_value(test_input) - # Use the hex representation and pass the fixed length_hint (24 bytes for - # Hash192) + # Use the hex representation parser = BinaryParser(issue_obj.to_hex()) - issue_from_parser = Issue.from_parser(parser, length_hint=24) + issue_from_parser = Issue.from_parser(parser) expected = { "mpt_issuance_id": "BAADF00DBAADF00DBAADF00DBAADF00DBAADF00DBAADF00D" } diff --git a/xrpl/core/binarycodec/types/issue.py b/xrpl/core/binarycodec/types/issue.py index 7527fcdac..5031a8854 100644 --- a/xrpl/core/binarycodec/types/issue.py +++ b/xrpl/core/binarycodec/types/issue.py @@ -10,8 +10,9 @@ from xrpl.core.binarycodec.exceptions import XRPLBinaryCodecException from xrpl.core.binarycodec.types.account_id import AccountID from xrpl.core.binarycodec.types.currency import Currency -from xrpl.core.binarycodec.types.hash192 import HASH192_BYTES, Hash192 +from xrpl.core.binarycodec.types.hash192 import Hash192 from xrpl.core.binarycodec.types.serialized_type import SerializedType +from xrpl.core.binarycodec.types.uint32 import UInt32 from xrpl.models.currencies import XRP as XRPModel from xrpl.models.currencies import IssuedCurrency as IssuedCurrencyModel from xrpl.models.currencies import MPTCurrency as MPTCurrencyModel @@ -20,6 +21,10 @@ class Issue(SerializedType): """Codec for serializing and deserializing issued currency fields.""" + BLACK_HOLED_ACCOUNT_ID = AccountID.from_value( + "0000000000000000000000000000000000000001" + ) + def __init__(self: Self, buffer: bytes) -> None: """ Construct an Issue from given bytes. @@ -54,9 +59,26 @@ def from_value(cls: Type[Self], value: Dict[str, str]) -> Self: issuer_bytes = bytes(AccountID.from_value(value["issuer"])) return cls(currency_bytes + issuer_bytes) + # MPT is serialized as: + # - 160 bits MPT issuer account (20 bytes) + # - 160 bits black hole account (20 bytes) + # - 32 bits sequence (4 bytes) + # Please look at STIssue.cpp inside rippled implementation for more details. + if MPTCurrencyModel.is_dict_of_model(value): + if len(value["mpt_issuance_id"]) != 48: + raise XRPLBinaryCodecException( + "Invalid mpt_issuance_id length: expected 48 characters, " + f"received {len(value['mpt_issuance_id'])} characters." + ) mpt_issuance_id_bytes = bytes(Hash192.from_value(value["mpt_issuance_id"])) - return cls(bytes(mpt_issuance_id_bytes)) + return cls( + bytes( + mpt_issuance_id_bytes[:20] + + bytes(cls.BLACK_HOLED_ACCOUNT_ID) + + bytes(mpt_issuance_id_bytes[20:]) + ) + ) raise XRPLBinaryCodecException( "Invalid type to construct an Issue: expected XRP, IssuedCurrency or " @@ -80,17 +102,26 @@ def from_parser( Returns: The Issue object constructed from a parser. """ - # Check if it's an MPTIssue by checking mpt_issuance_id byte size - if length_hint == HASH192_BYTES: - mpt_bytes = parser.read(HASH192_BYTES) - return cls(mpt_bytes) + currency_or_account = Currency.from_parser(parser) + if currency_or_account.to_json() == "XRP": + return cls(bytes(currency_or_account)) + + # check if this is an instance of MPTIssuanceID + issuer_account_id = AccountID.from_parser(parser) + if issuer_account_id.to_json() == cls.BLACK_HOLED_ACCOUNT_ID.to_json(): + sequence = UInt32.from_parser(parser) + return cls( + bytes(currency_or_account) + + bytes(cls.BLACK_HOLED_ACCOUNT_ID) + + bytes(sequence) + ) + + return cls(bytes(currency_or_account) + bytes(issuer_account_id)) - currency = Currency.from_parser(parser) - if currency.to_json() == "XRP": - return cls(bytes(currency)) - - issuer = parser.read(20) # the length in bytes of an account ID - return cls(bytes(currency) + issuer) + @classmethod + def _print_buffer(self: Self, buffer: bytes) -> None: + print("DEBUG: Inside Issue._print_buffer(), buffer: ", buffer.hex().upper()) + print("DEBUG: Inside Issue._print_buffer(), buffer length: ", len(buffer)) def to_json(self: Self) -> Union[str, Dict[Any, Any]]: """ @@ -99,9 +130,16 @@ def to_json(self: Self) -> Union[str, Dict[Any, Any]]: Returns: The JSON representation of an Issue. """ - # If the buffer is exactly 24 bytes, treat it as an MPT amount. - if len(self.buffer) == HASH192_BYTES: - return {"mpt_issuance_id": self.to_hex().upper()} + # If the buffer's length is 44 bytes (issuer-account + black-hole-account-id + + # sequence), treat it as a MPTCurrency. + # Note: hexadecimal representation of the buffer's length is doubled because 1 + # byte is represented by 2 characters in hex. + if len(self.buffer) == 20 + 20 + 4: + serialized_mpt_in_hex = self.to_hex().upper() + return { + "mpt_issuance_id": serialized_mpt_in_hex[:40] + + serialized_mpt_in_hex[80:] + } parser = BinaryParser(self.to_hex()) currency: Union[str, Dict[Any, Any]] = Currency.from_parser(parser).to_json() From 3b47b6f287d6e529c7331c34c8ffaeca73a4e09f Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 22 Sep 2025 08:43:17 -0700 Subject: [PATCH 13/21] remove debug helper method --- xrpl/core/binarycodec/types/issue.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/xrpl/core/binarycodec/types/issue.py b/xrpl/core/binarycodec/types/issue.py index 5031a8854..6af3db875 100644 --- a/xrpl/core/binarycodec/types/issue.py +++ b/xrpl/core/binarycodec/types/issue.py @@ -118,11 +118,6 @@ def from_parser( return cls(bytes(currency_or_account) + bytes(issuer_account_id)) - @classmethod - def _print_buffer(self: Self, buffer: bytes) -> None: - print("DEBUG: Inside Issue._print_buffer(), buffer: ", buffer.hex().upper()) - print("DEBUG: Inside Issue._print_buffer(), buffer length: ", len(buffer)) - def to_json(self: Self) -> Union[str, Dict[Any, Any]]: """ Returns the JSON representation of an issued currency. From 9c38b73faae8cc28e202a944871cd7bf9c4d1423 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 23 Sep 2025 12:37:46 -0700 Subject: [PATCH 14/21] integ test for VaultCreate txn with MPToken --- .../transactions/test_single_asset_vault.py | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/integration/transactions/test_single_asset_vault.py b/tests/integration/transactions/test_single_asset_vault.py index afdb4ea68..94419309c 100644 --- a/tests/integration/transactions/test_single_asset_vault.py +++ b/tests/integration/transactions/test_single_asset_vault.py @@ -19,15 +19,53 @@ ) from xrpl.models.amounts.issued_currency_amount import IssuedCurrencyAmount from xrpl.models.currencies import IssuedCurrency -from xrpl.models.requests import AccountObjects, LedgerEntry +from xrpl.models.currencies.mpt_currency import MPTCurrency +from xrpl.models.requests import AccountObjects, LedgerEntry, Tx from xrpl.models.requests.account_objects import AccountObjectType from xrpl.models.response import ResponseStatus +from xrpl.models.transactions.mptoken_issuance_create import MPTokenIssuanceCreate from xrpl.models.transactions.vault_create import WithdrawalPolicy from xrpl.utils import str_to_hex from xrpl.wallet import Wallet class TestSingleAssetVault(IntegrationTestCase): + @test_async_and_sync(globals()) + async def test_vault_with_mptoken(self, client): + vault_owner = Wallet.create() + await fund_wallet_async(vault_owner) + + # Create a MPToken + tx = MPTokenIssuanceCreate( + account=vault_owner.address, + ) + response = await sign_and_reliable_submission_async(tx, vault_owner, client) + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # fetch the mpt_issuance_id + response = await client.request( + Tx(transaction=response.result["tx_json"]["hash"]) + ) + MPT_ISSUANCE_ID = response.result["meta"]["mpt_issuance_id"] + + # Use LedgerEntry RPC to determine the existence of the MPTokenIssuance + # ledger object + response = await client.request(LedgerEntry(mpt_issuance=MPT_ISSUANCE_ID)) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["node"]["LedgerEntryType"], "MPTokenIssuance") + self.assertEqual(MPT_ISSUANCE_ID, response.result["node"]["mpt_issuance_id"]) + self.assertEqual(response.result["node"]["Issuer"], vault_owner.address) + + # create a vault + tx = VaultCreate( + account=vault_owner.address, + asset=MPTCurrency(mpt_issuance_id=MPT_ISSUANCE_ID), + ) + response = await sign_and_reliable_submission_async(tx, vault_owner, client) + self.assertTrue(response.is_successful()) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + @test_async_and_sync(globals()) async def test_sav_lifecycle(self, client): From f6daf470c37f7e07b261113995d0668c31cac731 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 23 Sep 2025 12:42:37 -0700 Subject: [PATCH 15/21] feature: allow xrpl-py integ tests to run on XRPL Devnet; This commit adds utility methods that aid in this effort --- tests/integration/it_utils.py | 67 ++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/tests/integration/it_utils.py b/tests/integration/it_utils.py index 6d8b65256..f07215ad4 100644 --- a/tests/integration/it_utils.py +++ b/tests/integration/it_utils.py @@ -39,6 +39,9 @@ JSON_TESTNET_URL = "https://s.altnet.rippletest.net:51234" WEBSOCKET_TESTNET_URL = "wss://s.altnet.rippletest.net:51233" +JSON_DEVNET_URL = "https://s.devnet.rippletest.net:51234/" +WEBSOCKET_DEVNET_URL = "wss://s.devnet.rippletest.net:51233/" + JSON_RPC_CLIENT = JsonRpcClient(JSON_RPC_URL) ASYNC_JSON_RPC_CLIENT = AsyncJsonRpcClient(JSON_RPC_URL) @@ -51,16 +54,27 @@ WEBSOCKET_TESTNET_CLIENT = WebsocketClient(WEBSOCKET_TESTNET_URL) ASYNC_WEBSOCKET_TESTNET_CLIENT = AsyncWebsocketClient(WEBSOCKET_TESTNET_URL) -# (is_async, is_json, is_testnet) -> client +JSON_RPC_DEVNET_CLIENT = JsonRpcClient(JSON_DEVNET_URL) +ASYNC_JSON_RPC_DEVNET_CLIENT = AsyncJsonRpcClient(JSON_DEVNET_URL) + +WEBSOCKET_DEVNET_CLIENT = WebsocketClient(WEBSOCKET_DEVNET_URL) +ASYNC_WEBSOCKET_DEVNET_CLIENT = AsyncWebsocketClient(WEBSOCKET_DEVNET_URL) + +# (is_async, is_json, is_testnet, use_devnet) -> client _CLIENTS = { - (True, True, True): ASYNC_JSON_RPC_TESTNET_CLIENT, - (True, True, False): ASYNC_JSON_RPC_CLIENT, - (True, False, True): ASYNC_WEBSOCKET_TESTNET_CLIENT, - (True, False, False): ASYNC_WEBSOCKET_CLIENT, - (False, True, True): JSON_RPC_TESTNET_CLIENT, - (False, True, False): JSON_RPC_CLIENT, - (False, False, True): WEBSOCKET_TESTNET_CLIENT, - (False, False, False): WEBSOCKET_CLIENT, + (True, True, True, False): ASYNC_JSON_RPC_TESTNET_CLIENT, + (True, True, False, False): ASYNC_JSON_RPC_CLIENT, + (True, False, True, False): ASYNC_WEBSOCKET_TESTNET_CLIENT, + (True, False, False, False): ASYNC_WEBSOCKET_CLIENT, + (False, True, True, False): JSON_RPC_TESTNET_CLIENT, + (False, True, False, False): JSON_RPC_CLIENT, + (False, False, True, False): WEBSOCKET_TESTNET_CLIENT, + (False, False, False, False): WEBSOCKET_CLIENT, + # Both use_testnet and use_devnet cannot be specified at the same time + (True, True, False, True): ASYNC_JSON_RPC_DEVNET_CLIENT, + (True, False, False, True): ASYNC_WEBSOCKET_DEVNET_CLIENT, + (False, True, False, True): JSON_RPC_DEVNET_CLIENT, + (False, False, False, True): WEBSOCKET_DEVNET_CLIENT, } MASTER_ACCOUNT = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" @@ -156,6 +170,7 @@ def sign_and_reliable_submission( wallet: Wallet, client: SyncClient = JSON_RPC_CLIENT, check_fee: bool = True, + use_devnet=False, ) -> Response: modified_transaction = transaction @@ -183,7 +198,10 @@ def sign_and_reliable_submission( response = submit_transaction( modified_transaction, wallet, client, check_fee=check_fee ) - client.request(LEDGER_ACCEPT_REQUEST) + + # On devnet, wait for the transaction to be validated + if not use_devnet: + client.request(LEDGER_ACCEPT_REQUEST) return response @@ -193,6 +211,7 @@ async def sign_and_reliable_submission_async( wallet: Wallet, client: AsyncClient = ASYNC_JSON_RPC_CLIENT, check_fee: bool = True, + use_devnet=False, ) -> Response: modified_transaction = transaction @@ -219,7 +238,10 @@ async def sign_and_reliable_submission_async( response = await submit_transaction_async( modified_transaction, wallet, client, check_fee=check_fee ) - await client.request(LEDGER_ACCEPT_REQUEST) + + # On devnet, wait for the transaction to be validated + if not use_devnet: + await client.request(LEDGER_ACCEPT_REQUEST) return response @@ -261,8 +283,10 @@ def _choose_client_async(use_json_client: bool) -> AsyncClient: return cast(AsyncClient, _CLIENTS[(True, use_json_client, False)]) -def _get_client(is_async: bool, is_json: bool, is_testnet: bool) -> Client: - return _CLIENTS[(is_async, is_json, is_testnet)] +def _get_client( + is_async: bool, is_json: bool, is_testnet: bool, is_devnet: bool +) -> Client: + return _CLIENTS[(is_async, is_json, is_testnet, is_devnet)] def test_async_and_sync( @@ -271,6 +295,7 @@ def test_async_and_sync( websockets_only=False, num_retries=1, use_testnet=False, + use_devnet=False, async_only=False, ): def decorator(test_function): @@ -345,18 +370,26 @@ def modified_test(self): if not websockets_only: with self.subTest(version="async", client="json"): asyncio.run( - _run_async_test(self, _get_client(True, True, use_testnet), 1) + _run_async_test( + self, _get_client(True, True, use_testnet, use_devnet), 1 + ) ) if not async_only: with self.subTest(version="sync", client="json"): - _run_sync_test(self, _get_client(False, True, use_testnet), 2) + _run_sync_test( + self, _get_client(False, True, use_testnet, use_devnet), 2 + ) with self.subTest(version="async", client="websocket"): asyncio.run( - _run_async_test(self, _get_client(True, False, use_testnet), 3) + _run_async_test( + self, _get_client(True, False, use_testnet, use_devnet), 3 + ) ) if not async_only: with self.subTest(version="sync", client="websocket"): - _run_sync_test(self, _get_client(False, False, use_testnet), 4) + _run_sync_test( + self, _get_client(False, False, use_testnet, use_devnet), 4 + ) return modified_test From 9f27a078c6193b252be6f5b62b9cf180689beb06 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 23 Sep 2025 16:21:32 -0700 Subject: [PATCH 16/21] fix: update the order of the encoding arguments in serialization of Issue --- xrpl/core/binarycodec/types/issue.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/xrpl/core/binarycodec/types/issue.py b/xrpl/core/binarycodec/types/issue.py index 6af3db875..e3115a80e 100644 --- a/xrpl/core/binarycodec/types/issue.py +++ b/xrpl/core/binarycodec/types/issue.py @@ -72,11 +72,14 @@ def from_value(cls: Type[Self], value: Dict[str, str]) -> Self: f"received {len(value['mpt_issuance_id'])} characters." ) mpt_issuance_id_bytes = bytes(Hash192.from_value(value["mpt_issuance_id"])) + + sequence_in_hex = mpt_issuance_id_bytes[:4] + issuer_account_in_hex = mpt_issuance_id_bytes[4:] return cls( bytes( - mpt_issuance_id_bytes[:20] + bytes(issuer_account_in_hex) + bytes(cls.BLACK_HOLED_ACCOUNT_ID) - + bytes(mpt_issuance_id_bytes[20:]) + + bytes(sequence_in_hex) ) ) @@ -132,8 +135,8 @@ def to_json(self: Self) -> Union[str, Dict[Any, Any]]: if len(self.buffer) == 20 + 20 + 4: serialized_mpt_in_hex = self.to_hex().upper() return { - "mpt_issuance_id": serialized_mpt_in_hex[:40] - + serialized_mpt_in_hex[80:] + "mpt_issuance_id": serialized_mpt_in_hex[80:] + + serialized_mpt_in_hex[:40] } parser = BinaryParser(self.to_hex()) From d47410a54afeb72e50ca55cc5b9a16f77fc9f74f Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 24 Sep 2025 13:40:25 -0700 Subject: [PATCH 17/21] add SAV integ test with MPToken as Vault asset --- .../transactions/test_single_asset_vault.py | 96 +++++++++++++++++-- 1 file changed, 88 insertions(+), 8 deletions(-) diff --git a/tests/integration/transactions/test_single_asset_vault.py b/tests/integration/transactions/test_single_asset_vault.py index 94419309c..c3a9476e5 100644 --- a/tests/integration/transactions/test_single_asset_vault.py +++ b/tests/integration/transactions/test_single_asset_vault.py @@ -18,12 +18,17 @@ VaultWithdraw, ) from xrpl.models.amounts.issued_currency_amount import IssuedCurrencyAmount +from xrpl.models.amounts.mpt_amount import MPTAmount from xrpl.models.currencies import IssuedCurrency from xrpl.models.currencies.mpt_currency import MPTCurrency from xrpl.models.requests import AccountObjects, LedgerEntry, Tx from xrpl.models.requests.account_objects import AccountObjectType from xrpl.models.response import ResponseStatus -from xrpl.models.transactions.mptoken_issuance_create import MPTokenIssuanceCreate +from xrpl.models.transactions.mptoken_authorize import MPTokenAuthorize +from xrpl.models.transactions.mptoken_issuance_create import ( + MPTokenIssuanceCreate, + MPTokenIssuanceCreateFlag, +) from xrpl.models.transactions.vault_create import WithdrawalPolicy from xrpl.utils import str_to_hex from xrpl.wallet import Wallet @@ -38,6 +43,8 @@ async def test_vault_with_mptoken(self, client): # Create a MPToken tx = MPTokenIssuanceCreate( account=vault_owner.address, + flags=MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER + + MPTokenIssuanceCreateFlag.TF_MPT_CAN_CLAWBACK, ) response = await sign_and_reliable_submission_async(tx, vault_owner, client) self.assertTrue(response.is_successful()) @@ -49,15 +56,30 @@ async def test_vault_with_mptoken(self, client): ) MPT_ISSUANCE_ID = response.result["meta"]["mpt_issuance_id"] - # Use LedgerEntry RPC to determine the existence of the MPTokenIssuance - # ledger object - response = await client.request(LedgerEntry(mpt_issuance=MPT_ISSUANCE_ID)) + # Create a holder wallet to validate VaultDeposit+VaultWithdraw+VaultClawback transactions + holder_wallet = Wallet.create() + await fund_wallet_async(holder_wallet) + + # holder provides authorization to hold the MPToken + tx = MPTokenAuthorize( + account=holder_wallet.address, + mptoken_issuance_id=MPT_ISSUANCE_ID, + ) + response = await sign_and_reliable_submission_async(tx, holder_wallet, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) - self.assertEqual(response.result["node"]["LedgerEntryType"], "MPTokenIssuance") - self.assertEqual(MPT_ISSUANCE_ID, response.result["node"]["mpt_issuance_id"]) - self.assertEqual(response.result["node"]["Issuer"], vault_owner.address) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") - # create a vault + # transfer some MPToken to the holder wallet + tx = Payment( + account=vault_owner.address, + amount=MPTAmount(mpt_issuance_id=MPT_ISSUANCE_ID, value="100"), + destination=holder_wallet.address, + ) + response = await sign_and_reliable_submission_async(tx, vault_owner, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-1: Create a vault tx = VaultCreate( account=vault_owner.address, asset=MPTCurrency(mpt_issuance_id=MPT_ISSUANCE_ID), @@ -66,6 +88,64 @@ async def test_vault_with_mptoken(self, client): self.assertTrue(response.is_successful()) self.assertEqual(response.result["engine_result"], "tesSUCCESS") + # Step-1.b: Verify the existence of the vault with account_objects RPC call + account_objects_response = await client.request( + AccountObjects(account=vault_owner.address, type=AccountObjectType.VAULT) + ) + self.assertEqual(len(account_objects_response.result["account_objects"]), 1) + + VAULT_ID = account_objects_response.result["account_objects"][0]["index"] + + # Step-2: Update the characteristics of the vault with VaultSet transaction + tx = VaultSet( + account=vault_owner.address, + vault_id=VAULT_ID, + data=str_to_hex("auxilliary data pertaining to the vault"), + ) + response = await sign_and_reliable_submission_async(tx, vault_owner, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-3: Execute a VaultDeposit transaction + tx = VaultDeposit( + account=holder_wallet.address, + vault_id=VAULT_ID, + amount=MPTAmount(mpt_issuance_id=MPT_ISSUANCE_ID, value="10"), + ) + response = await sign_and_reliable_submission_async(tx, holder_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-4: Execute a VaultWithdraw transaction + tx = VaultWithdraw( + account=holder_wallet.address, + vault_id=VAULT_ID, + amount=MPTAmount(mpt_issuance_id=MPT_ISSUANCE_ID, value="5"), + ) + response = await sign_and_reliable_submission_async(tx, holder_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-5: Execute a VaultClawback transaction + tx = VaultClawback( + account=vault_owner.address, + holder=holder_wallet.address, + vault_id=VAULT_ID, + amount=MPTAmount(mpt_issuance_id=MPT_ISSUANCE_ID, value="100"), + ) + response = await sign_and_reliable_submission_async(tx, vault_owner, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-6: Delete the Vault with VaultDelete transaction + tx = VaultDelete( + account=vault_owner.address, + vault_id=VAULT_ID, + ) + response = await sign_and_reliable_submission_async(tx, vault_owner, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + @test_async_and_sync(globals()) async def test_sav_lifecycle(self, client): From bd2f13a87035c2ace6ca03421ce1ad4c0531d259 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 24 Sep 2025 14:10:33 -0700 Subject: [PATCH 18/21] fix: big-endian format to interpret the sequence number in MPTID --- .../transactions/test_single_asset_vault.py | 3 ++- .../unit/core/binarycodec/types/test_issue.py | 21 +++++++++------- xrpl/core/binarycodec/types/issue.py | 25 ++++++++++++++++--- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/tests/integration/transactions/test_single_asset_vault.py b/tests/integration/transactions/test_single_asset_vault.py index c3a9476e5..f65b2a8eb 100644 --- a/tests/integration/transactions/test_single_asset_vault.py +++ b/tests/integration/transactions/test_single_asset_vault.py @@ -56,7 +56,8 @@ async def test_vault_with_mptoken(self, client): ) MPT_ISSUANCE_ID = response.result["meta"]["mpt_issuance_id"] - # Create a holder wallet to validate VaultDeposit+VaultWithdraw+VaultClawback transactions + # Create a holder wallet to validate VaultDeposit+VaultWithdraw+VaultClawback + # transactions holder_wallet = Wallet.create() await fund_wallet_async(holder_wallet) diff --git a/tests/unit/core/binarycodec/types/test_issue.py b/tests/unit/core/binarycodec/types/test_issue.py index 9d339a883..834811029 100644 --- a/tests/unit/core/binarycodec/types/test_issue.py +++ b/tests/unit/core/binarycodec/types/test_issue.py @@ -32,11 +32,11 @@ def test_from_value_mpt(self): # Test Issue creation for an MPT amount. # Use a valid 48-character hex string (24 bytes) for mpt_issuance_id. test_input = { - "mpt_issuance_id": "BAADF00DBAADF00DBAADF00DBAADF00DBAADF00DBAADF00D", + "mpt_issuance_id": "00001266F19FE2057AE426F72E923CAB3EC8E5BDB3341D9E", } issue_obj = Issue.from_value(test_input) expected = { - "mpt_issuance_id": "BAADF00DBAADF00DBAADF00DBAADF00DBAADF00DBAADF00D" + "mpt_issuance_id": "00001266F19FE2057AE426F72E923CAB3EC8E5BDB3341D9E" } self.assertEqual(issue_obj.to_json(), expected) @@ -48,19 +48,22 @@ def test_short_mpt_issuance_id(self): self.assertRaises(XRPLBinaryCodecException, Issue.from_value, test_input) def test_binary_representation_of_mpt_issuance_id(self): - # The issuer_account is represented by `A` and Sequence number - # (of the MPTokenIssuanceCreate transaction) is represented by `B`. - mpt_issuance_id_in_hex = "A" * 40 + "B" * 8 + # The Sequence number (of the MPTokenIssuanceCreate transaction) is represented + # by `B` and issuer_account is represented by `A`. + mpt_issuance_id_in_hex = "B" * 8 + "A" * 40 test_input = { "mpt_issuance_id": mpt_issuance_id_in_hex, } issue_obj = Issue.from_value(test_input) self.assertEqual( issue_obj.to_hex(), - mpt_issuance_id_in_hex[:40] + # issuer_account's hex representation + mpt_issuance_id_in_hex[8:48] # the below line is the hex representation of the # black-hole-account-id (ACCOUNT_ONE) - + "0000000000000000000000000000000000000001" + mpt_issuance_id_in_hex[40:], + + "0000000000000000000000000000000000000001" + # sequence number's hex representation + + mpt_issuance_id_in_hex[:8], ) self.assertEqual(issue_obj.to_json(), test_input) @@ -100,14 +103,14 @@ def test_from_parser_non_standard_currency(self): def test_from_parser_mpt(self): # Test round-trip: serialize an MPT Issue and then parse it back. test_input = { - "mpt_issuance_id": "BAADF00DBAADF00DBAADF00DBAADF00DBAADF00DBAADF00D", + "mpt_issuance_id": "00001266F19FE2057AE426F72E923CAB3EC8E5BDB3341D9E", } issue_obj = Issue.from_value(test_input) # Use the hex representation parser = BinaryParser(issue_obj.to_hex()) issue_from_parser = Issue.from_parser(parser) expected = { - "mpt_issuance_id": "BAADF00DBAADF00DBAADF00DBAADF00DBAADF00DBAADF00D" + "mpt_issuance_id": "00001266F19FE2057AE426F72E923CAB3EC8E5BDB3341D9E" } self.assertEqual(issue_from_parser.to_json(), expected) diff --git a/xrpl/core/binarycodec/types/issue.py b/xrpl/core/binarycodec/types/issue.py index e3115a80e..8a1b0e0c5 100644 --- a/xrpl/core/binarycodec/types/issue.py +++ b/xrpl/core/binarycodec/types/issue.py @@ -64,6 +64,10 @@ def from_value(cls: Type[Self], value: Dict[str, str]) -> Self: # - 160 bits black hole account (20 bytes) # - 32 bits sequence (4 bytes) # Please look at STIssue.cpp inside rippled implementation for more details. + # P.S: sequence number is stored in little-endian format, however it it + # interpreted in big-endian format. Read Indexes.cpp:makeMptID method for more + # details. + # https://github.com/XRPLF/rippled/blob/develop/src/libxrpl/protocol/Indexes.cpp#L173 if MPTCurrencyModel.is_dict_of_model(value): if len(value["mpt_issuance_id"]) != 48: @@ -73,13 +77,20 @@ def from_value(cls: Type[Self], value: Dict[str, str]) -> Self: ) mpt_issuance_id_bytes = bytes(Hash192.from_value(value["mpt_issuance_id"])) - sequence_in_hex = mpt_issuance_id_bytes[:4] + # rippled accepts sequence number in big-endian format only. + sequence_in_hex = mpt_issuance_id_bytes[:4].hex().upper() + sequenceBE = ( + sequence_in_hex[6:8] + + sequence_in_hex[4:6] + + sequence_in_hex[2:4] + + sequence_in_hex[0:2] + ) issuer_account_in_hex = mpt_issuance_id_bytes[4:] return cls( bytes( bytes(issuer_account_in_hex) + bytes(cls.BLACK_HOLED_ACCOUNT_ID) - + bytes(sequence_in_hex) + + bytearray.fromhex(sequenceBE) ) ) @@ -135,7 +146,15 @@ def to_json(self: Self) -> Union[str, Dict[Any, Any]]: if len(self.buffer) == 20 + 20 + 4: serialized_mpt_in_hex = self.to_hex().upper() return { - "mpt_issuance_id": serialized_mpt_in_hex[80:] + # Although the sequence bytes are stored in big-endian format, the JSON + # representation is in little-endian format. This is required for + # compatibility with c++ rippled implementation. + "mpt_issuance_id": ( + serialized_mpt_in_hex[86:88] + + serialized_mpt_in_hex[84:86] + + serialized_mpt_in_hex[82:84] + + serialized_mpt_in_hex[80:82] + ) + serialized_mpt_in_hex[:40] } From fc158fbfb50cb80b7d1918b5b62e6914399f6a9e Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S <21219765+ckeshava@users.noreply.github.com> Date: Wed, 24 Sep 2025 14:13:17 -0700 Subject: [PATCH 19/21] Update tests/integration/it_utils.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- tests/integration/it_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/it_utils.py b/tests/integration/it_utils.py index f07215ad4..d236f49c7 100644 --- a/tests/integration/it_utils.py +++ b/tests/integration/it_utils.py @@ -286,9 +286,10 @@ def _choose_client_async(use_json_client: bool) -> AsyncClient: def _get_client( is_async: bool, is_json: bool, is_testnet: bool, is_devnet: bool ) -> Client: + if is_testnet and is_devnet: + raise ValueError("use_testnet and use_devnet are mutually exclusive") return _CLIENTS[(is_async, is_json, is_testnet, is_devnet)] - def test_async_and_sync( original_globals, modules=None, From 2183f0ae7b4d1185efb4c07c929a45eb2c0abcd2 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 24 Sep 2025 14:23:16 -0700 Subject: [PATCH 20/21] address code rabbit suggestions --- tests/integration/it_utils.py | 7 +++++-- xrpl/core/binarycodec/types/issue.py | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/integration/it_utils.py b/tests/integration/it_utils.py index d236f49c7..f7e2a913e 100644 --- a/tests/integration/it_utils.py +++ b/tests/integration/it_utils.py @@ -275,12 +275,14 @@ async def accept_ledger_async( AsyncTestTimer(client, delay) +# The _choose_client(_async)? methods are only used to send LEDGER_ACCEPT_REQUEST. +# Hence, they are not applicable for devnet/testnet clients. def _choose_client(use_json_client: bool) -> SyncClient: - return cast(SyncClient, _CLIENTS[(False, use_json_client, False)]) + return cast(SyncClient, _CLIENTS[(False, use_json_client, False, False)]) def _choose_client_async(use_json_client: bool) -> AsyncClient: - return cast(AsyncClient, _CLIENTS[(True, use_json_client, False)]) + return cast(AsyncClient, _CLIENTS[(True, use_json_client, False, False)]) def _get_client( @@ -290,6 +292,7 @@ def _get_client( raise ValueError("use_testnet and use_devnet are mutually exclusive") return _CLIENTS[(is_async, is_json, is_testnet, is_devnet)] + def test_async_and_sync( original_globals, modules=None, diff --git a/xrpl/core/binarycodec/types/issue.py b/xrpl/core/binarycodec/types/issue.py index 8a1b0e0c5..d26bca6ed 100644 --- a/xrpl/core/binarycodec/types/issue.py +++ b/xrpl/core/binarycodec/types/issue.py @@ -145,6 +145,10 @@ def to_json(self: Self) -> Union[str, Dict[Any, Any]]: # byte is represented by 2 characters in hex. if len(self.buffer) == 20 + 20 + 4: serialized_mpt_in_hex = self.to_hex().upper() + if serialized_mpt_in_hex[40:80] != self.BLACK_HOLED_ACCOUNT_ID.to_hex(): + raise XRPLBinaryCodecException( + "Invalid MPT Issue encoding: black-hole AccountID mismatch." + ) return { # Although the sequence bytes are stored in big-endian format, the JSON # representation is in little-endian format. This is required for From 48bd4e76420a20f0998630aa15f1429585366970 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 24 Sep 2025 15:26:29 -0700 Subject: [PATCH 21/21] integ test: LendingProtocol Vault with MPToken asset --- .../transactions/test_lending_protocol.py | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/tests/integration/transactions/test_lending_protocol.py b/tests/integration/transactions/test_lending_protocol.py index 6fe966f4c..d264edf10 100644 --- a/tests/integration/transactions/test_lending_protocol.py +++ b/tests/integration/transactions/test_lending_protocol.py @@ -24,12 +24,20 @@ VaultDeposit, ) from xrpl.models.amounts import IssuedCurrencyAmount +from xrpl.models.amounts.mpt_amount import MPTAmount from xrpl.models.currencies.issued_currency import IssuedCurrency +from xrpl.models.currencies.mpt_currency import MPTCurrency from xrpl.models.currencies.xrp import XRP from xrpl.models.requests.account_objects import AccountObjectType +from xrpl.models.requests.tx import Tx from xrpl.models.response import ResponseStatus from xrpl.models.transactions.loan_manage import LoanManageFlag from xrpl.models.transactions.loan_set import CounterpartySignature +from xrpl.models.transactions.mptoken_authorize import MPTokenAuthorize +from xrpl.models.transactions.mptoken_issuance_create import ( + MPTokenIssuanceCreate, + MPTokenIssuanceCreateFlag, +) from xrpl.models.transactions.vault_create import WithdrawalPolicy from xrpl.wallet import Wallet @@ -370,3 +378,204 @@ async def test_lending_protocol_lifecycle_with_iou_asset(self, client): response = await sign_and_reliable_submission_async(tx, borrower_wallet, client) self.assertEqual(response.status, ResponseStatus.SUCCESS) self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + @test_async_and_sync(globals(), async_only=True) + async def test_lending_protocol_lifecycle_with_mpt_asset(self, client): + loan_issuer = Wallet.create() + await fund_wallet_async(loan_issuer) + + depositor_wallet = Wallet.create() + await fund_wallet_async(depositor_wallet) + borrower_wallet = Wallet.create() + await fund_wallet_async(borrower_wallet) + + # Step-0: issue the MPT + tx = MPTokenIssuanceCreate( + account=loan_issuer.address, + maximum_amount="5000", + flags=MPTokenIssuanceCreateFlag.TF_MPT_CAN_TRANSFER, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + tx_hash = response.result["tx_json"]["hash"] + tx_res = await client.request(Tx(transaction=tx_hash)) + MPT_ISSUANCE_ID = tx_res.result["meta"]["mpt_issuance_id"] + + # validate that the MPTIssuance was created + account_objects_response = await client.request( + AccountObjects( + account=loan_issuer.address, type=AccountObjectType.MPT_ISSUANCE + ) + ) + self.assertEqual(len(account_objects_response.result["account_objects"]), 1) + self.assertEqual( + account_objects_response.result["account_objects"][0]["mpt_issuance_id"], + MPT_ISSUANCE_ID, + ) + + # Step 0.2: Authorize the destination wallets to hold the MPT + response = await sign_and_reliable_submission_async( + MPTokenAuthorize( + account=depositor_wallet.classic_address, + mptoken_issuance_id=MPT_ISSUANCE_ID, + ), + depositor_wallet, + client, + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + response = await sign_and_reliable_submission_async( + MPTokenAuthorize( + account=borrower_wallet.classic_address, + mptoken_issuance_id=MPT_ISSUANCE_ID, + ), + borrower_wallet, + client, + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step 0.3: Send some MPT to the depositor_wallet and borrower_wallet + tx = Payment( + account=loan_issuer.address, + destination=depositor_wallet.address, + amount=MPTAmount(mpt_issuance_id=MPT_ISSUANCE_ID, value="1000"), + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + tx = Payment( + account=loan_issuer.address, + destination=borrower_wallet.address, + amount=MPTAmount(mpt_issuance_id=MPT_ISSUANCE_ID, value="1000"), + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-1: Create a vault + tx = VaultCreate( + account=loan_issuer.address, + asset=MPTCurrency(mpt_issuance_id=MPT_ISSUANCE_ID), + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + account_objects_response = await client.request( + AccountObjects(account=loan_issuer.address, type=AccountObjectType.VAULT) + ) + self.assertEqual(len(account_objects_response.result["account_objects"]), 1) + + VAULT_ID = account_objects_response.result["account_objects"][0]["index"] + + # Step-2: Create a loan broker + tx = LoanBrokerSet( + account=loan_issuer.address, + vault_id=VAULT_ID, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-2.1: Verify that the LoanBroker was successfully created + response = await client.request( + AccountObjects( + account=loan_issuer.address, type=AccountObjectType.LOAN_BROKER + ) + ) + self.assertEqual(len(response.result["account_objects"]), 1) + LOAN_BROKER_ID = response.result["account_objects"][0]["index"] + + # Step-3: Deposit funds into the vault + tx = VaultDeposit( + account=depositor_wallet.address, + vault_id=VAULT_ID, + amount=MPTAmount(mpt_issuance_id=MPT_ISSUANCE_ID, value="100"), + ) + response = await sign_and_reliable_submission_async( + tx, depositor_wallet, client + ) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Step-5: The Loan Broker and Borrower create a Loan object with a LoanSet + # transaction and the requested principal (excluding fees) is transered to + # the Borrower. + loan_issuer_signed_txn = await autofill_and_sign( + LoanSet( + account=loan_issuer.address, + loan_broker_id=LOAN_BROKER_ID, + principal_requested="100", + counterparty=borrower_wallet.address, + ), + client, + loan_issuer, + ) + + # borrower agrees to the terms of the loan + borrower_txn_signature = sign( + encode_for_signing(loan_issuer_signed_txn.to_xrpl()), + borrower_wallet.private_key, + ) + + loan_issuer_and_borrower_signature = loan_issuer_signed_txn.to_dict() + loan_issuer_and_borrower_signature["counterparty_signature"] = ( + CounterpartySignature( + signing_pub_key=borrower_wallet.public_key, + txn_signature=borrower_txn_signature, + ) + ) + + response = await submit( + Transaction.from_dict(loan_issuer_and_borrower_signature), + client, + fail_hard=True, + ) + + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Wait for the validation of the latest ledger + await client.request(LEDGER_ACCEPT_REQUEST) + + # fetch the Loan object + response = await client.request( + AccountObjects(account=borrower_wallet.address, type=AccountObjectType.LOAN) + ) + self.assertEqual(len(response.result["account_objects"]), 1) + LOAN_ID = response.result["account_objects"][0]["index"] + + # Delete the Loan object + tx = LoanDelete( + account=loan_issuer.address, + loan_id=LOAN_ID, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + # Loan cannot be deleted until all the remaining payments are completed + self.assertEqual(response.result["engine_result"], "tecHAS_OBLIGATIONS") + + # Test the LoanManage transaction + tx = LoanManage( + account=loan_issuer.address, + loan_id=LOAN_ID, + flags=LoanManageFlag.TF_LOAN_IMPAIR, + ) + response = await sign_and_reliable_submission_async(tx, loan_issuer, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS") + + # Test the LoanPay transaction + tx = LoanPay( + account=borrower_wallet.address, + loan_id=LOAN_ID, + amount=MPTAmount(mpt_issuance_id=MPT_ISSUANCE_ID, value="100"), + ) + response = await sign_and_reliable_submission_async(tx, borrower_wallet, client) + self.assertEqual(response.status, ResponseStatus.SUCCESS) + self.assertEqual(response.result["engine_result"], "tesSUCCESS")