Add more LoanSet unit tests, added LoanBroker LoanSequence field

- LoanSequence will prevent loan key collisions
This commit is contained in:
Ed Hennis
2025-04-28 21:10:11 -04:00
parent 0a4c0a555d
commit 70155a5ac2
15 changed files with 407 additions and 148 deletions

View File

@@ -133,15 +133,15 @@ public:
@param requireCanonicalSig If `true`, check that the signature is fully @param requireCanonicalSig If `true`, check that the signature is fully
canonical. If `false`, only check that the signature is valid. canonical. If `false`, only check that the signature is valid.
@param rules The current ledger rules. @param rules The current ledger rules.
@param sigObject The object that contains the signature fields. Will @param pSig Pointer to object that contains the signature fields, if not
most often be *this. using "this". Will most often be null
@return `true` if valid signature. If invalid, the error message string. @return `true` if valid signature. If invalid, the error message string.
*/ */
Expected<void, std::string> Expected<void, std::string>
checkSign( checkSign(
RequireFullyCanonicalSig requireCanonicalSig, RequireFullyCanonicalSig requireCanonicalSig,
Rules const& rules, Rules const& rules,
STObject const& sigObject) const; STObject const* pSig) const;
/** Check the signature. /** Check the signature.
@param requireCanonicalSig If `true`, check that the signature is fully @param requireCanonicalSig If `true`, check that the signature is fully
@@ -172,13 +172,13 @@ private:
Expected<void, std::string> Expected<void, std::string>
checkSingleSign( checkSingleSign(
RequireFullyCanonicalSig requireCanonicalSig, RequireFullyCanonicalSig requireCanonicalSig,
STObject const& sigObject) const; STObject const* pSig) const;
Expected<void, std::string> Expected<void, std::string>
checkMultiSign( checkMultiSign(
RequireFullyCanonicalSig requireCanonicalSig, RequireFullyCanonicalSig requireCanonicalSig,
Rules const& rules, Rules const& rules,
STObject const& sigObject) const; STObject const* pSig) const;
STBase* STBase*
copy(std::size_t n, void* buf) const override; copy(std::size_t n, void* buf) const override;

View File

@@ -501,6 +501,7 @@ LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({
{sfVaultID, soeREQUIRED}, {sfVaultID, soeREQUIRED},
{sfAccount, soeREQUIRED}, {sfAccount, soeREQUIRED},
{sfOwner, soeREQUIRED}, {sfOwner, soeREQUIRED},
{sfLoanSequence, soeREQUIRED},
{sfData, soeDEFAULT}, {sfData, soeDEFAULT},
{sfManagementFeeRate, soeDEFAULT}, {sfManagementFeeRate, soeDEFAULT},
{sfOwnerCount, soeDEFAULT}, {sfOwnerCount, soeDEFAULT},

View File

@@ -125,15 +125,16 @@ TYPED_SFIELD(sfPreviousPaymentDate, UINT32, 55)
TYPED_SFIELD(sfNextPaymentDueDate, UINT32, 56) TYPED_SFIELD(sfNextPaymentDueDate, UINT32, 56)
TYPED_SFIELD(sfPaymentRemaining, UINT32, 57) TYPED_SFIELD(sfPaymentRemaining, UINT32, 57)
TYPED_SFIELD(sfPaymentTotal, UINT32, 58) TYPED_SFIELD(sfPaymentTotal, UINT32, 58)
TYPED_SFIELD(sfLoanSequence, UINT32, 59)
// 32-bit integers represented as 1/10 basis points (bips) // 32-bit integers represented as 1/10 basis points (bips)
TYPED_SFIELD(sfCoverRateMinimum, TENTHBIPS32, 1) TYPED_SFIELD(sfCoverRateMinimum, TENTHBIPS32, 1)
TYPED_SFIELD(sfCoverRateLiquidation, TENTHBIPS32, 2) TYPED_SFIELD(sfCoverRateLiquidation, TENTHBIPS32, 2)
TYPED_SFIELD(sfInterestRate, TENTHBIPS32, 3) TYPED_SFIELD(sfOverpaymentFee, TENTHBIPS32, 3)
TYPED_SFIELD(sfLateInterestRate, TENTHBIPS32, 4) TYPED_SFIELD(sfInterestRate, TENTHBIPS32, 4)
TYPED_SFIELD(sfCloseInterestRate, TENTHBIPS32, 5) TYPED_SFIELD(sfLateInterestRate, TENTHBIPS32, 5)
TYPED_SFIELD(sfOverpaymentInterestRate, TENTHBIPS32, 6) TYPED_SFIELD(sfCloseInterestRate, TENTHBIPS32, 6)
TYPED_SFIELD(sfOverpaymentInterestRate, TENTHBIPS32, 7)
// 64-bit integers (common) // 64-bit integers (common)
TYPED_SFIELD(sfIndexNext, UINT64, 1) TYPED_SFIELD(sfIndexNext, UINT64, 1)
@@ -236,9 +237,8 @@ TYPED_SFIELD(sfLoanOriginationFee, NUMBER, 9)
TYPED_SFIELD(sfLoanServiceFee, NUMBER, 10) TYPED_SFIELD(sfLoanServiceFee, NUMBER, 10)
TYPED_SFIELD(sfLatePaymentFee, NUMBER, 11) TYPED_SFIELD(sfLatePaymentFee, NUMBER, 11)
TYPED_SFIELD(sfClosePaymentFee, NUMBER, 12) TYPED_SFIELD(sfClosePaymentFee, NUMBER, 12)
TYPED_SFIELD(sfOverpaymentFee, NUMBER, 13) TYPED_SFIELD(sfPrincipalOutstanding, NUMBER, 13)
TYPED_SFIELD(sfPrincipalOutstanding, NUMBER, 14) TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14)
TYPED_SFIELD(sfPrincipalRequested, NUMBER, 15)
// currency amount (common) // currency amount (common)
TYPED_SFIELD(sfAmount, AMOUNT, 1) TYPED_SFIELD(sfAmount, AMOUNT, 1)

View File

@@ -158,7 +158,7 @@ InnerObjectFormats::InnerObjectFormats()
add(sfCounterpartySignature.jsonName, add(sfCounterpartySignature.jsonName,
sfCounterpartySignature.getCode(), sfCounterpartySignature.getCode(),
{ {
{sfSigningPubKey, soeREQUIRED}, {sfSigningPubKey, soeOPTIONAL},
{sfTxnSignature, soeOPTIONAL}, {sfTxnSignature, soeOPTIONAL},
{sfSigners, soeOPTIONAL}, {sfSigners, soeOPTIONAL},
}); });

View File

@@ -245,17 +245,19 @@ Expected<void, std::string>
STTx::checkSign( STTx::checkSign(
RequireFullyCanonicalSig requireCanonicalSig, RequireFullyCanonicalSig requireCanonicalSig,
Rules const& rules, Rules const& rules,
STObject const& sigObject) const STObject const* pSig) const
{ {
try try
{ {
// Determine whether we're single- or multi-signing by looking // Determine whether we're single- or multi-signing by looking
// at the SigningPubKey. If it's empty we must be // at the SigningPubKey. If it's empty we must be
// multi-signing. Otherwise we're single-signing. // multi-signing. Otherwise we're single-signing.
STObject const& sigObject{pSig ? *pSig : *this};
Blob const& signingPubKey = sigObject.getFieldVL(sfSigningPubKey); Blob const& signingPubKey = sigObject.getFieldVL(sfSigningPubKey);
return signingPubKey.empty() return signingPubKey.empty()
? checkMultiSign(requireCanonicalSig, rules, sigObject) ? checkMultiSign(requireCanonicalSig, rules, pSig)
: checkSingleSign(requireCanonicalSig, sigObject); : checkSingleSign(requireCanonicalSig, pSig);
} }
catch (std::exception const&) catch (std::exception const&)
{ {
@@ -268,13 +270,13 @@ STTx::checkSign(
RequireFullyCanonicalSig requireCanonicalSig, RequireFullyCanonicalSig requireCanonicalSig,
Rules const& rules) const Rules const& rules) const
{ {
if (auto const ret = checkSign(requireCanonicalSig, rules, *this); !ret) if (auto const ret = checkSign(requireCanonicalSig, rules, nullptr); !ret)
return ret; return ret;
if (isFieldPresent(sfCounterpartySignature)) if (isFieldPresent(sfCounterpartySignature))
{ {
auto const counterSig = getFieldObject(sfCounterpartySignature); auto const counterSig = getFieldObject(sfCounterpartySignature);
if (auto const ret = checkSign(requireCanonicalSig, rules, counterSig); if (auto const ret = checkSign(requireCanonicalSig, rules, &counterSig);
!ret) !ret)
return Unexpected("Counterparty: " + ret.error()); return Unexpected("Counterparty: " + ret.error());
} }
@@ -363,8 +365,10 @@ STTx::getMetaSQL(
Expected<void, std::string> Expected<void, std::string>
STTx::checkSingleSign( STTx::checkSingleSign(
RequireFullyCanonicalSig requireCanonicalSig, RequireFullyCanonicalSig requireCanonicalSig,
STObject const& sigObject) const STObject const* pSig) const
{ {
STObject const& sigObject{pSig ? *pSig : *this};
// We don't allow both a non-empty sfSigningPubKey and an sfSigners. // We don't allow both a non-empty sfSigningPubKey and an sfSigners.
// That would allow the transaction to be signed two ways. So if both // That would allow the transaction to be signed two ways. So if both
// fields are present the signature is invalid. // fields are present the signature is invalid.
@@ -406,8 +410,10 @@ Expected<void, std::string>
STTx::checkMultiSign( STTx::checkMultiSign(
RequireFullyCanonicalSig requireCanonicalSig, RequireFullyCanonicalSig requireCanonicalSig,
Rules const& rules, Rules const& rules,
STObject const& sigObject) const STObject const* pSig) const
{ {
STObject const& sigObject{pSig ? *pSig : *this};
// Make sure the MultiSigners are present. Otherwise they are not // Make sure the MultiSigners are present. Otherwise they are not
// attempting multi-signing and we just have a bad SigningPubKey. // attempting multi-signing and we just have a bad SigningPubKey.
if (!sigObject.isFieldPresent(sfSigners)) if (!sigObject.isFieldPresent(sfSigners))
@@ -444,8 +450,8 @@ STTx::checkMultiSign(
{ {
auto const accountID = signer.getAccountID(sfAccount); auto const accountID = signer.getAccountID(sfAccount);
// The account owner may not multisign for themselves. // The account owner may not usually multisign for themselves.
if (accountID == txnAccountID) if (!pSig && accountID == txnAccountID)
return Unexpected("Invalid multisigner."); return Unexpected("Invalid multisigner.");
// No duplicate signers allowed. // No duplicate signers allowed.

View File

@@ -148,6 +148,7 @@ class LoanBroker_test : public beast::unit_test::suite
BEAST_EXPECT(broker->at(sfFlags) == 0); BEAST_EXPECT(broker->at(sfFlags) == 0);
BEAST_EXPECT(broker->at(sfSequence) == env.seq(alice) - 1); BEAST_EXPECT(broker->at(sfSequence) == env.seq(alice) - 1);
BEAST_EXPECT(broker->at(sfOwnerCount) == 0); BEAST_EXPECT(broker->at(sfOwnerCount) == 0);
BEAST_EXPECT(broker->at(sfLoanSequence) == 1);
BEAST_EXPECT(broker->at(sfDebtTotal) == 0); BEAST_EXPECT(broker->at(sfDebtTotal) == 0);
BEAST_EXPECT(broker->at(sfCoverAvailable) == 0); BEAST_EXPECT(broker->at(sfCoverAvailable) == 0);
if (checkBroker) if (checkBroker)

View File

@@ -23,6 +23,8 @@
#include <test/jtx/mpt.h> #include <test/jtx/mpt.h>
#include <test/jtx/vault.h> #include <test/jtx/vault.h>
#include <xrpld/app/tx/detail/LoanSet.h>
#include <xrpl/basics/base_uint.h> #include <xrpl/basics/base_uint.h>
#include <xrpl/beast/unit_test/suite.h> #include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_forwards.h> #include <xrpl/json/json_forwards.h>
@@ -354,34 +356,40 @@ class Loan_test : public beast::unit_test::suite
// MPT. That'll require three corresponding SAVs. // MPT. That'll require three corresponding SAVs.
Env env(*this, all); Env env(*this, all);
Account issuer{"issuer"}; Account const issuer{"issuer"};
// For simplicity, alice will be the sole actor for the vault & brokers. // For simplicity, lender will be the sole actor for the vault &
Account alice{"alice"}; // brokers.
Account const lender{"lender"};
// Borrower only wants to borrow
Account const borrower{"borrower"};
// Evan will attempt to be naughty // Evan will attempt to be naughty
Account evan{"evan"}; Account const evan{"evan"};
// Do not fund alice
Account const alice{"alice"};
Vault vault{env}; Vault vault{env};
// Fund the accounts and trust lines with the same amount so that tests // Fund the accounts and trust lines with the same amount so that tests
// can use the same values regardless of the asset. // can use the same values regardless of the asset.
env.fund(XRP(100'000), issuer, noripple(alice, evan)); env.fund(XRP(100'000), issuer, noripple(lender, borrower, evan));
env.close(); env.close();
// Create assets // Create assets
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
PrettyAsset const iouAsset = issuer["IOU"]; std::string const iouCurrency{"IOU"};
env(trust(alice, iouAsset(1'000'000))); PrettyAsset const iouAsset = issuer[iouCurrency];
env(trust(lender, iouAsset(1'000'000)));
env(trust(evan, iouAsset(1'000'000))); env(trust(evan, iouAsset(1'000'000)));
env(pay(issuer, evan, iouAsset(100'000))); env(pay(issuer, evan, iouAsset(100'000)));
env(pay(issuer, alice, iouAsset(100'000))); env(pay(issuer, lender, iouAsset(100'000)));
env.close(); env.close();
MPTTester mptt{env, issuer, mptInitNoFund}; MPTTester mptt{env, issuer, mptInitNoFund};
mptt.create( mptt.create(
{.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
PrettyAsset const mptAsset = mptt.issuanceID(); PrettyAsset const mptAsset = mptt.issuanceID();
mptt.authorize({.account = alice}); mptt.authorize({.account = lender});
mptt.authorize({.account = evan}); mptt.authorize({.account = evan});
env(pay(issuer, alice, mptAsset(100'000))); env(pay(issuer, lender, mptAsset(100'000)));
env(pay(issuer, evan, mptAsset(100'000))); env(pay(issuer, evan, mptAsset(100'000)));
env.close(); env.close();
@@ -392,114 +400,259 @@ class Loan_test : public beast::unit_test::suite
for (auto const& asset : assets) for (auto const& asset : assets)
{ {
auto [tx, vaultKeylet] = auto [tx, vaultKeylet] =
vault.create({.owner = alice, .asset = asset}); vault.create({.owner = lender, .asset = asset});
env(tx); env(tx);
env.close(); env.close();
BEAST_EXPECT(env.le(vaultKeylet)); BEAST_EXPECT(env.le(vaultKeylet));
env(vault.deposit( env(vault.deposit(
{.depositor = alice, {.depositor = lender,
.id = vaultKeylet.key, .id = vaultKeylet.key,
.amount = asset(5000)})); .amount = asset(50'000)}));
env.close(); env.close();
auto const keylet = keylet::loanbroker(alice.id(), env.seq(alice)); auto const keylet =
keylet::loanbroker(lender.id(), env.seq(lender));
auto const testData = "spam spam spam spam"; auto const testData = "spam spam spam spam";
using namespace loanBroker; using namespace loanBroker;
env(set(alice, vaultKeylet.key), env(set(lender, vaultKeylet.key),
fee(increment), fee(increment),
data(testData), data(testData),
managementFeeRate(TenthBips16(123)), managementFeeRate(TenthBips16(100)),
debtMaximum(Number(9)), debtMaximum(Number(25'000)),
coverRateMinimum(TenthBips32(100)), coverRateMinimum(TenthBips32(percentageToTenthBips(10))),
coverRateLiquidation(TenthBips32(200))); coverRateLiquidation(TenthBips32(percentageToTenthBips(25))));
env(coverDeposit(lender, keylet.key, asset(1000)));
brokers.emplace_back(asset, keylet.key); brokers.emplace_back(asset, keylet.key);
} }
#if 0 // Create and update Loans
// Create and update Loan Brokers for (auto const& broker : brokers)
for (auto const& vault : vaults)
{ {
using namespace loanBroker; using namespace loan;
using namespace std::chrono_literals;
auto badKeylet = keylet::vault(alice.id(), env.seq(alice)); auto const principalRequested = Number(1000);
auto const startDate = env.now() + 3600s;
auto const loanSetFee = fee(env.current()->fees().base * 2);
auto badKeylet = keylet::vault(lender.id(), env.seq(lender));
// Try some failure cases // Try some failure cases
// insufficient fee // insufficient fee - single sign
env(set(evan, vault.vaultID), ter(telINSUF_FEE_P)); env(set(borrower, broker.brokerID, principalRequested, startDate),
// not the vault owner sig(sfCounterpartySignature, lender),
env(set(evan, vault.vaultID), ter(telINSUF_FEE_P));
fee(increment), // insufficient fee - multisign
env(set(borrower, broker.brokerID, principalRequested, startDate),
counterparty(lender),
msig(evan, lender),
msig(sfCounterpartySignature, evan, borrower),
fee(env.current()->fees().base * 5 - 1),
ter(telINSUF_FEE_P));
// multisign sufficient fee, but no signers set up
env(set(borrower, broker.brokerID, principalRequested, startDate),
counterparty(lender),
msig(evan, lender),
msig(sfCounterpartySignature, evan, borrower),
fee(env.current()->fees().base * 5),
ter(tefNOT_MULTI_SIGNING));
// not the broker owner, no counterparty, not signed by broker owner
env(set(borrower, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, evan),
loanSetFee,
ter(tefBAD_AUTH));
// not the broker owner, counterparty is borrower
env(set(evan, broker.brokerID, principalRequested, startDate),
counterparty(borrower),
sig(sfCounterpartySignature, borrower),
loanSetFee,
ter(tecNO_PERMISSION)); ter(tecNO_PERMISSION));
// not a vault // not a LoanBroker object, no counterparty
env(set(alice, badKeylet.key), fee(increment), ter(tecNO_ENTRY)); env(set(lender, badKeylet.key, principalRequested, startDate),
sig(sfCounterpartySignature, evan),
loanSetFee,
ter(temBAD_SIGNER));
// not a LoanBroker object, counterparty is valid
env(set(lender, badKeylet.key, principalRequested, startDate),
counterparty(borrower),
sig(sfCounterpartySignature, borrower),
loanSetFee,
ter(tecNO_ENTRY));
// borrower doesn't exist
env(set(lender, broker.brokerID, principalRequested, startDate),
counterparty(alice),
sig(sfCounterpartySignature, alice),
loanSetFee,
ter(terNO_ACCOUNT));
// flags are checked first // flags are checked first
env(set(evan, vault.vaultID, ~tfUniversal), env(set(evan,
fee(increment), broker.brokerID,
principalRequested,
startDate,
tfLoanSetMask),
sig(sfCounterpartySignature, lender),
loanSetFee,
ter(temINVALID_FLAG)); ter(temINVALID_FLAG));
// Frozen trust line / locked MPT issuance
// XRP can not be frozen
if (!broker.asset.raw().native())
{
auto const brokerSLE =
env.le(keylet::loanbroker(broker.brokerID));
if (!BEAST_EXPECT(brokerSLE))
return;
auto const brokerPseudo = brokerSLE->at(sfAccount);
auto const pseudoAcct =
Account("Broker pseudo-account", brokerPseudo);
std::function<void(Account const& holder)> freeze;
std::function<void(Account const& holder)> unfreeze;
// Freeze / lock the asset
if (broker.asset.raw().holds<Issue>())
{
freeze = [&](Account const& holder) {
env(trust(issuer, holder[iouCurrency](0), tfSetFreeze));
};
unfreeze = [&](Account const& holder) {
env(trust(
issuer, holder[iouCurrency](0), tfClearFreeze));
};
}
else
{
freeze = [&](Account const& holder) {
mptt.set(
{.account = issuer,
.holder = holder,
.flags = tfMPTLock});
};
unfreeze = [&](Account const& holder) {
mptt.set(
{.account = issuer,
.holder = holder,
.flags = tfMPTUnlock});
};
}
}
// field length validation // field length validation
// sfData: good length, bad account // sfData: good length, bad account
env(set(evan, vault.vaultID), env(set(evan, broker.brokerID, principalRequested, startDate),
fee(increment), sig(sfCounterpartySignature, borrower),
data(std::string(maxDataPayloadLength, 'X')), data(std::string(maxDataPayloadLength, 'X')),
ter(tecNO_PERMISSION)); loanSetFee,
ter(tefBAD_AUTH));
// sfData: too long // sfData: too long
env(set(evan, vault.vaultID), env(set(evan, broker.brokerID, principalRequested, startDate),
fee(increment), sig(sfCounterpartySignature, lender),
data(std::string(maxDataPayloadLength + 1, 'Y')), data(std::string(maxDataPayloadLength + 1, 'Y')),
ter(temINVALID)); loanSetFee,
// sfManagementFeeRate: good value, bad account
env(set(evan, vault.vaultID),
managementFeeRate(maxManagementFeeRate),
fee(increment),
ter(tecNO_PERMISSION));
// sfManagementFeeRate: too big
env(set(evan, vault.vaultID),
managementFeeRate(maxManagementFeeRate + TenthBips16(10)),
fee(increment),
ter(temINVALID));
// sfCoverRateMinimum: good value, bad account
env(set(evan, vault.vaultID),
coverRateMinimum(maxCoverRate),
fee(increment),
ter(tecNO_PERMISSION));
// sfCoverRateMinimum: too big
env(set(evan, vault.vaultID),
coverRateMinimum(maxCoverRate + 1),
fee(increment),
ter(temINVALID));
// sfCoverRateLiquidation: good value, bad account
env(set(evan, vault.vaultID),
coverRateLiquidation(maxCoverRate),
fee(increment),
ter(tecNO_PERMISSION));
// sfCoverRateLiquidation: too big
env(set(evan, vault.vaultID),
coverRateLiquidation(maxCoverRate + 1),
fee(increment),
ter(temINVALID));
// sfDebtMaximum: good value, bad account
env(set(evan, vault.vaultID),
debtMaximum(Number(0)),
fee(increment),
ter(tecNO_PERMISSION));
// sfDebtMaximum: overflow
env(set(evan, vault.vaultID),
debtMaximum(Number(1, 100)),
fee(increment),
ter(temINVALID));
// sfDebtMaximum: negative
env(set(evan, vault.vaultID),
debtMaximum(Number(-1)),
fee(increment),
ter(temINVALID)); ter(temINVALID));
// field range validation
// sfOverpaymentFee: good value, bad account
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, borrower),
overpaymentFee(maxOverpaymentFee),
loanSetFee,
ter(tefBAD_AUTH));
// sfOverpaymentFee: too big
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, lender),
overpaymentFee(maxOverpaymentFee + 1),
loanSetFee,
ter(temINVALID));
// sfLateInterestRate: good value, bad account
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, borrower),
lateInterestRate(maxLateInterestRate),
loanSetFee,
ter(tefBAD_AUTH));
// sfLateInterestRate: too big
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, lender),
lateInterestRate(maxLateInterestRate + 1),
loanSetFee,
ter(temINVALID));
// sfCloseInterestRate: good value, bad account
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, borrower),
closeInterestRate(maxCloseInterestRate),
loanSetFee,
ter(tefBAD_AUTH));
// sfCloseInterestRate: too big
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, lender),
closeInterestRate(maxCloseInterestRate + 1),
loanSetFee,
ter(temINVALID));
// sfOverpaymentInterestRate: good value, bad account
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, borrower),
overpaymentInterestRate(maxOverpaymentInterestRate),
loanSetFee,
ter(tefBAD_AUTH));
// sfOverpaymentInterestRate: too big
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, lender),
overpaymentInterestRate(maxOverpaymentInterestRate + 1),
loanSetFee,
ter(temINVALID));
// sfPaymentTotal: good value, bad account
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, borrower),
paymentTotal(LoanSet::minPaymentTotal),
loanSetFee,
ter(tefBAD_AUTH));
// sfPaymentTotal: too small (there is no max)
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, lender),
paymentTotal(LoanSet::minPaymentTotal - 1),
loanSetFee,
ter(temINVALID));
// sfPaymentInterval: good value, bad account
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, borrower),
paymentInterval(LoanSet::minPaymentInterval),
loanSetFee,
ter(tefBAD_AUTH));
// sfPaymentInterval: too small (there is no max)
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, lender),
paymentInterval(LoanSet::minPaymentInterval - 1),
loanSetFee,
ter(temINVALID));
// sfGracePeriod: good value, bad account
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, borrower),
paymentInterval(LoanSet::minPaymentInterval * 2),
gracePeriod(LoanSet::minPaymentInterval * 2),
loanSetFee,
ter(tefBAD_AUTH));
// sfGracePeriod: larger than paymentInterval
env(set(evan, broker.brokerID, principalRequested, startDate),
sig(sfCounterpartySignature, lender),
paymentInterval(LoanSet::minPaymentInterval * 2),
gracePeriod(LoanSet::minPaymentInterval * 3),
loanSetFee,
ter(temINVALID));
#if 0
std::string testData; std::string testData;
lifecycle( lifecycle(
"default fields", "default fields",
env, env,
alice, lender,
evan, evan,
vault, vault,
// No modifications // No modifications
@@ -521,33 +674,33 @@ class Loan_test : public beast::unit_test::suite
// Update the fields // Update the fields
auto const nextKeylet = auto const nextKeylet =
keylet::loanbroker(alice.id(), env.seq(alice)); keylet::loanbroker(lender.id(), env.seq(lender));
// fields that can't be changed // fields that can't be changed
// LoanBrokerID // LoanBrokerID
env(set(alice, vault.vaultID), env(set(lender, broker.brokerID),
loanBrokerID(nextKeylet.key), loanBrokerID(nextKeylet.key),
ter(tecNO_ENTRY)); ter(tecNO_ENTRY));
// VaultID // VaultID
env(set(alice, nextKeylet.key), env(set(lender, nextKeylet.key),
loanBrokerID(broker->key()), loanBrokerID(broker->key()),
ter(tecNO_PERMISSION)); ter(tecNO_PERMISSION));
// Owner // Owner
env(set(evan, vault.vaultID), env(set(evan, broker.brokerID),
loanBrokerID(broker->key()), loanBrokerID(broker->key()),
ter(tecNO_PERMISSION)); ter(tecNO_PERMISSION));
// ManagementFeeRate // ManagementFeeRate
env(set(alice, vault.vaultID), env(set(lender, broker.brokerID),
loanBrokerID(broker->key()), loanBrokerID(broker->key()),
managementFeeRate(maxManagementFeeRate), managementFeeRate(maxManagementFeeRate),
ter(temINVALID)); ter(temINVALID));
// CoverRateMinimum // CoverRateMinimum
env(set(alice, vault.vaultID), env(set(lender, broker.brokerID),
loanBrokerID(broker->key()), loanBrokerID(broker->key()),
coverRateMinimum(maxManagementFeeRate), coverRateMinimum(maxManagementFeeRate),
ter(temINVALID)); ter(temINVALID));
// CoverRateLiquidation // CoverRateLiquidation
env(set(alice, vault.vaultID), env(set(lender, broker.brokerID),
loanBrokerID(broker->key()), loanBrokerID(broker->key()),
coverRateLiquidation(maxManagementFeeRate), coverRateLiquidation(maxManagementFeeRate),
ter(temINVALID)); ter(temINVALID));
@@ -555,18 +708,18 @@ class Loan_test : public beast::unit_test::suite
// fields that can be changed // fields that can be changed
testData = "Test Data 1234"; testData = "Test Data 1234";
// Bad data: too long // Bad data: too long
env(set(alice, vault.vaultID), env(set(lender, broker.brokerID),
loanBrokerID(broker->key()), loanBrokerID(broker->key()),
data(std::string(maxDataPayloadLength + 1, 'W')), data(std::string(maxDataPayloadLength + 1, 'W')),
ter(temINVALID)); ter(temINVALID));
// Bad debt maximum // Bad debt maximum
env(set(alice, vault.vaultID), env(set(lender, broker.brokerID),
loanBrokerID(broker->key()), loanBrokerID(broker->key()),
debtMaximum(Number(-175, -1)), debtMaximum(Number(-175, -1)),
ter(temINVALID)); ter(temINVALID));
// Data & Debt maximum // Data & Debt maximum
env(set(alice, vault.vaultID), env(set(lender, broker.brokerID),
loanBrokerID(broker->key()), loanBrokerID(broker->key()),
data(testData), data(testData),
debtMaximum(Number(175, -1))); debtMaximum(Number(175, -1)));
@@ -580,7 +733,7 @@ class Loan_test : public beast::unit_test::suite
lifecycle( lifecycle(
"non-default fields", "non-default fields",
env, env,
alice, lender,
evan, evan,
vault, vault,
[&](jtx::JTx const& jv) { [&](jtx::JTx const& jv) {
@@ -605,7 +758,7 @@ class Loan_test : public beast::unit_test::suite
}, },
[&](SLE::const_ref broker) { [&](SLE::const_ref broker) {
// Reset Data & Debt maximum to default values // Reset Data & Debt maximum to default values
env(set(alice, vault.vaultID), env(set(lender, broker.brokerID),
loanBrokerID(broker->key()), loanBrokerID(broker->key()),
data(""), data(""),
debtMaximum(Number(0))); debtMaximum(Number(0)));
@@ -615,8 +768,8 @@ class Loan_test : public beast::unit_test::suite
BEAST_EXPECT(!broker->isFieldPresent(sfData)); BEAST_EXPECT(!broker->isFieldPresent(sfData));
BEAST_EXPECT(!broker->isFieldPresent(sfDebtMaximum)); BEAST_EXPECT(!broker->isFieldPresent(sfDebtMaximum));
}); });
}
#endif #endif
}
} }
public: public:

View File

@@ -145,6 +145,29 @@ public:
} }
}; };
struct accountIDField : public JTxField<SF_ACCOUNT, AccountID, std::string>
{
using SF = SF_ACCOUNT;
using SV = AccountID;
using OV = std::string;
using base = JTxField<SF, SV, OV>;
protected:
using base::value_;
public:
explicit accountIDField(SF const& sfield, SV const& value)
: JTxField(sfield, value)
{
}
OV
value() const override
{
return toBase58(value_);
}
};
struct blobField : public JTxField<SF_VL, std::string> struct blobField : public JTxField<SF_VL, std::string>
{ {
using SF = SF_VL; using SF = SF_VL;
@@ -258,6 +281,10 @@ using valueUnitWrapper =
template <class SField, class StoredValue = typename SField::type::value_type> template <class SField, class StoredValue = typename SField::type::value_type>
using simpleField = JTxFieldWrapper<JTxField<SField, StoredValue>>; using simpleField = JTxFieldWrapper<JTxField<SField, StoredValue>>;
/** General field definitions, or fields used in multiple transaction namespaces
*/
auto const data = JTxFieldWrapper<blobField>(sfData);
// TODO We only need this long "requires" clause as polyfill, for C++20 // TODO We only need this long "requires" clause as polyfill, for C++20
// implementations which are missing <ranges> header. Replace with // implementations which are missing <ranges> header. Replace with
// `std::ranges::range<Input>`, and accordingly use std::ranges::begin/end // `std::ranges::range<Input>`, and accordingly use std::ranges::begin/end
@@ -659,8 +686,6 @@ coverWithdraw(
auto const loanBrokerID = JTxFieldWrapper<uint256Field>(sfLoanBrokerID); auto const loanBrokerID = JTxFieldWrapper<uint256Field>(sfLoanBrokerID);
auto const data = JTxFieldWrapper<blobField>(sfData);
auto const managementFeeRate = auto const managementFeeRate =
valueUnitWrapper<SF_TENTHBIPS16>(sfManagementFeeRate); valueUnitWrapper<SF_TENTHBIPS16>(sfManagementFeeRate);
@@ -685,7 +710,37 @@ set(AccountID const& account,
NetClock::time_point const& startDate, NetClock::time_point const& startDate,
uint32_t flags = 0); uint32_t flags = 0);
} auto const counterparty = JTxFieldWrapper<accountIDField>(sfCounterparty);
// For `CounterPartySignature`, use `sig(sfCounterpartySignature, ...)`
auto const loanOriginationFee = simpleField<SF_NUMBER>(sfLoanOriginationFee);
auto const loanServiceFee = simpleField<SF_NUMBER>(sfLoanServiceFee);
auto const latePaymentFee = simpleField<SF_NUMBER>(sfLatePaymentFee);
auto const closePaymentFee = simpleField<SF_NUMBER>(sfClosePaymentFee);
auto const overpaymentFee = valueUnitWrapper<SF_TENTHBIPS32>(sfOverpaymentFee);
auto const interestRate = valueUnitWrapper<SF_TENTHBIPS32>(sfInterestRate);
auto const lateInterestRate =
valueUnitWrapper<SF_TENTHBIPS32>(sfLateInterestRate);
auto const closeInterestRate =
valueUnitWrapper<SF_TENTHBIPS32>(sfCloseInterestRate);
auto const overpaymentInterestRate =
valueUnitWrapper<SF_TENTHBIPS32>(sfOverpaymentInterestRate);
auto const paymentTotal = simpleField<SF_UINT32>(sfPaymentTotal);
auto const paymentInterval = simpleField<SF_UINT32>(sfPaymentInterval);
auto const gracePeriod = simpleField<SF_UINT32>(sfGracePeriod);
} // namespace loan
} // namespace jtx } // namespace jtx
} // namespace test } // namespace test

View File

@@ -85,7 +85,11 @@ msig::operator()(Env& env, JTx& jt) const
// Where to put the signature. Supports sfCounterPartySignature. // Where to put the signature. Supports sfCounterPartySignature.
auto& sigObject = subField ? jtx[*subField] : jtx.jv; auto& sigObject = subField ? jtx[*subField] : jtx.jv;
sigObject[sfSigningPubKey] = ""; // The signing pub key is only required at the top level.
if (!subField)
sigObject[sfSigningPubKey] = "";
else if (sigObject.isNull())
sigObject = Json::Value(Json::objectValue);
std::optional<STObject> st; std::optional<STObject> st;
try try
{ {

View File

@@ -1642,13 +1642,6 @@ class Invariants_test : public beast::unit_test::suite
return std::make_pair(slePseudo, sleDir); return std::make_pair(slePseudo, sleDir);
}; };
// JLOG(j.fatal()) << "Invariant failed:";
// JLOG(j.fatal()) << "Invariant failed: ";
// JLOG(j.fatal())
// << "Invariant failed: ";
// JLOG(j.fatal())
// << "Invariant failed: ";
doInvariantCheck( doInvariantCheck(
{{"Loan Broker with zero OwnerCount has multiple directory " {{"Loan Broker with zero OwnerCount has multiple directory "
"pages"}}, "pages"}},
@@ -1763,6 +1756,29 @@ class Invariants_test : public beast::unit_test::suite
STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
createLoanBroker); createLoanBroker);
doInvariantCheck(
{{"Loan Broker sequence number decreased"}},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
if (loanBrokerKeylet.type != ltLOAN_BROKER)
return false;
auto sleBroker = ac.view().peek(loanBrokerKeylet);
if (!sleBroker)
return false;
if (!BEAST_EXPECT(sleBroker->at(sfLoanSequence) > 0))
return false;
// Need to touch sleBroker so that it is included in the
// modified entries for the invariant to find
ac.view().update(sleBroker);
sleBroker->at(sfLoanSequence) -= 1;
return true;
},
XRPAmount{},
STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
createLoanBroker);
} }
} }

View File

@@ -1846,7 +1846,7 @@ ValidLoanBroker::visitEntry(
{ {
if (after && after->getType() == ltLOAN_BROKER) if (after && after->getType() == ltLOAN_BROKER)
{ {
brokers_.emplace_back(after); brokers_.emplace_back(before, after);
} }
} }
@@ -1904,15 +1904,15 @@ ValidLoanBroker::finalize(
{ {
bool const enforce = view.rules().enabled(featureLendingProtocol); bool const enforce = view.rules().enabled(featureLendingProtocol);
for (auto const& broker : brokers_) for (auto const& [before, after] : brokers_)
{ {
// https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3123-invariants // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3123-invariants
// If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most // If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most
// one node (the root), which will only hold entries for `RippleState` // one node (the root), which will only hold entries for `RippleState`
// or `MPToken` objects. // or `MPToken` objects.
if (broker->at(sfOwnerCount) == 0) if (after->at(sfOwnerCount) == 0)
{ {
auto const dir = view.read(keylet::ownerDir(broker->at(sfAccount))); auto const dir = view.read(keylet::ownerDir(after->at(sfAccount)));
if (dir) if (dir)
{ {
if (!goodZeroDirectory(view, dir, j)) if (!goodZeroDirectory(view, dir, j))
@@ -1920,12 +1920,23 @@ ValidLoanBroker::finalize(
XRPL_ASSERT( XRPL_ASSERT(
enforce, enforce,
"ripple::ValidLoanBroker::finalize : Enforcing " "ripple::ValidLoanBroker::finalize : Enforcing "
"invariant"); "invariant: directory");
if (enforce) if (enforce)
return false; return false;
} }
} }
} }
if (before && before->at(sfLoanSequence) > after->at(sfLoanSequence))
{
JLOG(j.fatal()) << "Invariant failed: Loan Broker sequence number "
"decreased";
XRPL_ASSERT(
enforce,
"ripple::ValidLoanBroker::finalize : Enforcing "
"invariant: loan sequence");
if (enforce)
return false;
}
} }
return true; return true;
} }

View File

@@ -634,6 +634,7 @@ public:
*/ */
class NoModifiedUnmodifiableFields class NoModifiedUnmodifiableFields
{ {
// Pair is <before, after>.
std::set<std::pair<SLE::const_pointer, SLE::const_pointer>> changedEntries_; std::set<std::pair<SLE::const_pointer, SLE::const_pointer>> changedEntries_;
public: public:
@@ -690,7 +691,9 @@ public:
*/ */
class ValidLoanBroker class ValidLoanBroker
{ {
std::vector<SLE::const_pointer> brokers_; // Pair is <before, after>. After is used for most of the checks, except
// those that check changed values.
std::vector<std::pair<SLE::const_pointer, SLE::const_pointer>> brokers_;
bool bool
goodZeroDirectory( goodZeroDirectory(

View File

@@ -209,6 +209,7 @@ LoanBrokerSet::doApply()
broker->at(sfVaultID) = vaultID; broker->at(sfVaultID) = vaultID;
broker->at(sfOwner) = account_; broker->at(sfOwner) = account_;
broker->at(sfAccount) = pseudoId; broker->at(sfAccount) = pseudoId;
broker->at(sfLoanSequence) = 1;
if (auto const data = tx[~sfData]) if (auto const data = tx[~sfData])
broker->at(sfData) = *data; broker->at(sfData) = *data;
if (auto const rate = tx[~sfManagementFeeRate]) if (auto const rate = tx[~sfManagementFeeRate])

View File

@@ -71,6 +71,8 @@ LoanSet::doPreflight(PreflightContext const& ctx)
if (auto const data = tx[~sfData]; data && !data->empty() && if (auto const data = tx[~sfData]; data && !data->empty() &&
!validDataLength(tx[~sfData], maxDataPayloadLength)) !validDataLength(tx[~sfData], maxDataPayloadLength))
return temINVALID; return temINVALID;
if (!validNumericRange(tx[~sfOverpaymentFee], maxOverpaymentFee))
return temINVALID;
if (!validNumericRange(tx[~sfLateInterestRate], maxLateInterestRate)) if (!validNumericRange(tx[~sfLateInterestRate], maxLateInterestRate))
return temINVALID; return temINVALID;
if (!validNumericRange(tx[~sfCloseInterestRate], maxCloseInterestRate)) if (!validNumericRange(tx[~sfCloseInterestRate], maxCloseInterestRate))
@@ -84,7 +86,7 @@ LoanSet::doPreflight(PreflightContext const& ctx)
return temINVALID; return temINVALID;
if (auto const paymentInterval = if (auto const paymentInterval =
tx[~sfPaymentInterval].value_or(LoanSet::defaultPaymentInterval); tx[~sfPaymentInterval].value_or(LoanSet::defaultPaymentInterval);
paymentInterval < LoanSet::defaultPaymentInterval) paymentInterval < LoanSet::minPaymentInterval)
return temINVALID; return temINVALID;
else if (auto const gracePeriod = else if (auto const gracePeriod =
tx[~sfGracePeriod].value_or(LoanSet::defaultGracePeriod); tx[~sfGracePeriod].value_or(LoanSet::defaultGracePeriod);
@@ -160,18 +162,13 @@ LoanSet::preclaim(PreclaimContext const& ctx)
auto const brokerSle = ctx.view.read(keylet::loanbroker(brokerID)); auto const brokerSle = ctx.view.read(keylet::loanbroker(brokerID));
if (!brokerSle) if (!brokerSle)
{ {
// This can only be hit if there's a counterparty specified, otherwise
// it'll fail in the signature check
JLOG(ctx.j.warn()) << "LoanBroker does not exist."; JLOG(ctx.j.warn()) << "LoanBroker does not exist.";
return tecNO_ENTRY; return tecNO_ENTRY;
} }
auto const brokerOwner = brokerSle->at(sfOwner); auto const brokerOwner = brokerSle->at(sfOwner);
if (!tx.isFieldPresent(sfCounterparty) && account != brokerOwner)
{
JLOG(ctx.j.warn())
<< "Counterparty is not the owner of the LoanBroker.";
return tecNO_PERMISSION;
}
auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner);
auto const borrower = counterparty == brokerOwner ? account : counterparty;
if (account != brokerOwner && counterparty != brokerOwner) if (account != brokerOwner && counterparty != brokerOwner)
{ {
JLOG(ctx.j.warn()) << "Neither Account nor Counterparty are the owner " JLOG(ctx.j.warn()) << "Neither Account nor Counterparty are the owner "
@@ -179,11 +176,14 @@ LoanSet::preclaim(PreclaimContext const& ctx)
return tecNO_PERMISSION; return tecNO_PERMISSION;
} }
auto const borrower = counterparty == brokerOwner ? account : counterparty;
if (auto const borrowerSle = ctx.view.read(keylet::account(borrower)); if (auto const borrowerSle = ctx.view.read(keylet::account(borrower));
!borrowerSle) !borrowerSle)
{ {
// It may not be possible to hit this case, because it'll fail the
// signature check with terNO_ACCOUNT.
JLOG(ctx.j.warn()) << "Borrower does not exist."; JLOG(ctx.j.warn()) << "Borrower does not exist.";
return tecNO_ENTRY; return terNO_ACCOUNT;
} }
auto const brokerPseudo = brokerSle->at(sfAccount); auto const brokerPseudo = brokerSle->at(sfAccount);
@@ -332,10 +332,11 @@ LoanSet::doApply()
// interest minus the management fee) // interest minus the management fee)
auto const loanInterestToVault = loanInterest - auto const loanInterestToVault = loanInterest -
tenthBipsOfValue(loanInterest, managementFeeRate.value()); tenthBipsOfValue(loanInterest, managementFeeRate.value());
auto const loanSequence = tx.getSeqValue();
auto const startDate = tx[sfStartDate]; auto const startDate = tx[sfStartDate];
auto const paymentInterval = auto const paymentInterval =
tx[~sfPaymentInterval].value_or(defaultPaymentInterval); tx[~sfPaymentInterval].value_or(defaultPaymentInterval);
auto const loanSequence = brokerSle->at(sfLoanSequence).value();
brokerSle->at(sfLoanSequence) += 1;
// Create the loan // Create the loan
auto loan = auto loan =

View File

@@ -54,10 +54,17 @@ public:
TER TER
doApply() override; doApply() override;
private: public:
static std::uint32_t constexpr minPaymentTotal = 1;
static std::uint32_t constexpr defaultPaymentTotal = 1; static std::uint32_t constexpr defaultPaymentTotal = 1;
static_assert(defaultPaymentTotal >= minPaymentTotal);
static std::uint32_t constexpr minPaymentInterval = 60;
static std::uint32_t constexpr defaultPaymentInterval = 60; static std::uint32_t constexpr defaultPaymentInterval = 60;
static_assert(defaultPaymentInterval >= minPaymentInterval);
static std::uint32_t constexpr defaultGracePeriod = 60; static std::uint32_t constexpr defaultGracePeriod = 60;
static_assert(defaultGracePeriod >= minPaymentInterval);
}; };
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------