Skip to content

Add support for Lending Protocol - XLS-66#719

Open
Patel-Raj11 wants to merge 57 commits intomainfrom
rp/lending-protocol
Open

Add support for Lending Protocol - XLS-66#719
Patel-Raj11 wants to merge 57 commits intomainfrom
rp/lending-protocol

Conversation

@Patel-Raj11
Copy link
Copy Markdown
Collaborator

XLS spec - https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0066-lending-protocol

Transactions added/updated:

  • LoanBrokerSet
  • LoanBrokerDelete
  • LoanBrokerCoverDeposit
  • LoanBrokerCoverWithdraw
  • LoanBrokerCoverClawback
  • LoanSet
  • LoanDelete
  • LoanManage
  • LoanPay

Ledger objects added/updated:

  • LoanObject
  • LoanBrokerObject

Flags added:

  • LoanSetFlags
  • LoanManageFlags
  • LoanPayFlags
  • LoanFlags (ledger entry flags)

Signing added/updated:

  • TransactionSigner (added counterpartySign() and counterpartyMultiSign() for counterparty signing)

Binary codec added/updated:

  • XrplBinaryCodec (added encodeForMultiSigningWithSigningPubKey() — preserves SigningPubKey unlike encodeForMultiSigning)

Fees added/updated:

  • FeeUtils (added computeLoanSetNetworkFees() — accounts for extra counterparty signer in fee calculation)

@cybele-ripple
Copy link
Copy Markdown
Collaborator

General note: There are multiple places where 0000000000000000000000000000000000000000000000000000000000000000 is used (LoanSet.java, LoanBrokerSet.java, LoanDelete.java, LoanBrokerDelete.java, LoanManage.java, LoanPay.java, LoanBrokerCoverClawback.java, LoanBrokerCoverDeposit.java, LoanBrokerCoverWithdraw.java). We should put this value in a constant and then import this constant where needed throughout this PR

Comment thread xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/LoanBrokerSet.java Outdated
Comment thread xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/Wrappers.java Outdated
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create a constant for 79E25403E9FC010A277D80410EED5494FDD033A09FD4C1432335A1734A1D099D at the beginning of the file since this value is used multiple times here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think moving this to a class level constant would be appropriate if these were unit tests and had specific meaning across the tests. However, these are JSON tests and the value has no particular meaning. I think it's better to leave it here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider creating constants for ED1F863E4E0957C6965B7B0563D56C77E4F68D571E8026251834F0ADBB0411D6FD and 79E25403E9FC010A277D80410EED5494FDD033A09FD4C1432335A1734A1D099D

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like these values appear in other files as well. Maybe create this constant in some place where multiple files can easily reuse this value?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These have no particular significance and are readable in the current form along side the JSON string.

Base automatically changed from rp/single-asset-vault to main April 29, 2026 00:17
Comment thread xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/LendingProtocolIT.java Dismissed
Comment thread xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/LendingProtocolIT.java Dismissed
Comment thread xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/LendingProtocolIT.java Dismissed
Comment thread xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/LendingProtocolIT.java Dismissed
Comment thread xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/LendingProtocolIT.java Dismissed
Comment thread xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/LendingProtocolIT.java Dismissed
Comment thread xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/LendingProtocolIT.java Dismissed
Comment on lines +66 to +67
flagCount <= 1,
"Only one of tfLoanDefault, tfLoanImpair, or tfLoanUnimpair may be set."
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be flagCount == 1? The string on line 67 implies that a flagCount of 0 is not expected

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make sure we have unit test coverage for whatever the final decision is.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the comment in LoanPay. It applies here as well.

Comment on lines +79 to +80
flagCount <= 1,
"Only one of tfLoanLatePayment, tfLoanFullPayment, or tfLoanOverpayment may be set."
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue: should this be flagCount==1? Either this check should be updated or the string on line 80 should be updated to account for a possible flagCount of 0

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically the transaction passes at rippled even if none of the flags are set. So I have updated the error message to depict the same in 0f523c5. Also, there are unit tests present to test this.

