Merge branch 'develop' into ximinez/online-delete-gaps

This commit is contained in:
Ed Hennis
2026-05-21 14:25:41 -04:00
committed by GitHub
17 changed files with 1071 additions and 1034 deletions

View File

@@ -4,8 +4,38 @@
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/st.h>
#include <string_view>
namespace xrpl {
/**
* Broker cover preclaim precision guard (fixCleanup3_2_0).
*
* Prevents a "silent sub-ULP no-op" where a deposit, withdrawal, or clawback
* amount is so small that it rounds to zero at `sfCoverAvailable`'s scale.
* Without this guard, both the pseudo trust-line and `sfCoverAvailable` would
* identically absorb the rounded zero, resulting in a successful transaction
* (tesSUCCESS) where no funds actually moved.
*
* @param view Read view (rules used for amendment gating).
* @param sleBroker The loan broker SLE (read-only).
* @param vaultAsset The underlying vault asset (the broker's cover asset).
* @param amount The effective subtraction/addition amount.
* @param j Journal for logging.
* @param logPrefix Transactor name for log diagnostics.
*
* @return `tecPRECISION_LOSS` if the request rounds to zero at cover scale.
* `tesSUCCESS` if the amendment is disabled or the request is safely supra-ULP.
*/
[[nodiscard]] TER
canApplyToBrokerCover(
ReadView const& view,
SLE::const_ref sleBroker,
Asset const& vaultAsset,
STAmount const& amount,
beast::Journal j,
std::string_view logPrefix);
// Lending protocol has dependencies, so capture them here.
bool
checkLendingProtocolDependencies(Rules const& rules, STTx const& tx);

View File

@@ -184,6 +184,24 @@ public:
[[nodiscard]] STAmount const&
value() const noexcept;
/**
* Checks if this amount evaluates to zero when constrained to a specific
* accounting scale.
*
* For XRP and MPT `roundToScale` is a no-op, returns true only when the amount itself is zero.
* The `scale` argument is ignored in that case.
* For IOU, the amount is rounded to the given scale using Number::RoundingMode::ToNearest mode
* and the result is checked for zero; if `scale <= exponent()`, `roundToScale` short-circuits
* and returns the value unchanged, so this returns false for any non-zero amount.
*
* @param scale The target accounting scale to evaluate against.
* @return `true` if this amount rounds to zero at the given scale, `false` otherwise.
*
* @see roundToScale
*/
[[nodiscard]] bool
isZeroAtScale(int scale) const;
//--------------------------------------------------------------------------
//
// Operators

View File

@@ -90,11 +90,11 @@ private:
acceptor_type acceptor_;
boost::asio::strand<boost::asio::io_context::executor_type> strand_;
bool ssl_{
port_.protocol.count("https") > 0 || port_.protocol.count("wss") > 0 ||
port_.protocol.count("wss2") > 0 || port_.protocol.count("peer") > 0};
port_.protocol.contains("https") || port_.protocol.contains("wss") ||
port_.protocol.contains("wss2") || port_.protocol.contains("peer")};
bool plain_{
port_.protocol.count("http") > 0 || port_.protocol.count("ws") > 0 ||
(port_.protocol.count("ws2") != 0u)};
port_.protocol.contains("http") || port_.protocol.contains("ws") ||
(port_.protocol.contains("ws2"))};
static constexpr std::chrono::milliseconds kInitialAcceptDelay{50};
static constexpr std::chrono::milliseconds kMaxAcceptDelay{2000};
std::chrono::milliseconds acceptDelay_{kInitialAcceptDelay};

View File

@@ -8,6 +8,7 @@
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
@@ -24,10 +25,42 @@
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <string_view>
#include <utility>
namespace xrpl {
[[nodiscard]] TER
canApplyToBrokerCover(
ReadView const& view,
SLE::const_ref sleBroker,
Asset const& vaultAsset,
STAmount const& amount,
beast::Journal j,
std::string_view logPrefix)
{
XRPL_ASSERT(
sleBroker && sleBroker->getType() == ltLOAN_BROKER,
"xrpl::canApplyToBrokerCover : valid LoanBroker sle");
XRPL_ASSERT(vaultAsset == amount.asset(), "xrpl::canApplyToBrokerCover : valid asset");
if (!view.rules().enabled(fixCleanup3_2_0))
return tesSUCCESS;
if (amount == beast::kZero)
return tecPRECISION_LOSS;
int const coverScale = scale(sleBroker->at(sfCoverAvailable), vaultAsset);
if (amount.isZeroAtScale(coverScale))
{
JLOG(j.warn()) << logPrefix << ": amount " << amount.getFullText()
<< " rounds to zero at cover scale " << coverScale;
return tecPRECISION_LOSS;
}
return tesSUCCESS;
}
bool
checkLendingProtocolDependencies(Rules const& rules, STTx const& tx)
{
@@ -1058,11 +1091,22 @@ computePaymentComponents(
rules, periodicPayment, periodicRate, paymentRemaining - 1, managementFeeRate);
// Round the target to the loan's scale to match how actual loan values
// are stored.
// are stored. With fixCleanup3_2_0 enabled, principal is rounded upward
// and interest downward so that at coarse scale principal sticks at the
// floor (until the final payment clears it) while interest absorbs each
// periodic payment. Without the amendment the pre-existing round-to-
// nearest behavior is preserved (which can hit the "Partial principal
// payment" assertion on degenerate integer-scale loans).
bool const fixCleanup320Enabled = rules.enabled(fixCleanup3_2_0);
Number::RoundingMode const principalRounding =
fixCleanup320Enabled ? Number::RoundingMode::Upward : Number::getround();
Number::RoundingMode const interestRounding =
fixCleanup320Enabled ? Number::RoundingMode::Downward : Number::getround();
LoanState const roundedTarget = LoanState{
.valueOutstanding = roundToAsset(asset, trueTarget.valueOutstanding, scale),
.principalOutstanding = roundToAsset(asset, trueTarget.principalOutstanding, scale),
.interestDue = roundToAsset(asset, trueTarget.interestDue, scale),
.principalOutstanding =
roundToAsset(asset, trueTarget.principalOutstanding, scale, principalRounding),
.interestDue = roundToAsset(asset, trueTarget.interestDue, scale, interestRounding),
.managementFeeDue = roundToAsset(asset, trueTarget.managementFeeDue, scale)};
// Get the current actual loan state from the ledger values

View File

@@ -23,7 +23,7 @@ namespace {
//------------------------------------------------------------------------------
// clang-format off
// NOLINTNEXTLINE(readability-identifier-naming)
char const* const versionString = "3.3.0-b0"
char const* const versionString = "3.2.0-b6"
// clang-format on
;

View File

@@ -1738,4 +1738,9 @@ divRoundStrict(STAmount const& num, STAmount const& den, Asset const& asset, boo
return divRoundImpl<NumberRoundModeGuard>(num, den, asset, roundUp);
}
[[nodiscard]] bool
STAmount::isZeroAtScale(int scale) const
{
return roundToScale(*this, scale, Number::RoundingMode::ToNearest).signum() == 0;
}
} // namespace xrpl

View File

@@ -291,6 +291,10 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx)
}
STAmount const& clawAmount = *findClawAmount;
if (auto const ret = canApplyToBrokerCover(
ctx.view, sleBroker, vaultAsset, clawAmount, ctx.j, "LoanBrokerCoverClawback"))
return ret;
// Explicitly check the balance of the trust line / MPT to make sure the
// balance is actually there. It should always match `sfCoverAvailable`, so
// if there isn't, this is an internal error.

View File

@@ -1,9 +1,11 @@
#include <xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
@@ -87,6 +89,29 @@ LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx)
if (auto const ret = requireAuth(ctx.view, vaultAsset, account, AuthType::StrongAuth))
return ret;
// Deposit must round the amount Downward to cover scale and then reuse that rounded
// value for the actual transfer in doApply — otherwise implicit round-to-nearest during
// `sfCoverAvailable +=` could credit the broker more than the depositor paid Computing it
// here in preclaim lets us reject sub-cover-scale dust early with tecPRECISION_LOSS instead of
// failing only in doApply.
bool const fix320Enabled = ctx.view.rules().enabled(fixCleanup3_2_0);
auto const roundedAmount = [&]() -> STAmount {
if (!fix320Enabled)
return tx[sfAmount];
return roundToScale(
tx[sfAmount],
scale(sleBroker->at(sfCoverAvailable), vaultAsset),
Number::RoundingMode::Downward);
}();
if (fix320Enabled && roundedAmount == beast::kZero)
{
JLOG(ctx.j.warn()) << "LoanBrokerCoverDeposit: deposit amount: " << tx[sfAmount]
<< " is zero at loan broker scale";
return tecPRECISION_LOSS;
}
if (accountHolds(
ctx.view,
account,
@@ -94,7 +119,7 @@ LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx)
FreezeHandling::ZeroIfFrozen,
AuthHandling::ZeroIfUnauthorized,
ctx.j,
SpendableHandling::FullBalance) < amount)
SpendableHandling::FullBalance) < roundedAmount)
return tecINSUFFICIENT_FUNDS;
return tesSUCCESS;
@@ -106,8 +131,6 @@ LoanBrokerCoverDeposit::doApply()
auto const& tx = ctx_.tx;
auto const brokerID = tx[sfLoanBrokerID];
auto const amount = tx[sfAmount];
auto broker = view().peek(keylet::loanbroker(brokerID));
if (!broker)
return tecINTERNAL; // LCOV_EXCL_LINE
@@ -117,9 +140,32 @@ LoanBrokerCoverDeposit::doApply()
return tecINTERNAL; // LCOV_EXCL_LINE
auto const vaultAsset = vault->at(sfAsset);
auto const brokerPseudoID = broker->at(sfAccount);
// Re-round here (matches preclaim) so the same cover-scale-quantized
// value drives both the trustline transfer and the cover increment;
// see the rationale comment in preclaim.
bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0);
auto const amount = [&]() -> STAmount {
if (!fix320Enabled)
return tx[sfAmount];
return roundToScale(
tx[sfAmount],
scale(broker->at(sfCoverAvailable), vaultAsset),
Number::RoundingMode::Downward);
}();
// We validated zero-amount in preclaim, if we ended up with zero now, fail hard.
if (amount == beast::kZero)
{
// LCOV_EXCL_START
JLOG(j_.error()) << "LoanBrokerCoverDeposit: deposit amount: " << tx[sfAmount]
<< " is zero";
return tecINTERNAL;
// LCOV_EXCL_STOP
}
// Transfer assets from depositor to pseudo-account.
if (auto ter =
accountSend(view(), accountID_, brokerPseudoID, amount, j_, WaiveTransferFee::Yes))

View File

@@ -94,6 +94,11 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
if (amount.asset() != vaultAsset)
return tecWRONG_ASSET;
// Helper handles both IOU and MPT correctly without explicit branching.
if (auto const ret = canApplyToBrokerCover(
ctx.view, sleBroker, vaultAsset, amount, ctx.j, "LoanBrokerCoverWithdraw"))
return ret;
// The broker's pseudo-account is the source of funds.
auto const pseudoAccountID = sleBroker->at(sfAccount);
// Post-fixCleanup3_2_0: cover withdraw is a recovery path that bypasses

View File

@@ -8,9 +8,15 @@
#include <xrpl/basics/chrono.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/Units.h>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
@@ -287,9 +293,9 @@ class LendingHelpers_test : public beast::unit_test::Suite
std::uint32_t n;
};
auto const cases = std::vector<AboveThreshold>{
{"r=5%, n=3", Number{5, -2}, 3},
{"r=0.1%, n=1000", Number{1, -3}, 1'000},
{"r=1e-7, n=100 (above threshold by 10x)", Number{1, -7}, 100},
{.name = "r=5%, n=3", .r = Number{5, -2}, .n = 3},
{.name = "r=0.1%, n=1000", .r = Number{1, -3}, .n = 1'000},
{.name = "r=1e-7, n=100 (above threshold by 10x)", .r = Number{1, -7}, .n = 100},
};
for (auto const& tc : cases)
{
@@ -318,8 +324,10 @@ class LendingHelpers_test : public beast::unit_test::Suite
auto const cases = std::vector<BelowThreshold>{
// bug regime: r = 1 TenthBips32 over 600s payment interval
// → r ≈ 1.9e-10, r*n ≈ 3.8e-10 < 1e-9.
{"bug regime: r~1.9e-10, n=2", loanPeriodicRate(TenthBips32{1}, 600), 2},
{"r=1e-12, n=100", Number{1, -12}, 100},
{.name = "bug regime: r~1.9e-10, n=2",
.r = loanPeriodicRate(TenthBips32{1}, 600),
.n = 2},
{.name = "r=1e-12, n=100", .r = Number{1, -12}, .n = 100},
};
for (auto const& tc : cases)
{
@@ -356,8 +364,8 @@ class LendingHelpers_test : public beast::unit_test::Suite
std::uint32_t n;
};
auto const cases = std::vector<Boundary>{
{"r=1e-9, n=1", Number{1, -9}, 1},
{"r=1e-12, n=1000", Number{1, -12}, 1'000},
{.name = "r=1e-9, n=1", .r = Number{1, -9}, .n = 1},
{.name = "r=1e-12, n=1000", .r = Number{1, -12}, .n = 1'000},
};
for (auto const& tc : cases)
@@ -1439,6 +1447,84 @@ class LendingHelpers_test : public beast::unit_test::Suite
}
public:
void
testCanApplyToBrokerCover()
{
using namespace jtx;
Account const issuer{"issuer"};
PrettyAsset const iou = issuer["IOU"];
// sfCoverAvailable = Number{10} on an IOU → STAmount exponent = -14,
// so coverScale = -14. The ULP boundary is 5e-15; anything below
// that rounds to zero at cover scale. Number{1,-16} = 1e-16 is our
// representative sub-ULP probe.
struct TestCase
{
std::string name;
Number coverAvailable;
STAmount amount;
TER expected;
};
auto const testCases = std::vector<TestCase>{
{
.name = "Zero amount",
.coverAvailable = Number{10},
.amount = STAmount{iou, Number{0}},
.expected = tecPRECISION_LOSS,
},
{
.name = "Rounds to zero at cover scale",
.coverAvailable = Number{10},
.amount = STAmount{iou, Number{1, -16}},
.expected = tecPRECISION_LOSS,
},
{
.name = "Zero coverAvailable, whole-unit amount",
// coverScale = 0 (zero STAmount exponent); 1 IOU is not
// zero at integer scale → tesSUCCESS.
.coverAvailable = Number{0},
.amount = STAmount{iou, Number{1}},
.expected = tesSUCCESS,
},
{
.name = "Supra-ULP amount",
.coverAvailable = Number{10},
.amount = STAmount{iou, Number{1, -13}},
.expected = tesSUCCESS,
},
};
Env const env{*this};
for (auto const& tc : testCases)
{
testcase("canApplyToBrokerCover: " + tc.name);
auto sle = std::make_shared<SLE>(ltLOAN_BROKER, uint256{1u});
sle->at(sfCoverAvailable) = tc.coverAvailable;
BEAST_EXPECT(
canApplyToBrokerCover(*env.current(), sle, iou, tc.amount, env.journal, "test") ==
tc.expected);
}
// Amendment off → guard is bypassed regardless of amount.
{
testcase("canApplyToBrokerCover: amendment disabled");
Env const envOff{*this, testableAmendments() - fixCleanup3_2_0};
auto sle = std::make_shared<SLE>(ltLOAN_BROKER, uint256{1u});
sle->at(sfCoverAvailable) = Number{10};
BEAST_EXPECT(
canApplyToBrokerCover(
*envOff.current(),
sle,
iou,
STAmount{iou, Number{0}},
envOff.journal,
"test") == tesSUCCESS);
}
}
void
run() override
{
@@ -1462,6 +1548,7 @@ public:
testComputePaymentFactorNearZeroRate();
testComputeOverpaymentComponents();
testComputeInterestAndFeeParts();
testCanApplyToBrokerCover();
}
};

View File

@@ -55,6 +55,7 @@
#include <optional>
#include <string_view>
#include <tuple>
#include <utility>
#include <vector>
namespace xrpl::test {
@@ -1823,10 +1824,221 @@ class LoanBroker_test : public beast::unit_test::Suite
testRIPD4274MPT();
}
// Exercises canApplyToBrokerCover (fixCleanup3_2_0): a deposit, withdraw,
// or clawback whose amount rounds to zero at sfCoverAvailable's precision
// scale must be rejected with tecPRECISION_LOSS once the amendment is on,
// and must silently succeed without changing sfCoverAvailable when off.
void
testCoverPrecisionGuard()
{
using namespace jtx;
using namespace loanBroker;
Account const issuer{"issuer"};
Account const alice{"alice"};
// sfCoverAvailable = 10 IOU → STAmount exponent = -14.
// Anything < 5e-15 rounds to zero at that scale.
// 1e-16 is the representative sub-ULP probe amount.
// Shared setup: funds accounts, creates a vault + broker with 10 IOU
// cover, and returns {brokerKeylet, iou}.
auto const setup = [&](Env& env) -> std::pair<Keylet, PrettyAsset> {
Vault const vault{env};
env.fund(XRP(100'000), issuer, alice);
env.close();
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const iou = issuer["IOU"];
env(trust(alice, iou(1'000'000)));
env.close();
env(pay(issuer, alice, iou(1'000)));
env.close();
auto [createTx, vaultKeylet] = vault.create({.owner = alice, .asset = iou});
env(createTx);
env.close();
auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
env(set(alice, vaultKeylet.key));
env.close();
env(coverDeposit(alice, brokerKeylet.key, iou(10)));
env.close();
return {brokerKeylet, iou};
};
auto runTestCases = [&](FeatureBitset features) {
TER const expected =
features[fixCleanup3_2_0] ? TER{tecPRECISION_LOSS} : TER{tesSUCCESS};
{
testcase("Cover precision guard: Deposit zero-at-scale");
Env env{*this, features};
auto const [brokerKeylet, iou] = setup(env);
PrettyAmount const subUlpAmt = iou(Number{1, -16});
auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable);
env(coverDeposit(alice, brokerKeylet.key, subUlpAmt), Ter(expected));
env.close();
if (expected == tesSUCCESS)
{
if (auto const broker = env.le(brokerKeylet); BEAST_EXPECT(broker))
BEAST_EXPECT(broker->at(sfCoverAvailable) == coverBefore);
}
}
{
testcase("Cover precision guard: Deposit rounds down");
// Both cases succeed; post-fix the amount is rounded DOWN to
// cover scale first, so the delta differs from pre-fix
// Input: 1.8e-14 IOU (sub-scale at cover scale -14)
// Pre-fix: 10 + 1.8e-14 → round-to-nearest →
// 10.00000000000002 → delta 2e-14
// Post-fix: roundToScale(1.8e-14, -14, Downward) = 1e-14;
// 10 + 1e-14 = 10.00000000000001 → delta 1e-14
Env env{*this, features};
auto const [brokerKeylet, iou] = setup(env);
PrettyAmount const subUlpAmt = iou(Number{18, -15});
auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable);
env(coverDeposit(alice, brokerKeylet.key, subUlpAmt), Ter(tesSUCCESS));
env.close();
auto const brokerAfter = env.le(brokerKeylet);
if (!BEAST_EXPECT(brokerAfter))
return;
Number const delta = features[fixCleanup3_2_0] ? Number{1, -14} : Number{2, -14};
BEAST_EXPECT(brokerAfter->at(sfCoverAvailable) - coverBefore == delta);
}
// Property: post-fix, when the user deposits `x` and cover
// gains `x'`, we always have 0 <= x - x' < 1 ULP at cover
// scale (cover holds 10 IOU → ULP = 1e-14). Pre-fix uses
// STAmount's default round-to-nearest during `+=`, which can
// over-deposit (x' > x), so the property only holds with
// fixCleanup3_2_0 enabled.
if (features[fixCleanup3_2_0])
{
testcase("Cover precision guard: Deposit rounding bound");
Env env{*this, features};
auto const [brokerKeylet, iou] = setup(env);
Number const oneUlp{1, -14};
// Each requested amount lies strictly between 1·ULP and
// 2·ULP at cover scale; post-fix `roundDown` credits
// exactly `oneUlp` and leaves a strictly-positive,
// strictly-sub-ULP residual.
for (Number const requested : {Number{11, -15}, Number{15, -15}, Number{19, -15}})
{
auto const broker = env.le(brokerKeylet);
if (!BEAST_EXPECT(broker))
return;
Number const coverBefore = broker->at(sfCoverAvailable);
env(coverDeposit(alice, brokerKeylet.key, iou(requested)), Ter(tesSUCCESS));
env.close();
auto const brokerAfter = env.le(brokerKeylet);
if (!BEAST_EXPECT(brokerAfter))
return;
Number const coverAfter = brokerAfter->at(sfCoverAvailable);
Number const actual = coverAfter - coverBefore;
Number const lost = requested - actual;
BEAST_EXPECT(lost >= Number{0});
BEAST_EXPECT(lost < oneUlp);
}
}
{
testcase("Cover precision guard: Withdraw");
Env env{*this, features};
auto const [brokerKeylet, iou] = setup(env);
PrettyAmount const subUlpAmt = iou(Number{1, -16});
auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable);
auto const aliceBalanceBefore = env.balance(alice, iou);
env(coverWithdraw(alice, brokerKeylet.key, subUlpAmt), Ter(expected));
env.close();
if (expected == tesSUCCESS)
{
if (auto const broker = env.le(brokerKeylet); BEAST_EXPECT(broker))
BEAST_EXPECT(broker->at(sfCoverAvailable) == coverBefore);
BEAST_EXPECT(env.balance(alice, iou) == aliceBalanceBefore);
}
}
{
testcase("Cover precision guard: Clawback");
Env env{*this, features};
auto const [brokerKeylet, iou] = setup(env);
PrettyAmount const subUlpAmt = iou(Number{1, -16});
auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable);
env(coverClawback(issuer),
kLoanBrokerId(brokerKeylet.key),
kAmount(subUlpAmt),
Ter(expected));
env.close();
if (expected == tesSUCCESS)
{
if (auto const broker = env.le(brokerKeylet); BEAST_EXPECT(broker))
BEAST_EXPECT(broker->at(sfCoverAvailable) == coverBefore);
}
}
// MPT amounts are integers; scale is 0; the guard never rejects a
// positive integer amount. Verify all three callsites pass with amendment on.
{
testcase("Cover precision guard: MPT min amount passes");
Env env{*this, all_};
env.fund(XRP(100'000), issuer, alice);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
env.close();
PrettyAsset const mptAsset = mptt["MPT"];
mptt.authorize({.account = alice});
env.close();
env(pay(issuer, alice, mptAsset(100)));
env.close();
Vault const vault{env};
auto [createTx, vaultKeylet] = vault.create({.owner = alice, .asset = mptAsset});
env(createTx);
env.close();
auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
env(set(alice, vaultKeylet.key));
env.close();
env(coverDeposit(alice, brokerKeylet.key, mptAsset(10)));
env.close();
env(coverDeposit(alice, brokerKeylet.key, mptAsset(1)), Ter(tesSUCCESS));
env.close();
env(coverWithdraw(alice, brokerKeylet.key, mptAsset(1)), Ter(tesSUCCESS));
env.close();
env(coverClawback(issuer),
kLoanBrokerId(brokerKeylet.key),
kAmount(mptAsset(1)),
Ter(tesSUCCESS));
env.close();
}
};
runTestCases(all_);
runTestCases(all_ - fixCleanup3_2_0);
}
public:
void
run() override
{
testCoverPrecisionGuard();
testLoanBrokerSetDebtMaximum();
testLoanBrokerCoverDepositNullVault();

View File

@@ -69,6 +69,7 @@
#include <cstdint>
#include <cstdlib>
#include <functional>
#include <initializer_list>
#include <limits>
#include <map>
#include <optional>
@@ -90,8 +91,25 @@ class Loan_test : public beast::unit_test::Suite
protected:
// Ensure that all the features needed for Lending Protocol are included,
// even if they are set to unsupported.
FeatureBitset const all_{jtx::testableAmendments()};
// All 2^N permutations of `all_` with each subset of the given features
// excluded. The first entry is always `all_` itself (empty exclusion);
// the last excludes every feature in the list.
std::vector<FeatureBitset>
amendmentCombinations(std::initializer_list<uint256> features) const
{
std::vector<FeatureBitset> result{all_};
for (auto const& f : features)
{
auto const n = result.size();
for (std::size_t i = 0; i < n; ++i)
result.push_back(result[i] - f);
}
return result;
}
std::string const iouCurrency_{"IOU"};
void
@@ -1201,7 +1219,8 @@ protected:
runLoan(
AssetType assetType,
BrokerParameters const& brokerParams,
LoanParameters const& loanParams)
LoanParameters const& loanParams,
FeatureBitset features)
{
using namespace jtx;
@@ -1209,7 +1228,7 @@ protected:
Account const lender("lender");
Account const borrower("borrower");
Env env(*this, all_);
Env env(*this, features);
auto loanResult =
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
@@ -2896,7 +2915,7 @@ protected:
}
void
testLoanSet()
testLoanSet(FeatureBitset features)
{
using namespace jtx;
@@ -2915,7 +2934,7 @@ protected:
std::function<void(Env&, BrokerInfo const&, MPTTester&)> mptTest,
std::function<void(Env&, BrokerInfo const&)> iouTest,
CaseArgs args = {}) {
Env env(*this, all_);
Env env(*this, features);
env.fund(XRP(args.initialXRP), issuer, lender, borrower);
env.close();
if (args.requireAuth)
@@ -3453,14 +3472,14 @@ protected:
}
void
testLifecycle()
testLifecycle(FeatureBitset features)
{
testcase("Lifecycle");
using namespace jtx;
// Create 3 loan brokers: one for XRP, one for an IOU, and one for
// an MPT. That'll require three corresponding SAVs.
Env env(*this, all_);
Env env(*this, features);
Account const issuer{"issuer"};
// For simplicity, lender will be the sole actor for the vault &
@@ -3547,7 +3566,7 @@ protected:
}
void
testSelfLoan()
testSelfLoan(FeatureBitset features)
{
testcase << "Self Loan";
@@ -3555,7 +3574,7 @@ protected:
using namespace std::chrono_literals;
// Create 3 loan brokers: one for XRP, one for an IOU, and one for
// an MPT. That'll require three corresponding SAVs.
Env env(*this, all_);
Env env(*this, features);
Account const issuer{"issuer"};
// For simplicity, lender will be the sole actor for the vault &
@@ -3682,7 +3701,7 @@ protected:
}
void
testBatchBypassCounterparty()
testBatchBypassCounterparty(FeatureBitset features)
{
// From FIND-001
testcase << "Batch Bypass Counterparty";
@@ -3693,7 +3712,7 @@ protected:
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, all_);
Env env(*this, features);
Account const lender{"lender"};
Account const borrower{"borrower"};
@@ -3749,14 +3768,14 @@ protected:
}
void
testWrongMaxDebtBehavior()
testWrongMaxDebtBehavior(FeatureBitset features)
{
// From FIND-003
testcase << "Wrong Max Debt Behavior";
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, all_);
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
@@ -3795,7 +3814,7 @@ protected:
}
void
testLoanPayComputePeriodicPaymentValidRateInvariant()
testLoanPayComputePeriodicPaymentValidRateInvariant(FeatureBitset features)
{
// From FIND-012
testcase << "LoanPay xrpl::detail::computePeriodicPayment : "
@@ -3803,7 +3822,7 @@ protected:
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, all_);
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
@@ -3863,7 +3882,7 @@ protected:
}
void
testRPC()
testRPC(FeatureBitset features)
{
// This will expand as more test cases are added. Some functionality
// is tested in other test functions.
@@ -3871,7 +3890,7 @@ protected:
using namespace jtx;
Env env(*this, all_);
Env env(*this, features);
auto lowerFee = [&]() {
// Run the local fee back down.
@@ -4542,7 +4561,7 @@ protected:
}
void
testAccountSendMptMinAmountInvariant()
testAccountSendMptMinAmountInvariant(FeatureBitset features)
{
// (From FIND-006)
testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount "
@@ -4550,7 +4569,7 @@ protected:
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, all_);
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
@@ -4602,7 +4621,7 @@ protected:
}
void
testLoanPayDebtDecreaseInvariant()
testLoanPayDebtDecreaseInvariant(FeatureBitset features)
{
// From FIND-007
testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease "
@@ -4611,7 +4630,7 @@ protected:
using namespace jtx;
using namespace std::chrono_literals;
using namespace Lending;
Env env(*this, all_);
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
@@ -4695,14 +4714,14 @@ protected:
}
void
testLoanPayComputePeriodicPaymentValidTotalInterestInvariant()
testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(FeatureBitset features)
{
// From FIND-010
testcase << "xrpl::loanComputePaymentParts : valid total interest";
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, all_);
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
@@ -4902,7 +4921,7 @@ protected:
}
void
testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant()
testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(FeatureBitset features)
{
// From FIND-009
testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid "
@@ -4911,7 +4930,7 @@ protected:
using namespace jtx;
using namespace std::chrono_literals;
using namespace Lending;
Env env(*this, all_);
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
@@ -5004,7 +5023,7 @@ protected:
}
void
testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant()
testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(FeatureBitset features)
{
// From FIND-008
testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded";
@@ -5012,7 +5031,7 @@ protected:
using namespace jtx;
using namespace std::chrono_literals;
using namespace Lending;
Env env(*this, all_);
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
@@ -5089,7 +5108,7 @@ protected:
}
void
testLoanNextPaymentDueDateOverflow()
testLoanNextPaymentDueDateOverflow(FeatureBitset features)
{
// For FIND-013
testcase << "Prevent nextPaymentDueDate overflow";
@@ -5097,7 +5116,7 @@ protected:
using namespace jtx;
using namespace std::chrono_literals;
using namespace Lending;
Env env(*this, all_);
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
@@ -5621,14 +5640,14 @@ protected:
#if LOAN_TODO
void
testLoanPayLateFullPaymentBypassesPenalties()
testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features)
{
testcase("LoanPay full payment skips late penalties");
using namespace jtx;
using namespace loan;
using namespace std::chrono_literals;
Env env(*this, all);
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
@@ -5774,7 +5793,7 @@ protected:
}
void
testLoanCoverMinimumRoundingExploit()
testLoanCoverMinimumRoundingExploit(FeatureBitset features)
{
auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) {
testcase << "LoanBrokerCoverClawback drains cover via rounding"
@@ -5784,7 +5803,7 @@ protected:
using namespace loan;
using namespace loanBroker;
Env env(*this, all);
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
@@ -5860,7 +5879,7 @@ protected:
#endif
void
testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic()
testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features)
{
// --- PoC Summary ----------------------------------------------------
// Scenario: Borrower makes one periodic payment early (before next due)
@@ -5882,7 +5901,7 @@ protected:
using namespace loan;
using namespace std::chrono_literals;
Env env(*this, all_);
Env env{*this, features};
Account const lender{"poc_lender4"};
Account const borrower{"poc_borrower4"};
@@ -6085,13 +6104,13 @@ protected:
}
void
testDustManipulation()
testDustManipulation(FeatureBitset features)
{
testcase("Dust manipulation");
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, all_);
Env env{*this, features};
// Setup: Create accounts
Account const issuer{"issuer"};
@@ -6225,7 +6244,7 @@ protected:
}
void
testRIPD3831()
testRIPD3831(FeatureBitset features)
{
using namespace jtx;
@@ -6252,7 +6271,7 @@ protected:
auto const assetType = AssetType::XRP;
Env env(*this, all_);
Env env{*this, features};
auto loanResult =
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
@@ -6298,7 +6317,7 @@ protected:
}
void
testRIPD3459()
testRIPD3459(FeatureBitset features)
{
testcase("RIPD-3459 - LoanBroker incorrect debt total");
@@ -6323,7 +6342,7 @@ protected:
auto const assetType = AssetType::MPT;
Env env(*this, all_);
Env env{*this, features};
auto loanResult =
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
@@ -6423,14 +6442,14 @@ protected:
}
void
testRoundingAllowsUndercoverage()
testRoundingAllowsUndercoverage(FeatureBitset features)
{
testcase("Minimum cover rounding allows undercoverage (XRP)");
using namespace jtx;
using namespace loanBroker;
Env env(*this, all_);
Env env{*this, features};
Account const lender{"lender"};
Account const borrower{"borrower"};
@@ -6502,7 +6521,7 @@ protected:
}
void
testRIPD3902()
testRIPD3902(FeatureBitset features)
{
testcase("RIPD-3902 - 1 IOU loan payments");
@@ -6529,7 +6548,7 @@ protected:
auto const assetType = AssetType::IOU;
Env env(*this, all_);
Env env{*this, features};
auto loanResult =
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
@@ -6673,7 +6692,7 @@ protected:
}
void
testIssuerIsBorrower()
testIssuerIsBorrower(FeatureBitset features)
{
testcase("RIPD-4096 - Issuer as borrower");
@@ -6693,7 +6712,7 @@ protected:
auto const assetType = AssetType::IOU;
Env env(*this, all_);
Env env{*this, features};
auto loanResult =
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer);
@@ -6790,14 +6809,14 @@ protected:
}
void
testOverpaymentManagementFee()
testOverpaymentManagementFee(FeatureBitset features)
{
testcase("testOverpaymentManagementFee");
using namespace jtx;
using namespace loan;
Env env(*this, all_);
Env env{*this, features};
Account const lender{"lender"}, borrower{"borrower"};
@@ -6843,7 +6862,7 @@ protected:
}
void
testLoanPayBrokerOwnerMissingTrustline()
testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features)
{
testcase << "LoanPay Broker Owner Missing Trustline (PoC)";
using namespace jtx;
@@ -6852,7 +6871,7 @@ protected:
Account const borrower("borrower");
Account const broker("broker");
auto const iou = issuer["IOU"];
Env env(*this, all_);
Env env(*this, features);
env.fund(XRP(20'000), issuer, broker, borrower);
env.close();
// Set up trustlines and fund accounts
@@ -6911,7 +6930,7 @@ protected:
}
void
testLoanPayBrokerOwnerUnauthorizedMPT()
testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features)
{
testcase << "LoanPay Broker Owner MPT unauthorized";
using namespace jtx;
@@ -6921,7 +6940,7 @@ protected:
Account const borrower("borrower");
Account const broker("broker");
Env env(*this, all_);
Env env{*this, features};
env.fund(XRP(20'000), issuer, broker, borrower);
env.close();
@@ -6992,7 +7011,7 @@ protected:
}
void
testLoanPayBrokerOwnerNoPermissionedDomainMPT()
testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
{
testcase << "LoanPay Broker Owner without permissioned domain of the MPT";
using namespace jtx;
@@ -7002,7 +7021,7 @@ protected:
Account const borrower("borrower");
Account const broker("broker");
Env env(*this, all_);
Env env{*this, features};
env.fund(XRP(20'000), issuer, broker, borrower);
env.close();
@@ -7095,7 +7114,7 @@ protected:
}
void
testLoanSetBrokerOwnerNoPermissionedDomainMPT()
testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
{
testcase << "LoanSet Broker Owner without permissioned domain of the MPT";
using namespace jtx;
@@ -7105,7 +7124,7 @@ protected:
Account const borrower("borrower");
Account const broker("broker");
Env env(*this, all_);
Env env{*this, features};
env.fund(XRP(20'000), issuer, broker, borrower);
env.close();
@@ -7167,7 +7186,7 @@ protected:
}
void
testSequentialFLCDepletion()
testSequentialFLCDepletion(FeatureBitset features)
{
testcase << "First-Loss Capital Depletion on Sequential Defaults";
@@ -7175,7 +7194,7 @@ protected:
using namespace loan;
using namespace loanBroker;
Env env(*this, all_);
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
@@ -7427,7 +7446,7 @@ protected:
// loss from an impaired loan, ensuring the invariant check properly
// accounts for the loss.
void
testWithdrawReflectsUnrealizedLoss()
testWithdrawReflectsUnrealizedLoss(FeatureBitset features)
{
using namespace jtx;
using namespace loan;
@@ -7446,7 +7465,7 @@ protected:
static constexpr std::uint32_t kLocalPaymentInterval = 600;
static constexpr std::uint32_t kLocalPaymentTotal = 2;
Env env(*this, all_);
Env env{*this, features};
// Setup accounts
Account const issuer{"issuer"};
@@ -7553,6 +7572,110 @@ protected:
attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
}
// Regression for the dual-rounding fix at coarse (integer-MPT) scale.
//
// Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The
// amortization schedule produces a fractional principal
// (~0.47) which under round-to-nearest collapses to 0 in a single
// step, causing `doPayment`'s strict `>` assertion on principal to
// fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded
// upward (sticks at 1 across the first two periods) and only clears
// in the final payment.
//
// The test pays one period at a time across three LoanPay
// transactions and verifies the loan completes (paymentRemaining=0)
// with totals matching the loan's economics (1 principal + 2 interest).
void
testIntegerScalePrincipalSticks(FeatureBitset features)
{
// Without fixCleanup3_2_0, this behavior will abort the server, so
// don't run without it.
if (!features[fixCleanup3_2_0])
return;
testcase("edge: integer MPT principal stuck mid-loan completes via final");
using namespace jtx;
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer});
PrettyAsset const asset{mptt.issuanceID()};
mptt.authorize({.account = lender});
mptt.authorize({.account = borrower});
env(pay(issuer, lender, asset(10'000)));
env(pay(issuer, borrower, asset(10'000)));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
env(vaultTx);
env.close();
env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)}));
env.close();
auto const brokerKeylet = keylet::loanbroker(lender.id(), env.seq(lender));
env(loanBroker::set(lender, vaultKeylet.key),
loanBroker::kDebtMaximum(Number{100}),
Fee(env.current()->fees().base * 2));
env.close();
auto const brokerStateBefore = env.le(brokerKeylet);
if (!BEAST_EXPECT(brokerStateBefore))
return;
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
env(loan::set(borrower, brokerKeylet.key, Number{1}),
Sig(sfCounterpartySignature, lender),
loan::kInterestRate(TenthBips32{50'000}),
loan::kPaymentTotal(3),
loan::kPaymentInterval(31'536'000),
Fee(env.current()->fees().base * 2));
env.close();
auto const borrowerStart = env.balance(borrower, asset).value();
// Three separate periodic payments of 1 each. Expected per-period
// evolution at integer MPT scale (TVO = PO + interestDue +
// managementFeeDue):
// start: PO=1, TVO=3, paymentRemaining=3
// after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks)
// after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks)
// after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears)
std::array<Number, 3> const expectedPO{Number{1}, Number{1}, Number{0}};
std::array<Number, 3> const expectedTVO{Number{2}, Number{1}, Number{0}};
std::array<std::uint32_t, 3> const expectedRemaining{2, 1, 0};
for (int i = 0; i < 3; ++i)
{
env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS));
env.close();
auto const sle = env.le(loanKeylet);
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]);
BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]);
BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]);
}
// Borrower paid 3 total regardless of fee split (1 principal + 2
// interest+fee, matching loan economics).
auto const borrowerEnd = env.balance(borrower, asset).value();
BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value());
}
// A near-zero interest rate on a 100 USD loan
// produces total interest of ~6 units at loanScale -9. Numerical error
// in the amortization formula pushes the theoretical principal above
@@ -7735,74 +7858,91 @@ protected:
to_string(tolerance));
}
void
runAmendmentIndependent()
{
testDisabled();
testInvalidLoanSet();
testInvalidLoanDelete();
testInvalidLoanManage();
testInvalidLoanPay();
testIssuerLoan();
testServiceFeeOnBrokerDeepFreeze();
testRequireAuth();
testRIPD3901();
testBorrowerIsBroker();
testLimitExceeded();
for (auto const flags : {0u, tfLoanOverpayment})
testYieldTheftRounding(flags);
testBugInterestDueDeltaCrash();
testFullLifecycleVaultPnLNearZeroRate();
}
// Tests run under each entry in amendmentCombinations().
void
runAmendmentSensitive(FeatureBitset features)
{
#if LOAN_TODO
testLoanPayLateFullPaymentBypassesPenalties(features);
testLoanCoverMinimumRoundingExploit(features);
#endif
// Lifecycle
testSelfLoan(features);
testLoanSet(features);
testLifecycle(features);
// Payment paths
testWithdrawReflectsUnrealizedLoss(features);
testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features);
testBatchBypassCounterparty(features);
testLoanNextPaymentDueDateOverflow(features);
testCoverDepositWithdrawNonTransferableMPT(features);
testSequentialFLCDepletion(features);
// Invariants
testLoanPayComputePeriodicPaymentValidRateInvariant(features);
testAccountSendMptMinAmountInvariant(features);
testLoanPayDebtDecreaseInvariant(features);
testWrongMaxDebtBehavior(features);
testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(features);
testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(features);
testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(features);
// RPC
testRPC(features);
// Edge / rounding
testDustManipulation(features);
testRoundingAllowsUndercoverage(features);
testOverpaymentManagementFee(features);
testIssuerIsBorrower(features);
testIntegerScalePrincipalSticks(features);
// RIPD regressions
testRIPD3831(features);
testRIPD3459(features);
testRIPD3902(features);
// Broker-owner permissions
testLoanPayBrokerOwnerMissingTrustline(features);
testLoanPayBrokerOwnerUnauthorizedMPT(features);
testLoanPayBrokerOwnerNoPermissionedDomainMPT(features);
testLoanSetBrokerOwnerNoPermissionedDomainMPT(features);
}
public:
void
run() override
{
#if LOAN_TODO
testLoanPayLateFullPaymentBypassesPenalties();
testLoanCoverMinimumRoundingExploit();
#endif
for (auto const flags : {0u, tfLoanOverpayment})
{
testYieldTheftRounding(flags);
}
testBugInterestDueDeltaCrash();
testFullLifecycleVaultPnLNearZeroRate();
testWithdrawReflectsUnrealizedLoss();
testInvalidLoanSet();
auto const all = jtx::testableAmendments();
testCoverDepositWithdrawNonTransferableMPT(all);
testCoverDepositWithdrawNonTransferableMPT(all - featureMPTokensV2);
testCoverDepositWithdrawNonTransferableMPT(all - fixCleanup3_2_0);
runAmendmentIndependent();
testLoanSetBlockedLoanPayAllowedWhenCanTransferCleared();
testLendingCanTradeClearedNoImpact();
testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic();
testDisabled();
testSelfLoan();
testIssuerLoan();
testLoanSet();
testLifecycle();
testServiceFeeOnBrokerDeepFreeze();
testRPC();
testInvalidLoanDelete();
testInvalidLoanManage();
testInvalidLoanPay();
testBatchBypassCounterparty();
testLoanPayComputePeriodicPaymentValidRateInvariant();
testAccountSendMptMinAmountInvariant();
testLoanPayDebtDecreaseInvariant();
testWrongMaxDebtBehavior();
testLoanPayComputePeriodicPaymentValidTotalInterestInvariant();
testDosLoanPay(all | fixCleanup3_1_3);
testDosLoanPay(all - fixCleanup3_1_3);
testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant();
testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant();
testLoanNextPaymentDueDateOverflow();
testRequireAuth();
testDustManipulation();
testRIPD3831();
testRIPD3459();
testRIPD3901();
testRIPD3902();
testRoundingAllowsUndercoverage();
testBorrowerIsBroker();
testIssuerIsBorrower();
testLimitExceeded();
testOverpaymentManagementFee();
testLoanPayBrokerOwnerMissingTrustline();
testLoanPayBrokerOwnerUnauthorizedMPT();
testLoanPayBrokerOwnerNoPermissionedDomainMPT();
testLoanSetBrokerOwnerNoPermissionedDomainMPT();
testSequentialFLCDepletion();
testDosLoanPay(all_ | fixCleanup3_1_3);
testDosLoanPay(all_ - fixCleanup3_1_3);
for (auto const& features : amendmentCombinations({fixCleanup3_2_0, featureMPTokensV2}))
runAmendmentSensitive(features);
}
};
@@ -7867,7 +8007,7 @@ protected:
.payInterval = payInterval,
};
runLoan(assetType, brokerParams, loanParams);
runLoan(assetType, brokerParams, loanParams, all_);
}
public:
@@ -7926,7 +8066,7 @@ class LoanArbitrary_test : public LoanBatch_test
.payTotal = 2,
.payInterval = 200};
runLoan(AssetType::XRP, brokerParams, loanParams);
runLoan(AssetType::XRP, brokerParams, loanParams, all_);
}
};

