Merge branch 'develop' into bthomee/graceful

This commit is contained in:
Bart
2026-06-16 05:36:20 -04:00
committed by GitHub
17 changed files with 731 additions and 402 deletions

View File

@@ -10,7 +10,7 @@
{
"compiler": ["gcc", "clang"],
"build_type": ["Debug"],
"build_type": ["Debug", "Release"],
"arch": ["amd64"],
"sanitizers": ["address", "undefinedbehavior"]
},

View File

@@ -164,6 +164,27 @@ jobs:
${CMAKE_ARGS} \
..
# Export the sanitizer options before any instrumented binary runs. The
# protocol code-gen and build steps below invoke instrumented dependency
# tools (protoc, grpc), so setting UBSAN_OPTIONS here lets the UBSan
# suppression list silence their diagnostics too, not just at test time.
# GITHUB_WORKSPACE (not the github.workspace context) is used so the path
# resolves correctly inside the container job.
- name: Set sanitizer options
if: ${{ !inputs.build_only && env.SANITIZERS_ENABLED == 'true' }}
env:
CONFIG_NAME: ${{ inputs.config_name }}
run: |
SUPP="${GITHUB_WORKSPACE}/sanitizers/suppressions"
ASAN_OPTS="include=${SUPP}/runtime-asan-options.txt:suppressions=${SUPP}/asan.supp"
if [[ "${CONFIG_NAME}" == *gcc* ]]; then
ASAN_OPTS="${ASAN_OPTS}:alloc_dealloc_mismatch=0"
fi
echo "ASAN_OPTIONS=${ASAN_OPTS}" >>${GITHUB_ENV}
echo "TSAN_OPTIONS=include=${SUPP}/runtime-tsan-options.txt:suppressions=${SUPP}/tsan.supp" >>${GITHUB_ENV}
echo "UBSAN_OPTIONS=include=${SUPP}/runtime-ubsan-options.txt:suppressions=${SUPP}/ubsan.supp" >>${GITHUB_ENV}
echo "LSAN_OPTIONS=include=${SUPP}/runtime-lsan-options.txt:suppressions=${SUPP}/lsan.supp" >>${GITHUB_ENV}
- name: Check protocol autogen files are up-to-date
working-directory: ${{ env.BUILD_DIR }}
env:
@@ -279,20 +300,6 @@ jobs:
run: |
./xrpld --version | grep libvoidstar
- name: Set sanitizer options
if: ${{ !inputs.build_only && env.SANITIZERS_ENABLED == 'true' }}
env:
CONFIG_NAME: ${{ inputs.config_name }}
run: |
ASAN_OPTS="include=${GITHUB_WORKSPACE}/sanitizers/suppressions/runtime-asan-options.txt:suppressions=${GITHUB_WORKSPACE}/sanitizers/suppressions/asan.supp"
if [[ "${CONFIG_NAME}" == *gcc* ]]; then
ASAN_OPTS="${ASAN_OPTS}:alloc_dealloc_mismatch=0"
fi
echo "ASAN_OPTIONS=${ASAN_OPTS}" >>${GITHUB_ENV}
echo "TSAN_OPTIONS=include=${GITHUB_WORKSPACE}/sanitizers/suppressions/runtime-tsan-options.txt:suppressions=${GITHUB_WORKSPACE}/sanitizers/suppressions/tsan.supp" >>${GITHUB_ENV}
echo "UBSAN_OPTIONS=include=${GITHUB_WORKSPACE}/sanitizers/suppressions/runtime-ubsan-options.txt:suppressions=${GITHUB_WORKSPACE}/sanitizers/suppressions/ubsan.supp" >>${GITHUB_ENV}
echo "LSAN_OPTIONS=include=${GITHUB_WORKSPACE}/sanitizers/suppressions/runtime-lsan-options.txt:suppressions=${GITHUB_WORKSPACE}/sanitizers/suppressions/lsan.supp" >>${GITHUB_ENV}
- name: Run the separate tests
if: ${{ !inputs.build_only }}
working-directory: ${{ runner.os == 'Windows' && format('{0}/{1}', env.BUILD_DIR, inputs.build_type) || env.BUILD_DIR }}