* @return An optionally-present {@link Amount}.
*/
@JsonProperty("DebtTotal")
Optional<Amount> debtTotal();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the spec, the only two truly optional fields are ManagementFeeRate and Data. All other fields, including this field, are marked as required=yes with a default value of 0. So, it seems like we should change all of the non-optional fields to be like this:

@JsonProperty("DebtMaximum")
default Amount debtTotal(){
  return Amount.ZERO;
}

The one exclusion to this (that I can see in the spec) is DebtMaximum, which has the comment "The default value of 0 means there is no limit to the debt."

Because of that one comment, DebtMaximum seems like the only truly Optional field, and should be left alone.

Thoughts?

Copy link
Copy Markdown
Collaborator Author

@Patel-Raj11 Patel-Raj11 May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LoanBroker object has either soeDEFAULT or soeREQUIRED fields, as per ledger_entries.macro. So, I defaulted to Zero value for most of the soeDEFAULT fields.

Even though Data as marked as soeDEFAULT, it makes sense to keep it optional as it doesn't make sense to have return an empty string as Metadata if present cannot be empty.

I agree to keep DebtMaximum Optional in Java for the reason you mentioned.

Addressed 0f523c5

* @return An optionally-present {@link Amount}.
*/
@JsonProperty("PrincipalOutstanding")
Optional<Amount> principalOutstanding();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the spec, most of these fields (except LoanScale) are non-optional. Seems like these should just be default values with Amount.ZERO as the default.

Thoughts?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loan object has either soeDEFAULT or soeREQUIRED fields, as per ledger_entries.macro. So I have given default zero value for each type accordingly.

LoanScale is also soeDEFAULT, so I have defaulted it's value to 0. Does this sound right for LoanScale?

* @return An optionally-present {@link String}.
*/
@JsonProperty("SigningPubKey")
Optional<String> signingPubKey();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should use PublicKey here, like we do in BatchSigner. That way, developers are guided around how exactly to assemble bytes (no String), and serde does all the work.

Thoughts?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes makes sense to use PublicKey and Signature
0f523c5

* @return An optionally-present {@link String}.
*/
@JsonProperty("TxnSignature")
Optional<String> txnSignature();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use Signature here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed 0f523c5

Comment on lines +66 to +67
flagCount <= 1,
"Only one of tfLoanDefault, tfLoanImpair, or tfLoanUnimpair may be set."
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make sure we have unit test coverage for whatever the final decision is.