View File

@@ -1205,6 +1205,100 @@ public:
//--------------------------------------------------------------------------
void
testIsZeroAtScale()
{
testcase("isZeroAtScale");
Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)};
// IOU: 10 IOU — mantissa = kMinValue (10^15), exponent = -14.
// One ULP at this scale is 10^-14; half-ULP is 5*10^-15.
{
STAmount const ref{usd, STAmount::kMinValue, -14};
int const refScale = ref.exponent(); // -14
BEAST_EXPECT(refScale == -14);
// Zero rounds to zero at any scale.
STAmount const iouZero{usd, 0};
BEAST_EXPECT(iouZero.isZeroAtScale(refScale));
// Sub-ULP: 1e-16 IOU (mantissa = kMinValue, exponent = -31).
// Far below half-ULP → rounds to zero.
STAmount const subUlp{usd, STAmount::kMinValue, -31};
BEAST_EXPECT(subUlp.isZeroAtScale(refScale));
// One ULP: 1e-14 IOU (mantissa = kMinValue, exponent = -29).
// Exactly the smallest representable unit at refScale → not zero.
STAmount const oneUlp{usd, STAmount::kMinValue, -29};
BEAST_EXPECT(!oneUlp.isZeroAtScale(refScale));
// The reference value itself: exponent == scale → returned
// unchanged → not zero.
BEAST_EXPECT(!ref.isZeroAtScale(refScale));
// A much larger value: certainly not zero at this scale.
STAmount const large{usd, STAmount::kMinValue, 0}; // 1e15 IOU
BEAST_EXPECT(!large.isZeroAtScale(refScale));
// When scale equals the value's own exponent, roundToScale
// short-circuits and returns the value unchanged.
BEAST_EXPECT(!subUlp.isZeroAtScale(subUlp.exponent()));
BEAST_EXPECT(!oneUlp.isZeroAtScale(oneUlp.exponent()));
// Half-ULP boundary. roundToScale forms (value + ref) - ref
// where ref = 10 IOU has mantissa 1e15 (LSB 0, even).
// Number's default rounding is to-nearest-even, so an exact
// half-ULP tie rounds toward the even-LSB neighbour — the
// reference itself — and the round-trip result is zero.
// Just below half-ULP rounds the same way; just above
// clears half-ULP and bumps the mantissa to 1e15 + 1.
STAmount const justBelowHalf{usd, STAmount::kMinValue * 4, -30};
BEAST_EXPECT(justBelowHalf.isZeroAtScale(refScale));
STAmount const halfUlp{usd, STAmount::kMinValue * 5, -30};
BEAST_EXPECT(halfUlp.isZeroAtScale(refScale));
STAmount const justAboveHalf{usd, STAmount::kMinValue * 6, -30};
BEAST_EXPECT(!justAboveHalf.isZeroAtScale(refScale));
// Large magnitude gap: dust value far below an enormous scale.
// 1e-80 with scale +15 — the value vanishes utterly.
STAmount const dust{usd, STAmount::kMinValue, -95};
BEAST_EXPECT(dust.isZeroAtScale(15));
// Negative values mirror positive behaviour.
STAmount const negSubUlp{usd, STAmount::kMinValue, -31, true};
BEAST_EXPECT(negSubUlp.isZeroAtScale(refScale));
STAmount const negOneUlp{usd, STAmount::kMinValue, -29, true};
BEAST_EXPECT(!negOneUlp.isZeroAtScale(refScale));
}
// XRP is integral — roundToScale short-circuits, value is preserved.
{
STAmount const xrp{XRPAmount{1}};
BEAST_EXPECT(!xrp.isZeroAtScale(-14));
BEAST_EXPECT(!xrp.isZeroAtScale(0));
STAmount const xrpZero{XRPAmount{0}};
BEAST_EXPECT(xrpZero.isZeroAtScale(-14));
}
// MPT is integral — same short-circuit behaviour as XRP.
{
MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
STAmount const mptAmt{mpt, 1};
BEAST_EXPECT(!mptAmt.isZeroAtScale(0));
BEAST_EXPECT(!mptAmt.isZeroAtScale(-14));
STAmount const mptZero{mpt, 0};
BEAST_EXPECT(mptZero.isZeroAtScale(0));
}
}
//--------------------------------------------------------------------------
void
run() override
{
@@ -1223,6 +1317,7 @@ public:
testCanSubtractXRP();
testCanSubtractIOU();
testCanSubtractMPT();
testIsZeroAtScale();
}
};

View File

@@ -2,6 +2,7 @@
#include <xrpld/app/main/Application.h>
#include <xrpld/overlay/Cluster.h>
#include <xrpld/overlay/Peer.h>
#include <xrpld/overlay/detail/Handshake.h>
#include <xrpld/overlay/detail/OverlayImpl.h>
#include <xrpld/overlay/detail/PeerImp.h>
@@ -28,19 +29,17 @@
#include <boost/asio/ssl/stream_base.hpp>
#include <boost/asio/ssl/verify_mode.hpp>
#include <boost/asio/strand.hpp>
#include <boost/beast/core/stream_traits.hpp>
#include <boost/beast/http/impl/read.hpp>
#include <boost/beast/http/impl/write.hpp>
#include <boost/beast/http/status.hpp>
#include <boost/system/system_error.hpp>
#include <chrono>
#include <cstdint>
#include <exception>
#include <functional>
#include <memory>
#include <optional>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
@@ -52,7 +51,7 @@ ConnectAttempt::ConnectAttempt(
endpoint_type remoteEndpoint,
Resource::Consumer usage,
shared_context const& context,
std::uint32_t id,
Peer::id_t id,
std::shared_ptr<PeerFinder::Slot> const& slot,
beast::Journal journal,
OverlayImpl& overlay)
@@ -65,7 +64,6 @@ ConnectAttempt::ConnectAttempt(
, usage_(usage)
, strand_(boost::asio::make_strand(ioContext))
, timer_(ioContext)
, stepTimer_(ioContext)
, streamPtr_(
std::make_unique<stream_type>(
socket_type(std::forward<boost::asio::io_context&>(ioContext)),
@@ -78,10 +76,9 @@ ConnectAttempt::ConnectAttempt(
ConnectAttempt::~ConnectAttempt()
{
// slot_ will be null if we successfully connected
// and transferred ownership to a PeerImp
if (slot_ != nullptr)
overlay_.peerFinder().onClosed(slot_);
JLOG(journal_.trace()) << "~ConnectAttempt";
}
void
@@ -92,30 +89,17 @@ ConnectAttempt::stop()
boost::asio::post(strand_, std::bind(&ConnectAttempt::stop, shared_from_this()));
return;
}
if (!socket_.is_open())
return;
JLOG(journal_.debug()) << "stop: Stop";
shutdown();
if (socket_.is_open())
{
JLOG(journal_.debug()) << "Stop";
}
close();
}
void
ConnectAttempt::run()
{
if (!strand_.running_in_this_thread())
{
boost::asio::post(strand_, std::bind(&ConnectAttempt::run, shared_from_this()));
return;
}
JLOG(journal_.debug()) << "run: connecting to " << remoteEndpoint_;
ioPending_ = true;
// Allow up to connectTimeout_ seconds to establish remote peer connection
setTimer(ConnectionStep::TcpConnect);
setTimer();
stream_.next_layer().async_connect(
remoteEndpoint_,
@@ -126,73 +110,6 @@ ConnectAttempt::run()
//------------------------------------------------------------------------------
void
ConnectAttempt::shutdown()
{
XRPL_ASSERT(
strand_.running_in_this_thread(), "xrpl::ConnectAttempt::shutdown: strand in this thread");
if (!socket_.is_open())
return;
shutdown_ = true;
boost::beast::get_lowest_layer(stream_).cancel();
tryAsyncShutdown();
}
void
ConnectAttempt::tryAsyncShutdown()
{
XRPL_ASSERT(
strand_.running_in_this_thread(),
"xrpl::ConnectAttempt::tryAsyncShutdown : strand in this thread");
if (!shutdown_ || currentStep_ == ConnectionStep::ShutdownStarted)
return;
if (ioPending_)
return;
// gracefully shutdown the SSL socket, performing a shutdown handshake
if (currentStep_ != ConnectionStep::TcpConnect && currentStep_ != ConnectionStep::TlsHandshake)
{
setTimer(ConnectionStep::ShutdownStarted);
stream_.async_shutdown(bind_executor(
strand_,
std::bind(&ConnectAttempt::onShutdown, shared_from_this(), std::placeholders::_1)));
return;
}
close();
}
void
ConnectAttempt::onShutdown(error_code ec)
{
cancelTimer();
if (ec)
{
// - eof: the stream was cleanly closed
// - operation_aborted: an expired timer (slow shutdown)
// - stream_truncated: the tcp connection closed (no handshake) it could
// occur if a peer does not perform a graceful disconnect
// - broken_pipe: the peer is gone
// - application data after close notify: benign SSL shutdown condition
bool const shouldLog =
(ec != boost::asio::error::eof && ec != boost::asio::error::operation_aborted &&
ec.message().find("application data after close notify") == std::string::npos);
if (shouldLog)
{
JLOG(journal_.debug()) << "onShutdown: " << ec.message();
}
}
close();
}
void
ConnectAttempt::close()
{
@@ -201,93 +118,50 @@ ConnectAttempt::close()
if (!socket_.is_open())
return;
cancelTimer();
try
{
timer_.cancel();
socket_.close();
}
catch (boost::system::system_error const&) // NOLINT(bugprone-empty-catch)
{
// ignored
}
error_code ec;
socket_.close(ec); // NOLINT(bugprone-unused-return-value)
JLOG(journal_.debug()) << "Closed";
}
void
ConnectAttempt::fail(std::string const& reason)
{
JLOG(journal_.debug()) << reason;
shutdown();
close();
}
void
ConnectAttempt::fail(std::string const& name, error_code ec)
{
JLOG(journal_.debug()) << name << ": " << ec.message();
shutdown();
close();
}
void
ConnectAttempt::setTimer(ConnectionStep step)
ConnectAttempt::setTimer()
{
currentStep_ = step;
// Set global timer (only if not already set)
if (timer_.expiry() == std::chrono::steady_clock::time_point{})
{
try
{
timer_.expires_after(kConnectTimeout);
timer_.async_wait(
boost::asio::bind_executor(
strand_,
std::bind(
&ConnectAttempt::onTimer, shared_from_this(), std::placeholders::_1)));
}
catch (std::exception const& ex)
{
JLOG(journal_.error()) << "setTimer (global): " << ex.what();
close();
return;
}
}
// Set step-specific timer
try
{
std::chrono::seconds stepTimeout;
switch (step)
{
case ConnectionStep::TcpConnect:
stepTimeout = StepTimeouts::kTcpConnect;
break;
case ConnectionStep::TlsHandshake:
stepTimeout = StepTimeouts::kTlsHandshake;
break;
case ConnectionStep::HttpWrite:
stepTimeout = StepTimeouts::kHttpWrite;
break;
case ConnectionStep::HttpRead:
stepTimeout = StepTimeouts::kHttpRead;
break;
case ConnectionStep::ShutdownStarted:
stepTimeout = StepTimeouts::kTlsShutdown;
break;
case ConnectionStep::Complete:
case ConnectionStep::Init:
return; // No timer needed for init or complete step
}
// call to expires_after cancels previous timer
stepTimer_.expires_after(stepTimeout);
stepTimer_.async_wait(
boost::asio::bind_executor(
strand_,
std::bind(&ConnectAttempt::onTimer, shared_from_this(), std::placeholders::_1)));
JLOG(journal_.trace()) << "setTimer: " << stepToString(step)
<< " timeout=" << stepTimeout.count() << "s";
timer_.expires_after(std::chrono::seconds(15));
}
catch (std::exception const& ex)
catch (boost::system::system_error const& e)
{
JLOG(journal_.error()) << "setTimer (step " << stepToString(step) << "): " << ex.what();
close();
JLOG(journal_.error()) << "setTimer: " << e.code();
return;
}
timer_.async_wait(
boost::asio::bind_executor(
strand_,
std::bind(&ConnectAttempt::onTimer, shared_from_this(), std::placeholders::_1)));
}
void
@@ -296,7 +170,6 @@ ConnectAttempt::cancelTimer()
try
{
timer_.cancel();
stepTimer_.cancel();
}
catch (boost::system::system_error const&) // NOLINT(bugprone-empty-catch)
{
@@ -321,40 +194,18 @@ ConnectAttempt::onTimer(error_code ec)
close();
return;
}
// Determine which timer expired by checking their expiry times
auto const now = std::chrono::steady_clock::now();
bool const globalExpired = (timer_.expiry() <= now);
bool const stepExpired = (stepTimer_.expiry() <= now);
if (globalExpired)
{
JLOG(journal_.debug()) << "onTimer: Global timeout; step: " << stepToString(currentStep_);
}
else if (stepExpired)
{
JLOG(journal_.debug()) << "onTimer: Step timeout; step: " << stepToString(currentStep_);
}
else
{
JLOG(journal_.warn()) << "onTimer: Unexpected timer callback";
}
close();
fail("Timeout");
}
void
ConnectAttempt::onConnect(error_code ec)
{
ioPending_ = false;
cancelTimer();
if (ec)
{
if (ec == boost::asio::error::operation_aborted)
{
tryAsyncShutdown();
return;
}
fail("onConnect", ec);
return;
@@ -371,15 +222,7 @@ ConnectAttempt::onConnect(error_code ec)
return;
}
if (shutdown_)
{
tryAsyncShutdown();
return;
}
ioPending_ = true;
setTimer(ConnectionStep::TlsHandshake);
setTimer();
stream_.set_verify_mode(boost::asio::ssl::verify_none);
stream_.async_handshake(
@@ -392,15 +235,14 @@ ConnectAttempt::onConnect(error_code ec)
void
ConnectAttempt::onHandshake(error_code ec)
{
ioPending_ = false;
cancelTimer();
if (!socket_.is_open())
return;
if (ec)
{
if (ec == boost::asio::error::operation_aborted)
{
tryAsyncShutdown();
return;
}
fail("onHandshake", ec);
return;
@@ -413,21 +255,18 @@ ConnectAttempt::onHandshake(error_code ec)
return;
}
setTimer(ConnectionStep::HttpWrite);
// check if we connected to ourselves
if (!overlay_.peerFinder().onConnected(
slot_, beast::IPAddressConversion::fromAsio(localEndpoint)))
{
fail("Self connection");
fail("Duplicate connection");
return;
}
auto const sharedValue = makeSharedValue(*streamPtr_, journal_);
if (!sharedValue)
{
shutdown();
return; // makeSharedValue logs
close(); // makeSharedValue logs
return;
}
req_ = makeRequest(
@@ -445,14 +284,7 @@ ConnectAttempt::onHandshake(error_code ec)
remoteEndpoint_.address(),
app_);
if (shutdown_)
{
tryAsyncShutdown();
return;
}
ioPending_ = true;
setTimer();
boost::beast::http::async_write(
stream_,
req_,
@@ -464,30 +296,20 @@ ConnectAttempt::onHandshake(error_code ec)
void
ConnectAttempt::onWrite(error_code ec)
{
ioPending_ = false;
cancelTimer();
if (!socket_.is_open())
return;
if (ec)
{
if (ec == boost::asio::error::operation_aborted)
{
tryAsyncShutdown();
return;
}
fail("onWrite", ec);
return;
}
if (shutdown_)
{
tryAsyncShutdown();
return;
}
ioPending_ = true;
setTimer(ConnectionStep::HttpRead);
boost::beast::http::async_read(
stream_,
readBuf_,
@@ -501,21 +323,24 @@ void
ConnectAttempt::onRead(error_code ec)
{
cancelTimer();
ioPending_ = false;
currentStep_ = ConnectionStep::Complete;
if (!socket_.is_open())
return;
if (ec)
{
if (ec == boost::asio::error::operation_aborted)
return;
if (ec == boost::asio::error::eof)
{
JLOG(journal_.debug()) << "EOF";
shutdown();
return;
}
if (ec == boost::asio::error::operation_aborted)
{
tryAsyncShutdown();
setTimer();
stream_.async_shutdown(
boost::asio::bind_executor(
strand_,
std::bind(
&ConnectAttempt::onShutdown, shared_from_this(), std::placeholders::_1)));
return;
}
@@ -523,13 +348,25 @@ ConnectAttempt::onRead(error_code ec)
return;
}
if (shutdown_)
processResponse();
}
void
ConnectAttempt::onShutdown(error_code ec)
{
cancelTimer();
if (!ec)
{
tryAsyncShutdown();
close();
return;
}
processResponse();
if (ec != boost::asio::error::eof)
{
fail("onShutdown", ec);
return;
}
close();
}
//--------------------------------------------------------------------------
@@ -537,71 +374,47 @@ ConnectAttempt::onRead(error_code ec)
void
ConnectAttempt::processResponse()
{
if (!OverlayImpl::isPeerUpgrade(response_))
if (response_.result() == boost::beast::http::status::service_unavailable)
{
// A peer may respond with service_unavailable and a list of alternative
// peers to connect to, a differing status code is unexpected
if (response_.result() != boost::beast::http::status::service_unavailable)
{
JLOG(journal_.warn()) << "Unable to upgrade to peer protocol: " << response_.result()
<< " (" << response_.reason() << ")";
shutdown();
return;
}
// Parse response body to determine if this is a redirect or other
// service unavailable
std::string responseBody;
responseBody.reserve(boost::asio::buffer_size(response_.body().data()));
json::Value json;
json::Reader r;
std::string s;
s.reserve(boost::asio::buffer_size(response_.body().data()));
for (auto const buffer : response_.body().data())
{
responseBody.append(
static_cast<char const*>(buffer.data()), boost::asio::buffer_size(buffer));
s.append(static_cast<char const*>(buffer.data()), boost::asio::buffer_size(buffer));
}
json::Value json;
json::Reader reader;
auto const isValidJson = reader.parse(responseBody, json);
// Check if this is a redirect response (contains peer-ips field)
auto const isRedirect = isValidJson && json.isObject() && json.isMember("peer-ips");
if (!isRedirect)
auto const success = r.parse(s, json);
if (success)
{
JLOG(journal_.warn()) << "processResponse: " << remoteEndpoint_
<< " failed to upgrade to peer protocol: " << response_.result()
<< " (" << response_.reason() << ")";
shutdown();
return;
if (json.isObject() && json.isMember("peer-ips"))
{
json::Value const& ips = json["peer-ips"];
if (ips.isArray())
{
std::vector<boost::asio::ip::tcp::endpoint> eps;
eps.reserve(ips.size());
for (auto const& v : ips)
{
if (v.isString())
{
error_code ec;
auto const ep = parseEndpoint(v.asString(), ec);
if (!ec)
eps.push_back(ep);
}
}
overlay_.peerFinder().onRedirects(remoteEndpoint_, eps);
}
}
}
}
json::Value const& peerIps = json["peer-ips"];
if (!peerIps.isArray())
{
fail("processResponse: invalid peer-ips format");
return;
}
// Extract and validate peer endpoints
std::vector<boost::asio::ip::tcp::endpoint> redirectEndpoints;
redirectEndpoints.reserve(peerIps.size());
for (auto const& ipValue : peerIps)
{
if (!ipValue.isString())
continue;
error_code ec;
auto const endpoint = parseEndpoint(ipValue.asString(), ec);
if (!ec)
redirectEndpoints.push_back(endpoint);
}
// Notify PeerFinder about the redirect redirectEndpoints may be empty
overlay_.peerFinder().onRedirects(remoteEndpoint_, redirectEndpoints);
fail("processResponse: failed to connect to peer: redirected");
if (!OverlayImpl::isPeerUpgrade(response_))
{
JLOG(journal_.info()) << "Unable to upgrade to peer protocol: " << response_.result()
<< " (" << response_.reason() << ")";
close();
return;
}
@@ -625,8 +438,8 @@ ConnectAttempt::processResponse()
auto const sharedValue = makeSharedValue(*streamPtr_, journal_);
if (!sharedValue)
{
shutdown();
return; // makeSharedValue logs
close(); // makeSharedValue logs
return;
}
try
@@ -641,30 +454,21 @@ ConnectAttempt::processResponse()
usage_.setPublicKey(publicKey);
JLOG(journal_.debug()) << "Protocol: " << to_string(*negotiatedProtocol);
JLOG(journal_.info()) << "Public Key: " << toBase58(TokenType::NodePublic, publicKey);
JLOG(journal_.debug()) << "Protocol: " << to_string(*negotiatedProtocol);
auto const member = app_.getCluster().member(publicKey);
if (member)
{
JLOG(journal_.info()) << "Cluster name: " << *member;
}
auto const result = overlay_.peerFinder().activate(slot_, publicKey, member.has_value());
auto const result =
overlay_.peerFinder().activate(slot_, publicKey, static_cast<bool>(member));
if (result != PeerFinder::Result::Success)
{
std::stringstream ss;
ss << "Outbound Connect Attempt " << remoteEndpoint_ << " " << to_string(result);
fail(ss.str());
return;
}
if (!socket_.is_open())
return;
if (shutdown_)
{
tryAsyncShutdown();
fail("Outbound " + std::string(to_string(result)));
return;
}

View File

@@ -1,42 +1,13 @@
#pragma once
#include <xrpld/overlay/Peer.h>
#include <xrpld/overlay/detail/OverlayImpl.h>
#include <chrono>
#include <sstream>
namespace xrpl {
/**
* @class ConnectAttempt
* @brief Manages outbound peer connection attempts with comprehensive timeout
* handling
*
* The ConnectAttempt class handles the complete lifecycle of establishing an
* outbound connection to a peer in the XRPL network. It implements a
* sophisticated dual-timer system that provides both global timeout protection
* and per-step timeout diagnostics.
*
* The connection establishment follows these steps:
* 1. **TCP Connect**: Establish basic network connection
* 2. **TLS Handshake**: Negotiate SSL/TLS encryption
* 3. **HTTP Write**: Send peer handshake request
* 4. **HTTP Read**: Receive and validate peer response
* 5. **Complete**: Connection successfully established
*
* Uses a hybrid timeout approach:
* - **Global Timer**: Hard limit (20s) for entire connection process
* - **Step Timers**: Individual timeouts for each connection phase
*
* - All errors result in connection termination
*
* All operations are serialized using boost::asio::strand to ensure thread
* safety. The class is designed to be used exclusively within the ASIO event
* loop.
*
* @note This class should not be used directly. It is managed by OverlayImpl
* as part of the peer discovery and connection management system.
*
*/
/** Manages an outbound connection attempt. */
class ConnectAttempt : public OverlayImpl::Child,
public std::enable_shared_from_this<ConnectAttempt>
{
@@ -45,95 +16,29 @@ private:
using endpoint_type = boost::asio::ip::tcp::endpoint;
using request_type = boost::beast::http::request<boost::beast::http::empty_body>;
using response_type = boost::beast::http::response<boost::beast::http::dynamic_body>;
using socket_type = boost::asio::ip::tcp::socket;
using middle_type = boost::beast::tcp_stream;
using stream_type = boost::beast::ssl_stream<middle_type>;
using shared_context = std::shared_ptr<boost::asio::ssl::context>;
/**
* @enum ConnectionStep
* @brief Represents the current phase of the connection establishment
* process
*
* Used for tracking progress and providing detailed timeout diagnostics.
* Each step has its own timeout value defined in StepTimeouts.
*/
enum class ConnectionStep {
Init, // Initial state, nothing started
TcpConnect, // Establishing TCP connection to remote peer
TlsHandshake, // Performing SSL/TLS handshake
HttpWrite, // Sending HTTP upgrade request
HttpRead, // Reading HTTP upgrade response
Complete, // Connection successfully established
ShutdownStarted // Connection shutdown has started
};
// A timeout for connection process, greater than all step timeouts
static constexpr std::chrono::seconds kConnectTimeout{25};
/**
* @struct StepTimeouts
* @brief Defines timeout values for each connection step
*
* These timeouts are designed to detect slow individual phases while
* allowing the global timeout to enforce the overall time limit.
*/
struct StepTimeouts
{
// TCP connection timeout
static constexpr std::chrono::seconds kTcpConnect{8};
// SSL handshake timeout
static constexpr std::chrono::seconds kTlsHandshake{8};
// HTTP write timeout
static constexpr std::chrono::seconds kHttpWrite{3};
// HTTP read timeout
static constexpr std::chrono::seconds kHttpRead{3};
// SSL shutdown timeout
static constexpr std::chrono::seconds kTlsShutdown{2};
};
// Core application and networking components
Application& app_;
Peer::id_t const id_;
std::uint32_t const id_;
beast::WrappedSink sink_;
beast::Journal const journal_;
endpoint_type remoteEndpoint_;
Resource::Consumer usage_;
boost::asio::strand<boost::asio::io_context::executor_type> strand_;
boost::asio::basic_waitable_timer<std::chrono::steady_clock> timer_;
boost::asio::basic_waitable_timer<std::chrono::steady_clock> stepTimer_;
std::unique_ptr<stream_type> streamPtr_; // SSL stream (owned)
std::unique_ptr<stream_type> streamPtr_;
socket_type& socket_;
stream_type& stream_;
boost::beast::multi_buffer readBuf_;
response_type response_;
std::shared_ptr<PeerFinder::Slot> slot_;
request_type req_;
bool shutdown_ = false; // Shutdown has been initiated
bool ioPending_ = false; // Async I/O operation in progress
ConnectionStep currentStep_ = ConnectionStep::Init;
public:
/**
* @brief Construct a new ConnectAttempt object
*
* @param app Application context providing configuration and services
* @param ioContext ASIO I/O context for async operations
* @param remoteEndpoint Target peer endpoint to connect to
* @param usage Resource usage tracker for rate limiting
* @param context Shared SSL context for encryption
* @param id Unique peer identifier for this connection attempt
* @param slot PeerFinder slot representing this connection
* @param journal Logging interface for diagnostics
* @param overlay Parent overlay manager
*
* @note The constructor only initializes the object. Call run() to begin
* the actual connection attempt.
*/
ConnectAttempt(
Application& app,
boost::asio::io_context& ioContext,
@@ -147,111 +52,38 @@ public:
~ConnectAttempt() override;
/**
* @brief Stop the connection attempt
*
* This method is thread-safe and can be called from any thread.
*/
void
stop() override;
/**
* @brief Begin the connection attempt
*
* This method is thread-safe and posts to the strand if needed.
*/
void
run();
private:
/**
* @brief Set timers for the specified connection step
*
* @param step The connection step to set timers for
*
* Sets both the step-specific timer and the global timer (if not already
* set).
*/
void
setTimer(ConnectionStep step);
/**
* @brief Cancel both global and step timers
*
* Used during cleanup and when connection completes successfully.
* Exceptions from timer cancellation are safely ignored.
*/
close();
void
fail(std::string const& reason);
void
fail(std::string const& name, error_code ec);
void
setTimer();
void
cancelTimer();
/**
* @brief Handle timer expiration events
*
* @param ec Error code from timer operation
*
* Determines which timer expired (global vs step) and logs appropriate
* diagnostic information before terminating the connection.
*/
void
onTimer(error_code ec);
// Connection phase handlers
void
onConnect(error_code ec); // TCP connection completion handler
onConnect(error_code ec);
void
onHandshake(error_code ec); // TLS handshake completion handler
onHandshake(error_code ec);
void
onWrite(error_code ec); // HTTP write completion handler
onWrite(error_code ec);
void
onRead(error_code ec); // HTTP read completion handler
// Error and cleanup handlers
onRead(error_code ec);
void
fail(std::string const& reason); // Fail with custom reason
void
fail(std::string const& name, error_code ec); // Fail with system error
void
shutdown(); // Initiate graceful shutdown
void
tryAsyncShutdown(); // Attempt async SSL shutdown
void
onShutdown(error_code ec); // SSL shutdown completion handler
void
close(); // Force close socket
/**
* @brief Process the HTTP upgrade response from peer
*
* Validates the peer's response, extracts protocol information,
* verifies handshake, and either creates a PeerImp or handles
* redirect responses.
*/
onShutdown(error_code ec);
void
processResponse();
static std::string
stepToString(ConnectionStep step)
{
switch (step)
{
case ConnectionStep::Init:
return "Init";
case ConnectionStep::TcpConnect:
return "TcpConnect";
case ConnectionStep::TlsHandshake:
return "TlsHandshake";
case ConnectionStep::HttpWrite:
return "HttpWrite";
case ConnectionStep::HttpRead:
return "HttpRead";
case ConnectionStep::Complete:
return "Complete";
case ConnectionStep::ShutdownStarted:
return "ShutdownStarted";
}
return "Unknown";
};
template <class = void>
static boost::asio::ip::tcp::endpoint
parseEndpoint(std::string const& s, boost::system::error_code& ec)

View File

@@ -74,7 +74,7 @@
#include <boost/asio/write.hpp>
#include <boost/beast/core/multi_buffer.hpp>
#include <boost/beast/core/ostream.hpp>
#include <boost/beast/core/stream_traits.hpp>
#include <boost/system/system_error.hpp>
#include <google/protobuf/message.h>
@@ -110,9 +110,6 @@ constexpr std::chrono::milliseconds kPeerHighLatency{300};
/** How often we PING the peer to check for latency and sendq probe */
constexpr std::chrono::seconds kPeerTimerInterval{60};
/** The timeout for a shutdown timer */
constexpr std::chrono::seconds kShutdownTimerInterval{5};
} // namespace
// TODO: Remove this exclusion once unit tests are added after the hotfix
@@ -270,13 +267,7 @@ PeerImp::stop()
if (!socket_.is_open())
return;
// The rationale for using different severity levels is that
// outbound connections are under our control and may be logged
// at a higher level, but inbound connections are more numerous and
// uncontrolled so to prevent log flooding the severity is reduced.
JLOG(journal_.debug()) << "stop: Stop";
shutdown();
close();
}
//------------------------------------------------------------------------------
@@ -289,17 +280,13 @@ PeerImp::send(std::shared_ptr<Message> const& m)
post(strand_, std::bind(&PeerImp::send, shared_from_this(), m));
return;
}
if (gracefulClose_)
return;
if (detaching_)
return;
if (!socket_.is_open())
return;
// we are in progress of closing the connection
if (shutdown_)
{
tryAsyncShutdown();
return;
}
auto validator = m->getValidatorKey();
if (validator && !squelch_.expireSquelch(*validator))
{
@@ -338,7 +325,6 @@ PeerImp::send(std::shared_ptr<Message> const& m)
if (sendqSize != 0)
return;
writePending_ = true;
boost::asio::async_write(
stream_,
boost::asio::buffer(sendQueue_.front()->getBuffer(compressionEnabled_)),
@@ -621,16 +607,20 @@ PeerImp::hasRange(std::uint32_t uMin, std::uint32_t uMax)
//------------------------------------------------------------------------------
void
PeerImp::fail(std::string const& name, error_code ec)
PeerImp::close()
{
XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::fail : strand in this thread");
XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::close : strand in this thread");
if (!socket_.is_open())
return;
JLOG(journal_.warn()) << name << ": " << ec.message();
detaching_ = true; // DEPRECATED
shutdown();
cancelTimer();
error_code ec;
socket_.close(ec); // NOLINT(bugprone-unused-return-value)
overlay_.incPeerDisconnect();
JLOG((inbound_ ? journal_.debug() : journal_.info())) << "close: Closed";
}
void
@@ -644,123 +634,71 @@ PeerImp::fail(std::string const& reason)
(void (Peer::*)(std::string const&))&PeerImp::fail, shared_from_this(), reason));
return;
}
if (!socket_.is_open())
return;
// Call to name() locks, log only if the message will be outputted
if (journal_.active(beast::Severity::Warning))
if (journal_.active(beast::Severity::Warning) && socket_.is_open())
{
std::string const n = name();
JLOG(journal_.warn()) << n << " failed: " << reason;
}
shutdown();
close();
}
void
PeerImp::tryAsyncShutdown()
PeerImp::fail(std::string const& name, error_code ec)
{
XRPL_ASSERT(
strand_.running_in_this_thread(),
"xrpl::PeerImp::tryAsyncShutdown : strand in this thread");
if (!shutdown_ || shutdownStarted_)
XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::fail : strand in this thread");
if (!socket_.is_open())
return;
if (readPending_ || writePending_)
return;
shutdownStarted_ = true;
setTimer(kShutdownTimerInterval);
// gracefully shutdown the SSL socket, performing a shutdown handshake
stream_.async_shutdown(bind_executor(
strand_, std::bind(&PeerImp::onShutdown, shared_from_this(), std::placeholders::_1)));
}
void
PeerImp::shutdown()
{
XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::shutdown: strand in this thread");
if (!socket_.is_open() || shutdown_)
return;
shutdown_ = true;
boost::beast::get_lowest_layer(stream_).cancel();
tryAsyncShutdown();
}
void
PeerImp::onShutdown(error_code ec)
{
cancelTimer();
if (ec)
{
// - eof: the stream was cleanly closed
// - operation_aborted: an expired timer (slow shutdown)
// - stream_truncated: the tcp connection closed (no handshake) it could
// occur if a peer does not perform a graceful disconnect
// - broken_pipe: the peer is gone
bool const shouldLog =
(ec != boost::asio::error::eof && ec != boost::asio::error::operation_aborted &&
ec.message().find("application data after close notify") == std::string::npos);
if (shouldLog)
{
JLOG(journal_.debug()) << "onShutdown: " << ec.message();
}
}
JLOG(journal_.warn()) << name << ": " << ec.message();
close();
}
void
PeerImp::close()
PeerImp::gracefulClose()
{
XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::close : strand in this thread");
if (!socket_.is_open())
XRPL_ASSERT(
strand_.running_in_this_thread(), "xrpl::PeerImp::gracefulClose : strand in this thread");
XRPL_ASSERT(socket_.is_open(), "xrpl::PeerImp::gracefulClose : socket is open");
XRPL_ASSERT(!gracefulClose_, "xrpl::PeerImp::gracefulClose : socket is not closing");
gracefulClose_ = true;
if (!sendQueue_.empty())
return;
cancelTimer();
error_code ec;
socket_.close(ec); // NOLINT(bugprone-unused-return-value)
overlay_.incPeerDisconnect();
// The rationale for using different severity levels is that
// outbound connections are under our control and may be logged
// at a higher level, but inbound connections are more numerous and
// uncontrolled so to prevent log flooding the severity is reduced.
JLOG((inbound_ ? journal_.debug() : journal_.info())) << "close: Closed";
setTimer();
stream_.async_shutdown(bind_executor(
strand_, std::bind(&PeerImp::onShutdown, shared_from_this(), std::placeholders::_1)));
}
//------------------------------------------------------------------------------
void
PeerImp::setTimer(std::chrono::seconds interval)
PeerImp::setTimer()
{
try
{
timer_.expires_after(interval);
timer_.expires_after(kPeerTimerInterval);
}
catch (std::exception const& ex)
catch (boost::system::system_error const& e)
{
JLOG(journal_.error()) << "setTimer: " << ex.what();
shutdown();
JLOG(journal_.error()) << "setTimer: " << e.code();
return;
}
timer_.async_wait(bind_executor(
strand_, std::bind(&PeerImp::onTimer, shared_from_this(), std::placeholders::_1)));
}
// convenience for ignoring the error code
void
PeerImp::cancelTimer() noexcept
{
try
{
timer_.cancel();
}
catch (boost::system::system_error const&) // NOLINT(bugprone-empty-catch)
{
// ignored
}
}
//------------------------------------------------------------------------------
std::string
@@ -774,14 +712,11 @@ PeerImp::makePrefix(std::string const& fingerprint)
void
PeerImp::onTimer(error_code const& ec)
{
XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::onTimer : strand in this thread");
if (!socket_.is_open())
return;
if (ec)
{
// do not initiate shutdown, timers are frequently cancelled
if (ec == boost::asio::error::operation_aborted)
return;
@@ -791,15 +726,6 @@ PeerImp::onTimer(error_code const& ec)
return;
}
// the timer expired before the shutdown completed
// force close the connection
if (shutdown_)
{
JLOG(journal_.debug()) << "onTimer: shutdown timer expired";
close();
return;
}
if (largeSendq_++ >= Tuning::kSendqIntervals)
{
fail("Large send queue");
@@ -840,20 +766,32 @@ PeerImp::onTimer(error_code const& ec)
send(std::make_shared<Message>(message, protocol::mtPING));
setTimer(kPeerTimerInterval);
setTimer();
}
void
PeerImp::cancelTimer() noexcept
PeerImp::onShutdown(error_code ec)
{
try
cancelTimer();
if (ec)
{
timer_.cancel();
}
catch (std::exception const& ex)
{
JLOG(journal_.error()) << "cancelTimer: " << ex.what();
// - eof: the stream was cleanly closed
// - operation_aborted: an expired timer (slow shutdown)
// - stream_truncated: the tcp connection closed (no handshake) it could
// occur if a peer does not perform a graceful disconnect
// - broken_pipe: the peer is gone
bool const shouldLog =
(ec != boost::asio::error::eof && ec != boost::asio::error::operation_aborted &&
ec.message().find("application data after close notify") == std::string::npos);
if (shouldLog)
{
JLOG(journal_.debug()) << "onShutdown: " << ec.message();
}
}
close();
}
//------------------------------------------------------------------------------
@@ -862,15 +800,6 @@ PeerImp::doAccept()
{
XRPL_ASSERT(readBuffer_.size() == 0, "xrpl::PeerImp::doAccept : empty read buffer");
JLOG(journal_.debug()) << "doAccept";
// a shutdown was initiated before the handshake, there is nothing to do
if (shutdown_)
{
tryAsyncShutdown();
return;
}
auto const sharedValue = makeSharedValue(*streamPtr_, journal_);
// This shouldn't fail since we already computed
@@ -881,7 +810,7 @@ PeerImp::doAccept()
return;
}
JLOG(journal_.debug()) << "Protocol: " << to_string(protocol_);
JLOG(journal_.info()) << "Protocol: " << to_string(protocol_);
if (auto member = app_.getCluster().member(publicKey_))
{
@@ -921,16 +850,15 @@ PeerImp::doAccept()
error_code ec, std::size_t bytesTransferred) {
if (!socket_.is_open())
return;
if (ec == boost::asio::error::operation_aborted)
{
tryAsyncShutdown();
return;
}
if (ec)
{
if (ec == boost::asio::error::operation_aborted)
return;
fail("onWriteResponse", ec);
return;
}
if (writeBuffer->size() == bytesTransferred)
{
doProtocolStart();
@@ -961,13 +889,6 @@ PeerImp::domain() const
void
PeerImp::doProtocolStart()
{
// a shutdown was initiated before the handshare, there is nothing to do
if (shutdown_)
{
tryAsyncShutdown();
return;
}
onReadMessage(error_code(), 0);
// Send all the validator lists that have been loaded
@@ -999,45 +920,31 @@ PeerImp::doProtocolStart()
if (auto m = overlay_.getManifestsMessage())
send(m);
setTimer(kPeerTimerInterval);
setTimer();
}
// Called repeatedly with protocol message data
void
PeerImp::onReadMessage(error_code ec, std::size_t bytesTransferred)
{
XRPL_ASSERT(
strand_.running_in_this_thread(), "xrpl::PeerImp::onReadMessage : strand in this thread");
readPending_ = false;
if (!socket_.is_open())
return;
if (ec)
{
if (ec == boost::asio::error::operation_aborted)
return;
if (ec == boost::asio::error::eof)
{
JLOG(journal_.debug()) << "EOF";
shutdown();
return;
}
if (ec == boost::asio::error::operation_aborted)
{
tryAsyncShutdown();
JLOG(journal_.info()) << "EOF";
gracefulClose();
return;
}
fail("onReadMessage", ec);
return;
}
// we started shutdown, no reason to process further data
if (shutdown_)
{
tryAsyncShutdown();
return;
}
if (auto stream = journal_.trace())
{
@@ -1062,34 +969,23 @@ PeerImp::onReadMessage(error_code ec, std::size_t bytesTransferred)
350ms,
journal_);
if (!socket_.is_open())
return;
// the error_code is produced by invokeProtocolMessage
// it could be due to a bad message
if (ec)
{
fail("onReadMessage", ec);
return;
}
if (!socket_.is_open())
return;
if (gracefulClose_)
return;
if (bytesConsumed == 0)
break;
readBuffer_.consume(bytesConsumed);
}
// check if a shutdown was initiated while processing messages
if (shutdown_)
{
tryAsyncShutdown();
return;
}
readPending_ = true;
XRPL_ASSERT(!shutdownStarted_, "xrpl::PeerImp::onReadMessage : shutdown started");
// Timeout on writes only
stream_.async_read_some(
readBuffer_.prepare(std::max(Tuning::kReadBufferBytes, hint)),
@@ -1105,26 +1001,17 @@ PeerImp::onReadMessage(error_code ec, std::size_t bytesTransferred)
void
PeerImp::onWriteMessage(error_code ec, std::size_t bytesTransferred)
{
XRPL_ASSERT(
strand_.running_in_this_thread(), "xrpl::PeerImp::onWriteMessage : strand in this thread");
writePending_ = false;
if (!socket_.is_open())
return;
if (ec)
{
if (ec == boost::asio::error::operation_aborted)
{
tryAsyncShutdown();
return;
}
fail("onWriteMessage", ec);
return;
}
if (auto stream = journal_.trace())
{
stream << "onWriteMessage: "
@@ -1135,18 +1022,8 @@ PeerImp::onWriteMessage(error_code ec, std::size_t bytesTransferred)
XRPL_ASSERT(!sendQueue_.empty(), "xrpl::PeerImp::onWriteMessage : non-empty send buffer");
sendQueue_.pop();
if (shutdown_)
{
tryAsyncShutdown();
return;
}
if (!sendQueue_.empty())
{
writePending_ = true;
XRPL_ASSERT(!shutdownStarted_, "xrpl::PeerImp::onWriteMessage : shutdown started");
// Timeout on writes only
boost::asio::async_write(
stream_,
@@ -1160,6 +1037,13 @@ PeerImp::onWriteMessage(error_code ec, std::size_t bytesTransferred)
std::placeholders::_2)));
return;
}
if (gracefulClose_)
{
stream_.async_shutdown(bind_executor(
strand_, std::bind(&PeerImp::onShutdown, shared_from_this(), std::placeholders::_1)));
return;
}
}
//------------------------------------------------------------------------------

View File

@@ -30,68 +30,6 @@ namespace xrpl {
struct ValidatorBlobInfo;
class SHAMap;
/**
* @class PeerImp
* @brief This class manages established peer-to-peer connections, handles
message exchange, monitors connection health, and graceful shutdown.
*
* The PeerImp shutdown mechanism is a multi-stage process
* designed to ensure graceful connection termination while handling ongoing
* I/O operations safely. The shutdown can be initiated from multiple points
* and follows a deterministic state machine.
*
* The shutdown process can be triggered from several entry points:
* - **External requests**: `stop()` method called by overlay management
* - **Error conditions**: `fail(error_code)` or `fail(string)` on protocol
* violations
* - **Timer expiration**: Various timeout scenarios (ping timeout, large send
* queue)
* - **Connection health**: Peer tracking divergence or unknown state timeouts
*
* The shutdown follows this progression:
*
* Normal Operation → shutdown() → tryAsyncShutdown() → onShutdown() → close()
* ↓ ↓ ↓ ↓
* Set shutdown_ SSL graceful Timer cancel Socket close
* Cancel timer shutdown start & cleanup & metrics
* 5s safety timer Set shutdownStarted_ update
*
* Two primary flags coordinate the shutdown process:
* - `shutdown_`: Set when shutdown is requested
* - `shutdownStarted_`: Set when SSL shutdown begins
*
* The shutdown mechanism carefully coordinates with ongoing read/write
* operations:
*
* **Read Operations (`onReadMessage`)**:
* - Checks `shutdown_` flag after processing each message batch
* - If shutdown initiated during processing, calls `tryAsyncShutdown()`
*
* **Write Operations (`onWriteMessage`)**:
* - Checks `shutdown_` flag before queuing new writes
* - Calls `tryAsyncShutdown()` when shutdown flag detected
*
* Multiple timers require coordination during shutdown:
* 1. **Peer Timer**: Regular ping/pong timer cancelled immediately in
* `shutdown()`
* 2. **Shutdown Timer**: 5-second safety timer ensures shutdown completion
* 3. **Operation Cancellation**: All pending async operations are cancelled
*
* The shutdown implements fallback mechanisms:
* - **Graceful Path**: SSL shutdown → Socket close → Cleanup
* - **Forced Path**: If SSL shutdown fails or times out, proceeds to socket
* close
* - **Safety Timer**: 5-second timeout prevents hanging shutdowns
*
* All shutdown operations are serialized through the boost::asio::strand to
* ensure thread safety. The strand guarantees that shutdown state changes
* and I/O operation callbacks are executed sequentially.
*
* @note This class requires careful coordination between async operations,
* timer management, and shutdown procedures to ensure no resource leaks
* or hanging connections in high-throughput networking scenarios.
*/
class PeerImp : public Peer, public std::enable_shared_from_this<PeerImp>, public OverlayImpl::Child
{
public:
@@ -121,8 +59,6 @@ private:
socket_type& socket_;
stream_type& stream_;
boost::asio::strand<boost::asio::executor> strand_;
// Multi-purpose timer for peer activity monitoring and shutdown safety
waitable_timer timer_;
// Updated at each stage of the connection process to reflect
@@ -139,6 +75,7 @@ private:
std::atomic<Tracking> tracking_;
clock_type::time_point trackingTime_;
bool detaching_ = false;
// Node public key of peer.
PublicKey const publicKey_;
std::string name_;
@@ -216,19 +153,7 @@ private:
http_response_type response_;
boost::beast::http::fields const& headers_;
std::queue<std::shared_ptr<Message>> sendQueue_;
// Primary shutdown flag set when shutdown is requested
bool shutdown_ = false;
// SSL shutdown coordination flag
bool shutdownStarted_ = false;
// Indicates a read operation is currently pending
bool readPending_ = false;
// Indicates a write operation is currently pending
bool writePending_ = false;
bool gracefulClose_ = false;
int largeSendq_ = 0;
std::unique_ptr<LoadEvent> loadEvent_;
// The highest sequence of each PublisherList that has
@@ -476,6 +401,9 @@ public:
bool
isHighLatency() const override;
void
fail(std::string const& reason);
bool
compressionEnabled() const override
{
@@ -489,129 +417,32 @@ public:
}
private:
/**
* @brief Handles a failure associated with a specific error code.
*
* This function is called when an operation fails with an error code. It
* logs the warning message and gracefully shutdowns the connection.
*
* The function will do nothing if the connection is already closed or if a
* shutdown is already in progress.
*
* @param name The name of the operation that failed (e.g., "read",
* "write").
* @param ec The error code associated with the failure.
* @note This function must be called from within the object's strand.
*/
void
fail(std::string const& name, error_code ec);
/**
* @brief Handles a failure described by a reason string.
*
* This overload is used for logical errors or protocol violations not
* associated with a specific error code. It logs a warning with the
* given reason, then initiates a graceful shutdown.
*
* The function will do nothing if the connection is already closed or if a
* shutdown is already in progress.
*
* @param reason A descriptive string explaining the reason for the failure.
* @note This function must be called from within the object's strand.
*/
void
fail(std::string const& reason);
/** @brief Initiates the peer disconnection sequence.
*
* This is the primary entry point to start closing a peer connection. It
* marks the peer for shutdown and cancels any outstanding asynchronous
* operations. This cancellation allows the graceful shutdown to proceed
* once the handlers for the cancelled operations have completed.
*
* @note This method must be called on the peer's strand.
*/
void
shutdown();
/** @brief Attempts to perform a graceful SSL shutdown if conditions are
* met.
*
* This helper function checks if the peer is in a state where a graceful
* SSL shutdown can be performed (i.e., shutdown has been requested and no
* I/O operations are currently in progress).
*
* @note This method must be called on the peer's strand.
*/
void
tryAsyncShutdown();
/**
* @brief Handles the completion of the asynchronous SSL shutdown.
*
* This function is the callback for the `async_shutdown` operation started
* in `shutdown()`. Its first action is to cancel the timer. It
* then inspects the error code to determine the outcome.
*
* Regardless of the result, this function proceeds to call `close()` to
* ensure the underlying socket is fully closed.
*
* @param ec The error code resulting from the `async_shutdown` operation.
*/
void
onShutdown(error_code ec);
/**
* @brief Forcibly closes the underlying socket connection.
*
* This function provides the final, non-graceful shutdown of the peer
* connection. It ensures any pending timers are cancelled and then
* immediately closes the TCP socket, bypassing the SSL shutdown handshake.
*
* After closing, it notifies the overlay manager of the disconnection.
*
* @note This function must be called from within the object's strand.
*/
void
close();
/**
* @brief Sets and starts the peer timer.
*
* This function starts timer, which is used to detect inactivity
* and prevent stalled connections. It sets the timer to expire after the
* predefined `peerTimerInterval`.
*
* @note This function will terminate the connection in case of any errors.
*/
void
setTimer(std::chrono::seconds interval);
fail(std::string const& name, error_code ec);
/**
* @brief Handles the expiration of the peer activity timer.
*
* This callback is invoked when the timer set by `setTimer` expires. It
* watches the peer connection, checking for various timeout and health
* conditions.
*
* @param ec The error code associated with the timer's expiration.
* `operation_aborted` is expected if the timer was cancelled.
*/
void
onTimer(error_code const& ec);
gracefulClose();
void
setTimer();
/**
* @brief Cancels any pending wait on the peer activity timer.
*
* This function is called to stop the timer. It gracefully manages any
* errors that might occur during the cancellation process.
*/
void
cancelTimer() noexcept;
static std::string
makePrefix(std::string const& fingerprint);
// Called when the timer wait completes
void
onTimer(boost::system::error_code const& ec);
// Called when SSL shutdown completes
void
onShutdown(error_code ec);
void
doAccept();