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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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") From 59b543bdff3413e541b8b42ae8bbe5234ca9ec3e Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Mon, 8 Dec 2025 14:35:23 -0800 Subject: [PATCH 22/28] update: PermissionDelegation and LP features have been updated to Supported::no. Remove their names from the amendment-list inside the ci-config file --- .ci-config/rippled.cfg | 2 -- 1 file changed, 2 deletions(-) diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 9a4ac2f65..965391acb 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -201,11 +201,9 @@ PermissionedDomains SingleAssetVault fixFrozenLPTokenTransfer fixInvalidTxFlags -PermissionDelegationV1_1 PermissionedDEX Batch TokenEscrow -LendingProtocol # This section can be used to simulate various FeeSettings scenarios for rippled node in standalone mode [voting] From 2ba7a5a016992fd9a44fc4324b47cc294fdde577 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 9 Dec 2025 13:06:15 -0800 Subject: [PATCH 23/28] feat: Update the design of autofill transaction-fees for the LoanSet transaction. The client library takes a conservative approach to estimate the transaction fees. We assume that all the signers of the counterparty might provide their signatures to the transaction. However, it is likely that only the minimum quorum of signers sign the transaction and hence, the fees might be over-estimated by the library. If users prefer optimized version of the transaction fees, they need to input the fees themselves without relying on autofill. --- .ci-config/rippled.cfg | 1 + .../transactions/test_lending_protocol.py | 134 ++++++++++++++++++ xrpl/asyncio/transaction/main.py | 58 ++++++-- 3 files changed, 185 insertions(+), 8 deletions(-) diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 965391acb..36b13a9ba 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -204,6 +204,7 @@ fixInvalidTxFlags 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/integration/transactions/test_lending_protocol.py b/tests/integration/transactions/test_lending_protocol.py index d264edf10..a8bc5a1f7 100644 --- a/tests/integration/transactions/test_lending_protocol.py +++ b/tests/integration/transactions/test_lending_protocol.py @@ -38,6 +38,7 @@ MPTokenIssuanceCreate, MPTokenIssuanceCreateFlag, ) +from xrpl.models.transactions.signer_list_set import SignerEntry, SignerListSet from xrpl.models.transactions.vault_create import WithdrawalPolicy from xrpl.wallet import Wallet @@ -181,6 +182,139 @@ async def test_lending_protocol_lifecycle(self, 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_autofill_loan_set_txn_multisigned(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) + + borrower_signer1 = Wallet.create() + await fund_wallet_async(borrower_signer1) + borrower_signer2 = Wallet.create() + await fund_wallet_async(borrower_signer2) + + # Setup borrower wallet with multiple signers to validate the correctness + # of the fees-autofill logic. + NUM_SIGNERS = 2 + tx = SignerListSet( + account=borrower_wallet.address, + signer_quorum=NUM_SIGNERS, + signer_entries=[ + SignerEntry(account=borrower_signer1.address, signer_weight=1), + SignerEntry(account=borrower_signer2.address, signer_weight=1), + ], + ) + 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-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.A: 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, + ) + + # Note: The transaction reference fee is specified in the rippled.cfg file for + # integration tests. + self.assertEqual( + loan_issuer_signed_txn.fee, + str(200 + NUM_SIGNERS * 200), + ) + + # Step-5.B: 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) + @test_async_and_sync( globals(), ["xrpl.transaction.autofill_and_sign", "xrpl.transaction.submit"] ) diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index a76427a14..05f49cde4 100644 --- a/xrpl/asyncio/transaction/main.py +++ b/xrpl/asyncio/transaction/main.py @@ -24,6 +24,7 @@ Transaction, TransactionFlag, ) +from xrpl.models.requests.account_info import AccountInfo from xrpl.models.transactions.transaction import ( transaction_json_to_binary_codec_form as model_transaction_to_binary_codec, ) @@ -464,6 +465,36 @@ async def _check_fee( ) +async def _fetch_counterparty_signers_count( + client: Client, counterparty_account: str +) -> int: + """ + Fetches the number of signers of the counterparty account. Documentation for the + AccountInfo RPC response format: + https://xrpl.org/docs/references/http-websocket-apis/public-api-methods/account-methods/account_info#response-format + + Args: + client: the network client with which to submit the transaction. + counterparty_account: the account of the counterparty. + + Returns: + The number of signers of the counterparty account (or) 1 if no signer_list is + configured for the account. + """ + account_info_response = await client._request_impl( + AccountInfo(account=counterparty_account, signer_lists=True) + ) + + # If the SignerList of the counterparty is not set (or) mis-configured, presume + # there is only one signer. + if ( + "signer_lists" not in account_info_response.result + or len(account_info_response.result["signer_lists"]) != 1 + ): + return 1 + return len(account_info_response.result["signer_lists"][0]["SignerEntries"]) + + async def _calculate_fee_per_transaction_type( transaction: Transaction, client: Client, @@ -521,7 +552,8 @@ async def _calculate_fee_per_transaction_type( ) elif transaction.transaction_type == TransactionType.LOAN_SET: # Compute the additional cost of each signature in the - # CounterpartySignature, whether a single signature or a multisignature + # CounterpartySignature field. The transaction fees depend on the number of + # signatures specified in the field. loan_set = cast(LoanSet, transaction) if loan_set.counterparty_signature is not None: signer_count = ( @@ -531,13 +563,23 @@ async def _calculate_fee_per_transaction_type( ) 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 + counterparty_signers_count = await _fetch_counterparty_signers_count( + client, loan_set.counterparty + ) + + if counterparty_signers_count > 1: + print( + ( + f"Warning: You are using autofill feature for the LoanSet " + f"transaction: {transaction.to_dict()}. The transaction fee " + "estimation is based on the number of signers in the " + "CounterpartySignature field. It might be possible to optimize " + "the fee further by considering the minimum quorum of signers." + "\nIf you prefer optimized transaction fee, please fill the fee" + " field manually." + ) + ) + base_fee += net_fee * counterparty_signers_count # Multi-signed/Multi-Account Batch Transactions # BaseFee × (1 + Number of Signatures Provided) From 53bb44c0d72e3971660051fdf4c493a1b9a46e31 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 9 Dec 2025 14:17:55 -0800 Subject: [PATCH 24/28] feat: update autofill method to handle the case where LoanBroker owner is present in both sides of LoanSet transaction. In this case, the presence of Counterparty field is optional in the LoanSet transaction --- .../transactions/test_lending_protocol.py | 113 ++++++++++++++++++ xrpl/asyncio/transaction/main.py | 15 ++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/tests/integration/transactions/test_lending_protocol.py b/tests/integration/transactions/test_lending_protocol.py index a8bc5a1f7..f40852b3d 100644 --- a/tests/integration/transactions/test_lending_protocol.py +++ b/tests/integration/transactions/test_lending_protocol.py @@ -315,6 +315,119 @@ async def test_autofill_loan_set_txn_multisigned(self, client): ) self.assertEqual(len(response.result["account_objects"]), 1) + @test_async_and_sync( + globals(), ["xrpl.transaction.autofill_and_sign", "xrpl.transaction.submit"] + ) + async def test_loan_set_txn_counterparty_is_loan_broker_owner(self, client): + loan_issuer = Wallet.create() + await fund_wallet_async(loan_issuer) + + depositor_wallet = Wallet.create() + await fund_wallet_async(depositor_wallet) + + # 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.A: The Loan Broker and Borrower (Borrower is the Owner of the + # LoanBroker, i.e. loan_issuer account) create a Loan object with a LoanSet + # transaction and the requested principal (excluding fees) is transferred to + # the Borrower. + borrower_wallet: str = loan_issuer + + loan_issuer_signed_txn = await autofill_and_sign( + LoanSet( + account=loan_issuer.address, + loan_broker_id=LOAN_BROKER_ID, + principal_requested="100", + ), + client, + loan_issuer, + ) + + # The loan_issuer is involved in both sides of this transaction. + self.assertEqual( + loan_issuer_signed_txn.fee, + str(200 + 200), + # Usual transaction reference fee and additional fee for the counterparty + # signature. Note: Multi-signing is not enabled on the loan_issuer account. + ) + + # Step-5.B: 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) + @test_async_and_sync( globals(), ["xrpl.transaction.autofill_and_sign", "xrpl.transaction.submit"] ) diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index 05f49cde4..0902d496d 100644 --- a/xrpl/asyncio/transaction/main.py +++ b/xrpl/asyncio/transaction/main.py @@ -25,6 +25,7 @@ TransactionFlag, ) from xrpl.models.requests.account_info import AccountInfo +from xrpl.models.requests.ledger_entry import LedgerEntry from xrpl.models.transactions.transaction import ( transaction_json_to_binary_codec_form as model_transaction_to_binary_codec, ) @@ -563,8 +564,20 @@ async def _calculate_fee_per_transaction_type( ) base_fee += net_fee * signer_count else: + counterparty_account: str + if loan_set.counterparty is not None: + counterparty_account = loan_set.counterparty + else: + # The sfCounterparty field is optional if the counterparty is the + # LoanBroker owner. Deduce the counterparty account from the LoanBroker + # ID. + loan_broker_object_info = await client._request_impl( + LedgerEntry(index=loan_set.loan_broker_id) + ) + print("Loan broker object info: ", loan_broker_object_info.result) + counterparty_account = loan_broker_object_info.result["node"]["Owner"] counterparty_signers_count = await _fetch_counterparty_signers_count( - client, loan_set.counterparty + client, counterparty_account ) if counterparty_signers_count > 1: From adc59295ab100b69fc64d904cb2cbf675c21fd30 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 9 Dec 2025 14:31:35 -0800 Subject: [PATCH 25/28] address minor coderabbit suggestions --- .../transactions/test_lending_protocol.py | 10 +++++----- xrpl/asyncio/transaction/main.py | 3 --- xrpl/core/binarycodec/definitions/definitions.json | 1 - xrpl/models/transactions/loan_pay.py | 2 -- xrpl/models/transactions/loan_set.py | 13 +++++++++---- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/integration/transactions/test_lending_protocol.py b/tests/integration/transactions/test_lending_protocol.py index f40852b3d..b9309caf5 100644 --- a/tests/integration/transactions/test_lending_protocol.py +++ b/tests/integration/transactions/test_lending_protocol.py @@ -105,7 +105,7 @@ async def test_lending_protocol_lifecycle(self, client): 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 + # transaction and the requested principal (excluding fees) is transferred to # the Borrower. loan_issuer_signed_txn = await autofill_and_sign( @@ -262,7 +262,7 @@ async def test_autofill_loan_set_txn_multisigned(self, client): self.assertEqual(response.result["engine_result"], "tesSUCCESS") # Step-5.A: The Loan Broker and Borrower create a Loan object with a LoanSet - # transaction and the requested principal (excluding fees) is transered to + # transaction and the requested principal (excluding fees) is transferred to # the Borrower. loan_issuer_signed_txn = await autofill_and_sign( @@ -376,7 +376,7 @@ async def test_loan_set_txn_counterparty_is_loan_broker_owner(self, client): # LoanBroker, i.e. loan_issuer account) create a Loan object with a LoanSet # transaction and the requested principal (excluding fees) is transferred to # the Borrower. - borrower_wallet: str = loan_issuer + borrower_wallet: Wallet = loan_issuer loan_issuer_signed_txn = await autofill_and_sign( LoanSet( @@ -548,7 +548,7 @@ async def test_lending_protocol_lifecycle_with_iou_asset(self, client): 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 + # transaction and the requested principal (excluding fees) is transferred to # the Borrower. loan_issuer_signed_txn = await autofill_and_sign( LoanSet( @@ -751,7 +751,7 @@ async def test_lending_protocol_lifecycle_with_mpt_asset(self, client): 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 + # transaction and the requested principal (excluding fees) is transferred to # the Borrower. loan_issuer_signed_txn = await autofill_and_sign( LoanSet( diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index 0902d496d..b92a84fc7 100644 --- a/xrpl/asyncio/transaction/main.py +++ b/xrpl/asyncio/transaction/main.py @@ -184,8 +184,6 @@ async def submit( if response.is_successful(): return response - print("input txn: ", transaction) - print(response.result) raise XRPLRequestFailureException(response.result) @@ -574,7 +572,6 @@ async def _calculate_fee_per_transaction_type( loan_broker_object_info = await client._request_impl( LedgerEntry(index=loan_set.loan_broker_id) ) - print("Loan broker object info: ", loan_broker_object_info.result) counterparty_account = loan_broker_object_info.result["node"]["Owner"] counterparty_signers_count = await _fetch_counterparty_signers_count( client, counterparty_account diff --git a/xrpl/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 9041695d1..f47e37da1 100644 --- a/xrpl/core/binarycodec/definitions/definitions.json +++ b/xrpl/core/binarycodec/definitions/definitions.json @@ -837,7 +837,6 @@ "isSigningField": true, "isVLEncoded": false, "nth": 68, - "nth": 53, "type": "UInt32" } ], diff --git a/xrpl/models/transactions/loan_pay.py b/xrpl/models/transactions/loan_pay.py index 61e5dd86e..e8ac9699a 100644 --- a/xrpl/models/transactions/loan_pay.py +++ b/xrpl/models/transactions/loan_pay.py @@ -1,7 +1,5 @@ """Model for LoanPay transaction type.""" -# from __future__ import annotations # Requires Python 3.7+ - from dataclasses import dataclass, field from xrpl.models.amounts import Amount diff --git a/xrpl/models/transactions/loan_set.py b/xrpl/models/transactions/loan_set.py index 0c47cd18d..f862e6a8b 100644 --- a/xrpl/models/transactions/loan_set.py +++ b/xrpl/models/transactions/loan_set.py @@ -24,8 +24,13 @@ @dataclass(frozen=True, **KW_ONLY_DATACLASS) class CounterpartySignature(BaseModel): """ - An inner object that contains the signature of the Lender over the transaction. - The fields contained in this object are: + Signature payload supplied by the counterparty. + Fields: + - signing_pub_key: hex-encoded public key of the counterparty (required if + txn_signature is set). + - txn_signature: hex-encoded signature over the canonical LoanSet transaction + (required if signing_pub_key is set). + - signers: optional multisign array reusing the standard Signer objects. """ signing_pub_key: Optional[str] = None @@ -108,13 +113,13 @@ class LoanSet(Transaction): interest_rate: Optional[int] = None """ - Annualized interest rate of the Loan in in 1/10th basis points. Valid values are + Annualized interest rate of the Loan 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. + A premium added to the interest rate for late payments in 1/10th basis points. Valid values are between 0 and 100000 inclusive. (0 - 100%) """ From ffb6608d4566fed3e9cf2cc0e4c91a85c52dc0b5 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 9 Dec 2025 15:57:14 -0800 Subject: [PATCH 26/28] feat: add support and tests for sign method --- tests/integration/reqs/test_sign.py | 45 +++++++++++++++++++++++++ tests/unit/models/requests/test_sign.py | 8 +++++ xrpl/models/requests/sign.py | 1 + 3 files changed, 54 insertions(+) create mode 100644 tests/integration/reqs/test_sign.py diff --git a/tests/integration/reqs/test_sign.py b/tests/integration/reqs/test_sign.py new file mode 100644 index 000000000..47b6e75b1 --- /dev/null +++ b/tests/integration/reqs/test_sign.py @@ -0,0 +1,45 @@ +from tests.integration.integration_test_case import IntegrationTestCase +from tests.integration.it_utils import fund_wallet_async, test_async_and_sync +from xrpl.asyncio.transaction import autofill_and_sign +from xrpl.models.requests import Sign +from xrpl.models.transactions import LoanSet +from xrpl.wallet import Wallet + +_SECRET = "randomsecretkey" +_LOAN_BROKER_ID = "D1B9DFF432B4F56127BE947281A327B656F202FC1530DD6409D771F7C4CA4F4B" +_COUNTERPARTY_ADDRESS = "rnFRDVZUV9GmqoUJ65gkaQnMEiAnCdxb2m" + + +class TestSign(IntegrationTestCase): + @test_async_and_sync(globals(), ["xrpl.transaction.autofill_and_sign"]) + async def test_basic_functionality(self, client): + loan_issuer = Wallet.create() + await fund_wallet_async(loan_issuer) + + loan_issuer_signed_txn = await autofill_and_sign( + LoanSet( + account=loan_issuer.address, + loan_broker_id=_LOAN_BROKER_ID, + principal_requested="100", + counterparty=_COUNTERPARTY_ADDRESS, + ), + client, + loan_issuer, + ) + response = await client.request( + Sign( + transaction=loan_issuer_signed_txn, + signature_target="CounterpartySignature", + secret=_SECRET, + ) + ) + self.assertTrue(response.is_successful()) + self.assertTrue(response.result["tx_json"]["CounterpartySignature"] is not None) + self.assertTrue( + response.result["tx_json"]["CounterpartySignature"]["SigningPubKey"] + is not None + ) + self.assertTrue( + response.result["tx_json"]["CounterpartySignature"]["TxnSignature"] + is not None + ) diff --git a/tests/unit/models/requests/test_sign.py b/tests/unit/models/requests/test_sign.py index abf274967..863188259 100644 --- a/tests/unit/models/requests/test_sign.py +++ b/tests/unit/models/requests/test_sign.py @@ -67,6 +67,14 @@ def test_valid_secret(self): ) self.assertTrue(request.is_valid()) + def test_valid_signature_target(self): + request = Sign( + transaction=_TRANSACTION, + signature_target="CounterpartySignature", + secret=_SECRET, + ) + self.assertTrue(request.is_valid()) + def test_valid_seed(self): request = Sign( transaction=_TRANSACTION, seed=_SEED, key_type=CryptoAlgorithm.SECP256K1 diff --git a/xrpl/models/requests/sign.py b/xrpl/models/requests/sign.py index 3f1f80c49..b2cfd9788 100644 --- a/xrpl/models/requests/sign.py +++ b/xrpl/models/requests/sign.py @@ -66,6 +66,7 @@ class Sign(Request): build_path: Optional[bool] = None # note: None does have meaning here fee_mult_max: int = 10 fee_div_max: int = 1 + signature_target: Optional[str] = None @classmethod def from_dict(cls: Type[Self], value: Dict[str, Any]) -> Self: From 9188107bd4a96b3c74d9b064ec700d8ba389234a Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 10 Dec 2025 13:20:03 -0800 Subject: [PATCH 27/28] fix: remove moot case in autofilling fees for LoanSet transaction --- xrpl/asyncio/transaction/main.py | 56 ++++++++++++++------------------ 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index b92a84fc7..35c53ce2c 100644 --- a/xrpl/asyncio/transaction/main.py +++ b/xrpl/asyncio/transaction/main.py @@ -554,42 +554,34 @@ async def _calculate_fee_per_transaction_type( # CounterpartySignature field. The transaction fees depend on the number of # signatures specified in the field. 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 + counterparty_account: str + if loan_set.counterparty is not None: + counterparty_account = loan_set.counterparty else: - counterparty_account: str - if loan_set.counterparty is not None: - counterparty_account = loan_set.counterparty - else: - # The sfCounterparty field is optional if the counterparty is the - # LoanBroker owner. Deduce the counterparty account from the LoanBroker - # ID. - loan_broker_object_info = await client._request_impl( - LedgerEntry(index=loan_set.loan_broker_id) - ) - counterparty_account = loan_broker_object_info.result["node"]["Owner"] - counterparty_signers_count = await _fetch_counterparty_signers_count( - client, counterparty_account + # The sfCounterparty field is optional if the counterparty is the + # LoanBroker owner. Deduce the counterparty account from the LoanBroker + # ID. + loan_broker_object_info = await client._request_impl( + LedgerEntry(index=loan_set.loan_broker_id) ) + counterparty_account = loan_broker_object_info.result["node"]["Owner"] + counterparty_signers_count = await _fetch_counterparty_signers_count( + client, counterparty_account + ) - if counterparty_signers_count > 1: - print( - ( - f"Warning: You are using autofill feature for the LoanSet " - f"transaction: {transaction.to_dict()}. The transaction fee " - "estimation is based on the number of signers in the " - "CounterpartySignature field. It might be possible to optimize " - "the fee further by considering the minimum quorum of signers." - "\nIf you prefer optimized transaction fee, please fill the fee" - " field manually." - ) + if counterparty_signers_count > 1: + print( + ( + f"Warning: You are using autofill feature for the LoanSet " + f"transaction: {transaction.to_dict()}. The transaction fee " + "estimation is based on the number of signers in the " + "CounterpartySignature field. It might be possible to optimize " + "the fee further by considering the minimum quorum of signers." + "\nIf you prefer optimized transaction fee, please fill the fee" + " field manually." ) - base_fee += net_fee * counterparty_signers_count + ) + base_fee += net_fee * counterparty_signers_count # Multi-signed/Multi-Account Batch Transactions # BaseFee × (1 + Number of Signatures Provided) From bc273773f6485a356d7326fd4c95f5c793789a8f Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 11 Dec 2025 14:33:12 -0800 Subject: [PATCH 28/28] fix: add PermissionDelegationV1_1 amendment into the config file for explicit inclusion --- .ci-config/rippled.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 36b13a9ba..9c4721e93 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -205,6 +205,7 @@ PermissionedDEX Batch TokenEscrow LendingProtocol +PermissionDelegationV1_1 # This section can be used to simulate various FeeSettings scenarios for rippled node in standalone mode [voting]