Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
373fb52
Models and unit tests for LP amendment; TODO: Integ tests are remaini…
ckeshava Sep 8, 2025
aadadbe
update integration test with LoanSet transaction
ckeshava Sep 12, 2025
1db1744
add integ tests for loan-crud operations; update changelog
ckeshava Sep 13, 2025
b062bc3
address first batch of coderabbit AI suggestions
ckeshava Sep 15, 2025
faca6ae
Merge branch 'main' into xls66d
ckeshava Sep 16, 2025
9083a84
fix linter errors
ckeshava Sep 16, 2025
c672015
update Number internal rippled type into str JSON type
ckeshava Sep 17, 2025
029d65c
add unit tests and validation for loan_broker_set txn
ckeshava Sep 18, 2025
1ddf348
loan_set validation and unit tests
ckeshava Sep 18, 2025
4e5cf35
add hex validation for data field
ckeshava Sep 18, 2025
388553d
update tests for LoanSet txn; remove start_date field
ckeshava Sep 18, 2025
31b699e
integ test for Lending Protocol with IOU
ckeshava Sep 18, 2025
e39509f
fix the errors in STIssue codec
ckeshava Sep 19, 2025
88eade6
Merge branch 'updateIssueCodec' into xls66d
ckeshava Sep 19, 2025
3b47b6f
remove debug helper method
ckeshava Sep 22, 2025
9c38b73
integ test for VaultCreate txn with MPToken
ckeshava Sep 23, 2025
f6daf47
feature: allow xrpl-py integ tests to run on XRPL Devnet; This commit…
ckeshava Sep 23, 2025
9f27a07
fix: update the order of the encoding arguments in serialization of I…
ckeshava Sep 23, 2025
d47410a
add SAV integ test with MPToken as Vault asset
ckeshava Sep 24, 2025
bd2f13a
fix: big-endian format to interpret the sequence number in MPTID
ckeshava Sep 24, 2025
fc158fb
Update tests/integration/it_utils.py
ckeshava Sep 24, 2025
2183f0a
address code rabbit suggestions
ckeshava Sep 24, 2025
3162f69
Merge branch 'updateIssueCodec' into xls66d
ckeshava Sep 24, 2025
48bd4e7
integ test: LendingProtocol Vault with MPToken asset
ckeshava Sep 24, 2025
98288b8
Merge branch 'main' into xls66d
ckeshava Sep 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ci-config/rippled.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
186 changes: 186 additions & 0 deletions tests/integration/transactions/test_lending_protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import datetime

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,
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_manage import LoanManageFlag
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")

# 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)
# The borrower cannot pay the loan before the start date
self.assertEqual(response.result["engine_result"], "tecTOO_SOON")
55 changes: 55 additions & 0 deletions tests/unit/models/transactions/test_loan_broker_cover_clawback.py
Original file line number Diff line number Diff line change
@@ -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 <class 'str'>, 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())
50 changes: 50 additions & 0 deletions tests/unit/models/transactions/test_loan_set.py
Original file line number Diff line number Diff line change
@@ -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())
20 changes: 20 additions & 0 deletions xrpl/asyncio/transaction/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from xrpl.models import (
Batch,
EscrowFinish,
LoanSet,
Response,
ServerState,
Simulate,
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading