mirror of
https://github.com/XRPLF/rippled.git
synced 2026-07-28 17:40:25 +00:00
Merge remote-tracking branch 'origin/tapanito/lending-cash-basis' into ripple/lending-protocol-fv
This commit is contained in:
@@ -308,6 +308,7 @@ words:
|
||||
- superpeers
|
||||
- takergets
|
||||
- takerpays
|
||||
- tapanito
|
||||
- ters
|
||||
- TMEndpointv2
|
||||
- toolchain
|
||||
|
||||
2
.github/workflows/check-pr-description.yml
vendored
2
.github/workflows/check-pr-description.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Write PR body to file
|
||||
env:
|
||||
|
||||
4
.github/workflows/check-tools.yml
vendored
4
.github/workflows/check-tools.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Read nix image tag
|
||||
id: tag
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
container: ${{ !matrix.nix_develop && format('ghcr.io/xrplf/xrpld/nix-ubuntu:{0}', needs.linux-image-tag.outputs.tag) || null }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Prepare runner
|
||||
uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606
|
||||
|
||||
2
.github/workflows/on-pr.yml
vendored
2
.github/workflows/on-pr.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Determine changed files
|
||||
# This step checks whether any files have changed that should
|
||||
# cause the next jobs to run. We do it this way rather than
|
||||
|
||||
2
.github/workflows/publish-docs.yml
vendored
2
.github/workflows/publish-docs.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-40cdf49
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Prepare runner
|
||||
uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606
|
||||
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Prepare runner
|
||||
uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check levelization
|
||||
run: python .github/scripts/levelization/generate.py
|
||||
- name: Check for differences
|
||||
|
||||
2
.github/workflows/reusable-check-rename.yml
vendored
2
.github/workflows/reusable-check-rename.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check definitions
|
||||
run: .github/scripts/rename/definitions.sh .
|
||||
- name: Check copyright notices
|
||||
|
||||
2
.github/workflows/reusable-clang-tidy.yml
vendored
2
.github/workflows/reusable-clang-tidy.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Prepare runner
|
||||
uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606
|
||||
|
||||
4
.github/workflows/reusable-package.yml
vendored
4
.github/workflows/reusable-package.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
matrix: ${{ steps.generate.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Download pre-built binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
matrix: ${{ steps.generate.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
|
||||
2
.github/workflows/reusable-upload-recipe.yml
vendored
2
.github/workflows/reusable-upload-recipe.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
CONAN_PASSWORD_XRPLF: ${{ secrets.remote_password }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Generate build version number
|
||||
id: version
|
||||
|
||||
2
.github/workflows/upload-conan-deps.yml
vendored
2
.github/workflows/upload-conan-deps.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Prepare runner
|
||||
uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606
|
||||
|
||||
@@ -286,6 +286,77 @@ computeFullPaymentInterest(
|
||||
std::uint32_t startDate,
|
||||
TenthBips32 closeInterestRate);
|
||||
|
||||
// Deltas applied to Vault.AssetsTotal and LoanBroker.DebtTotal at a single
|
||||
// accounting touch point (origination, payment, impair/unimpair/default).
|
||||
struct AccountingDeltas
|
||||
{
|
||||
Number assetsTotalDelta;
|
||||
Number debtTotalDelta;
|
||||
};
|
||||
|
||||
// Whole-life (pre-LendingProtocolV1_1) recognition model: interest is
|
||||
// recognized into AssetsTotal/DebtTotal up front, at origination.
|
||||
namespace Accrual {
|
||||
|
||||
// LoanSet origination: what's added to Vault.AssetsTotal and LoanBroker.DebtTotal
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(Number const& principalRequested, Number const& interestDue);
|
||||
|
||||
// LoanSet origination: would recognizing this loan's interest push
|
||||
// Vault.AssetsTotal past Vault.AssetsMaximum?
|
||||
bool
|
||||
loanOriginationExceedsVaultMaximum(
|
||||
Number const& vaultMaximum,
|
||||
Number const& vaultTotal,
|
||||
Number const& interestDue);
|
||||
|
||||
// LoanManage impair/unimpair/default: the vault's exposure to this loan
|
||||
Number
|
||||
loanVaultExposure(SLE::const_ref loanSle);
|
||||
|
||||
// LoanPay: what's added to Vault.AssetsTotal and subtracted from LoanBroker.DebtTotal for a payment
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(LoanPaymentParts const& parts);
|
||||
|
||||
} // namespace Accrual
|
||||
|
||||
// Cash-basis (LendingProtocolV1_1) recognition model: AssetsTotal/DebtTotal
|
||||
// are principal-only, interest is recognized only as it's actually paid.
|
||||
namespace CashBasis {
|
||||
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(Number const& principalRequested);
|
||||
|
||||
Number
|
||||
loanVaultExposure(SLE::const_ref loanSle);
|
||||
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(LoanPaymentParts const& parts);
|
||||
|
||||
} // namespace CashBasis
|
||||
|
||||
// Public dispatchers: pick CashBasis:: if featureLendingProtocolV1_1 is
|
||||
// enabled AND the Vault's LEVersion (VaultHelpers::getVaultVersion) is
|
||||
// VaultVersion::CashBasis, else Accrual::. These are the only entry points
|
||||
// transactors call.
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(
|
||||
SLE::const_ref vaultSle,
|
||||
Number const& principalRequested,
|
||||
Number const& interestDue);
|
||||
|
||||
bool
|
||||
loanOriginationExceedsVaultMaximum(
|
||||
SLE::const_ref vaultSle,
|
||||
Number const& vaultTotal,
|
||||
Number const& interestDue);
|
||||
|
||||
Number
|
||||
loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle);
|
||||
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts);
|
||||
|
||||
namespace detail {
|
||||
// These classes and functions should only be accessed by LendingHelper
|
||||
// functions and unit tests
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
|
||||
@@ -107,4 +108,19 @@ sharesToAssetsWithdraw(
|
||||
[[nodiscard]] bool
|
||||
isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance);
|
||||
|
||||
/**
|
||||
* Resolves a Vault's LEVersion, the single point every accounting touch
|
||||
* point should call to determine which recognition model (accrual vs.
|
||||
* cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1
|
||||
* activated never have sfLEVersion set, which resolves here to
|
||||
* VaultVersion::Legacy.
|
||||
*
|
||||
* @param vault The vault SLE.
|
||||
*
|
||||
* @return The Vault's LEVersion, or VaultVersion::Legacy if the field is
|
||||
* absent.
|
||||
*/
|
||||
[[nodiscard]] VaultVersion
|
||||
getVaultVersion(SLE::const_ref vault);
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
@@ -316,6 +316,17 @@ constexpr std::uint8_t kVaultDefaultIouScale = 6;
|
||||
*/
|
||||
constexpr std::uint8_t kVaultMaximumIouScale = 18;
|
||||
|
||||
/**
|
||||
* Vault ledger-entry schema versions. Assigned to newly created
|
||||
* Vaults once featureLendingProtocolV1_1 is enabled. Vaults created before
|
||||
* activation are left without LEVersion (implicit legacy version 0,
|
||||
* accrual-basis accounting).
|
||||
*/
|
||||
enum class VaultVersion : uint8_t {
|
||||
Legacy = 0,
|
||||
CashBasis,
|
||||
};
|
||||
|
||||
/**
|
||||
* Maximum recursion depth for vault shares being put as an asset inside
|
||||
* another vault; counted from 0
|
||||
|
||||
@@ -505,6 +505,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({
|
||||
{sfShareMPTID, SoeRequired},
|
||||
{sfWithdrawalPolicy, SoeRequired},
|
||||
{sfScale, SoeDefault},
|
||||
{sfLEVersion, SoeDefault},
|
||||
// no SharesTotal ever (use MPTIssuance.sfOutstandingAmount)
|
||||
// no PermissionedDomainID ever (use MPTIssuance.sfDomainID)
|
||||
}))
|
||||
|
||||
@@ -18,6 +18,7 @@ TYPED_SFIELD(sfMethod, UINT8, 2)
|
||||
TYPED_SFIELD(sfTransactionResult, UINT8, 3)
|
||||
TYPED_SFIELD(sfScale, UINT8, 4)
|
||||
TYPED_SFIELD(sfAssetScale, UINT8, 5)
|
||||
TYPED_SFIELD(sfLEVersion, UINT8, 6)
|
||||
|
||||
// 8-bit integers (uncommon)
|
||||
TYPED_SFIELD(sfTickSize, UINT8, 16)
|
||||
|
||||
@@ -287,6 +287,30 @@ public:
|
||||
{
|
||||
return this->sle_->isFieldPresent(sfScale);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get sfLEVersion (SoeDefault)
|
||||
* @return The field value, or std::nullopt if not present.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
protocol_autogen::Optional<SF_UINT8::type::value_type>
|
||||
getLEVersion() const
|
||||
{
|
||||
if (hasLEVersion())
|
||||
return this->sle_->at(sfLEVersion);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if sfLEVersion is present.
|
||||
* @return True if the field is present, false otherwise.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
bool
|
||||
hasLEVersion() const
|
||||
{
|
||||
return this->sle_->isFieldPresent(sfLEVersion);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -508,6 +532,17 @@ public:
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set sfLEVersion (SoeDefault)
|
||||
* @return Reference to this builder for method chaining.
|
||||
*/
|
||||
VaultBuilder&
|
||||
setLEVersion(std::decay_t<typename SF_UINT8::type::value_type> const& value)
|
||||
{
|
||||
object_[sfLEVersion] = value;
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Build and return the completed Vault wrapper.
|
||||
* @param index The ledger entry index.
|
||||
|
||||
7
merged-prs.md
Normal file
7
merged-prs.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Merged PRs
|
||||
|
||||
PRs merged into the `ripple/lending-protocol-fv` branch.
|
||||
|
||||
| PR | Title | Author | Branch | Merged |
|
||||
| --------------------------------------------------- | ------------------------------------------------ | --------- | ----------------------------- | ---------- |
|
||||
| [#7817](https://github.com/XRPLF/rippled/pull/7817) | feat: Implement LoanBroker cash-basis accounting | @Tapanito | `tapanito/lending-cash-basis` | 2026-07-27 |
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/ledger/View.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
@@ -130,6 +131,127 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale)
|
||||
roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
|
||||
}
|
||||
|
||||
namespace Accrual {
|
||||
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(Number const& principalRequested, Number const& interestDue)
|
||||
{
|
||||
return {.assetsTotalDelta = interestDue, .debtTotalDelta = principalRequested + interestDue};
|
||||
}
|
||||
|
||||
bool
|
||||
loanOriginationExceedsVaultMaximum(
|
||||
Number const& vaultMaximum,
|
||||
Number const& vaultTotal,
|
||||
Number const& interestDue)
|
||||
{
|
||||
return vaultMaximum != 0 && interestDue > vaultMaximum - vaultTotal;
|
||||
}
|
||||
|
||||
/*
|
||||
XLS-66 section 3.2.3.2, defines the default amount as
|
||||
|
||||
DefaultAmount = (Loan.PrincipalOutstanding + Loan.InterestOutstanding)
|
||||
|
||||
Which is equivalent to (Loan.TotalValueOutstanding - Loan.ManagementFeeOutstanding)
|
||||
*/
|
||||
Number
|
||||
loanVaultExposure(SLE::const_ref loanSle)
|
||||
{
|
||||
return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding);
|
||||
}
|
||||
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(LoanPaymentParts const& parts)
|
||||
{
|
||||
return {
|
||||
.assetsTotalDelta = parts.valueChange,
|
||||
.debtTotalDelta = (parts.principalPaid + parts.interestPaid) - parts.valueChange};
|
||||
}
|
||||
|
||||
} // namespace Accrual
|
||||
|
||||
namespace CashBasis {
|
||||
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(Number const& principalRequested)
|
||||
{
|
||||
return {.assetsTotalDelta = kNumZero, .debtTotalDelta = principalRequested};
|
||||
}
|
||||
|
||||
/*
|
||||
* Under CashBasis accounting, Loan default amount is:
|
||||
*
|
||||
* DefaultAmount = Loan.PrincipalOutstanding
|
||||
*/
|
||||
Number
|
||||
loanVaultExposure(SLE::const_ref loanSle)
|
||||
{
|
||||
return loanSle->at(sfPrincipalOutstanding);
|
||||
}
|
||||
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(LoanPaymentParts const& parts)
|
||||
{
|
||||
return {.assetsTotalDelta = parts.interestPaid, .debtTotalDelta = parts.principalPaid};
|
||||
}
|
||||
|
||||
} // namespace CashBasis
|
||||
|
||||
namespace {
|
||||
|
||||
// Cash-basis accounting applies only when featureLendingProtocolV1_1 is
|
||||
// enabled AND the specific Vault was created under it (LEVersion ==
|
||||
// VaultVersion::CashBasis). Vaults created before activation keep accrual-basis
|
||||
// accounting forever, even after the amendment later turns on.
|
||||
bool
|
||||
cashBasisEnabled(SLE::const_ref vaultSle)
|
||||
{
|
||||
return getVaultVersion(vaultSle) == VaultVersion::CashBasis;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(
|
||||
SLE::const_ref vaultSle,
|
||||
Number const& principalRequested,
|
||||
Number const& interestDue)
|
||||
{
|
||||
return cashBasisEnabled(vaultSle)
|
||||
? CashBasis::loanOriginationDeltas(principalRequested)
|
||||
: Accrual::loanOriginationDeltas(principalRequested, interestDue);
|
||||
}
|
||||
|
||||
bool
|
||||
loanOriginationExceedsVaultMaximum(
|
||||
SLE::const_ref vaultSle,
|
||||
Number const& vaultTotal,
|
||||
Number const& interestDue)
|
||||
{
|
||||
// Cash-basis origination doesn't recognize interest into AssetsTotal, so
|
||||
// interest due can never push the vault past AssetsMaximum at origination.
|
||||
if (cashBasisEnabled(vaultSle))
|
||||
return false;
|
||||
|
||||
auto const vaultMaximum = vaultSle->at(sfAssetsMaximum);
|
||||
return Accrual::loanOriginationExceedsVaultMaximum(vaultMaximum, vaultTotal, interestDue);
|
||||
}
|
||||
|
||||
Number
|
||||
loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle)
|
||||
{
|
||||
return cashBasisEnabled(vaultSle) ? CashBasis::loanVaultExposure(loanSle)
|
||||
: Accrual::loanVaultExposure(loanSle);
|
||||
}
|
||||
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts)
|
||||
{
|
||||
return cashBasisEnabled(vaultSle) ? CashBasis::loanPaymentDeltas(parts)
|
||||
: Accrual::loanPaymentDeltas(parts);
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
|
||||
void
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
@@ -13,6 +14,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
@@ -137,4 +139,22 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref
|
||||
return sleToken->getFieldU64(sfMPTAmount) == outstanding;
|
||||
}
|
||||
|
||||
[[nodiscard]] VaultVersion
|
||||
getVaultVersion(SLE::const_ref vault)
|
||||
{
|
||||
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultVersion : valid Vault sle");
|
||||
if (!vault->isFieldPresent(sfLEVersion))
|
||||
return VaultVersion::Legacy;
|
||||
|
||||
auto const version = vault->at(sfLEVersion);
|
||||
if (version > std::to_underlying(VaultVersion::CashBasis))
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
UNREACHABLE("xrpl::getVaultVersion : invalid vault version");
|
||||
return VaultVersion::Legacy;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
return static_cast<VaultVersion>(version);
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
@@ -127,23 +127,6 @@ LoanManage::preclaim(PreclaimContext const& ctx)
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
static Number
|
||||
owedToVault(SLE::ref loanSle)
|
||||
{
|
||||
// Spec section 3.2.3.2, defines the default amount as
|
||||
//
|
||||
// DefaultAmount = (Loan.PrincipalOutstanding + Loan.InterestOutstanding)
|
||||
//
|
||||
// Loan.InterestOutstanding is not stored directly on ledger.
|
||||
// It is computed as
|
||||
//
|
||||
// Loan.TotalValueOutstanding - Loan.PrincipalOutstanding -
|
||||
// Loan.ManagementFeeOutstanding
|
||||
//
|
||||
// Add that to the original formula, and you get this:
|
||||
return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding);
|
||||
}
|
||||
|
||||
TER
|
||||
LoanManage::defaultLoan(
|
||||
ApplyView& view,
|
||||
@@ -158,7 +141,7 @@ LoanManage::defaultLoan(
|
||||
std::int32_t const loanScale = loanSle->at(sfLoanScale);
|
||||
auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal);
|
||||
|
||||
Number const totalDefaultAmount = owedToVault(loanSle);
|
||||
Number const totalDefaultAmount = loanVaultExposure(vaultSle, loanSle);
|
||||
|
||||
// Apply the First-Loss Capital to the Default Amount
|
||||
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
|
||||
@@ -304,7 +287,7 @@ LoanManage::impairLoan(
|
||||
Asset const& vaultAsset,
|
||||
beast::Journal j)
|
||||
{
|
||||
Number const lossUnrealized = owedToVault(loanSle);
|
||||
Number const lossUnrealized = loanVaultExposure(vaultSle, loanSle);
|
||||
|
||||
// The vault may be at a different scale than the loan. Reduce rounding
|
||||
// errors during the accounting by rounding some of the values to that
|
||||
@@ -353,7 +336,7 @@ LoanManage::unimpairLoan(
|
||||
|
||||
// Update the Vault object(clear "paper loss")
|
||||
auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
|
||||
Number const lossReversed = owedToVault(loanSle);
|
||||
Number const lossReversed = loanVaultExposure(vaultSle, loanSle);
|
||||
if (vaultLossUnrealizedProxy < lossReversed)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
|
||||
@@ -420,10 +420,13 @@ LoanPay::doApply()
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(vaultSle, *paymentParts);
|
||||
|
||||
JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid
|
||||
<< ", interest paid: " << paymentParts->interestPaid
|
||||
<< ", fee paid: " << paymentParts->feePaid
|
||||
<< ", value change: " << paymentParts->valueChange;
|
||||
<< ", assets total delta: " << assetsTotalDelta
|
||||
<< ", debt total delta: " << debtTotalDelta;
|
||||
|
||||
//------------------------------------------------------
|
||||
// LoanBroker object state changes
|
||||
@@ -439,13 +442,6 @@ LoanPay::doApply()
|
||||
!asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded,
|
||||
"xrpl::LoanPay::doApply",
|
||||
"rounding does nothing for integral asset");
|
||||
// Account for value changes when reducing the broker's debt:
|
||||
// - Positive value change (from full/late/overpayments): Subtract from the
|
||||
// amount credited toward debt to avoid over-reducing the debt.
|
||||
// - Negative value change (from full/overpayments): Add to the amount
|
||||
// credited toward debt,effectively increasing the debt reduction.
|
||||
auto const totalPaidToVaultForDebt = totalPaidToVaultRaw - paymentParts->valueChange;
|
||||
|
||||
auto const totalPaidToBroker = paymentParts->feePaid;
|
||||
|
||||
XRPL_ASSERT_PARTS(
|
||||
@@ -455,16 +451,16 @@ LoanPay::doApply()
|
||||
"payments add up");
|
||||
|
||||
// Decrease LoanBroker Debt by the amount paid, add the Loan value change
|
||||
// (which might be negative). totalPaidToVaultForDebt may be negative,
|
||||
// increasing the debt
|
||||
// (which might be negative). debtTotalDelta may be negative, increasing the
|
||||
// debt
|
||||
XRPL_ASSERT_PARTS(
|
||||
isRounded(asset, totalPaidToVaultForDebt, loanScale),
|
||||
isRounded(asset, debtTotalDelta, loanScale),
|
||||
"xrpl::LoanPay::doApply",
|
||||
"totalPaidToVaultForDebt rounding good");
|
||||
"debtTotalDelta rounding good");
|
||||
// Despite our best efforts, it's possible for rounding errors to accumulate
|
||||
// in the loan broker's debt total. This is because the broker may have more
|
||||
// than one loan with significantly different scales.
|
||||
adjustImpreciseNumber(debtTotalProxy, -totalPaidToVaultForDebt, asset, vaultScale);
|
||||
adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale);
|
||||
|
||||
//------------------------------------------------------
|
||||
// Vault object state changes
|
||||
@@ -490,7 +486,7 @@ LoanPay::doApply()
|
||||
#endif
|
||||
|
||||
assetsAvailableProxy += totalPaidToVaultRounded;
|
||||
assetsTotalProxy += paymentParts->valueChange;
|
||||
assetsTotalProxy += assetsTotalDelta;
|
||||
|
||||
XRPL_ASSERT_PARTS(
|
||||
*assetsAvailableProxy <= *assetsTotalProxy,
|
||||
@@ -543,11 +539,11 @@ LoanPay::doApply()
|
||||
return tecPRECISION_LOSS;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
if (paymentParts->valueChange != beast::kZero && assetsTotalAfter == assetsTotalBefore)
|
||||
if (assetsTotalDelta != beast::kZero && assetsTotalAfter == assetsTotalBefore)
|
||||
{
|
||||
// Non-zero valueChange with an unchanged assetsTotal indicates that the
|
||||
// actual value change rounded to zero. That should be impossible, but I
|
||||
// can't rule it out for extreme edge cases, so fail gracefully if it
|
||||
// Non-zero assetsTotalDelta with an unchanged assetsTotal indicates that
|
||||
// the actual value change rounded to zero. That should be impossible, but
|
||||
// I can't rule it out for extreme edge cases, so fail gracefully if it
|
||||
// happens.
|
||||
//
|
||||
// LCOV_EXCL_START
|
||||
@@ -555,20 +551,21 @@ LoanPay::doApply()
|
||||
<< "LoanPay: Vault assets expected change, but unchanged after rounding: " //
|
||||
<< "Before: " << assetsTotalBefore //
|
||||
<< ", After: " << assetsTotalAfter //
|
||||
<< ", ValueChange: " << paymentParts->valueChange;
|
||||
<< ", AssetsTotalDelta: " << assetsTotalDelta;
|
||||
return tecPRECISION_LOSS;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
if (paymentParts->valueChange == beast::kZero && assetsTotalAfter != assetsTotalBefore)
|
||||
if (assetsTotalDelta == beast::kZero && assetsTotalAfter != assetsTotalBefore)
|
||||
{
|
||||
// A change in assetsTotal when there was no valueChange indicates that
|
||||
// something really weird happened. That should be flat out impossible.
|
||||
// A change in assetsTotal when there was no assetsTotalDelta indicates
|
||||
// that something really weird happened. That should be flat out
|
||||
// impossible.
|
||||
//
|
||||
// LCOV_EXCL_START
|
||||
JLOG(j_.fatal()) << "LoanPay: Vault assets changed unexpectedly after rounding: " //
|
||||
<< "Before: " << assetsTotalBefore //
|
||||
<< ", After: " << assetsTotalAfter //
|
||||
<< ", ValueChange: " << paymentParts->valueChange;
|
||||
<< ", AssetsTotalDelta: " << assetsTotalDelta;
|
||||
return tecINTERNAL;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
@@ -439,12 +439,12 @@ LoanSet::doApply()
|
||||
principalRequested,
|
||||
properties.loanState.managementFeeDue);
|
||||
|
||||
auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum);
|
||||
XRPL_ASSERT_PARTS(
|
||||
vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy,
|
||||
*vaultSle->at(sfAssetsMaximum) == 0 || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy,
|
||||
"xrpl::LoanSet::doApply",
|
||||
"Vault is below maximum limit");
|
||||
if (vaultMaximum != 0 && state.interestDue > vaultMaximum - vaultTotalProxy)
|
||||
|
||||
if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue))
|
||||
{
|
||||
JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault";
|
||||
return tecLIMIT_EXCEEDED;
|
||||
@@ -490,8 +490,9 @@ LoanSet::doApply()
|
||||
|
||||
auto const loanAssetsToBorrower = principalRequested - originationFee;
|
||||
|
||||
auto const newDebtDelta = principalRequested + state.interestDue;
|
||||
auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta;
|
||||
auto const [assetsTotalDelta, debtTotalDelta] =
|
||||
loanOriginationDeltas(vaultSle, principalRequested, state.interestDue);
|
||||
auto const newDebtTotal = brokerSle->at(sfDebtTotal) + debtTotalDelta;
|
||||
if (auto const debtMaximum = brokerSle->at(sfDebtMaximum);
|
||||
debtMaximum != 0 && debtMaximum < newDebtTotal)
|
||||
{
|
||||
@@ -634,7 +635,7 @@ LoanSet::doApply()
|
||||
|
||||
// Update the balances in the vault
|
||||
vaultAvailableProxy -= principalRequested;
|
||||
vaultTotalProxy += state.interestDue;
|
||||
vaultTotalProxy += assetsTotalDelta;
|
||||
XRPL_ASSERT_PARTS(
|
||||
*vaultAvailableProxy <= *vaultTotalProxy,
|
||||
"xrpl::LoanSet::doApply",
|
||||
@@ -642,7 +643,7 @@ LoanSet::doApply()
|
||||
view.update(vaultSle);
|
||||
|
||||
// Update the balances in the loan broker
|
||||
adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale);
|
||||
adjustImpreciseNumber(brokerSle->at(sfDebtTotal), debtTotalDelta, vaultAsset, vaultScale);
|
||||
adjustLoanBrokerOwnerCount(view, brokerSle, 1, j_);
|
||||
loanSequenceProxy += 1;
|
||||
// The sequence should be extremely unlikely to roll over, but fail if it
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
@@ -241,6 +242,8 @@ VaultCreate::doApply()
|
||||
}
|
||||
if (scale != 0u)
|
||||
vault->at(sfScale) = scale;
|
||||
if (view().rules().enabled(featureLendingProtocolV1_1))
|
||||
vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis);
|
||||
view().insert(vault);
|
||||
|
||||
// Explicitly create MPToken for the vault owner
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
@@ -19,6 +20,7 @@
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace xrpl::test {
|
||||
@@ -1470,6 +1472,326 @@ class LendingHelpers_test : public beast::unit_test::Suite
|
||||
Number{-18304, -5}));
|
||||
}
|
||||
|
||||
void
|
||||
testAccrualLoanOriginationDeltas()
|
||||
{
|
||||
using namespace xrpl::Accrual;
|
||||
|
||||
struct TestCase
|
||||
{
|
||||
std::string name;
|
||||
Number principalRequested;
|
||||
Number interestDue;
|
||||
};
|
||||
|
||||
auto const testCases = std::vector<TestCase>{
|
||||
{.name = "Zero interest",
|
||||
.principalRequested = Number{1'000},
|
||||
.interestDue = Number{0}},
|
||||
{.name = "Nonzero interest",
|
||||
.principalRequested = Number{1'000},
|
||||
.interestDue = Number{75}},
|
||||
};
|
||||
|
||||
for (auto const& tc : testCases)
|
||||
{
|
||||
testcase("Accrual::loanOriginationDeltas: " + tc.name);
|
||||
|
||||
auto const deltas = loanOriginationDeltas(tc.principalRequested, tc.interestDue);
|
||||
BEAST_EXPECTS(
|
||||
deltas.assetsTotalDelta == tc.interestDue,
|
||||
"assetsTotalDelta mismatch: expected " + to_string(tc.interestDue) + ", got " +
|
||||
to_string(deltas.assetsTotalDelta));
|
||||
BEAST_EXPECTS(
|
||||
deltas.debtTotalDelta == tc.principalRequested + tc.interestDue,
|
||||
"debtTotalDelta mismatch: expected " +
|
||||
to_string(tc.principalRequested + tc.interestDue) + ", got " +
|
||||
to_string(deltas.debtTotalDelta));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testCashBasisLoanOriginationDeltas()
|
||||
{
|
||||
using namespace xrpl::CashBasis;
|
||||
|
||||
testcase("CashBasis::loanOriginationDeltas: interestDue is ignored");
|
||||
|
||||
Number const principalRequested{1'000};
|
||||
Number const interestDue{75};
|
||||
|
||||
auto const deltas = loanOriginationDeltas(principalRequested);
|
||||
BEAST_EXPECTS(
|
||||
deltas.assetsTotalDelta == 0,
|
||||
"assetsTotalDelta mismatch: expected 0, got " + to_string(deltas.assetsTotalDelta));
|
||||
BEAST_EXPECTS(
|
||||
deltas.debtTotalDelta == principalRequested,
|
||||
"debtTotalDelta mismatch: expected " + to_string(principalRequested) + ", got " +
|
||||
to_string(deltas.debtTotalDelta));
|
||||
}
|
||||
|
||||
void
|
||||
testAccrualLoanOriginationExceedsVaultMaximum()
|
||||
{
|
||||
using namespace xrpl::Accrual;
|
||||
|
||||
struct TestCase
|
||||
{
|
||||
std::string name;
|
||||
Number vaultMaximum;
|
||||
Number vaultTotal;
|
||||
Number interestDue;
|
||||
bool expected;
|
||||
};
|
||||
|
||||
auto const testCases = std::vector<TestCase>{
|
||||
{.name = "No maximum configured",
|
||||
.vaultMaximum = Number{0},
|
||||
.vaultTotal = Number{900},
|
||||
.interestDue = Number{1'000},
|
||||
.expected = false},
|
||||
{.name = "Interest fits under headroom",
|
||||
.vaultMaximum = Number{1'000},
|
||||
.vaultTotal = Number{900},
|
||||
.interestDue = Number{50},
|
||||
.expected = false},
|
||||
{.name = "Interest exactly fills headroom",
|
||||
.vaultMaximum = Number{1'000},
|
||||
.vaultTotal = Number{900},
|
||||
.interestDue = Number{100},
|
||||
.expected = false},
|
||||
{.name = "Interest exceeds headroom",
|
||||
.vaultMaximum = Number{1'000},
|
||||
.vaultTotal = Number{900},
|
||||
.interestDue = Number{101},
|
||||
.expected = true},
|
||||
};
|
||||
|
||||
for (auto const& tc : testCases)
|
||||
{
|
||||
testcase("Accrual::loanOriginationExceedsVaultMaximum: " + tc.name);
|
||||
BEAST_EXPECT(
|
||||
loanOriginationExceedsVaultMaximum(
|
||||
tc.vaultMaximum, tc.vaultTotal, tc.interestDue) == tc.expected);
|
||||
}
|
||||
}
|
||||
|
||||
// Constructs a minimal ltLOAN SLE with just the fields needed by
|
||||
// loanVaultExposure. Mirrors the bare-SLE pattern used by
|
||||
// testCanApplyToBrokerCover for ltLOAN_BROKER.
|
||||
static std::shared_ptr<SLE>
|
||||
makeLoanSle(
|
||||
Number const& totalValueOutstanding,
|
||||
Number const& principalOutstanding,
|
||||
Number const& managementFeeOutstanding)
|
||||
{
|
||||
auto sle = std::make_shared<SLE>(ltLOAN, uint256{1u});
|
||||
sle->at(sfTotalValueOutstanding) = totalValueOutstanding;
|
||||
sle->at(sfPrincipalOutstanding) = principalOutstanding;
|
||||
sle->at(sfManagementFeeOutstanding) = managementFeeOutstanding;
|
||||
return sle;
|
||||
}
|
||||
|
||||
// Constructs a minimal ltVAULT SLE with just LEVersion set (or left
|
||||
// absent), for exercising the dispatchers' per-Vault gating.
|
||||
static std::shared_ptr<SLE>
|
||||
makeVaultSle(
|
||||
std::optional<VaultVersion> leVersion = std::nullopt,
|
||||
std::optional<Number> assetsMaximum = std::nullopt,
|
||||
std::optional<Number> assetsTotal = std::nullopt)
|
||||
{
|
||||
auto sle = std::make_shared<SLE>(ltVAULT, uint256{2u});
|
||||
if (leVersion)
|
||||
sle->at(sfLEVersion) = std::to_underlying(*leVersion);
|
||||
if (assetsMaximum)
|
||||
sle->at(sfAssetsMaximum) = *assetsMaximum;
|
||||
if (assetsTotal)
|
||||
sle->at(sfAssetsTotal) = *assetsTotal;
|
||||
return sle;
|
||||
}
|
||||
|
||||
void
|
||||
testAccrualLoanVaultExposure()
|
||||
{
|
||||
testcase("Accrual::loanVaultExposure");
|
||||
|
||||
auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
|
||||
BEAST_EXPECT(xrpl::Accrual::loanVaultExposure(sle) == Number{950});
|
||||
}
|
||||
|
||||
void
|
||||
testCashBasisLoanVaultExposure()
|
||||
{
|
||||
testcase("CashBasis::loanVaultExposure");
|
||||
|
||||
auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
|
||||
BEAST_EXPECT(xrpl::CashBasis::loanVaultExposure(sle) == Number{800});
|
||||
}
|
||||
|
||||
void
|
||||
testLoanPaymentDeltas()
|
||||
{
|
||||
// principalPaid, interestPaid, feePaid, valueChange are all distinct
|
||||
// and nonzero, with a nonzero valueChange simulating a late-payment
|
||||
// penalty, so Accrual's formula is meaningfully exercised.
|
||||
LoanPaymentParts const parts{
|
||||
.principalPaid = Number{100},
|
||||
.interestPaid = Number{20},
|
||||
.valueChange = Number{5},
|
||||
.feePaid = Number{3}};
|
||||
|
||||
{
|
||||
testcase("Accrual::loanPaymentDeltas: nonzero valueChange");
|
||||
auto const deltas = xrpl::Accrual::loanPaymentDeltas(parts);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == parts.valueChange);
|
||||
BEAST_EXPECT(
|
||||
deltas.debtTotalDelta ==
|
||||
(parts.principalPaid + parts.interestPaid) - parts.valueChange);
|
||||
}
|
||||
|
||||
{
|
||||
testcase("CashBasis::loanPaymentDeltas: nonzero valueChange ignored");
|
||||
auto const deltas = xrpl::CashBasis::loanPaymentDeltas(parts);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == parts.interestPaid);
|
||||
BEAST_EXPECT(deltas.debtTotalDelta == parts.principalPaid);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testLoanOriginationDeltasDispatcher()
|
||||
{
|
||||
using namespace jtx;
|
||||
|
||||
Number const principalRequested{1'000};
|
||||
Number const interestDue{75};
|
||||
|
||||
auto const legacyVault = makeVaultSle();
|
||||
auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
|
||||
|
||||
{
|
||||
testcase(
|
||||
"loanOriginationDeltas dispatcher: amendment enabled, legacy vault picks "
|
||||
"Accrual");
|
||||
Env const env{*this};
|
||||
auto const deltas = loanOriginationDeltas(legacyVault, principalRequested, interestDue);
|
||||
auto const expected =
|
||||
xrpl::Accrual::loanOriginationDeltas(principalRequested, interestDue);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
|
||||
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
|
||||
}
|
||||
|
||||
{
|
||||
testcase(
|
||||
"loanOriginationDeltas dispatcher: amendment enabled, LEVersion == "
|
||||
"VaultVersion::CashBasis picks CashBasis");
|
||||
Env const env{*this};
|
||||
auto const deltas =
|
||||
loanOriginationDeltas(cashBasisVault, principalRequested, interestDue);
|
||||
auto const expected = xrpl::CashBasis::loanOriginationDeltas(principalRequested);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
|
||||
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testLoanOriginationExceedsVaultMaximumDispatcher()
|
||||
{
|
||||
using namespace jtx;
|
||||
|
||||
Number const vaultMaximum{1'000};
|
||||
Number const vaultTotal{900};
|
||||
// Exceeds Accrual's headroom (100), but must never trip CashBasis.
|
||||
Number const interestDue{101};
|
||||
|
||||
auto const legacyVault = makeVaultSle(std::nullopt, vaultMaximum, vaultTotal);
|
||||
auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis, vaultMaximum, vaultTotal);
|
||||
|
||||
{
|
||||
testcase(
|
||||
"loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, legacy vault "
|
||||
"picks Accrual");
|
||||
Env const env{*this};
|
||||
BEAST_EXPECT(
|
||||
loanOriginationExceedsVaultMaximum(legacyVault, vaultTotal, interestDue) ==
|
||||
xrpl::Accrual::loanOriginationExceedsVaultMaximum(
|
||||
vaultMaximum, vaultTotal, interestDue));
|
||||
}
|
||||
|
||||
{
|
||||
testcase(
|
||||
"loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, LEVersion == "
|
||||
"VaultVersion::CashBasis picks CashBasis");
|
||||
Env const env{*this};
|
||||
BEAST_EXPECT(
|
||||
loanOriginationExceedsVaultMaximum(cashBasisVault, vaultTotal, interestDue) ==
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testLoanVaultExposureDispatcher()
|
||||
{
|
||||
using namespace jtx;
|
||||
|
||||
auto const legacyVault = makeVaultSle();
|
||||
auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
|
||||
|
||||
{
|
||||
testcase("loanVaultExposure dispatcher: amendment enabled, legacy vault picks Accrual");
|
||||
Env const env{*this};
|
||||
auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
|
||||
BEAST_EXPECT(
|
||||
loanVaultExposure(legacyVault, sle) == xrpl::Accrual::loanVaultExposure(sle));
|
||||
}
|
||||
|
||||
{
|
||||
testcase(
|
||||
"loanVaultExposure dispatcher: amendment enabled, LEVersion == "
|
||||
"VaultVersion::CashBasis "
|
||||
"picks CashBasis");
|
||||
Env const env{*this};
|
||||
auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
|
||||
BEAST_EXPECT(
|
||||
loanVaultExposure(cashBasisVault, sle) == xrpl::CashBasis::loanVaultExposure(sle));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testLoanPaymentDeltasDispatcher()
|
||||
{
|
||||
using namespace jtx;
|
||||
|
||||
LoanPaymentParts const parts{
|
||||
.principalPaid = Number{100},
|
||||
.interestPaid = Number{20},
|
||||
.valueChange = Number{5},
|
||||
.feePaid = Number{3}};
|
||||
|
||||
auto const legacyVault = makeVaultSle();
|
||||
auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
|
||||
|
||||
{
|
||||
testcase("loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks Accrual");
|
||||
Env const env{*this};
|
||||
auto const deltas = loanPaymentDeltas(legacyVault, parts);
|
||||
auto const expected = xrpl::Accrual::loanPaymentDeltas(parts);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
|
||||
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
|
||||
}
|
||||
|
||||
{
|
||||
testcase(
|
||||
"loanPaymentDeltas dispatcher: amendment enabled, LEVersion == "
|
||||
"VaultVersion::CashBasis "
|
||||
"picks CashBasis");
|
||||
Env const env{*this};
|
||||
auto const deltas = loanPaymentDeltas(cashBasisVault, parts);
|
||||
auto const expected = xrpl::CashBasis::loanPaymentDeltas(parts);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
|
||||
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
testCanApplyToBrokerCover()
|
||||
@@ -1573,6 +1895,17 @@ public:
|
||||
testComputeOverpaymentComponents();
|
||||
testComputeInterestAndFeeParts();
|
||||
testCanApplyToBrokerCover();
|
||||
|
||||
testAccrualLoanOriginationDeltas();
|
||||
testCashBasisLoanOriginationDeltas();
|
||||
testAccrualLoanOriginationExceedsVaultMaximum();
|
||||
testAccrualLoanVaultExposure();
|
||||
testCashBasisLoanVaultExposure();
|
||||
testLoanPaymentDeltas();
|
||||
testLoanOriginationDeltasDispatcher();
|
||||
testLoanOriginationExceedsVaultMaximumDispatcher();
|
||||
testLoanVaultExposureDispatcher();
|
||||
testLoanPaymentDeltasDispatcher();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7645,6 +7645,83 @@ class Vault_test : public beast::unit_test::Suite
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testVaultCreateLEVersion()
|
||||
{
|
||||
using namespace test::jtx;
|
||||
|
||||
Account const owner{"owner"};
|
||||
PrettyAsset const xrpAsset = xrpIssue();
|
||||
|
||||
{
|
||||
testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent");
|
||||
Env env{*this};
|
||||
env.disableFeature(featureLendingProtocolV1_1);
|
||||
env.fund(XRP(1'000'000), owner);
|
||||
env.close();
|
||||
|
||||
Vault const vault{env};
|
||||
auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
|
||||
env(tx, Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const sleVault = env.le(keylet);
|
||||
BEAST_EXPECT(sleVault);
|
||||
BEAST_EXPECT(!sleVault->isFieldPresent(sfLEVersion));
|
||||
}
|
||||
|
||||
{
|
||||
testcase(
|
||||
"VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == "
|
||||
"VaultVersion::CashBasis");
|
||||
Env env{*this};
|
||||
env.fund(XRP(1'000'000), owner);
|
||||
env.close();
|
||||
|
||||
Vault const vault{env};
|
||||
auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
|
||||
env(tx, Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const sleVault = env.le(keylet);
|
||||
BEAST_EXPECT(sleVault);
|
||||
BEAST_EXPECT(sleVault->isFieldPresent(sfLEVersion));
|
||||
BEAST_EXPECT(sleVault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis));
|
||||
}
|
||||
|
||||
{
|
||||
testcase("VaultCreate rejects LEVersion set in the transaction");
|
||||
Env env{*this};
|
||||
env.fund(XRP(1'000'000), owner);
|
||||
env.close();
|
||||
|
||||
Vault const vault{env};
|
||||
auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
|
||||
tx[sfLEVersion] = 2;
|
||||
env(tx, Ter(temMALFORMED));
|
||||
env.close();
|
||||
|
||||
BEAST_EXPECT(!env.le(keylet));
|
||||
}
|
||||
|
||||
{
|
||||
testcase("VaultSet rejects LEVersion set in the transaction");
|
||||
Env env{*this};
|
||||
env.fund(XRP(1'000'000), owner);
|
||||
env.close();
|
||||
|
||||
Vault const vault{env};
|
||||
auto const [createTx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
|
||||
env(createTx, Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto setTx = vault.set({.owner = owner, .id = keylet.key});
|
||||
setTx[sfLEVersion] = 2;
|
||||
env(setTx, Ter(temMALFORMED));
|
||||
env.close();
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testVaultDepositFreezeIOU()
|
||||
{
|
||||
@@ -8317,6 +8394,7 @@ public:
|
||||
testVaultEscrowedMPT();
|
||||
testAssetsMaximum();
|
||||
testVaultDeleteMemoData();
|
||||
testVaultCreateLEVersion();
|
||||
testBug6LimitBypassWithShares();
|
||||
testRemoveEmptyHoldingLockedAmount();
|
||||
testRemoveEmptyHoldingConfidentialBalances();
|
||||
|
||||
@@ -40,6 +40,17 @@ public:
|
||||
*/
|
||||
[[nodiscard]] virtual unsigned
|
||||
version() const = 0;
|
||||
|
||||
/**
|
||||
* Close the client's connection to the server.
|
||||
*
|
||||
* Releases the connection the client holds against the server's per-port
|
||||
* connection limit. After this call the client must not be used to
|
||||
* invoke() again. Tests use this to deterministically free the slot
|
||||
* rather than waiting for the server's idle timeout to drop it.
|
||||
*/
|
||||
virtual void
|
||||
disconnect() = 0;
|
||||
};
|
||||
|
||||
} // namespace xrpl::test
|
||||
|
||||
@@ -482,11 +482,52 @@ public:
|
||||
app().getNumberOfThreads() == 1,
|
||||
"syncClose() is only useful on an application with a single thread");
|
||||
auto const result = close();
|
||||
auto serverBarrier = std::make_shared<std::promise<void>>();
|
||||
auto future = serverBarrier->get_future();
|
||||
boost::asio::post(app().getIOContext(), [serverBarrier]() { serverBarrier->set_value(); });
|
||||
auto const status = future.wait_for(timeout);
|
||||
return result && status == std::future_status::ready;
|
||||
return result && drainServerIo(timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the Env's built-in client and wait for the server to
|
||||
* register the dropped connection.
|
||||
*
|
||||
* Env holds one persistent client connection to the server's RPC port for
|
||||
* its whole lifetime (see client()), and that connection counts against
|
||||
* the port's connection limit. Tests that need a known starting occupancy
|
||||
* can call this to deterministically release that slot instead of waiting
|
||||
* out the server's localhost idle timeout.
|
||||
*
|
||||
* The server decrements its per-port connection count in the peer's
|
||||
* destructor, which runs when the io_context processes the end-of-stream
|
||||
* on the closed socket. After closing the client this drains the server's
|
||||
* io_context twice: the first barrier guarantees the reactor has reaped
|
||||
* the closed socket and queued the peer's teardown, and the second
|
||||
* guarantees that teardown (and therefore the count decrement) has run.
|
||||
*
|
||||
* This is only sound when the server uses a single io_context thread, so
|
||||
* that draining establishes ordering against the teardown - configure the
|
||||
* Env with singleThreadIo() (as syncClose() also requires). Like
|
||||
* syncClose(), it relies on loopback teardown latency being negligible.
|
||||
*
|
||||
* @param timeout Maximum time to wait for each barrier task to execute
|
||||
* @return true if both barriers executed within timeout, false otherwise
|
||||
*/
|
||||
[[nodiscard]] bool
|
||||
disconnectClient(std::chrono::steady_clock::duration timeout = std::chrono::seconds{1})
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
app().getNumberOfThreads() == 1,
|
||||
"disconnectClient() is only useful on an application with a single "
|
||||
"thread");
|
||||
|
||||
bundle_.client->disconnect();
|
||||
|
||||
// Drain the server's single io thread twice: the first barrier flushes
|
||||
// the reactor's reap of the closed socket (queuing the peer teardown),
|
||||
// the second flushes that teardown - and therefore the connection-count
|
||||
// decrement. Both run unconditionally so a timed-out first drain does
|
||||
// not short-circuit the second.
|
||||
bool const reaped = drainServerIo(timeout);
|
||||
bool const toreDown = drainServerIo(timeout);
|
||||
return reaped && toreDown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -846,6 +887,25 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
/**
|
||||
* Drain the (single) server io_context thread once.
|
||||
*
|
||||
* Posts a barrier task to the server's io_context and blocks until it
|
||||
* runs, so every task queued before it has been processed. Only meaningful
|
||||
* with a single io thread (see syncClose()/disconnectClient()).
|
||||
*
|
||||
* @param timeout Maximum time to wait for the barrier task to execute
|
||||
* @return true if the barrier ran within timeout, false otherwise
|
||||
*/
|
||||
[[nodiscard]] bool
|
||||
drainServerIo(std::chrono::steady_clock::duration timeout)
|
||||
{
|
||||
auto barrier = std::make_shared<std::promise<void>>();
|
||||
auto future = barrier->get_future();
|
||||
boost::asio::post(app().getIOContext(), [barrier]() { barrier->set_value(); });
|
||||
return future.wait_for(timeout) == std::future_status::ready;
|
||||
}
|
||||
|
||||
void
|
||||
fund(bool setDefaultRipple, STAmount const& amount, Account const& account);
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@ class WSClient_test : public beast::unit_test::Suite
|
||||
{
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
testSmoke()
|
||||
{
|
||||
testcase("smoke");
|
||||
using namespace jtx;
|
||||
Env env(*this);
|
||||
auto wsc = makeWSClient(env.app().config());
|
||||
@@ -28,6 +29,47 @@ public:
|
||||
auto jv = wsc->getMsg(std::chrono::seconds(1));
|
||||
pass();
|
||||
}
|
||||
|
||||
void
|
||||
testGracefulDisconnect()
|
||||
{
|
||||
testcase("graceful disconnect");
|
||||
using namespace jtx;
|
||||
using namespace std::chrono;
|
||||
|
||||
Env env(*this);
|
||||
auto wsc = makeWSClient(env.app().config());
|
||||
|
||||
// Put real traffic on the connection before closing it.
|
||||
json::Value stream;
|
||||
stream["streams"] = json::ValueType::Array;
|
||||
stream["streams"].append("ledger");
|
||||
auto const sub = wsc->invoke("subscribe", stream);
|
||||
BEAST_EXPECT(sub.isMember("result") || sub.isMember("status"));
|
||||
|
||||
// disconnect() performs a graceful WebSocket closing handshake and
|
||||
// blocks until the server acknowledges. On loopback that completes in
|
||||
// well under its internal 1s timeout; only a broken async_close/ack
|
||||
// coordination would fall through to the force-close path at ~1s. A
|
||||
// generous bound keeps this from flaking under load while still
|
||||
// catching that regression.
|
||||
auto const start = steady_clock::now();
|
||||
wsc->disconnect();
|
||||
auto const elapsed = duration_cast<milliseconds>(steady_clock::now() - start);
|
||||
BEAST_EXPECT(elapsed < milliseconds{750});
|
||||
|
||||
// disconnect() must be idempotent: a second call (and the subsequent
|
||||
// destructor) must not hang, double-close, or crash.
|
||||
wsc->disconnect();
|
||||
pass();
|
||||
}
|
||||
|
||||
void
|
||||
run() override
|
||||
{
|
||||
testSmoke();
|
||||
testGracefulDisconnect();
|
||||
}
|
||||
};
|
||||
|
||||
BEAST_DEFINE_TESTSUITE(WSClient, jtx, xrpl);
|
||||
|
||||
@@ -14,18 +14,23 @@
|
||||
#include <xrpl/server/Port.h>
|
||||
|
||||
#include <boost/asio/buffer.hpp>
|
||||
#include <boost/asio/error.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/ip/address_v4.hpp>
|
||||
#include <boost/asio/ip/address_v6.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
#include <boost/beast/core/multi_buffer.hpp>
|
||||
#include <boost/beast/http/dynamic_body.hpp>
|
||||
#include <boost/beast/http/error.hpp>
|
||||
#include <boost/beast/http/message.hpp>
|
||||
#include <boost/beast/http/read.hpp>
|
||||
#include <boost/beast/http/string_body.hpp>
|
||||
#include <boost/beast/http/verb.hpp>
|
||||
#include <boost/beast/http/write.hpp>
|
||||
#include <boost/system/system_error.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
@@ -84,6 +89,40 @@ class JSONRPCClient : public AbstractClient
|
||||
boost::beast::multi_buffer bout_;
|
||||
unsigned rpcVersion_;
|
||||
|
||||
bool disconnected_ = false;
|
||||
|
||||
// Errors that mean the persistent keep-alive connection was dropped by the
|
||||
// server (rather than a genuine protocol failure), so the request can be
|
||||
// safely retried on a fresh connection.
|
||||
static bool
|
||||
droppedConnection(boost::system::error_code const& ec)
|
||||
{
|
||||
namespace error = boost::asio::error;
|
||||
static auto const kDroppedConnectionErrors = std::to_array<boost::system::error_code>({
|
||||
boost::beast::http::error::end_of_stream,
|
||||
error::eof,
|
||||
error::connection_reset,
|
||||
error::connection_aborted,
|
||||
error::broken_pipe,
|
||||
error::not_connected,
|
||||
});
|
||||
|
||||
return std::ranges::any_of(
|
||||
kDroppedConnectionErrors,
|
||||
[&ec](boost::system::error_code const& e) { return ec == e; });
|
||||
}
|
||||
|
||||
// Tear down and re-establish the socket to ep_, discarding any buffered
|
||||
// bytes left over from the dropped connection.
|
||||
void
|
||||
reconnect()
|
||||
{
|
||||
boost::system::error_code ec;
|
||||
stream_.close(ec);
|
||||
bin_.clear();
|
||||
stream_.connect(ep_);
|
||||
}
|
||||
|
||||
public:
|
||||
explicit JSONRPCClient(Config const& cfg, unsigned rpcVersion)
|
||||
: ep_(getEndpoint(cfg)), stream_(ios_), rpcVersion_(rpcVersion)
|
||||
@@ -91,12 +130,10 @@ public:
|
||||
stream_.connect(ep_);
|
||||
}
|
||||
|
||||
/*
|
||||
Return value is an Object type with up to three keys:
|
||||
status
|
||||
error
|
||||
result
|
||||
*/
|
||||
// Return value is an Object type with up to three keys:
|
||||
// status
|
||||
// error
|
||||
// result
|
||||
json::Value
|
||||
invoke(std::string const& cmd, json::Value const& params) override
|
||||
{
|
||||
@@ -104,6 +141,13 @@ public:
|
||||
using namespace boost::asio;
|
||||
using namespace std::string_literals;
|
||||
|
||||
// Once disconnect() has released the slot, the client must not be
|
||||
// reused (see AbstractClient::disconnect). Refuse rather than let the
|
||||
// failed write/read below trip the reconnect path and silently
|
||||
// re-consume a connection slot, which would defeat disconnectClient().
|
||||
if (disconnected_)
|
||||
Throw<std::logic_error>("JSONRPCClient::invoke called after disconnect()");
|
||||
|
||||
request<string_body> req;
|
||||
req.method(boost::beast::http::verb::post);
|
||||
req.target("/");
|
||||
@@ -131,10 +175,29 @@ public:
|
||||
req.body() = to_string(jr);
|
||||
}
|
||||
req.prepare_payload();
|
||||
write(stream_, req);
|
||||
|
||||
// The client keeps a single keep-alive connection for its whole
|
||||
// lifetime, but the server drops idle localhost connections after a few
|
||||
// seconds (BaseHTTPPeer::kTimeoutSecondsLocal). If a slow gap between
|
||||
// requests let the server close the socket, the write/read here fails
|
||||
// with end_of_stream; reconnect and retry the request exactly once.
|
||||
response<dynamic_body> res;
|
||||
read(stream_, bin_, res);
|
||||
auto writeAndRead = [&] {
|
||||
write(stream_, req);
|
||||
read(stream_, bin_, res);
|
||||
};
|
||||
try
|
||||
{
|
||||
writeAndRead();
|
||||
}
|
||||
catch (boost::system::system_error const& e)
|
||||
{
|
||||
if (!droppedConnection(e.code()))
|
||||
throw;
|
||||
reconnect();
|
||||
res = {};
|
||||
writeAndRead();
|
||||
}
|
||||
|
||||
json::Reader jr;
|
||||
json::Value jv;
|
||||
@@ -151,6 +214,19 @@ public:
|
||||
{
|
||||
return rpcVersion_;
|
||||
}
|
||||
|
||||
void
|
||||
disconnect() override
|
||||
{
|
||||
if (disconnected_)
|
||||
return;
|
||||
|
||||
disconnected_ = true;
|
||||
|
||||
boost::system::error_code ec;
|
||||
stream_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec);
|
||||
stream_.close(ec);
|
||||
}
|
||||
};
|
||||
|
||||
std::unique_ptr<AbstractClient>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <xrpld/core/Config.h>
|
||||
|
||||
#include <xrpl/basics/Mutex.hpp>
|
||||
#include <xrpl/basics/contract.h>
|
||||
#include <xrpl/config/BasicConfig.h>
|
||||
#include <xrpl/config/Constants.h>
|
||||
@@ -112,10 +113,11 @@ class WSClientImpl : public WSClient
|
||||
|
||||
bool peerClosed_ = false;
|
||||
|
||||
// synchronize destructor
|
||||
bool b0_ = false;
|
||||
std::mutex m0_;
|
||||
std::condition_variable cv0_;
|
||||
// disconnect() waits on this until the read loop ends (for any reason:
|
||||
// the server acknowledged our close, or a timeout force-closed the socket).
|
||||
static constexpr auto kDisconnectTimeout = std::chrono::seconds{1};
|
||||
xrpl::Mutex<bool> readEnded_;
|
||||
std::condition_variable readEndCv_;
|
||||
|
||||
// synchronize message queue
|
||||
std::mutex m_;
|
||||
@@ -127,23 +129,26 @@ class WSClientImpl : public WSClient
|
||||
void
|
||||
cleanup()
|
||||
{
|
||||
boost::asio::post(ios_, boost::asio::bind_executor(strand_, [this] {
|
||||
if (!peerClosed_)
|
||||
{
|
||||
ws_.async_close(
|
||||
{}, boost::asio::bind_executor(strand_, [&](error_code) {
|
||||
try
|
||||
{
|
||||
stream_.cancel();
|
||||
}
|
||||
// NOLINTNEXTLINE(bugprone-empty-catch)
|
||||
catch (boost::system::system_error const&)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}));
|
||||
}
|
||||
}));
|
||||
boost::asio::post(
|
||||
ios_, //
|
||||
boost::asio::bind_executor(strand_, [this] {
|
||||
if (!peerClosed_)
|
||||
{
|
||||
ws_.async_close(
|
||||
{}, //
|
||||
boost::asio::bind_executor(strand_, [&](error_code) {
|
||||
try
|
||||
{
|
||||
stream_.cancel();
|
||||
}
|
||||
// NOLINTNEXTLINE(bugprone-empty-catch)
|
||||
catch (boost::system::system_error const&)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}));
|
||||
}
|
||||
}));
|
||||
work_ = std::nullopt;
|
||||
thread_.join();
|
||||
}
|
||||
@@ -289,6 +294,44 @@ public:
|
||||
return rpcVersion_;
|
||||
}
|
||||
|
||||
void
|
||||
disconnect() override
|
||||
{
|
||||
// Perform a graceful WebSocket closing handshake and block until the
|
||||
// read loop ends, so the server observes a clean close (not a RST) and
|
||||
// has finished tearing the connection down by the time we return.
|
||||
// If the server already closed, the wait below returns immediately.
|
||||
boost::asio::post(
|
||||
ios_,
|
||||
boost::asio::bind_executor(
|
||||
strand_, //
|
||||
[this] {
|
||||
if (!peerClosed_)
|
||||
{
|
||||
ws_.async_close(
|
||||
boost::beast::websocket::close_code::normal,
|
||||
boost::asio::bind_executor(strand_, [](error_code) {}));
|
||||
}
|
||||
}));
|
||||
|
||||
auto lock = readEnded_.lock<std::unique_lock>();
|
||||
readEndCv_.wait_for(lock, kDisconnectTimeout, [&lock] { return *lock; });
|
||||
|
||||
// On timeout (server gone or not replying) force the socket closed so
|
||||
// the outstanding read ends and the worker thread can later be joined.
|
||||
if (!*lock)
|
||||
{
|
||||
boost::asio::post(
|
||||
ios_,
|
||||
boost::asio::bind_executor(
|
||||
strand_, //
|
||||
[this] {
|
||||
boost::system::error_code ec;
|
||||
stream_.close(ec);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
void
|
||||
onReadMsg(error_code const& ec)
|
||||
@@ -297,33 +340,31 @@ private:
|
||||
{
|
||||
if (ec == boost::beast::websocket::error::closed)
|
||||
peerClosed_ = true;
|
||||
|
||||
*readEnded_.lock() = true;
|
||||
readEndCv_.notify_all();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
json::Value jv;
|
||||
json::Reader jr;
|
||||
|
||||
jr.parse(bufferString(rb_.data()), jv);
|
||||
rb_.consume(rb_.size());
|
||||
|
||||
auto m = std::make_shared<Msg>(std::move(jv));
|
||||
{
|
||||
std::scoped_lock const lock(m_);
|
||||
msgs_.push_front(m);
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
ws_.async_read(
|
||||
rb_, boost::asio::bind_executor(strand_, [this](error_code const& ec, std::size_t) {
|
||||
onReadMsg(ec);
|
||||
}));
|
||||
}
|
||||
|
||||
// Called when the read op terminates
|
||||
void
|
||||
onReadDone()
|
||||
{
|
||||
std::scoped_lock const lock(m0_);
|
||||
b0_ = true;
|
||||
cv0_.notify_all();
|
||||
}
|
||||
};
|
||||
|
||||
std::unique_ptr<WSClient>
|
||||
|
||||
@@ -558,10 +558,12 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
|
||||
using namespace test::jtx;
|
||||
using namespace boost::asio;
|
||||
using namespace boost::beast::http;
|
||||
Env env{*this, envconfig([&](std::unique_ptr<Config> cfg) {
|
||||
// Run the server with a single io thread so disconnectClient() below
|
||||
// can deterministically drain the server's io_context (see its docs).
|
||||
Env env{*this, singleThreadIo(envconfig([&](std::unique_ptr<Config> cfg) {
|
||||
(*cfg)[Sections::kPortRpc].set(Keys::kLimit, std::to_string(limit));
|
||||
return cfg;
|
||||
})};
|
||||
}))};
|
||||
|
||||
auto const section = env.app().config().section(Sections::kPortRpc);
|
||||
// NOLINTBEGIN(bugprone-unchecked-optional-access)
|
||||
@@ -580,16 +582,27 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
|
||||
BEAST_EXPECT(!ec);
|
||||
|
||||
std::vector<std::pair<ip::tcp::socket, boost::beast::multi_buffer>> clients;
|
||||
int connectionCount{1}; // starts at 1 because the Env already has one
|
||||
// for JSONRPCCLient
|
||||
|
||||
// for nonzero limits, go one past the limit, although failures happen
|
||||
// at the limit, so this really leads to the last two clients failing.
|
||||
// for zero limit, pick an arbitrary nonzero number of clients - all
|
||||
// should connect fine.
|
||||
// Env owns a persistent JSON-RPC HTTP client connection to port_rpc as
|
||||
// part of startup, which counts against this port's connection limit.
|
||||
// This test wants a known starting occupancy of zero, so for nonzero
|
||||
// limits it deterministically drops that hidden client and waits for
|
||||
// the server to register the disconnect before opening its own clients.
|
||||
//
|
||||
// Starting from zero is important because the port limit rejects once
|
||||
// the incremented connection count reaches the configured limit. With a
|
||||
// zero baseline and N = limit + 1 test-owned clients, exactly the last
|
||||
// two requests should be rejected.
|
||||
if (limit != 0)
|
||||
BEAST_EXPECT(env.disconnectClient());
|
||||
|
||||
// For nonzero limits, go one past the limit. The port rejects at the
|
||||
// limit, not only above it, so this yields the last two clients
|
||||
// failing. For zero limit, pick an arbitrary nonzero number of clients
|
||||
// and expect them all to succeed.
|
||||
|
||||
int const testTo = (limit == 0) ? 50 : limit + 1;
|
||||
while (connectionCount < testTo)
|
||||
while (static_cast<int>(clients.size()) < testTo)
|
||||
{
|
||||
clients.emplace_back(ip::tcp::socket{ios}, boost::beast::multi_buffer{});
|
||||
async_connect(clients.back().first, it, yield[ec]);
|
||||
@@ -597,19 +610,24 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
|
||||
auto req = makeHTTPRequest(ip, port, to_string(jr), {});
|
||||
async_write(clients.back().first, req, yield[ec]);
|
||||
BEAST_EXPECT(!ec);
|
||||
++connectionCount;
|
||||
}
|
||||
|
||||
int readCount = 0;
|
||||
int successfulReads = 0;
|
||||
for (auto& [soc, buf] : clients)
|
||||
{
|
||||
boost::beast::http::response<boost::beast::http::string_body> resp;
|
||||
async_read(soc, buf, resp, yield[ec]);
|
||||
++readCount;
|
||||
// expect the reads to fail for the clients that connected at or
|
||||
// above the limit. If limit is 0, all reads should succeed
|
||||
BEAST_EXPECT((limit == 0 || readCount < limit - 1) ? (!ec) : bool(ec));
|
||||
if (!ec)
|
||||
++successfulReads;
|
||||
}
|
||||
|
||||
// This test cares about the exact number of accepted requests, not which
|
||||
// specific client observed the rejection. With a zero baseline (the
|
||||
// hidden Env client dropped above), the server accepts until the
|
||||
// connection count reaches the limit: all clients for limit 0, else
|
||||
// limit - 1 of the limit + 1 clients (the last two are rejected).
|
||||
int const expectedReads = (limit == 0) ? static_cast<int>(clients.size()) : limit - 1;
|
||||
BEAST_EXPECT(successfulReads == expectedReads);
|
||||
}
|
||||
|
||||
void
|
||||
|
||||
@@ -35,6 +35,7 @@ TEST(VaultTests, BuilderSettersRoundTrip)
|
||||
auto const shareMPTIDValue = canonical_UINT192();
|
||||
auto const withdrawalPolicyValue = canonical_UINT8();
|
||||
auto const scaleValue = canonical_UINT8();
|
||||
auto const lEVersionValue = canonical_UINT8();
|
||||
|
||||
VaultBuilder builder{
|
||||
previousTxnIDValue,
|
||||
@@ -54,6 +55,7 @@ TEST(VaultTests, BuilderSettersRoundTrip)
|
||||
builder.setAssetsMaximum(assetsMaximumValue);
|
||||
builder.setLossUnrealized(lossUnrealizedValue);
|
||||
builder.setScale(scaleValue);
|
||||
builder.setLEVersion(lEVersionValue);
|
||||
|
||||
builder.setLedgerIndex(index);
|
||||
builder.setFlags(0x1u);
|
||||
@@ -166,6 +168,14 @@ TEST(VaultTests, BuilderSettersRoundTrip)
|
||||
EXPECT_TRUE(entry.hasScale());
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = lEVersionValue;
|
||||
auto const actualOpt = entry.getLEVersion();
|
||||
ASSERT_TRUE(actualOpt.has_value());
|
||||
expectEqualField(expected, *actualOpt, "sfLEVersion");
|
||||
EXPECT_TRUE(entry.hasLEVersion());
|
||||
}
|
||||
|
||||
EXPECT_TRUE(entry.hasLedgerIndex());
|
||||
auto const ledgerIndex = entry.getLedgerIndex();
|
||||
ASSERT_TRUE(ledgerIndex.has_value());
|
||||
@@ -194,6 +204,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
|
||||
auto const shareMPTIDValue = canonical_UINT192();
|
||||
auto const withdrawalPolicyValue = canonical_UINT8();
|
||||
auto const scaleValue = canonical_UINT8();
|
||||
auto const lEVersionValue = canonical_UINT8();
|
||||
|
||||
auto sle = std::make_shared<SLE>(Vault::entryType, index);
|
||||
|
||||
@@ -212,6 +223,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
|
||||
sle->at(sfShareMPTID) = shareMPTIDValue;
|
||||
sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue;
|
||||
sle->at(sfScale) = scaleValue;
|
||||
sle->at(sfLEVersion) = lEVersionValue;
|
||||
|
||||
VaultBuilder builderFromSle{sle};
|
||||
EXPECT_TRUE(builderFromSle.validate());
|
||||
@@ -390,6 +402,19 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
|
||||
expectEqualField(expected, *fromBuilderOpt, "sfScale");
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = lEVersionValue;
|
||||
|
||||
auto const fromSleOpt = entryFromSle.getLEVersion();
|
||||
auto const fromBuilderOpt = entryFromBuilder.getLEVersion();
|
||||
|
||||
ASSERT_TRUE(fromSleOpt.has_value());
|
||||
ASSERT_TRUE(fromBuilderOpt.has_value());
|
||||
|
||||
expectEqualField(expected, *fromSleOpt, "sfLEVersion");
|
||||
expectEqualField(expected, *fromBuilderOpt, "sfLEVersion");
|
||||
}
|
||||
|
||||
EXPECT_EQ(entryFromSle.getKey(), index);
|
||||
EXPECT_EQ(entryFromBuilder.getKey(), index);
|
||||
}
|
||||
@@ -472,5 +497,7 @@ TEST(VaultTests, OptionalFieldsReturnNullopt)
|
||||
EXPECT_FALSE(entry.getLossUnrealized().has_value());
|
||||
EXPECT_FALSE(entry.hasScale());
|
||||
EXPECT_FALSE(entry.getScale().has_value());
|
||||
EXPECT_FALSE(entry.hasLEVersion());
|
||||
EXPECT_FALSE(entry.getLEVersion().has_value());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user