View File

@@ -233,8 +233,10 @@ words:
- pyenv
- pyparsing
- qalloc
- qbsprofile
- queuable
- Raphson
- rcflags
- replayer
- rerere
- retriable

13
flake.lock generated
View File

@@ -2,17 +2,18 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1780749050,
"narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=",
"lastModified": 1781173989,
"narHash": "sha256-fnzKKPvS+oieI/pTzotA5tkoM47EB1NpaBcgk4R97hE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a799d3e3886da994fa307f817a6bc705ae538eeb",
"rev": "8c91a71d13451abc40eb9dae8910f972f979852f",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-unstable",
"type": "indirect"
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-custom-glibc": {

View File

@@ -1,7 +1,7 @@
{
description = "Nix related things for xrpld";
inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable";
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
# nixpkgs snapshot (2020-06-30) that shipped glibc 2.31 as the primary
# version — matches the system libc on Ubuntu 20.04 LTS. Imported
# manually (flake = false) because this revision predates nixpkgs'

View File

@@ -15,6 +15,7 @@
// Add new amendments to the top of this list.
// Keep it sorted in reverse chronological order.
XRPL_FIX (Cleanup3_3_0, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FIX (Cleanup3_2_0, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(MPTokensV2, Supported::No, VoteBehavior::DefaultNo)
XRPL_FIX (Cleanup3_1_3, Supported::Yes, VoteBehavior::DefaultYes)

View File

@@ -6,6 +6,7 @@ ccache --version
clang --version
clang++ --version
clang-format --version
ClangBuildAnalyzer --version
cmake --version
conan --version
curl --version

View File

@@ -9,6 +9,7 @@ in
{
commonPackages = with pkgs; [
ccache
clangbuildanalyzer
cmake
conan
curlMinimal # needed for codecov/codecov-action

View File

@@ -1 +1 @@
halt_on_error=false
halt_on_error=true

View File

@@ -72,7 +72,7 @@ vptr:boost
# Google protobuf - intentional overflows in hash functions
undefined:protobuf
unsigned-integer-overflow:google/protobuf/stubs/stringpiece.h
unsigned-integer-overflow:protobuf
# gRPC intentional overflows in timer calculations
unsigned-integer-overflow:grpc
@@ -102,47 +102,103 @@ undefined:nudb
# Snappy compression library intentional overflows
unsigned-integer-overflow:snappy.cc
# Abseil intentional overflows
unsigned-integer-overflow:absl/strings/numbers.cc
unsigned-integer-overflow:absl/strings/internal/cord_rep_flat.h
unsigned-integer-overflow:absl/base/internal/low_level_alloc.cc
unsigned-integer-overflow:absl/hash/internal/hash.h
unsigned-integer-overflow:absl/container/internal/raw_hash_set.h
# Abseil intentional overflows in hashing, RNG and time arithmetic.
# Matched at library scope (like boost above): the wraparound is by design
# across many absl files (hash mixing, raw_hash_set probing, duration math,
# int128, uniform_int_distribution), so listing individual files just churns.
unsigned-integer-overflow:absl
# Standard library intentional overflows
unsigned-integer-overflow:basic_string.h
unsigned-integer-overflow:bits/align.h
unsigned-integer-overflow:bits/basic_string.tcc
unsigned-integer-overflow:bits/chrono.h
unsigned-integer-overflow:bits/random.h
unsigned-integer-overflow:bits/random.tcc
unsigned-integer-overflow:bits/stl_algobase.h
unsigned-integer-overflow:bits/string_view.tcc
unsigned-integer-overflow:bits/uniform_int_dist.h
unsigned-integer-overflow:string_view
unsigned-integer-overflow:__random/seed_seq.h
unsigned-integer-overflow:__charconv/traits.h
unsigned-integer-overflow:__chrono/duration.h
# libstdc++ <bit> (std::__bit_ceil etc.) negates an unsigned width; <bit> is a
# distinct header from the bits/ directory so it needs its own entry.
unsigned-integer-overflow:include/c++/*/bit
# =============================================================================
# Rippled code suppressions
# =============================================================================
# Signed integer negation (-value) in amount types.
# INT64_MIN cannot occur in practice due to domain invariants (mantissa ranges
# are well within int64_t bounds), but UBSan flags the pattern as potential
# signed overflow. Narrowed to operator- to avoid suppressing unrelated
# overflows anywhere in a stack trace containing these type names.
signed-integer-overflow:operator-*IOUAmount*
signed-integer-overflow:operator-*XRPAmount*
signed-integer-overflow:operator-*MPTAmount*
signed-integer-overflow:operator-*STAmount*
# These suppressions are keyed by SOURCE FILE, not function name. This UBSan
# build runs without symbol information, so the runtime only knows the
# file:line of each report, never the enclosing function — function-name
# patterns silently never match. Each entry below is therefore scoped to the
# file whose arithmetic is intentional; the comment names the specific
# construct.
# STAmount::operator+ signed addition — operands are bounded by total supply
# (~10^17 for XRP, ~10^18 for MPT) so overflow cannot occur in practice.
signed-integer-overflow:operator+*STAmount*
# STAmount amount-type arithmetic. Unary negation of the mantissa in xrp()/
# iou()/mpt()/canonicalize() and getInt64Value, plus bounded +/- on amounts:
# INT64_MIN cannot occur because canonicalize() keeps the mantissa well within
# int64_t, and operands are bounded by total supply (~10^17 XRP, ~10^18 MPT).
signed-integer-overflow:protocol/STAmount.cpp
# STAmount::getRate uses unsigned shift and addition
unsigned-integer-overflow:*STAmount*getRate*
# STAmount::serialize uses unsigned bitwise operations
unsigned-integer-overflow:*STAmount*serialize*
# nft::cipheredTaxon uses intentional uint32 wraparound (LCG permutation);
# the helper lives in the generated protocol header nft.h.
unsigned-integer-overflow:protocol/nft.h
# nft::cipheredTaxon uses intentional uint32 wraparound (LCG permutation)
unsigned-integer-overflow:cipheredTaxon
# STPathElement::getHash multiplies/adds accumulators (non-secure, speed-first).
unsigned-integer-overflow:protocol/STPathSet.cpp
# beast XorShiftEngine PRNG and murmurhash3 mixing wrap by design.
unsigned-integer-overflow:beast/xor_shift_engine.h
# Number::normalizeToRange multiplies the mantissa by powers of ten; the result
# is intentionally allowed to wrap while searching for the in-range value.
unsigned-integer-overflow:basics/Number.h
# Counter / sequence arithmetic with intentional unsigned wraparound, each
# guarded by an explicit overflow or domain check at the call site:
# base_uint operator++/-- wrap by definition;
# ApplyView::insertPage ++page is asserted to wrap to 0 (page exhaustion);
# confineOwnerCount documents "overflow is well defined on unsigned";
# NFTokenMint checks tokenSeq + 1u == 0u; AmendmentTable does (seq - 1) / 256.
unsigned-integer-overflow:basics/base_uint.h
unsigned-integer-overflow:ledger/ApplyView.cpp
unsigned-integer-overflow:ledger/helpers/AccountRootHelpers.cpp
unsigned-integer-overflow:tx/transactors/nft/NFTokenMint.cpp
unsigned-integer-overflow:app/misc/detail/AmendmentTable.cpp
# Sentinel / bounded subtractions that wrap by design (loop counters, reverse
# iteration, "not found" sentinels, balance math bounded by issuance invariants,
# base58/base64 codec index math, hash-router and role bit math).
unsigned-integer-overflow:shamap/SHAMap.cpp
unsigned-integer-overflow:protocol/Permissions.cpp
unsigned-integer-overflow:protocol/tokens.cpp
unsigned-integer-overflow:basics/base64.cpp
unsigned-integer-overflow:json/json_value.cpp
unsigned-integer-overflow:app/misc/NetworkOPs.cpp
unsigned-integer-overflow:rpc/detail/Role.cpp
unsigned-integer-overflow:tx/transactors/oracle/OracleSet.cpp
unsigned-integer-overflow:ledger/helpers/MPTokenHelpers.cpp
unsigned-integer-overflow:crypto/RFC1751.cpp
unsigned-integer-overflow:tx/paths/detail/StrandFlow.h
unsigned-integer-overflow:protocol/STObject.h
# GetAggregatePrice negates an unsigned trim count to step a reverse iterator;
# trimCount is bounded by the price set size.
unsigned-integer-overflow:rpc/handlers/orderbook/GetAggregatePrice.cpp
# Test-only intentional overflow/underflow in fixture and unit-test arithmetic.
unsigned-integer-overflow:tests/libxrpl/basics/RangeSet.cpp
unsigned-integer-overflow:test/app/Batch_test.cpp
unsigned-integer-overflow:test/app/Invariants_test.cpp
unsigned-integer-overflow:test/app/Loan_test.cpp
unsigned-integer-overflow:test/app/NFToken_test.cpp
unsigned-integer-overflow:test/app/OfferMPT_test.cpp
unsigned-integer-overflow:test/app/Offer_test.cpp
unsigned-integer-overflow:test/app/Path_test.cpp
unsigned-integer-overflow:test/jtx/impl/acctdelete.cpp
unsigned-integer-overflow:test/ledger/SkipList_test.cpp
unsigned-integer-overflow:test/rpc/Subscribe_test.cpp
signed-integer-overflow:test/basics/XRPAmount_test.cpp

View File

@@ -17,6 +17,7 @@
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Book.h>
#include <xrpl/protocol/Concepts.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/IOUAmount.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/MPTAmount.h>
@@ -249,7 +250,13 @@ TOfferStreamBase<TIn, TOut>::step()
continue;
}
if (entry->isFieldPresent(sfDomainID) &&
// Pre-fixCleanup3_3_0: validate domain membership for any book.
// Post-fixCleanup3_3_0: only validate when walking a domain book.
// Hybrid offers carry sfDomainID but also participate in the open
// book; expiry of the owner's domain credential should not evict
// the offer from the open book.
if ((!view_.rules().enabled(fixCleanup3_3_0) || book_.domain.has_value()) &&
entry->isFieldPresent(sfDomainID) &&
!permissioned_dex::offerInDomain(
view_, entry->key(), entry->getFieldH256(sfDomainID), j_))
{

View File

@@ -1091,10 +1091,13 @@ AMMWithdraw::singleWithdrawEPrice(
// t = T*(T + A*E*(f - 2))/(T*f - A*E)
Number const ae = amountBalance * ePrice;
auto const f = getFee(tfee);
auto tokNoRoundCb = [&] {
return lptAMMBalance * (lptAMMBalance + ae * (f - 2)) / (lptAMMBalance * f - ae);
};
auto tokProdCb = [&] { return (lptAMMBalance + ae * (f - 2)) / (lptAMMBalance * f - ae); };
auto const denom = lptAMMBalance * f - ae;
// fixCleanup3_3_0: guard against division by zero
// when ePrice == lptAMMBalance*f/amountBalance
if (view.rules().enabled(fixCleanup3_3_0) && denom == beast::kZero)
return {tecAMM_FAILED, STAmount{}};
auto tokNoRoundCb = [&] { return lptAMMBalance * (lptAMMBalance + ae * (f - 2)) / denom; };
auto tokProdCb = [&] { return (lptAMMBalance + ae * (f - 2)) / denom; };
auto const tokensAdj =
getRoundedLPTokens(view.rules(), tokNoRoundCb, lptAMMBalance, tokProdCb, IsDeposit::No);
if (tokensAdj <= beast::kZero)

View File

@@ -2229,6 +2229,31 @@ private:
ammAlice.withdraw(alice_, XRPAmount{9'999'999'999});
BEAST_EXPECT(ammAlice.expectBalances(XRPAmount{1}, USD(10'000), IOUAmount{100}));
});
// singleWithdrawEPrice: crafted ePrice = lptAMMBalance*f/amountBalance
// makes the denominator (T*f - A*E) exactly zero.
// Pre-fixCleanup3_3_0: std::overflow_error escapes to the
// transactor backstop and is returned as tefEXCEPTION.
// Post-fixCleanup3_3_0: denominator check returns tecAMM_FAILED.
//
// Pool: USD(100)/EUR(100), baseFee=1000 (1%).
// Alice is the creator so her discounted fee is 100 (0.1%), f=0.001.
// ePrice = lptAMMBalance(100) * f(0.001) / amountBalance(100) = 0.001
testAMM(
[&](AMM& ammAlice, Env& env) {
auto const err =
env.enabled(fixCleanup3_3_0) ? Ter(tecAMM_FAILED) : Ter(tefEXCEPTION);
ammAlice.withdraw(
WithdrawArg{
.account = alice_,
.asset1Out = USD(0),
.maxEP = IOUAmount{1, -3}, // ePrice=0.001 → denom=0
.err = err});
},
{{USD(100), EUR(100)}},
1000,
std::nullopt,
{all - fixCleanup3_3_0, all});
}
void

File diff suppressed because it is too large Load Diff

View File

@@ -3,12 +3,21 @@
#include <test/jtx/Oracle.h>
#include <test/jtx/amount.h>
#include <xrpld/app/ledger/OpenLedger.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/jss.h>
#include <cstdlib>
#include <memory>
#include <optional>
#include <string>
#include <vector>
@@ -312,11 +321,91 @@ public:
}
}
void
testNullTxReadMeta()
{
testcase("Null txRead metadata");
using namespace jtx;
// Verify that iteratePriceData handles a null txRead result
// gracefully (returns early) rather than crashing with a
// nullptr dereference. This simulates local data corruption
// where a transaction referenced by sfPreviousTxnID is missing
// from the ledger's transaction map.
Env env(*this);
auto const baseFee = static_cast<int>(env.current()->fees().base.drops());
Account const owner{"owner"};
env.fund(XRP(1'000), owner);
// Create oracle with XRP/USD and XRP/EUR
Oracle oracle(
env,
{.owner = owner,
.series = {{"XRP", "USD", 740, 1}, {"XRP", "EUR", 840, 1}},
.fee = baseFee});
// Update oracle to only have XRP/EUR, pushing XRP/USD into
// history. iteratePriceData will need to read historical tx
// metadata to find the XRP/USD price.
oracle.set(UpdateArg{.series = {{"XRP", "EUR", 850, 1}}, .fee = baseFee});
OraclesData const oracles{{owner, oracle.documentID()}};
// Precondition: with an uncorrupted oracle, the historical
// traversal must succeed and produce a price for XRP/USD.
// This proves the test reaches iteratePriceData's history
// path; without it, a future change that breaks the setup
// could turn the post-corruption assertion into a vacuous
// pass (objectNotFound is reachable from many unrelated
// code paths).
{
auto const ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles);
BEAST_EXPECT(!ret.isMember(jss::error));
BEAST_EXPECT(ret.isMember(jss::median));
}
// Simulate data corruption: modify the oracle SLE in the open
// ledger to have a bogus sfPreviousTxnID that doesn't exist in
// any ledger. sfPreviousTxnLgrSeq still points to a valid closed
// ledger, so getLedgerBySeq succeeds but txRead returns null.
auto const oracleKeylet = keylet::oracle(owner, oracle.documentID());
uint256 const bogusTxnID{0xABCABCAB};
bool const modified = env.app().getOpenLedger().modify(
[&oracleKeylet, &bogusTxnID](OpenView& view, beast::Journal) -> bool {
auto const sle = view.read(oracleKeylet);
if (!sle)
return false;
auto replacement = std::make_shared<SLE>(*sle, sle->key());
replacement->setFieldH256(sfPreviousTxnID, bogusTxnID);
view.rawReplace(replacement);
return true;
});
// Confirm the injection actually took effect: modify must
// report success, and re-reading the SLE must show the
// bogus hash. Otherwise the failure-mode assertion below
// would not be exercising the null-txRead path at all.
BEAST_EXPECT(modified);
if (auto const sle = env.current()->read(oracleKeylet); BEAST_EXPECT(sle))
BEAST_EXPECT(sle->getFieldH256(sfPreviousTxnID) == bogusTxnID);
// Query for XRP/USD using the "current" (open) ledger.
// The oracle SLE now has a bogus sfPreviousTxnID. The current
// oracle only has EUR, so iteratePriceData will try to read
// history. txRead returns null for the bogus hash, and the
// null check should cause a graceful early return instead of
// a nullptr dereference.
auto const ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles);
BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound");
}
void
run() override
{
testErrors();
testRpc();
testNullTxReadMeta();
}
};

View File

@@ -58,7 +58,6 @@
#include <xrpl/resource/Disposition.h>
#include <xrpl/resource/Fees.h>
#include <xrpl/resource/Gossip.h>
#include <xrpl/server/Handoff.h>
#include <xrpl/server/LoadFeeTrack.h>
#include <xrpl/server/NetworkOPs.h>
#include <xrpl/shamap/SHAMapNodeID.h>
@@ -68,6 +67,7 @@
#include <boost/asio/bind_executor.hpp>
#include <boost/asio/buffer.hpp>
#include <boost/asio/completion_condition.hpp>
#include <boost/asio/dispatch.hpp>
#include <boost/asio/error.hpp>
#include <boost/asio/post.hpp>
#include <boost/asio/strand.hpp>
@@ -392,13 +392,15 @@ PeerImp::removeTxQueue(uint256 const& hash)
void
PeerImp::charge(Resource::Charge const& fee, std::string const& context)
{
if ((usage_.charge(fee, context) == Resource::Disposition::Drop) &&
usage_.disconnect(pJournal_) && strand_.running_in_this_thread())
{
// Sever the connection
overlay_.incPeerDisconnectCharges();
fail("charge: Resources");
}
dispatch(strand_, [this, self = shared_from_this(), fee, context]() {
if (usage_.charge(fee, context) == Resource::Disposition::Drop &&
usage_.disconnect(pJournal_))
{
// Sever the connection.
overlay_.incPeerDisconnectCharges();
fail("charge: Resources");
}
});
}
//------------------------------------------------------------------------------

View File

@@ -30,6 +30,7 @@
#include <expected>
#include <limits>
#include <memory>
#include <string>
#include <utility>
namespace xrpl {
@@ -349,13 +350,15 @@ doLedgerGrpc(RPC::GRPCContext<org::xrpl::rpc::v1::GetLedgerRequest>& context)
auto end = std::chrono::system_clock::now();
auto duration =
std::chrono::duration_cast<std::chrono::milliseconds>(end - begin).count() * 1.0;
// Guard the per-item rates: an empty ledger has zero objects and/or zero
// transactions, and dividing by zero is undefined for these doubles.
auto const numObjects = response.ledger_objects().objects_size();
auto const numTxns = response.transactions_list().transactions_size();
std::string const msPerObj = numObjects > 0 ? std::to_string(duration / numObjects) : "n/a";
std::string const msPerTxn = numTxns > 0 ? std::to_string(duration / numTxns) : "n/a";
JLOG(context.j.warn()) << __func__ << " - Extract time = " << duration
<< " - num objects = " << response.ledger_objects().objects_size()
<< " - num txns = " << response.transactions_list().transactions_size()
<< " - ms per obj "
<< duration / response.ledger_objects().objects_size()
<< " - ms per txn "
<< duration / response.transactions_list().transactions_size();
<< " - num objects = " << numObjects << " - num txns = " << numTxns
<< " - ms per obj " << msPerObj << " - ms per txn " << msPerTxn;
return {response, status};
}