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/CHANGELOG.md b/CHANGELOG.md index 06e1d178d..e435e9293 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 new file mode 100644 index 000000000..d264edf10 --- /dev/null +++ b/tests/integration/transactions/test_lending_protocol.py @@ -0,0 +1,581 @@ +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, +) +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, + LoanDelete, + LoanManage, + LoanPay, + LoanSet, + Payment, + Transaction, + TrustSet, + VaultCreate, + 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 + + +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-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", + 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="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") + + @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") + + @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") diff --git a/tests/integration/transactions/test_single_asset_vault.py b/tests/integration/transactions/test_single_asset_vault.py index 474a88ea7..f65b2a8eb 100644 --- a/tests/integration/transactions/test_single_asset_vault.py +++ b/tests/integration/transactions/test_single_asset_vault.py @@ -89,8 +89,6 @@ async def test_vault_with_mptoken(self, client): self.assertTrue(response.is_successful()) self.assertEqual(response.result["engine_result"], "tesSUCCESS") - print(response.result) - # 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) 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_cover_clawback.py b/tests/unit/models/transactions/test_loan_broker_cover_clawback.py new file mode 100644 index 000000000..22ed86361 --- /dev/null +++ b/tests/unit/models/transactions/test_loan_broker_cover_clawback.py @@ -0,0 +1,55 @@ +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], + "{'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): + 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_broker_set.py b/tests/unit/models/transactions/test_loan_broker_set.py new file mode 100644 index 000000000..e055863e3 --- /dev/null +++ b/tests/unit/models/transactions/test_loan_broker_set.py @@ -0,0 +1,147 @@ +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_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( + 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/tests/unit/models/transactions/test_loan_set.py b/tests/unit/models/transactions/test_loan_set.py new file mode 100644 index 000000000..f66ca3408 --- /dev/null +++ b/tests/unit/models/transactions/test_loan_set.py @@ -0,0 +1,212 @@ +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_data_too_long(self): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + data="A" * 257 * 2, + ) + self.assertEqual( + error.exception.args[0], + "{'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", + 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): + with self.assertRaises(XRPLModelException) as error: + LoanSet( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + 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", + 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", + 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", + 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", + 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", + 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", + 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", + 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", + 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", + 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( + account=_SOURCE, + loan_broker_id=_ISSUER, + principal_requested="100000000", + 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", + 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", + ) + self.assertTrue(tx.is_valid()) diff --git a/xrpl/asyncio/transaction/main.py b/xrpl/asyncio/transaction/main.py index 4ec1dda28..a76427a14 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, @@ -181,6 +182,8 @@ async def submit( if response.is_successful(): return response + print("input txn: ", transaction) + print(response.result) raise XRPLRequestFailureException(response.result) @@ -516,6 +519,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/core/binarycodec/definitions/definitions.json b/xrpl/core/binarycodec/definitions/definitions.json index 9fdd5ff6a..a08372e54 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": 54, + "type": "UInt32" + } + ], + [ + "PaymentInterval", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 55, + "type": "UInt32" + } + ], + [ + "GracePeriod", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 56, + "type": "UInt32" + } + ], + [ + "PreviousPaymentDate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 57, + "type": "UInt32" + } + ], + [ + "NextPaymentDueDate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 58, + "type": "UInt32" + } + ], + [ + "PaymentRemaining", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 59, + "type": "UInt32" + } + ], + [ + "PaymentTotal", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 60, + "type": "UInt32" + } + ], + [ + "LoanSequence", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 61, + "type": "UInt32" + } + ], + [ + "CoverRateMinimum", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 62, + "type": "UInt32" + } + ], + [ + "CoverRateLiquidation", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 63, + "type": "UInt32" + } + ], + [ + "OverpaymentFee", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 64, + "type": "UInt32" + } + ], + [ + "InterestRate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 65, + "type": "UInt32" + } + ], + [ + "LateInterestRate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 66, + "type": "UInt32" + } + ], + [ + "CloseInterestRate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 67, + "type": "UInt32" + } + ], + [ + "OverpaymentInterestRate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 68, + "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", { @@ -3076,6 +3395,8 @@ "FeeSettings": 115, "Invalid": -1, "LedgerHashes": 104, + "Loan": 137, + "LoanBroker": 136, "MPToken": 127, "MPTokenIssuance": 126, "NFTokenOffer": 55, @@ -3316,6 +3637,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/core/binarycodec/types/issue.py b/xrpl/core/binarycodec/types/issue.py index cac46c067..c2f420838 100644 --- a/xrpl/core/binarycodec/types/issue.py +++ b/xrpl/core/binarycodec/types/issue.py @@ -78,7 +78,6 @@ def from_value(cls: Type[Self], value: Dict[str, str]) -> Self: mpt_issuance_id_bytes = bytes(Hash192.from_value(value["mpt_issuance_id"])) # rippled accepts sequence number in big-endian format only. - # sequence_in_hex = mpt_issuance_id_bytes[:4].hex().upper() sequenceBE = ( int.from_bytes( mpt_issuance_id_bytes[:4], byteorder="little", signed=False diff --git a/xrpl/models/requests/account_objects.py b/xrpl/models/requests/account_objects.py index d7b96991c..07aa9154f 100644 --- a/xrpl/models/requests/account_objects.py +++ b/xrpl/models/requests/account_objects.py @@ -27,6 +27,8 @@ class AccountObjectType(str, Enum): DELEGATE = "delegate" DID = "did" ESCROW = "escrow" + LOAN = "loan" + LOAN_BROKER = "loan_broker" MPT_ISSUANCE = "mpt_issuance" MPTOKEN = "mptoken" NFT_OFFER = "nft_offer" 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..9606e602f --- /dev/null +++ b/xrpl/models/transactions/loan_broker_cover_clawback.py @@ -0,0 +1,68 @@ +"""Model for LoanBrokerCoverClawback transaction type.""" + +from __future__ import annotations # Requires Python 3.7+ + +from dataclasses import dataclass, field +from typing import Dict, Optional, Union + +from typing_extensions import Self + +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 + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class LoanBrokerCoverClawback(Transaction): + """This transaction claws back First-Loss Capital from a Loan Broker""" + + loan_broker_id: Optional[str] = None + """ + 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[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. + """ + + 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..6a4db5ba7 --- /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 + """ + The Loan Broker ID to deposit First-Loss Capital. + """ + + amount: Amount = REQUIRED + """ + The First-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..b34cd7a00 --- /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 + """ + The Loan Broker ID from which to withdraw First-Loss Capital. + """ + + amount: Amount = REQUIRED + """ + The First-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..4e3eeb770 --- /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 + """ + 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..5fcd03b2e --- /dev/null +++ b/xrpl/models/transactions/loan_broker_set.py @@ -0,0 +1,112 @@ +"""Model for LoanBrokerSet 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.constants import HEX_REGEX +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 + """ + 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[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. + """ + + 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, + ) + + 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.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 + ): + 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 diff --git a/xrpl/models/transactions/loan_delete.py b/xrpl/models/transactions/loan_delete.py new file mode 100644 index 000000000..b6dcc0b8e --- /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 + """ + 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..e3067bd83 --- /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 + """ + 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..61e5dd86e --- /dev/null +++ b/xrpl/models/transactions/loan_pay.py @@ -0,0 +1,34 @@ +"""Model for LoanPay 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 LoanPay(Transaction): + """The Borrower submits a LoanPay transaction to make a Payment on the Loan.""" + + loan_id: str = REQUIRED + """ + The ID of the Loan object to be paid to. + This field is required. + """ + + amount: Amount = REQUIRED + """ + 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..0c47cd18d --- /dev/null +++ b/xrpl/models/transactions/loan_set.py @@ -0,0 +1,237 @@ +"""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.constants import HEX_REGEX +from xrpl.models.base_model import BaseModel +from xrpl.models.required import REQUIRED +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 + + +@require_kwargs_on_init +@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: + """ + + signing_pub_key: Optional[str] = None + txn_signature: Optional[str] = None + signers: Optional[List[Signer]] = 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 + """ + 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[str] = None + """ + A nominal funds amount paid to the LoanBroker.Owner when the Loan is created. + """ + + loan_service_fee: Optional[str] = None + """ + A nominal amount paid to the LoanBroker.Owner with every Loan payment. + """ + + late_payment_fee: Optional[str] = None + """ + A nominal funds amount paid to the LoanBroker.Owner when a payment is late. + """ + + close_payment_fee: Optional[str] = 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: str = REQUIRED + """ + The principal amount requested by the Borrower. + """ + + 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, + ) + + MAX_DATA_LENGTH = 256 * 2 + 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 + for key, value in { + **super()._get_errors(), + }.items() + if value is not None + } + + if self.data is not None and len(self.data) > self.MAX_DATA_LENGTH: + 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 + 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." + ) + + 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"