@JsonDeserialize(as = VaultData.class, using = VaultDataDeserializer.class)
@Beta
abstract static class _VaultData extends Wrapper<String> implements Serializable {
abstract static class Metadata extends Wrapper<String> implements Serializable {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a unit test that covers Metadata directly (not implicitly, such as VaultDataTest or otherwise)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed 0f523c5

Comment on lines +228 to +237
public String encodeForMultiSigningWithSigningPubKey(
String json, String xrpAccountId
) throws JsonProcessingException {
JsonNode node = BINARY_CODEC_OBJECT_MAPPER.readTree(json);
if (!node.isObject()) {
throw new IllegalArgumentException("JSON object required for signing");
}
// NOTE: Unlike encodeForMultiSigning, we do NOT clear SigningPubKey here.
String suffix = new AccountIdType().fromJson(new TextNode(xrpAccountId)).toHex();
return TRX_MULTI_SIGNATURE_PREFIX + encode(removeNonSigningFields(node)) + suffix;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: LOW

encodeForMultiSigningWithSigningPubKey does not validate that SigningPubKey is present and non-empty. If the transaction retains its default empty SigningPubKey, the output is identical to encodeForMultiSigning, eliminating the intended domain separation between regular multi-sign and counterparty multi-sign contexts.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Add a validation check after the JSON object check to ensure that the SigningPubKey field is present and non-empty. This prevents the method from silently producing output identical to encodeForMultiSigning when SigningPubKey is missing or empty, which would eliminate the intended domain separation between regular multi-sign and counterparty multi-sign contexts.

After the if (!node.isObject()) check (around line 233), add:

JsonNode signingPubKeyNode = node.get("SigningPubKey");
if (signingPubKeyNode == null || signingPubKeyNode.asText().isEmpty()) {
  throw new IllegalArgumentException("SigningPubKey must be present and non-empty for counterparty multi-signing");
}

⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.

Suggested change
public String encodeForMultiSigningWithSigningPubKey(
String json, String xrpAccountId
) throws JsonProcessingException {
JsonNode node = BINARY_CODEC_OBJECT_MAPPER.readTree(json);
if (!node.isObject()) {
throw new IllegalArgumentException("JSON object required for signing");
}
// NOTE: Unlike encodeForMultiSigning, we do NOT clear SigningPubKey here.
String suffix = new AccountIdType().fromJson(new TextNode(xrpAccountId)).toHex();
return TRX_MULTI_SIGNATURE_PREFIX + encode(removeNonSigningFields(node)) + suffix;
public String encodeForMultiSigningWithSigningPubKey(
String json, String xrpAccountId
) throws JsonProcessingException {
JsonNode node = BINARY_CODEC_OBJECT_MAPPER.readTree(json);
if (!node.isObject()) {
throw new IllegalArgumentException("JSON object required for signing");
}
JsonNode signingPubKeyNode = node.get("SigningPubKey");
if (signingPubKeyNode == null || signingPubKeyNode.asText().isEmpty()) {
throw new IllegalArgumentException("SigningPubKey must be present and non-empty for counterparty multi-signing");
}
// NOTE: Unlike encodeForMultiSigning, we do NOT clear SigningPubKey here.
String suffix = new AccountIdType().fromJson(new TextNode(xrpAccountId)).toHex();
return TRX_MULTI_SIGNATURE_PREFIX + encode(removeNonSigningFields(node)) + suffix;
}

Comment on lines +186 to +192
public UnsignedByteArray toCounterpartyMultiSignableBytes(final LoanSet transaction, final Address signerAddress) {
Objects.requireNonNull(transaction);
Objects.requireNonNull(signerAddress);

try {
final String unsignedJson = objectMapper.writeValueAsString(transaction);
final String unsignedBinaryHex = binaryCodec.encodeForMultiSigningWithSigningPubKey(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: LOW

toCounterpartyMultiSignableBytes does not verify transaction.signingPublicKey() is non-empty before encoding. A LoanSet with default empty SigningPubKey will silently produce multi-sign bytes indistinguishable from toMultiSignableBytes, breaking domain separation guarantees.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Add a precondition check after the null checks (line 188) to verify that transaction.signingPublicKey() is non-empty before encoding. When signingPublicKey has an empty value (i.e., it equals PublicKey.MULTI_SIGN_PUBLIC_KEY), the output of encodeForMultiSigningWithSigningPubKey becomes indistinguishable from encodeForMultiSigning, breaking domain separation between regular multi-signing and counterparty multi-signing. Add: if (transaction.signingPublicKey().value().length() == 0) { throw new IllegalArgumentException("LoanSet.signingPublicKey must be non-empty for counterparty multi-signing"); }

⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.

Suggested change
public UnsignedByteArray toCounterpartyMultiSignableBytes(final LoanSet transaction, final Address signerAddress) {
Objects.requireNonNull(transaction);
Objects.requireNonNull(signerAddress);
try {
final String unsignedJson = objectMapper.writeValueAsString(transaction);
final String unsignedBinaryHex = binaryCodec.encodeForMultiSigningWithSigningPubKey(
public UnsignedByteArray toCounterpartyMultiSignableBytes(final LoanSet transaction, final Address signerAddress) {
Objects.requireNonNull(transaction);
Objects.requireNonNull(signerAddress);
if (transaction.signingPublicKey().value().length() == 0) {
throw new IllegalArgumentException(
"LoanSet.signingPublicKey must be non-empty for counterparty multi-signing to ensure domain separation"
);
}
try {
final String unsignedJson = objectMapper.writeValueAsString(transaction);
final String unsignedBinaryHex = binaryCodec.encodeForMultiSigningWithSigningPubKey(
unsignedJson, signerAddress.value()
);
return UnsignedByteArray.fromHex(unsignedBinaryHex);
} catch (JsonProcessingException e) {
throw new RuntimeException(e.getMessage(), e);
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants