Compare commits

...
Author SHA1 Message Date
Bart bda01e0575 Merge remote-tracking branch 'origin/develop' into bthomee/graceful
# Conflicts:
#	src/libxrpl/shamap/SHAMapSync.cpp
#	src/tests/libxrpl/shamap/SHAMapSync.cpp
#	src/tests/libxrpl/shamap/common.h
2026-10-16 19:05:18 +09:00
BartandBart 4087861411 fix: Refuse to walk an invalid SHAMap in getMissingNodes (#8086)
Co-authored-by: Bart <[email protected]>
2026-10-16 05:47:08 +00:00
BartandBart dc43faafed fix: Refuse to make an invalid SHAMap or Ledger immutable (#8085)
Co-authored-by: Bart <[email protected]>
2026-10-16 05:47:07 +00:00
6af97c07d9 fix: Report a node the sync path refuses as invalid data (#8084)
Co-authored-by: Bart <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 05:47:06 +00:00
4c7d920e66 fix: Make the SHAMap sync-path state atomic (#8083)
Co-authored-by: Bart <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 05:47:06 +00:00
yinyiqian1 8b59d9d307 docs: Add todo comment for key rotation zkproof (#8589) 2026-10-16 00:11:06 +00:00
b0fa295595 fix: Judge a header's account hash on both acquisition routes (#8476)
Co-authored-by: Bart <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-15 21:00:21 +00:00
BartandBart 9af432cb0e fix: Signal every InboundLedger failure found in local data (#8082)
Co-authored-by: Bart <[email protected]>
2026-10-15 21:00:20 +00:00
BartandBart 4435ca4cbf refactor: Add a reusable peer harness for acquisition tests (#8310)
Co-authored-by: Bart <[email protected]>
2026-10-15 21:00:19 +00:00
Olek cd72a11786 refactor: TxQ (#8064) 2026-10-15 18:11:46 +00:00
Peter Chen b398b88385 fix: Reject a zero credential ID in deposit_authorized (#8533) 2026-10-15 18:11:18 +00:00
Olek 31f6f90206 feat: Add IssuerMirrorEncryptionKey and InitialIssuerEncryptionKey (#8325) 2026-10-15 18:04:29 +00:00
Olek 1d8c2f9172 test: Sponsor checkSign additional tests (#8331) 2026-10-15 18:03:48 +00:00
f105418bdb feat: Book FixedPrecision LoanPay and LoanManage (#8271)
Co-authored-by: Jingchen <[email protected]>
Co-authored-by: Alex Kremer <[email protected]>
2026-10-15 18:02:36 +00:00
Ayaz Salikhov 4a134eb883 docs: Clarify OS and toolchain requirements (#8588) 2026-10-15 17:58:11 +00:00
Ayaz Salikhov ec3f251ad8 ci: Fix running pre-commit after clang-tidy (#8601) 2026-10-15 16:15:35 +00:00
Mayukha Vadari 5faa861226 feat: Add Smart Escrow fee voting (#8214) 2026-10-15 15:50:16 +00:00
Jingchen 88fd1222d6 build: Fix unity build by addressing name clashes (#8599) 2026-10-15 12:59:20 +00:00
Ayaz SalikhovandEd Hennis f1ce9449f2 fix: Skip bootstrap DNS lookups in standalone mode (#8583)
Co-authored-by: Ed Hennis <[email protected]>
2026-10-14 21:02:45 +00:00
Timur Yalymov 7237b3321c fix: Charge transfer fee on third-party vault withdrawals (#8267) 2026-10-14 16:35:42 +00:00
Bart 05c5998634 fix: Report unreadable SHAMap nodes instead of throwing
`SHAMap::walkMap` and `walkMapParallel` treat a null `descendNoStore` result as
"this node is missing" and record it in their `missingNodes` output, but
`descendNoStore` used the throwing `fetchNode`, so that branch could never run for
a backed map. `Ledger::walkLedger` propagated the throw instead of returning
false, its "N missing account node(s)" log never printed, and `LedgerCleaner` never
reached the path that clears the ledger and re-acquires it. `descendNoStore` now
uses `fetchNodeNT`, which makes all three behave as written, and the null check
after `fetchNode` in `descend` goes away as the dead code it always was.

`visitNodes` and `visitLeaves` now return whether the walk read every node it
reached, and the walk ends at the first node it cannot read, since no caller can use
a partial result. `SHAMapStoreImp::run` abandons the rotation cycle on false, which
is what its former `catch (SHAMapMissingNode)` did and what `clearPrior` plus the
archive deletion in `rotate` require. `processReplayDeltaRequest` answers
`reNO_NODE`, the code `xrpl.proto` documents for nodes we do not have, rather than
sending a transaction list it knows is short, and clears the header it had already
set so an error reply carries no partial payload. The two `RCLConsensus` walks run
on unbacked maps, which have no node store to fail to read, so they cannot report an
incomplete result.

`walkMapParallel` decided its result from the exceptions its workers caught, yet
those workers record an unreadable child in `missingNodes` instead, so it never
consulted the list it was filling. The result now counts what this call recorded,
measured against the caller's initial vector size. The pass that reads the root's
children runs before any worker and dropped a null child silently, because the loop
that spawns workers skips one; it records the miss itself now. A one-node map
reports complete, which is what `walkMap` already reported for the same input, and
the worker handler catches `std::exception` so that nothing leaves a worker's
thread. The workers share one missing-node budget, so the critical section tests it
before it adds to the list and not only after, which keeps the total within the cap
the caller asked for.

Eleven gtests in `src/tests/libxrpl/shamap/SHAMapMissingNode.cpp` cover the three
walks over a partially copied map, the missing-node budget both within one walk and
across the workers, an empty branch of the root, an early stop by the visitor, a
stop at the root, a map holding only its root, and a map whose root is a leaf. A
case in `src/test/app/LedgerReplay_test.cpp` asks for a replay delta on a ledger
whose transaction map holds nothing below its root, and checks the error reply.
2026-09-24 06:06:09 +02:00
122 changed files with 14277 additions and 1618 deletions

No files matched your search

+2
View File
@@ -103,6 +103,7 @@ words:
- dearmor
- decryptor
- dedented
- dedup
- deleteme
- demultiplexer
- deserializaton
@@ -344,6 +345,7 @@ words:
- unambiguity
- unauthorizes
- unauthorizing
- undeserializable
- unergonomic
- unfetched
- unfindable
@@ -62,6 +62,7 @@ libxrpl.tx > xrpl.protocol
libxrpl.tx > xrpl.server
libxrpl.tx > xrpl.tx
test.app > test.jtx
test.app > tests.libxrpl
test.app > test.unit_test
test.app > xrpl.basics
test.app > xrpl.config
+8 -2
View File
@@ -123,13 +123,19 @@ jobs:
- name: Fix style
if: ${{ steps.files_changed.outcome != 'success' }}
# pre-commit installs every hook's environment, even for hooks that match
# no files, and node-based hooks need libatomic, which this image lacks.
# Exit code 1 just means hooks modified files or found issues.
env:
SKIP: prettier,cspell
run: |
pre-commit run --all-files || true
git add --update
pre-commit run || [ $? -eq 1 ]
- name: Generate git diff
if: ${{ steps.files_changed.outcome != 'success' }}
run: |
git diff | tee "${DIFF_FILE}"
git diff HEAD | tee "${DIFF_FILE}"
- name: Upload clang-tidy diff output
if: ${{ github.event.repository.visibility == 'public' && steps.files_changed.outcome != 'success' }}
+1
View File
@@ -36,6 +36,7 @@ Version 3.5.0 is not yet released.
- `channel_authorize`: The `channel_id` field now returns an `invalidParams` error if the value is not a string. [#7582](https://github.com/XRPLF/rippled/pull/7582)
- `channel_verify`: The `channel_id` and `signature` fields now return an `invalidParams` error if the value is not a string. [#7582](https://github.com/XRPLF/rippled/pull/7582)
- `account_currencies`, `account_nfts`, `account_offers`: A request with more than one problem may now report a different one of them. An invalid `ledger_hash` or `ledger_index` is reported before an invalid or missing `account`. An invalid `limit` or `marker` is reported before `lgrNotFound` or `actNotFound`, and for `account_offers` also before `actMalformed`. A request with a single problem returns the same error as before. [#8561](https://github.com/XRPLF/rippled/pull/8561)
- `deposit_authorized`: A `credentials` entry that is all zeros (for example `"0"` or 64 `0` characters) now returns a `badCredentials` error instead of looking up a credential that can never exist.
### Bugfixes in 3.5.0
+5 -26
View File
@@ -2,33 +2,12 @@
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| These instructions assume you have a C++ development environment ready with Git, Python, Conan, CMake, and a C++ compiler. For help setting one up on Linux, macOS, or Windows, [see this guide](./docs/build/environment.md).<br><br>These instructions also assume a basic familiarity with Conan and CMake. If you are unfamiliar with Conan, you can read our [crash course](./docs/build/conan.md) or the official [Getting Started][conan-getting-started] walkthrough. |
## Minimum Requirements
## Supported platforms
For the hardware needed to run a node, see
[System Requirements](https://xrpl.org/system-requirements.html).
For the software needed to build xrpld, see the
[environment setup guide](./docs/build/environment.md).
## Operating Systems
### Linux
The Ubuntu Linux distribution has received the highest level of quality
assurance, testing, and support. We also support Red Hat and use Debian
internally.
Our Linux CI tooling is distro-independent and uses a Nix-based environment, so it should be possible to build on other Linux distributions as well, although we have not tested them.
### macOS
Many `xrpld` engineers use macOS for development.
The minimum supported version is macOS 15 (Sequoia).
CI testing is done in macOS 26 (Tahoe), but the build defaults `CMAKE_OSX_DEPLOYMENT_TARGET` to 15.
### Windows
Windows is used by some engineers for development only, and is not recommended
for production use.
Building and testing are supported on Linux, macOS, and (best effort) Windows;
see [Supported development platforms](./docs/build/environment.md#supported-development-platforms).
Running `xrpld` is officially supported on Linux only;
see [Requirements](./docs/install.md#requirements).
## Steps
+1 -1
View File
@@ -332,7 +332,7 @@ This ensures that configuration changes don't introduce new warnings across the
### Installing clang-tidy
See the [environment setup guide](./docs/build/environment.md#clang-tidy) for how to get clang-tidy.
The [Nix development shell](./docs/build/nix.md) provides `clang-tidy` at the [tested version](./docs/build/environment.md#tested-compiler-versions).
### Running clang-tidy locally
+33
View File
@@ -1360,6 +1360,39 @@
# Example:
# owner_reserve = 200000 # 0.2 XRP
#
# gas_limit = <gas>
#
# The gas limit is the maximum amount of gas that can be
# consumed by a single transaction. The gas limit is used to prevent
# transactions from consuming too many resources.
#
# If this parameter is unspecified, xrpld will use an internal
# default. Don't change this without understanding the consequences.
#
# Example:
# gas_limit = 1000000 # 1 million gas
#
# bytecode_size_limit = <bytes>
#
# The bytecode size limit is the maximum size of a WASM extension in
# bytes. The size limit is used to prevent extensions from consuming
# too many resources.
#
# If this parameter is unspecified, xrpld will use an internal
# default. Don't change this without understanding the consequences.
#
# Example:
# bytecode_size_limit = 100000 # 100 kb
#
# gas_price = <micro-drops>
#
# The gas price is the conversion between WASM gas and its price in drops.
#
# If this parameter is unspecified, xrpld will use an internal
# default. Don't change this without understanding the consequences.
#
# Example:
# gas_price = 1000000 # 1 drop per gas
#-------------------------------------------------------------------------------
#
# 9. Misc Settings
+32 -77
View File
@@ -4,6 +4,18 @@ This document explains how to set one up.
[BUILD.md]: ../../BUILD.md
## Supported development platforms
- **Linux**: the [Nix development shell](./nix.md),
which provides the same toolchain CI uses on any distribution.
- **macOS** 15 (Sequoia) or newer: the [Nix development shell](./nix.md),
or the system [Apple Clang](#macos-apple-clang).
CI runs on macOS 26 (Tahoe), but the build defaults `CMAKE_OSX_DEPLOYMENT_TARGET` to 15.
- **Windows**: best effort, see [Windows](#windows).
For the platforms supported for running `xrpld`,
see [Requirements](../install.md#requirements).
## Tested compiler versions
`xrpld` is built in the **C++23** dialect by default, so your toolchain has to
@@ -35,20 +47,16 @@ compiler building. Treat support for anything outside the table as best-effort.
## Required tools
Besides a compiler, building `xrpld` requires:
| Tool | Minimum version |
| ------------------------------------------- | --------------- |
| [Git](https://git-scm.com/downloads) | any recent |
| [Python](https://www.python.org/downloads/) | 3.11 |
| [Conan](https://conan.io/downloads.html) | 2.17 |
| [CMake](https://cmake.org/download/) | 3.16 |
Besides a compiler, building `xrpld` requires
[Git](https://git-scm.com/downloads),
[Python](https://www.python.org/downloads/),
[Conan](https://conan.io/downloads.html),
and [CMake](https://cmake.org/download/).
Building with `-Drust=ON` additionally requires a Rust toolchain,
see [Rust](#rust).
On Linux and macOS, the [Nix development shell](./nix.md) provides all of them
(see below). On Windows they have to be installed manually.
Building with `-Drust=ON` additionally requires a Rust toolchain, see
[Rust](#rust). A default build does not, so it is not in the table above.
at the versions CI uses, recorded in [`nix/check-tools/`](../../nix/check-tools).
Once they are in place, verify that everything is installed and runnable with:
@@ -58,60 +66,19 @@ Once they are in place, verify that everything is installed and runnable with:
## Linux and macOS
The **recommended way** to get a development environment on Linux and macOS is
the Nix development shell. It provides the exact tooling used in CI — `git`,
`python`, `conan`, `cmake`, `clang-tidy`, `clang-format`, and everything else —
with a single command and without installing anything system-wide:
Use the [Nix development shell](./nix.md):
it provides the compiler and every tool CI uses,
without installing anything system-wide.
```bash
nix --experimental-features 'nix-command flakes' develop
```
### macOS: Apple Clang
On **Linux**, Nix also provides the compiler (GCC); on **macOS**, it provides
Clang. If you instead opt to use your system-wide Apple Clang (via
`nix develop .#apple-clang`), you need to manage its version yourself (see
below).
See [Using the Nix development shell](./nix.md) for installation and usage
details, including how to select a different compiler and why we recommend Nix
over a hand-maintained environment.
### macOS: managing the Apple Clang version
If you use your system-wide Apple Clang on macOS (via `nix develop .#apple-clang`),
the compiler version is whatever your installed Xcode (or Command Line Tools)
provides. The following command should return a version greater than or equal to
the [tested one](#tested-compiler-versions):
```bash
clang --version
```
If you develop other applications using Xcode, you might be consistently
updating to the newest version of Apple Clang, which will likely cause issues
building xrpld. You may want to install and pin a specific version of Xcode:
1. **Download Xcode**
- Visit [Apple Developer Downloads](https://developer.apple.com/download/more/)
- Sign in with your Apple Developer account
- Search for an Xcode version that includes the expected Apple Clang version
- Download the `.xip` file
2. **Install and configure Xcode**
```bash
# Extract the .xip file and rename for version management
# Example: Xcode_16.2.app
# Move to Applications directory
sudo mv Xcode_16.2.app /Applications/
# Set as default toolchain (persistent)
sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer
# Set as environment variable (temporary)
export DEVELOPER_DIR=/Applications/Xcode_16.2.app/Contents/Developer
```
You can also build with the system Apple Clang,
installing the other [required tools](#required-tools) any way you like
(or getting them from `nix develop .#apple-clang`).
Its version is whatever your installed Xcode (or Command Line Tools) provides,
so check that `clang --version` is at least the [tested one](#tested-compiler-versions).
If several Xcode versions are installed,
select one with `sudo xcode-select -s <path>` or the `DEVELOPER_DIR` environment variable.
## Windows
@@ -123,8 +90,7 @@ manually:
"x64 Native Tools Command Prompt". CI configures CMake with the
`Visual Studio 18 2026` generator.
- [Git for Windows](https://git-scm.com/download/win)
- Python, Conan, and CMake, at the versions listed in
[Required tools](#required-tools).
- recent releases of Python, Conan, and CMake
- a [Rust toolchain](https://rustup.rs) — only needed to build with
`-Drust=ON`, see [Rust](#rust)
@@ -145,16 +111,5 @@ version.
Everything else the Rust build needs on the CMake side comes from Conan along
with the rest of the dependencies, so there is nothing further to install.
## Clang-tidy
`clang-tidy` is required to run static analysis checks locally (see
[CONTRIBUTING.md](../../CONTRIBUTING.md)). It is not required to build the
project. The version this project uses is listed in
[Tested compiler versions](#tested-compiler-versions).
On Linux and macOS, the [Nix development shell](./nix.md) provides that exact
version out of the box — run it via `run-clang-tidy`. No separate installation
is needed.
[cpp23-support]: https://en.cppreference.com/w/cpp/compiler_support/23
[packages.nix]: ../../nix/packages.nix
+2 -9
View File
@@ -9,9 +9,7 @@ This guide explains how to use Nix to set up a reproducible development environm
- **Reproducible environment**: Everyone gets the same versions of tools and compilers
- **Matches CI**: The Linux CI runs in Docker images built from this exact Nix environment, and CI builds some macOS configurations in it as well
- **No system pollution**: Dependencies are isolated and don't affect your system packages
- **Consistent compilers**: The GCC and Clang shells use the same versions as CI
- **Quick setup**: Get started with a single command
- **Works on Linux and macOS**: Consistent experience across platforms
## Install Nix
@@ -27,13 +25,8 @@ From the root of the xrpld repository, enter the default development shell:
nix --experimental-features 'nix-command flakes' develop
```
This will:
- Download and set up all required development tools (CMake, Ninja, Conan, etc.)
- Configure the appropriate compiler for your platform:
- **Linux**: GCC (provided by Nix)
- **macOS**: Clang (provided by Nix)
This downloads and sets up all required development tools (CMake, Ninja, Conan, etc.)
and the compiler for your platform (see [Platform notes](#platform-notes)).
The first time you run this command, it will take a few minutes to download and build the environment. Subsequent runs will be much faster.
### Platform notes
+13
View File
@@ -10,6 +10,19 @@ and DNF on Red Hat-based distributions such as RHEL, AlmaLinux, and Rocky Linux,
where `yum` is a symlink to `dnf`.
To build from source instead, see [BUILD.md](../BUILD.md).
## Requirements
Linux is the only operating system officially supported for running `xrpld`;
macOS and Windows builds are for development only.
Any x86-64 Linux distribution with APT or DNF and glibc 2.31 or newer should work
(see [How libc is handled](../nix/docker/README.md#how-libc-is-handled)).
CI tests the packages on Debian 11–13, Ubuntu 20.04–26.04,
RHEL (UBI) 9–10, AlmaLinux 9–10, and Rocky Linux 9–10.
For the hardware needed to run a node,
see [System Requirements](https://xrpl.org/system-requirements.html).
## Release channels
Packages are published to four channels:
+3
View File
@@ -94,6 +94,7 @@ struct Keys
static constexpr auto kBbtOptions = "bbt_options";
static constexpr auto kBgThreads = "bg_threads";
static constexpr auto kBlockSize = "block_size";
static constexpr auto kBytecodeSizeLimit = "bytecode_size_limit";
static constexpr auto kCacheAge = "cache_age";
static constexpr auto kCacheMb = "cache_mb";
static constexpr auto kCacheSize = "cache_size";
@@ -108,6 +109,8 @@ struct Keys
static constexpr auto kFileSizeMult = "file_size_mult";
static constexpr auto kFilterBits = "filter_bits";
static constexpr auto kFilterFull = "filter_full";
static constexpr auto kGasLimit = "gas_limit";
static constexpr auto kGasPrice = "gas_price";
static constexpr auto kHardSet = "hard_set";
static constexpr auto kHighThreads = "high_threads";
static constexpr auto kHoldTime = "hold_time";
+65 -15
View File
@@ -257,13 +257,42 @@ public:
header_.validated = true;
}
void
/**
* Mark this ledger as accepted and attempt to make it immutable.
*
* The close-time fields are recorded first, since the ledger hash covers
* them.
*
* @param closeTime The consensus-agreed close time.
* @param closeResolution The close time resolution.
* @param correctCloseTime Whether consensus agreed on the close time. If
* false, kSLcfNoConsensusTime is recorded in closeFlags instead.
* @return What setImmutable() returned.
*/
[[nodiscard]] bool
setAccepted(
NetClock::time_point closeTime,
NetClock::duration closeResolution,
bool correctCloseTime);
void
/**
* Mark this ledger as immutable, so it can no longer be modified.
*
* A ledger built or loaded locally cannot have an invalid map, since only
* a map syncing against hashes from outside can be proven impossible (see
* SHAMap::addKnownNode), so a caller on such a path may treat a false
* return as a broken internal invariant. A caller assembling a ledger from
* peer data may not: for it, false is an outcome a peer can produce.
*
* @param rehash Whether to recompute the ledger hash from the header
* fields. The transaction and account hashes are recomputed from the
* maps too, but only the first time.
* @return false if either map is Invalid, leaving the immutable flag as it
* was and the header untouched. A map invalidated partway through can
* still leave the other one immutable, so a false return means the
* ledger must be discarded rather than retried.
*/
[[nodiscard]] bool
setImmutable(bool rehash = true);
bool
@@ -272,23 +301,28 @@ public:
return immutable_;
}
/* Mark this ledger as "should be full".
/**
* @return Whether both maps can still be the maps the header names. See
* SHAMap::isValid().
*/
[[nodiscard]] bool
mapsValid() const
{
return txMap_.isValid() && stateMap_.isValid();
}
"Full" is metadata property of the ledger, it indicates
that the local server wants all the corresponding nodes
in durable storage.
This is marked `const` because it reflects metadata
and not data that is in common with other nodes on the
network.
*/
/**
* Mark this ledger as "should be full", indicating that the local server
* wants all the corresponding nodes in durable storage.
*
* Const because it reflects metadata, not data this ledger shares with
* other nodes on the network.
*/
void
setFull() const
{
txMap_.setFull();
txMap_.setLedgerSeq(header_.seq);
stateMap_.setFull();
stateMap_.setLedgerSeq(header_.seq);
txMap_.setFull(header_.seq);
stateMap_.setFull(header_.seq);
}
void
@@ -418,6 +452,22 @@ private:
static std::pair<std::shared_ptr<STTx const>, std::shared_ptr<STObject const>>
deserializeTxPlusMeta(SHAMapItem const& item);
/**
* Make both maps immutable.
*
* Each map is settled on its own, so neither is left mid-sync because the
* other refused.
*
* @return Whether both maps are immutable.
*/
[[nodiscard]] bool
setMapsImmutable()
{
bool const txImmutable = txMap_.setImmutable();
bool const stateImmutable = stateMap_.setImmutable();
return txImmutable && stateImmutable;
}
bool immutable_;
// A SHAMap containing the transactions associated with this ledger.
+9 -1
View File
@@ -265,6 +265,13 @@ canWithdraw(
[[nodiscard]] TER
canWithdraw(ReadView const& view, STTx const& tx);
/**
* Pays out a withdrawal from a vault or loan broker pseudo-account.
*
* `sourceAmount` leaves `sourceAcct` and `destinationAmount` reaches `dstAcct`.
* The two differ only when the withdrawal carries a transfer fee; the
* difference is the fee and is settled through the issuer.
*/
[[nodiscard]] TER
doWithdraw(
ApplyViewContext ctx,
@@ -272,7 +279,8 @@ doWithdraw(
AccountID const& dstAcct,
AccountID const& sourceAcct,
XRPAmount priorBalance,
STAmount const& amount,
STAmount const& destinationAmount,
STAmount const& sourceAmount,
beast::Journal j);
/**
@@ -330,14 +330,6 @@ adjustImpreciseNumber(
value = 0;
}
inline int
getAssetsTotalScale(SLE::ConstRef vaultSle)
{
if (!vaultSle)
return Number::kMinExponent - 1; // LCOV_EXCL_LINE
return scale(vaultSle->at(sfAssetsTotal), vaultSle->at(sfAsset));
}
/**
* Apply a signed delta to a LoanBroker's DebtTotal: on FixedPrecision Vaults,
* add the exact delta (DebtTotal moves by exactly the same amount as the
@@ -316,6 +316,20 @@ transferRate(ReadView const& view, Asset const& asset);
[[nodiscard]] Rate
transferRate(ReadView const& view, STAmount const& amount);
/**
* Returns the amount a sender must part with for `amount` to reach the
* receiver once the issuer takes the transfer fee. Rounds up.
*/
[[nodiscard]] STAmount
addTransferFee(STAmount const& amount, Rate const& rate);
/**
* Returns the amount a receiver gets when `amount` leaves the sender and the
* issuer takes the transfer fee. Rounds down.
*/
[[nodiscard]] STAmount
subtractTransferFee(STAmount const& amount, Rate const& rate);
//------------------------------------------------------------------------------
//
// Holding operations (Asset-based dispatchers)
+9 -7
View File
@@ -164,18 +164,20 @@ posteriorAssetScale(
/**
* Shared core of creditToPosteriorAvailableScale and
* creditToPosteriorBrokerCoverScale: floors reference + raw at scale and
* returns the difference from reference, so the delta applied is exactly
* what moves reference onto the floored sum. See
* creditToPosteriorAvailableScale's doc for why the sum, not raw alone,
* must be floored. raw is the exact amount; it is not rounded on its own
* before being added to reference.
* creditToPosteriorBrokerCoverScale: floors reference + raw at the sum's
* own live scale (never finer than baseScale) and returns the difference
* from reference, so the delta applied is exactly what moves reference onto
* the floored sum. See creditToPosteriorAvailableScale's doc for why the
* sum, not raw alone, must be floored. raw is the exact amount; it is not
* rounded on its own before being added to reference. The sum and its scale
* are computed once, in roundingMode, so a Downward credit is always
* between zero and raw.
*/
[[nodiscard]] STAmount
creditToPosteriorScale(
Asset const& asset,
Number const& reference,
int atScale,
int baseScale,
Number const& raw,
Number::RoundingMode roundingMode);
+10 -5
View File
@@ -348,16 +348,19 @@ isAuditorMirrorCurrent(SLE const& issuance, SLE const& mptoken);
areMirrorsCurrent(SLE const& issuance, SLE const& mptoken);
/**
* @brief Set the holder's issuer mirror epoch to match the issuance's current issuer key epoch.
* @brief Set the holder's issuer mirror epoch, and the issuer mirror key, to match the issuance's
* current key.
*
* Call this after writing the issuer mirror ciphertext under the issuance's
* currently registered issuer key, so that the mirror reads as current afterwards.
* Also records sfIssuerMirrorEncryptionKey as the issuance's current issuer key
* (both fields stay absent at epoch 0, where the original issuer key applies).
*
* @param issuance The MPTokenIssuance ledger object.
* @param mptoken The holder's MPToken ledger entry to update.
*/
void
setIssuerMirrorEpoch(SLE const& issuance, SLE& mptoken);
setIssuerMirrorCurrent(SLE const& issuance, SLE& mptoken);
/**
* @brief Set the holder's auditor mirror epoch to match the issuance's current auditor key epoch.
@@ -372,16 +375,18 @@ void
setAuditorMirrorEpoch(SLE const& issuance, SLE& mptoken);
/**
* @brief Set the holder's MPToken mirror epochs to match the issuance's current key epochs.
* @brief Set the holder's MPToken mirror epochs, and the issuer mirror key, to match the issuance's
* current keys.
*
* Call this after writing mirror ciphertexts under the issuance's currently
* registered keys, so that the mirrors read as current afterwards.
* registered keys, so that the mirrors read as current afterwards. Also records
* sfIssuerMirrorEncryptionKey via setIssuerMirrorCurrent.
*
* @param issuance The MPTokenIssuance ledger object.
* @param mptoken The holder's MPToken ledger entry to update.
*/
void
setMirrorEpochs(SLE const& issuance, SLE& mptoken);
setMirrorsCurrent(SLE const& issuance, SLE& mptoken);
/**
* @brief Verifies revealed amount encryptions for all recipients.
+54 -1
View File
@@ -10,6 +10,25 @@ namespace xrpl {
// This was the reference fee units used in the old fee calculation.
inline constexpr std::uint32_t kFeeUnitsDeprecated = 10;
// Number of micro-drops in one drop.
inline constexpr std::uint32_t microDropsPerDrop{1'000'000};
/**
* Hard protocol bounds on the Feature Extension fee settings. A voted value
* can never fall outside these, so `preflight`, which has no view, may bound
* against them. Gas and bytecode limits are capped; gas price has a floor
* (zero would make WASM execution effectively free).
*/
inline constexpr std::uint32_t kMaxGasLimit{2'000'000};
inline constexpr std::uint32_t kMaxBytecodeSizeLimit{200'000};
inline constexpr std::uint32_t kMinGasPrice{1};
// The following default values of fee settings will seed into FeeSettings
// and write to the ledger on featureSmartEscrow activation.
inline constexpr std::uint32_t kDefaultGasLimit{1'000'000};
inline constexpr std::uint32_t kDefaultBytecodeSizeLimit{100'000};
inline constexpr std::uint32_t kDefaultGasPrice{1'000'000};
/**
* Reflects the fee settings for a particular ledger.
*
@@ -33,13 +52,47 @@ struct Fees
*/
XRPAmount increment{0};
/**
* @brief Gas limit for Feature Extensions (instructions).
*
* This and the one below default to the protocol values rather than 0,
* because an explicit 0 is the voted kill switch.
*/
std::uint32_t gasLimit{kDefaultGasLimit};
/**
* @brief Bytecode size limit for Feature Extensions (bytes).
*/
std::uint32_t bytecodeSizeLimit{kDefaultBytecodeSizeLimit};
/**
* @brief Price of WASM gas (micro-drops).
*/
std::uint32_t gasPrice{kDefaultGasPrice};
explicit Fees() = default;
Fees(Fees const&) = default;
Fees&
operator=(Fees const&) = default;
Fees(XRPAmount base, XRPAmount reserve, XRPAmount increment)
: base(base), reserve(reserve), increment(increment)
: base{base}, reserve{reserve}, increment{increment}
{
}
Fees(
XRPAmount base,
XRPAmount reserve,
XRPAmount increment,
std::uint32_t gasLimit,
std::uint32_t bytecodeSizeLimit,
std::uint32_t gasPrice)
: base{base}
, reserve{reserve}
, increment{increment}
, gasLimit{gasLimit}
, bytecodeSizeLimit{bytecodeSizeLimit}
, gasPrice{gasPrice}
{
}
+10
View File
@@ -696,6 +696,16 @@ divRound(STAmount const& v1, STAmount const& v2, Asset const& asset, bool roundU
STAmount
divRoundStrict(STAmount const& v1, STAmount const& v2, Asset const& asset, bool roundUp);
/**
* Returns amt * num / den, rounded in the requested direction.
*
* Dispatches to the XRPAmount, IOUAmount or MPTAmount overload. XRP and MPT
* results are exact; an IOU result is rounded to the 16-digit mantissa in the
* requested direction. Only Upward and Downward are supported.
*/
[[nodiscard]] STAmount
mulRatio(STAmount const& amt, std::uint32_t num, std::uint32_t den, Number::RoundingMode rounding);
// Someone is offering X for Y, what is the rate?
// Rate: smaller is better, the taker wants the most out: in/out
// VFALCO TODO Return a Quality object
@@ -417,6 +417,7 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({
{sfAuditorEncryptionKey, SoeOptional},
{sfIssuerKeyEpoch, SoeOptional},
{sfAuditorKeyEpoch, SoeOptional},
{sfInitialIssuerEncryptionKey, SoeOptional},
{sfConfidentialOutstandingAmount, SoeDefault},
}))
@@ -438,6 +439,7 @@ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({
{sfAuditorEncryptedBalance, SoeOptional},
{sfIssuerKeyMirrorEpoch, SoeOptional},
{sfAuditorKeyMirrorEpoch, SoeOptional},
{sfIssuerMirrorEncryptionKey, SoeOptional},
{sfHolderEncryptionKey, SoeOptional},
{sfRecoveryKey, SoeOptional},
}))
@@ -333,6 +333,8 @@ TYPED_SFIELD(sfAmountCommitment, VL, 45)
TYPED_SFIELD(sfBalanceCommitment, VL, 46)
TYPED_SFIELD(sfBytecode, VL, 47)
TYPED_SFIELD(sfRecoveryKey, VL, 48)
TYPED_SFIELD(sfInitialIssuerEncryptionKey, VL, 49)
TYPED_SFIELD(sfIssuerMirrorEncryptionKey, VL, 50)
// account (common)
TYPED_SFIELD(sfAccount, ACCOUNT, 1)
+3
View File
@@ -252,6 +252,9 @@ JSS(expected_date); // out: any (warnings)
JSS(expected_date_UTC); // out: any (warnings)
JSS(expected_ledger_size); // out: TxQ
JSS(expiration); // out: AccountOffers, AccountChannels, ValidatorList, amm_info
JSS(gas_limit); // out: NetworkOPs
JSS(bytecode_size_limit); // out: NetworkOPs
JSS(gas_price); // out: NetworkOPs
JSS(fail_hard); // in: Sign, Submit
JSS(failed); // out: InboundLedger
JSS(feature); // in: Feature
@@ -316,6 +316,30 @@ public:
return this->sle_->isFieldPresent(sfAuditorKeyMirrorEpoch);
}
/**
* @brief Get sfIssuerMirrorEncryptionKey (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_VL::type::value_type>
getIssuerMirrorEncryptionKey() const
{
if (hasIssuerMirrorEncryptionKey())
return this->sle_->at(sfIssuerMirrorEncryptionKey);
return std::nullopt;
}
/**
* @brief Check if sfIssuerMirrorEncryptionKey is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasIssuerMirrorEncryptionKey() const
{
return this->sle_->isFieldPresent(sfIssuerMirrorEncryptionKey);
}
/**
* @brief Get sfHolderEncryptionKey (SoeOptional)
* @return The field value, or std::nullopt if not present.
@@ -565,6 +589,17 @@ public:
return *this;
}
/**
* @brief Set sfIssuerMirrorEncryptionKey (SoeOptional)
* @return Reference to this builder for method chaining.
*/
MPTokenBuilder&
setIssuerMirrorEncryptionKey(std::decay_t<typename SF_VL::type::value_type> const& value)
{
object_[sfIssuerMirrorEncryptionKey] = value;
return *this;
}
/**
* @brief Set sfHolderEncryptionKey (SoeOptional)
* @return Reference to this builder for method chaining.
@@ -399,6 +399,30 @@ public:
return this->sle_->isFieldPresent(sfAuditorKeyEpoch);
}
/**
* @brief Get sfInitialIssuerEncryptionKey (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_VL::type::value_type>
getInitialIssuerEncryptionKey() const
{
if (hasInitialIssuerEncryptionKey())
return this->sle_->at(sfInitialIssuerEncryptionKey);
return std::nullopt;
}
/**
* @brief Check if sfInitialIssuerEncryptionKey is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasInitialIssuerEncryptionKey() const
{
return this->sle_->isFieldPresent(sfInitialIssuerEncryptionKey);
}
/**
* @brief Get sfConfidentialOutstandingAmount (SoeDefault)
* @return The field value, or std::nullopt if not present.
@@ -670,6 +694,17 @@ public:
return *this;
}
/**
* @brief Set sfInitialIssuerEncryptionKey (SoeOptional)
* @return Reference to this builder for method chaining.
*/
MPTokenIssuanceBuilder&
setInitialIssuerEncryptionKey(std::decay_t<typename SF_VL::type::value_type> const& value)
{
object_[sfInitialIssuerEncryptionKey] = value;
return *this;
}
/**
* @brief Set sfConfidentialOutstandingAmount (SoeDefault)
* @return Reference to this builder for method chaining.
+263 -35
View File
@@ -2,6 +2,7 @@
#include <xrpl/basics/Blob.h>
#include <xrpl/basics/IntrusivePointer.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/SHAMapHash.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Journal.h>
@@ -17,6 +18,7 @@
#include <xrpl/shamap/SHAMapNodeID.h>
#include <xrpl/shamap/SHAMapTreeNode.h>
#include <atomic>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
@@ -40,7 +42,7 @@ class SHAMapSyncFilter;
/**
* Describes the current state of a given SHAMap
*/
enum class SHAMapState {
enum class SHAMapState : std::uint8_t {
/**
* The map is in flux and objects can be added and removed.
*
@@ -120,16 +122,42 @@ private:
*/
std::uint32_t cowid_ = 1;
// ledgerSeq_, state_ and full_ are touched on the nodestore fetch path and on a
// getMissingNodes() walk, neither of which may block, so pin them lock-free.
static_assert(std::atomic<std::uint32_t>::is_always_lock_free);
static_assert(std::atomic<SHAMapState>::is_always_lock_free);
static_assert(std::atomic<bool>::is_always_lock_free);
/**
* The sequence of the ledger that this map references, if any.
*
* Written when a map's ledger sequence is established (Ledger::setFull(),
* InboundLedger) while a nodestore reader thread reads it. Relaxed either
* way: it serves as a lookup hint for a nodestore keyed by hash.
*/
std::uint32_t ledgerSeq_ = 0;
std::atomic<std::uint32_t> ledgerSeq_ = 0;
SHAMapTreeNodePtr root_;
mutable SHAMapState state_;
/**
* The map's state.
*
* A getMissingNodes() walk writes it, through setInvalid() and
* clearSynching(), while whatever drives the acquisition reads it.
* The walk runs with the acquisition's lock released, so this is atomic
* rather than guarded.
*/
mutable std::atomic<SHAMapState> state_;
SHAMapType const type_;
bool backed_ = true; // Map is backed by the database
mutable bool full_ = false; // Map is believed complete in database
bool backed_ = true; // Map is backed by the database
/**
* Map is believed complete in database.
*
* Atomic because finishFetch() clears it on any nodestore reader thread,
* several of which can run at once.
*/
mutable std::atomic<bool> full_ = false;
public:
/**
@@ -152,7 +180,14 @@ public:
SHAMap&
operator=(SHAMap const&) = delete;
// Take a snapshot of the given map:
/**
* Take a snapshot of the given map.
*
* @param other The map to snapshot. An Invalid source yields an Invalid
* snapshot.
* @param isMutable Whether the snapshot may be modified. Ignored when other
* is Invalid.
*/
SHAMap(SHAMap const& other, bool isMutable);
// build new map
@@ -190,17 +225,33 @@ public:
//--------------------------------------------------------------------------
// Returns a new map that's a snapshot of this one.
// Handles copy on write for mutable snapshots.
/**
* Return a new map that is a snapshot of this one.
*
* Handles copy on write for mutable snapshots. An invalid map yields an
* invalid snapshot.
*
* @param isMutable Whether the snapshot may be modified.
* @return The snapshot.
*/
std::shared_ptr<SHAMap>
snapShot(bool isMutable) const;
/* Mark this SHAMap as "should be full", indicating
that the local server wants all the corresponding nodes
in durable storage.
*/
/**
* Mark this map as "should be full": the local server wants every node
* it references in durable storage, so a nodestore miss is a gap to report.
*
* Publishes the ledger sequence before the flag. The sequence store is
* relaxed and the flag store is release, and finishFetch() reads the
* sequence only after acquiring the flag, so the one miss that withdraws
* the flag always reports the sequence stored here. Keeping both stores in
* one place is what makes that order impossible to get wrong.
*
* @param ledgerSeq Sequence of the ledger this map belongs to, which the
* gap report names.
*/
void
setFull();
setFull(std::uint32_t ledgerSeq);
void
setLedgerSeq(std::uint32_t lseq);
@@ -261,12 +312,17 @@ public:
lowerBound(UInt256 const& id) const;
/**
* Visit every node in this SHAMap
* Visit every node in this SHAMap.
*
* @param function called with every node visited.
* If function returns false, visitNodes exits.
* The walk ends at the first node it cannot read, so a caller that needs a
* complete walk to be correct must check the return value.
*
* @param function Called with every node visited. Returning false from it
* ends the walk, which on its own keeps the result true.
* @return True when the walk read every node it reached. False when a node
* could not be read.
*/
void
bool
visitNodes(std::function<bool(SHAMapTreeNode&)> const& function) const;
/**
@@ -280,11 +336,12 @@ public:
visitDifferences(SHAMap const* have, std::function<bool(SHAMapTreeNode const&)> const&) const;
/**
* Visit every leaf node in this SHAMap
* Visit every leaf node in this SHAMap.
*
* @param function called with every non inner node visited.
* @param function Called with every non-inner node visited.
* @return What visitNodes reports about the walk.
*/
void
bool
visitLeaves(std::function<void(boost::intrusive_ptr<SHAMapItem const> const&)> const&) const;
// comparison/sync functions
@@ -296,9 +353,13 @@ public:
* concurrency, to discover nodes referenced in the
* SHAMap but not available locally.
*
* Only a leaf may occupy a position at or beyond kLeafDepth. A map that
* breaks that is marked Invalid and the traversal is abandoned, so callers
* ask isValid() to tell an empty result from a satisfied map.
*
* @param maxNodes The maximum number of found nodes to return
* @param filter The filter to use when retrieving nodes
* @param return The nodes known to be missing
* @return The nodes known to be missing, or empty if the map is Invalid
*/
std::vector<std::pair<SHAMapNodeID, UInt256>>
getMissingNodes(int maxNodes, SHAMapSyncFilter const* filter);
@@ -341,6 +402,9 @@ public:
* This function is used when receiving the root node of a SHAMap from a peer during ledger
* synchronization. The node must already have been deserialized.
*
* A root offered under a hash the map does not hold names another tree and
* is reported as invalid data. A root matching that hash is a duplicate.
*
* @param hash The expected hash of the root node.
* @param rootNode A deserialized root node to add.
* @param filter Optional sync filter to track received nodes.
@@ -360,6 +424,10 @@ public:
* is inserted at the position specified by nodeID. The node must already have been
* deserialized.
*
* A node that no valid tree can hold makes the map Invalid, which is
* terminal: the root hash committed to an impossible shape, so no peer
* can satisfy it.
*
* @param nodeID The position in the tree where this node belongs.
* @param treeNode A deserialized tree node to add.
* @param filter Optional sync filter to track received nodes.
@@ -375,16 +443,57 @@ public:
SHAMapTreeNodePtr treeNode,
SHAMapSyncFilter const* filter);
// status functions
void
/**
* Mark this map as immutable, so it can no longer be modified.
*
* @return false if the map is Invalid and was left unchanged, true
* otherwise.
*/
[[nodiscard]] bool
setImmutable();
bool
/**
* @return Whether the map is being synced against a hash it was given, so
* its hash is fixed while nodes may still be added.
*/
[[nodiscard]] bool
isSynching() const;
/**
* @return Whether the map is settled, with its hash and nodes fixed.
*/
[[nodiscard]] bool
isImmutable() const;
/**
* Mark this map as syncing, fixing its hash while still allowing missing
* nodes to be added.
*
* The map must be freshly constructed. The body asserts that the map is not
* Invalid, so a caller that ignores the precondition stops a build with
* assertions enabled.
*/
void
setSynching();
/**
* Mark this map as no longer syncing, so it can be modified again.
*
* Moves only a Synching map. An Immutable map stays settled, a Modifying
* map has nothing to clear, and Invalid is terminal.
*/
void
clearSynching();
bool
/**
* Whether the map can still be the map it claims to be.
*
* A map that is merely missing nodes is valid, and stays valid until
* something proves the tree it is syncing against cannot exist.
*
* @return Whether the map has not been proven impossible.
*/
[[nodiscard]] bool
isValid() const;
// caution: otherMap must be accessed only by this function
@@ -524,6 +633,51 @@ private:
using DeltaRef =
std::pair<boost::intrusive_ptr<SHAMapItem const>, boost::intrusive_ptr<SHAMapItem const>>;
/**
* The sequence of the ledger this map references, read atomically.
*
* @return The sequence, or zero if the map references no ledger.
*/
[[nodiscard]] std::uint32_t
ledgerSeq() const;
/**
* The current state, read atomically.
*
* Orders state_ alone. The tree's nodes carry no ordering guarantees.
*
* @return The state as of the call, which a concurrent walk may already
* have moved past.
*/
[[nodiscard]] SHAMapState
state() const;
/**
* Record that the map is provably not the one it claims to be.
*
* Private because only the map itself can prove that, from a node that
* contradicts the hashes it is syncing against. Cannot fail, since
* Invalid outranks every other state; see trySetState().
*/
void
setInvalid();
/**
* Move the map to a new state, atomically.
*
* With clearSynching(), the only writer of state_ past construction, so
* the order between the states lives in one place: Invalid outranks all
* of them and is always stored, while every other transition is refused
* once the map is Invalid, which is what makes that verdict terminal.
* clearSynching() is narrower and moves only Synching.
*
* @param desired The state to move to.
* @return false if the map is Invalid and the requested state is not,
* leaving it unchanged; true otherwise.
*/
bool
trySetState(SHAMapState desired);
// tree node cache operations
SHAMapTreeNodePtr
cacheLookup(SHAMapHash const& hash) const;
@@ -739,46 +893,120 @@ private:
};
inline void
SHAMap::setFull()
SHAMap::setFull(std::uint32_t ledgerSeq)
{
full_ = true;
ledgerSeq_.store(ledgerSeq, std::memory_order_relaxed);
full_.store(true, std::memory_order_release);
}
inline void
SHAMap::setLedgerSeq(std::uint32_t lseq)
{
ledgerSeq_ = lseq;
ledgerSeq_.store(lseq, std::memory_order_relaxed);
}
inline void
inline std::uint32_t
SHAMap::ledgerSeq() const
{
return ledgerSeq_.load(std::memory_order_relaxed);
}
inline SHAMapState
SHAMap::state() const
{
return state_.load(std::memory_order_acquire);
}
inline bool
SHAMap::trySetState(SHAMapState desired)
{
// Invalid is stored outright: a walk reaching that verdict has to win against a thread
// settling the map.
if (desired == SHAMapState::Invalid)
{
state_.store(SHAMapState::Invalid, std::memory_order_release);
return true;
}
// A failed exchange both reports the state and refreshes expected, so no load is needed
// ahead of the loop.
auto expected = SHAMapState::Modifying;
while (expected != SHAMapState::Invalid)
{
if (state_.compare_exchange_weak(
expected, desired, std::memory_order_acq_rel, std::memory_order_acquire))
{
return true;
}
}
return false;
}
inline bool
SHAMap::setImmutable()
{
XRPL_ASSERT(state_ != SHAMapState::Invalid, "xrpl::SHAMap::setImmutable : state is valid");
state_ = SHAMapState::Immutable;
SOMETIMES(!isValid(), "xrpl::SHAMap::setImmutable : map is invalid");
return trySetState(SHAMapState::Immutable);
}
inline bool
SHAMap::isSynching() const
{
return state_ == SHAMapState::Synching;
return state() == SHAMapState::Synching;
}
inline bool
SHAMap::isImmutable() const
{
return state() == SHAMapState::Immutable;
}
inline void
SHAMap::setSynching()
{
state_ = SHAMapState::Synching;
// Guarded, so this is not a way out of Invalid.
if (!trySetState(SHAMapState::Synching))
{
// Only ever called on a freshly constructed map.
// LCOV_EXCL_START
UNREACHABLE("xrpl::SHAMap::setSynching : map is invalid");
// LCOV_EXCL_STOP
}
}
inline void
SHAMap::clearSynching()
{
state_ = SHAMapState::Modifying;
// Only Synching moves. A walk can end after the ledger settled, and then the map stays
// Immutable; a Modifying map has nothing to clear; an invalid map stays invalid. Only that
// last refusal is logged, since a concurrent walk is what produces it.
auto expected = SHAMapState::Synching;
if (state_.compare_exchange_strong(
expected, SHAMapState::Modifying, std::memory_order_acq_rel, std::memory_order_acquire))
{
return;
}
bool const invalid = expected == SHAMapState::Invalid;
SOMETIMES(invalid, "xrpl::SHAMap::clearSynching : map is invalid");
if (invalid)
{
JLOG(journal_.warn()) << "Refused to clear synching on an invalid map, root hash "
<< root_->getHash();
}
}
inline bool
SHAMap::isValid() const
{
return state_ != SHAMapState::Invalid;
return state() != SHAMapState::Invalid;
}
inline void
SHAMap::setInvalid()
{
// Through trySetState() like every other transition, so state_ has one writer funnel.
trySetState(SHAMapState::Invalid);
}
inline void
+15 -3
View File
@@ -31,7 +31,19 @@ private:
*/
TaggedPointer hashesAndChildren_;
std::uint32_t fullBelowGen_ = 0;
// Pin that wrapping fullBelowGen_ in an atomic keeps the packed layout, and that the load
// isFullBelow() does once per node of every walk stays lock-free.
static_assert(std::atomic<std::uint32_t>::is_always_lock_free);
static_assert(sizeof(std::atomic<std::uint32_t>) == sizeof(std::uint32_t));
static_assert(alignof(std::atomic<std::uint32_t>) == alignof(std::uint32_t));
/**
* Written from more than one thread, since canonicalization shares a node
* between maps and a walk can run with the acquisition lock released (see
* SHAMap::state_). Relaxed both ways, since a generation is only compared
* for equality and the children it vouches for are published through lock_.
*/
std::atomic<std::uint32_t> fullBelowGen_ = 0;
std::uint16_t isBranch_ = 0;
/**
@@ -204,13 +216,13 @@ SHAMapInnerNode::getBranchCount() const
inline bool
SHAMapInnerNode::isFullBelow(std::uint32_t generation) const
{
return fullBelowGen_ == generation;
return fullBelowGen_.load(std::memory_order_relaxed) == generation;
}
inline void
SHAMapInnerNode::setFullBelowGen(std::uint32_t gen)
{
fullBelowGen_ = gen;
fullBelowGen_.store(gen, std::memory_order_relaxed);
}
} // namespace xrpl
@@ -5,6 +5,7 @@
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
@@ -45,9 +46,29 @@ public:
SLE::Ref loanSle,
SLE::Ref brokerSle,
SLE::Ref vaultSle,
Asset const& vaultAsset,
beast::Journal j);
/**
* @brief Compute the First-Loss Capital cover amount for a defaulted Loan on a FixedPrecision
* Vault, capped at the LoanBroker's CoverAvailable and floored so that AssetsAvailable + cover
* is exactly on its posterior grid.
*
* The LoanBroker absorbs the rounding. When CoverAvailable - cover needs finer digits than the
* broker's posterior grid, it rounds to nearest, as the broker's trust line does, so
* CoverAvailable can move by up to half a posterior ulp more or less than the cover. When the
* two grids differ, no cover is exact on both sides; the Vault side is kept exact.
*
* The one exception: when AssetsAvailable has digits finer than the 16-digit cover can carry,
* no cover cancels them, and the balance writer rounds AssetsAvailable + cover to nearest,
* by at most half a posterior ulp. VaultDeposit and LoanPay credits have the same limit.
* @param loanSle The Loan being defaulted.
* @param brokerSle The Loan's LoanBroker.
* @param vaultSle The LoanBroker's Vault.
* @return The cover amount, in the Vault's Asset.
*/
static STAmount
calculateDefaultCover(SLE::Ref loanSle, SLE::Ref brokerSle, SLE::Ref vaultSle);
/**
* Helper function that might be needed by other transactors
*/
@@ -52,6 +52,13 @@ private:
TER
applyFee();
/**
* Write the default Feature Extension fee settings into FeeSettings on
* `featureSmartEscrow` activation.
*/
void
initializeVMFees();
TER
applyUNLModify();
};
+92 -14
View File
@@ -200,11 +200,23 @@ Ledger::Ledger(
sle->at(sfReserveIncrement) = *f;
sle->at(sfReferenceFeeUnits) = kFeeUnitsDeprecated;
}
if (std::ranges::find(amendments, featureSmartEscrow) != amendments.end())
{
sle->at(sfGasLimit) = fees.gasLimit;
sle->at(sfBytecodeSizeLimit) = fees.bytecodeSizeLimit;
sle->at(sfGasPrice) = fees.gasPrice;
}
rawInsert(sle);
}
stateMap_.flushDirty(NodeObjectType::AccountNode);
setImmutable();
// Built locally. See Ledger::setImmutable().
if (!setImmutable())
{
// LCOV_EXCL_START
logicError("Ledger::Ledger(CreateGenesisT, ...): genesis ledger map is invalid");
// LCOV_EXCL_STOP
}
}
Ledger::Ledger(
@@ -215,7 +227,7 @@ Ledger::Ledger(
Fees const& fees,
Family& family,
beast::Journal j)
: immutable_(true)
: immutable_(false)
, txMap_(SHAMapType::TRANSACTION, info.txHash, family)
, stateMap_(SHAMapType::STATE, info.accountHash, family)
, fees_(fees)
@@ -238,8 +250,20 @@ Ledger::Ledger(
JLOG(j.warn()) << "Don't have state data root for ledger" << header_.seq;
}
txMap_.setImmutable();
stateMap_.setImmutable();
// Loaded locally. See Ledger::setImmutable().
if (setMapsImmutable())
{
immutable_ = true;
}
else
{
// LCOV_EXCL_START
JLOG(j.error()) << "Invalid map for ledger " << header_.seq;
UNREACHABLE("xrpl::Ledger::Ledger(LedgerHeader const&, ...) : map is invalid");
// Treat it as a damaged ledger: the code below recomputes the hash and re-acquires.
loaded = false;
// LCOV_EXCL_STOP
}
if (!setup())
loaded = false;
@@ -310,27 +334,50 @@ Ledger::Ledger(
setup();
}
void
bool
Ledger::setImmutable(bool rehash)
{
// Force update, since this is the only
// place the hash transitions to valid
if (!immutable_ && rehash)
// A map found structurally invalid during sync must never be made immutable: isValid() tests
// only for Invalid, and an immutable ledger is treated as persistable. Asked before anything is
// written, so a refusal leaves the header exactly as it was rather than half relabeled.
if (!mapsValid())
return false;
// Read here but written to the header below, once the maps are settled: getHash() can unshare a
// dirty tree, so it has to run while the map is still mutable, while a write to the header must
// wait until both maps have made it. Skipped once the ledger is immutable, since its maps can
// no longer change.
bool const deriveMapHashes = !immutable_ && rehash;
UInt256 const txHash = deriveMapHashes ? txMap_.getHash().asUInt256() : UInt256{};
UInt256 const accountHash = deriveMapHashes ? stateMap_.getHash().asUInt256() : UInt256{};
// Both were valid at the check above, but a concurrent walk can invalidate one in between (see
// SHAMap::state_), so the result is checked. setInvalid() outranks Immutable and can land
// after both maps have been settled, so this narrows the window it guards.
bool const bothImmutable = setMapsImmutable();
SOMETIMES(!bothImmutable, "xrpl::Ledger::setImmutable : map invalidated while going immutable");
if (!bothImmutable)
return false;
// Written only now, so losing the race above leaves the header describing what the ledger was
// built from rather than a map that has since been abandoned. Forced rather than conditional,
// since this is the only place the hash transitions to valid.
if (deriveMapHashes)
{
header_.txHash = txMap_.getHash().asUInt256();
header_.accountHash = stateMap_.getHash().asUInt256();
header_.txHash = txHash;
header_.accountHash = accountHash;
}
if (rehash)
header_.hash = calculateLedgerHash(header_);
// Set last, so isImmutable() reports only a ledger whose maps are both immutable.
immutable_ = true;
txMap_.setImmutable();
stateMap_.setImmutable();
setup();
return true;
}
void
bool
Ledger::setAccepted(
NetClock::time_point closeTime,
NetClock::duration closeResolution,
@@ -342,7 +389,17 @@ Ledger::setAccepted(
header_.closeTime = closeTime;
header_.closeTimeResolution = closeResolution;
header_.closeFlags = correctCloseTime ? 0 : kSLcfNoConsensusTime;
setImmutable();
// Built locally. See Ledger::setImmutable().
if (!setImmutable())
{
// LCOV_EXCL_START
JLOG(j_.error()) << "Invalid map for accepted ledger " << header_.seq;
UNREACHABLE("xrpl::Ledger::setAccepted : map is invalid");
return false;
// LCOV_EXCL_STOP
}
return true;
}
bool
@@ -566,6 +623,7 @@ Ledger::setup()
{
bool oldFees = false;
bool newFees = false;
bool extensionFees = false;
{
auto const baseFee = sle->at(~sfBaseFee);
auto const reserveBase = sle->at(~sfReserveBase);
@@ -600,6 +658,20 @@ Ledger::setup()
assign(fees_.increment, reserveIncrementXRP);
newFees = baseFeeXRP || reserveBaseXRP || reserveIncrementXRP;
}
{
auto const gasLimit = sle->at(~sfGasLimit);
auto const bytecodeSizeLimit = sle->at(~sfBytecodeSizeLimit);
auto const gasPrice = sle->at(~sfGasPrice);
// An absent field resolves to the protocol constant.
if (rules_.enabled(featureSmartEscrow))
{
fees_.gasLimit = gasLimit.value_or(kDefaultGasLimit);
fees_.bytecodeSizeLimit = bytecodeSizeLimit.value_or(kDefaultBytecodeSizeLimit);
fees_.gasPrice = gasPrice.value_or(kDefaultGasPrice);
}
extensionFees = gasLimit || bytecodeSizeLimit || gasPrice;
}
if (oldFees && newFees)
{
// Should be all of one or the other, but not both
@@ -610,6 +682,12 @@ Ledger::setup()
// Can't populate the new fees before the amendment is enabled
ret = false;
}
if (!rules_.enabled(featureSmartEscrow) && extensionFees)
{
// Can't populate the extension fees before the amendment is
// enabled
ret = false;
}
}
}
catch (SHAMapMissingNode const&)
+29 -7
View File
@@ -511,9 +511,14 @@ doWithdraw(
AccountID const& dstAcct,
AccountID const& sourceAcct,
XRPAmount priorBalance,
STAmount const& amount,
STAmount const& destinationAmount,
STAmount const& sourceAmount,
beast::Journal j)
{
XRPL_ASSERT(
destinationAmount.asset() == sourceAmount.asset(),
"xrpl::doWithdraw : delivered and source amounts use the same asset");
auto const dstSle = ctx.view.read(keylet::account(dstAcct));
// Create a trust line or MPToken for a self-destination only when there
@@ -523,9 +528,10 @@ doWithdraw(
// create+delete MPTokens in the same transaction.
if (dstAcct == senderAcct)
{
if (amount > beast::kZero || !ctx.view.rules().enabled(fixCleanup3_4_0))
if (destinationAmount > beast::kZero || !ctx.view.rules().enabled(fixCleanup3_4_0))
{
if (auto const ter = addEmptyHolding(ctx, senderAcct, priorBalance, amount.asset(), j);
if (auto const ter =
addEmptyHolding(ctx, senderAcct, priorBalance, destinationAmount.asset(), j);
!isTesSuccess(ter) && ter != tecDUPLICATE)
return ter;
}
@@ -540,17 +546,33 @@ doWithdraw(
if (accountHolds(
ctx.view,
sourceAcct,
amount.asset(),
destinationAmount.asset(),
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j) < amount)
j) < sourceAmount)
{
// LCOV_EXCL_START
JLOG(j.error()) << "doWithdraw: negative balance of broker cover assets.";
JLOG(j.error()) << "doWithdraw: source account holds fewer assets than the withdrawal.";
return tefINTERNAL;
// LCOV_EXCL_STOP
}
// A transfer fee applies. Redeem the gross amount with the issuer, then
// issue the net amount to the destination.
if (sourceAmount != destinationAmount)
{
auto const issuer = destinationAmount.getIssuer();
XRPL_ASSERT(
!destinationAmount.native() && sourceAcct != issuer && dstAcct != issuer,
"xrpl::doWithdraw : transfer fee applies between token holders");
if (auto const ter = directSendNoFee(ctx.view, sourceAcct, issuer, sourceAmount, false, j);
!isTesSuccess(ter))
return ter; // LCOV_EXCL_LINE
return directSendNoFee(ctx.view, issuer, dstAcct, destinationAmount, false, j);
}
// A reserve sponsor only covers tx.Account's own objects, so resolve the
// sponsor against the destination. accountSend can auto-create a holding
// for dstAcct; keying on the destination ensures a third-party destination's
@@ -562,7 +584,7 @@ doWithdraw(
// Move the funds directly from the broker's pseudo-account to the
// dstAcct
return accountSend(
ctx.view, sourceAcct, dstAcct, amount, j, *sponsorSle, WaiveTransferFee::Yes);
ctx.view, sourceAcct, dstAcct, destinationAmount, j, *sponsorSle, WaiveTransferFee::Yes);
}
TER
@@ -130,8 +130,8 @@ creditToPosteriorBrokerCoverScale(
}
Number const reference = broker->at(sfCoverAvailable);
int const scale = detail::getPosteriorBrokerCoverScale(vault, broker, raw);
return detail::creditToPosteriorScale(asset, reference, scale, raw, roundingMode);
return detail::creditToPosteriorScale(
asset, reference, getVaultBaseScale(vault), raw, roundingMode);
}
[[nodiscard]] STAmount
+21 -6
View File
@@ -2,6 +2,7 @@
#include <xrpl/basics/Log.h>
#include <xrpl/basics/MathUtilities.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
@@ -551,6 +552,22 @@ transferRate(ReadView const& view, STAmount const& amount)
return transferRate(view, amount.asset());
}
STAmount
addTransferFee(STAmount const& amount, Rate const& rate)
{
if (rate == kParityRate)
return amount;
return mulRatio(amount, rate.value, QUALITY_ONE, Number::RoundingMode::Upward);
}
STAmount
subtractTransferFee(STAmount const& amount, Rate const& rate)
{
if (rate == kParityRate)
return amount;
return mulRatio(amount, QUALITY_ONE, rate.value, Number::RoundingMode::Downward);
}
//------------------------------------------------------------------------------
//
// Holding operations
@@ -1347,9 +1364,8 @@ directSendNoLimitMPT(
{
// Number math loses precision on large MPT amounts, which can
// overcharge the sender. MPTs are integral, so compute the cost
// exactly and round it up, matching the payment engine.
auto const cost = mulRatio(saAmount.mpt(), rate.value, QUALITY_ONE, true);
saActual = STAmount(saAmount.asset(), cost.value());
// exactly, matching the payment engine.
saActual = addTransferFee(saAmount, rate);
}
else
{
@@ -1474,9 +1490,8 @@ directSendNoLimitMultiMPT(
{
// Number math loses precision on large MPT amounts, which can
// overcharge the sender. MPTs are integral, so compute the
// cost exactly and round it up, matching the payment engine.
auto const cost = mulRatio(amount.mpt(), rate.value, QUALITY_ONE, true);
actualSend = STAmount(amount.asset(), cost.value());
// cost exactly, matching the payment engine.
actualSend = addTransferFee(amount, rate);
}
else
{
+19 -7
View File
@@ -48,7 +48,7 @@ fixedBaseScale(SLE::ConstRef vault)
}
// Thin, vault-specific wrapper over posteriorAssetScale: used by
// getPosteriorVaultScale and creditToPosteriorAvailableScale.
// getPosteriorVaultScale and the cash outflow rounding below.
[[nodiscard]] int
posteriorScale(SLE::ConstRef vault, Number const& reference, Number const& delta)
{
@@ -195,11 +195,11 @@ posteriorAssetScale(
creditToPosteriorScale(
Asset const& asset,
Number const& reference,
int atScale,
int baseScale,
Number const& raw,
Number::RoundingMode roundingMode)
{
// Floor the SUM (reference + raw), not just raw, at atScale. See
// Floor the SUM (reference + raw), not just raw. See
// creditToPosteriorAvailableScale's doc: a delta floored on its own grid
// can still leave a 17-digit sum once the reference has crossed a power
// of ten.
@@ -208,9 +208,21 @@ creditToPosteriorScale(
// than Number holds (the sum is on the posterior grid, the reference on
// the finer one). Compute both under roundingMode, not the caller's
// ambient mode, so the credit this returns never exceeds raw.
//
// Take the grid from this same sum. A sum just under a power of ten that
// rounds up to it in another mode would pick the coarser grid, and
// flooring the unrounded sum on it would land below reference.
NumberRoundModeGuard const rg(roundingMode);
Number const flooredSum = roundToAsset(asset, reference + raw, atScale, roundingMode);
return STAmount{asset, flooredSum - reference};
Number const sum = reference + raw;
int const atScale = liveScale(sum, asset, baseScale);
Number const flooredSum = roundToAsset(asset, sum, atScale, roundingMode);
STAmount const credit{asset, flooredSum - reference};
XRPL_ASSERT_PARTS(
roundingMode != Number::RoundingMode::Downward ||
(credit >= beast::kZero && Number(credit) <= raw),
"xrpl::detail::creditToPosteriorScale",
"credit is between zero and raw");
return credit;
}
[[nodiscard]] int
@@ -399,8 +411,8 @@ creditToPosteriorAvailableScale(
}
Number const reference = vault->at(sfAssetsAvailable);
int const scale = posteriorScale(vault, reference, raw);
return detail::creditToPosteriorScale(asset, reference, scale, raw, roundingMode);
return detail::creditToPosteriorScale(
asset, reference, fixedBaseScale(vault), raw, roundingMode);
}
[[nodiscard]] Number
+12 -5
View File
@@ -435,18 +435,25 @@ areMirrorsCurrent(SLE const& issuance, SLE const& mptoken)
}
void
setIssuerMirrorEpoch(SLE const& issuance, SLE& mptoken)
setIssuerMirrorCurrent(SLE const& issuance, SLE& mptoken)
{
XRPL_ASSERT(
issuance.getType() == ltMPTOKEN_ISSUANCE,
"xrpl::setIssuerMirrorEpoch : issuance MPTokenIssuance object");
"xrpl::setIssuerMirrorCurrent : issuance MPTokenIssuance object");
XRPL_ASSERT(
mptoken.getType() == ltMPTOKEN, "xrpl::setIssuerMirrorEpoch : mptoken MPToken object");
mptoken.getType() == ltMPTOKEN, "xrpl::setIssuerMirrorCurrent : mptoken MPToken object");
// Unlike the auditor mirror, the issuer mirror is not optional: every
// confidential MPToken carries one, so there is no existence check here.
//
// Record the key and epoch under which the mirror is now encrypted. Both fields remain absent
// at epoch 0: such mirrors use the original issuer key. The original key is stored in
// sfInitialIssuerEncryptionKey after the first rotation.
if (auto const epoch = issuance[~sfIssuerKeyEpoch].value_or(0); epoch != 0)
{
mptoken[sfIssuerKeyMirrorEpoch] = epoch;
mptoken[sfIssuerMirrorEncryptionKey] = issuance[sfIssuerEncryptionKey];
}
}
void
@@ -466,9 +473,9 @@ setAuditorMirrorEpoch(SLE const& issuance, SLE& mptoken)
}
void
setMirrorEpochs(SLE const& issuance, SLE& mptoken)
setMirrorsCurrent(SLE const& issuance, SLE& mptoken)
{
setIssuerMirrorEpoch(issuance, mptoken);
setIssuerMirrorCurrent(issuance, mptoken);
setAuditorMirrorEpoch(issuance, mptoken);
}
+18
View File
@@ -1763,6 +1763,24 @@ divRoundStrict(STAmount const& num, STAmount const& den, Asset const& asset, boo
return divRoundImpl<NumberRoundModeGuard>(num, den, asset, roundUp);
}
STAmount
mulRatio(STAmount const& amt, std::uint32_t num, std::uint32_t den, Number::RoundingMode rounding)
{
XRPL_ASSERT(
rounding == Number::RoundingMode::Upward || rounding == Number::RoundingMode::Downward,
"xrpl::mulRatio(STAmount) : rounding is Upward or Downward");
bool const roundUp = rounding == Number::RoundingMode::Upward;
return amt.asset().visit(
[&](Issue const& issue) -> STAmount {
if (amt.native())
return mulRatio(amt.xrp(), num, den, roundUp);
return {mulRatio(amt.iou(), num, den, roundUp), issue};
},
[&](MPTIssue const& issue) -> STAmount {
return {mulRatio(amt.mpt(), num, den, roundUp), issue};
});
}
[[nodiscard]] bool
STAmount::isZeroAtScale(int scale) const
{
+33 -19
View File
@@ -26,6 +26,7 @@
#include <boost/smart_ptr/intrusive_ptr.hpp>
#include <atomic>
#include <cstdint>
#include <exception>
#include <functional>
@@ -77,14 +78,23 @@ SHAMap::SHAMap(SHAMap const& other, bool isMutable)
: f_(other.f_)
, journal_(other.f_.journal())
, cowid_(other.cowid_ + 1)
, ledgerSeq_(other.ledgerSeq_)
, ledgerSeq_(other.ledgerSeq())
, root_(other.root_)
, state_(isMutable ? SHAMapState::Modifying : SHAMapState::Immutable)
, type_(other.type_)
, backed_(other.backed_)
{
// A snapshot shares the source's root, so Invalid carries over. Read once into a local, since
// the source's state can change while this runs.
auto const otherState = other.state();
auto const ownState = [&] {
if (otherState == SHAMapState::Invalid)
return SHAMapState::Invalid;
return isMutable ? SHAMapState::Modifying : SHAMapState::Immutable;
}();
state_.store(ownState, std::memory_order_release);
// If either map may change, they cannot share nodes
if ((state_ != SHAMapState::Immutable) || (other.state_ != SHAMapState::Immutable))
if ((ownState != SHAMapState::Immutable) || (otherState != SHAMapState::Immutable))
{
unshare();
}
@@ -105,7 +115,7 @@ SHAMap::dirtyUp(NodePathStack& stack, UInt256 const& target, SHAMapTreeNodePtr c
// child can be an inner node or a leaf
XRPL_ASSERT(
(state_ != SHAMapState::Synching) && (state_ != SHAMapState::Immutable),
(state() != SHAMapState::Synching) && (state() != SHAMapState::Immutable),
"xrpl::SHAMap::dirtyUp : valid state");
XRPL_ASSERT(child && (child->cowid() == cowid_), "xrpl::SHAMap::dirtyUp : valid child input");
@@ -170,7 +180,7 @@ SHAMapTreeNodePtr
SHAMap::fetchNodeFromDB(SHAMapHash const& hash) const
{
XRPL_ASSERT(backed_, "xrpl::SHAMap::fetchNodeFromDB : is backed");
auto const obj = f_.db().fetchNodeObject(hash.asUInt256(), ledgerSeq_);
auto const obj = f_.db().fetchNodeObject(hash.asUInt256(), ledgerSeq());
return finishFetch(hash, obj);
}
@@ -183,10 +193,17 @@ SHAMap::finishFetch(SHAMapHash const& hash, std::shared_ptr<NodeObject> const& o
{
if (!object)
{
if (full_)
// A missing node disproves full_, so withdraw it and report the gap. The exchange
// makes exactly one reader report it, and its acquire pairs with the release store in
// setFull(). It needs no release: the only other reader of full_ is this same path,
// and a reader that loses the exchange does nothing with what the winner did. The load
// ahead of it is relaxed, since all it decides is whether to try the exchange: a stale
// true costs one exchange that loses, and a stale false means another reader won, or
// setFull() is not visible yet and the gap goes unreported.
if (full_.load(std::memory_order_relaxed) &&
full_.exchange(false, std::memory_order_acquire))
{
full_ = false;
f_.missingNodeAcquireBySeq(ledgerSeq_, hash.asUInt256());
f_.missingNodeAcquireBySeq(ledgerSeq(), hash.asUInt256());
}
return {};
}
@@ -219,7 +236,7 @@ SHAMap::checkFilter(SHAMapHash const& hash, SHAMapSyncFilter const* filter) cons
auto node = SHAMapTreeNode::makeFromPrefix(makeSlice(*nodeData), hash);
if (node)
{
filter->gotNode(true, hash, ledgerSeq_, std::move(*nodeData), node->getType());
filter->gotNode(true, hash, ledgerSeq(), std::move(*nodeData), node->getType());
if (backed_)
canonicalize(hash, node);
}
@@ -326,9 +343,6 @@ SHAMap::descend(SHAMapInnerNode& parent, unsigned int branch) const
return node;
node = fetchNode(parent.getChildHash(branch));
if (!node)
return {};
node = parent.canonicalizeChild(branch, std::move(node));
return node;
}
@@ -340,7 +354,7 @@ SHAMap::descendNoStore(SHAMapInnerNode& parent, unsigned int branch) const
{
SHAMapTreeNodePtr ret = parent.getChild(branch);
if (!ret && backed_)
ret = fetchNode(parent.getChildHash(branch));
ret = fetchNodeNT(parent.getChildHash(branch));
return ret;
}
@@ -399,7 +413,7 @@ SHAMap::descendAsync(
{
f_.db().asyncFetch(
hash.asUInt256(),
ledgerSeq_,
ledgerSeq(),
[this, hash, cb{std::move(callback)}](std::shared_ptr<NodeObject> const& object) {
auto const node = finishFetch(hash, object);
cb(node, hash);
@@ -424,7 +438,7 @@ SHAMap::unshareNode(intr_ptr::SharedPtr<Node> node, SHAMapNodeID const& nodeID)
if (node->cowid() != cowid_)
{
// have a CoW
XRPL_ASSERT(state_ != SHAMapState::Immutable, "xrpl::SHAMap::unshareNode : not immutable");
XRPL_ASSERT(state() != SHAMapState::Immutable, "xrpl::SHAMap::unshareNode : not immutable");
node = intr_ptr::staticPointerCast<Node>(node->clone(cowid_));
if (nodeID.isRoot())
root_ = node;
@@ -637,7 +651,7 @@ bool
SHAMap::delItem(UInt256 const& id)
{
// delete the item with this ID
XRPL_ASSERT(state_ != SHAMapState::Immutable, "xrpl::SHAMap::delItem : not immutable");
XRPL_ASSERT(state() != SHAMapState::Immutable, "xrpl::SHAMap::delItem : not immutable");
NodePathStack stack;
walkTowardsKey(id, &stack);
@@ -719,7 +733,7 @@ SHAMap::delItem(UInt256 const& id)
bool
SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr<SHAMapItem const> item)
{
XRPL_ASSERT(state_ != SHAMapState::Immutable, "xrpl::SHAMap::addGiveItem : not immutable");
XRPL_ASSERT(state() != SHAMapState::Immutable, "xrpl::SHAMap::addGiveItem : not immutable");
XRPL_ASSERT(type != SHAMapNodeType::TnInner, "xrpl::SHAMap::addGiveItem : valid type input");
// add the specified item, does not update
@@ -810,7 +824,7 @@ SHAMap::updateGiveItem(SHAMapNodeType type, boost::intrusive_ptr<SHAMapItem cons
// can't change the tag but can change the hash
UInt256 const tag = item->key();
XRPL_ASSERT(state_ != SHAMapState::Immutable, "xrpl::SHAMap::updateGiveItem : not immutable");
XRPL_ASSERT(state() != SHAMapState::Immutable, "xrpl::SHAMap::updateGiveItem : not immutable");
NodePathStack stack;
walkTowardsKey(tag, &stack);
@@ -901,7 +915,7 @@ SHAMap::writeNode(NodeObjectType t, SHAMapTreeNodePtr node) const
Serializer s;
node->serializeWithPrefix(s);
f_.db().store(t, std::move(s.modData()), node->getHash().asUInt256(), ledgerSeq_);
f_.db().store(t, std::move(s.modData()), node->getHash().asUInt256(), ledgerSeq());
return node;
}
+58 -14
View File
@@ -13,9 +13,11 @@
#include <boost/smart_ptr/intrusive_ptr.hpp>
#include <array>
#include <exception>
#include <mutex>
#include <sstream>
#include <stack>
#include <string>
#include <thread>
#include <utility>
#include <vector>
@@ -283,21 +285,36 @@ bool
SHAMap::walkMapParallel(std::vector<SHAMapMissingNode>& missingNodes, int maxMissing) const
{
if (!root_->isInner()) // root_ is only node, and we have it
return false;
return true;
// Only the nodes this call records count towards the result, so remember what the
// caller already had.
auto const initialMissing = missingNodes.size();
using StackEntry = intr_ptr::SharedPtr<SHAMapInnerNode>;
std::array<SHAMapTreeNodePtr, SHAMapInnerNode::kBranchFactor> topChildren;
{
// This loop runs before the workers start, so it needs no lock.
auto const& innerRoot = intr_ptr::staticPointerCast<SHAMapInnerNode>(root_);
for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i)
{
if (!innerRoot->isEmptyBranch(i))
topChildren[i] = descendNoStore(*innerRoot, i);
if (innerRoot->isEmptyBranch(i))
continue;
topChildren[i] = descendNoStore(*innerRoot, i);
if (!topChildren[i])
{
// A root child that cannot be read hides its whole subtree. Record it here,
// because the loop below skips a null child without visiting it.
missingNodes.emplace_back(type_, innerRoot->getChildHash(i));
if (--maxMissing <= 0)
return false;
}
}
}
std::vector<std::thread> workers;
workers.reserve(SHAMapInnerNode::kBranchFactor);
std::vector<SHAMapMissingNode> exceptions;
std::vector<std::string> exceptions;
exceptions.reserve(SHAMapInnerNode::kBranchFactor);
std::array<std::stack<StackEntry, std::vector<StackEntry>>, SHAMapInnerNode::kBranchFactor>
@@ -346,6 +363,14 @@ SHAMap::walkMapParallel(std::vector<SHAMapMissingNode>& missingNodes, int maxMis
else
{
std::scoped_lock const l{m};
// Another worker may have spent the budget while this one
// was walking, so test it before adding to the list and not
// only after. Testing only after lets every worker still
// running add one more node than the caller asked for.
if (maxMissing <= 0)
return;
missingNodes.emplace_back(type_, node->getChildHash(i));
if (--maxMissing <= 0)
return;
@@ -353,10 +378,14 @@ SHAMap::walkMapParallel(std::vector<SHAMapMissingNode>& missingNodes, int maxMis
}
}
}
catch (SHAMapMissingNode const& e)
catch (std::exception const& e)
{
// LCOV_EXCL_START
// A worker must not let an exception leave its thread, so record it
// and let the join below report it.
std::scoped_lock const l(m);
exceptions.push_back(e);
exceptions.emplace_back(e.what());
// LCOV_EXCL_STOP
}
},
std::move(nodeStacks[rootChildIndex]));
@@ -366,14 +395,29 @@ SHAMap::walkMapParallel(std::vector<SHAMapMissingNode>& missingNodes, int maxMis
worker.join();
std::scoped_lock const l(m);
if (exceptions.empty())
return true;
std::stringstream ss;
ss << "Exception(s) in ledger load: ";
for (auto const& e : exceptions)
ss << e.what() << ", ";
JLOG(journal_.error()) << ss.str();
return false;
if (!exceptions.empty())
{
// LCOV_EXCL_START
std::stringstream ss;
ss << "Exception(s) in ledger load: ";
for (auto const& e : exceptions)
ss << e << ", ";
JLOG(journal_.error()) << ss.str();
return false;
// LCOV_EXCL_STOP
}
// A node the workers could not read is recorded in `missingNodes` rather than thrown,
// so the result has to consult it too.
auto const found = missingNodes.size() - initialMissing;
if (found != 0)
{
JLOG(journal_.error()) << "Missing node(s) in ledger load: " << found
<< ", first: " << missingNodes[initialMissing].what();
return false;
}
return true;
}
} // namespace xrpl
+4 -1
View File
@@ -16,6 +16,7 @@
#include <xrpl/shamap/detail/TaggedPointer.h>
#include <xrpl/shamap/detail/TaggedPointer.ipp>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <mutex>
@@ -77,7 +78,9 @@ SHAMapInnerNode::clone(std::uint32_t cowid) const
auto p = intr_ptr::makeShared<SHAMapInnerNode>(cowid, branchCount);
p->hash_ = hash_;
p->isBranch_ = isBranch_;
p->fullBelowGen_ = fullBelowGen_;
// Relaxed, as everywhere. p is not reachable by another thread until this returns.
p->fullBelowGen_.store(
fullBelowGen_.load(std::memory_order_relaxed), std::memory_order_relaxed);
SHAMapHash* cloneHashes = nullptr;
SHAMapHash* thisHashes = nullptr;
SHAMapTreeNodePtr* cloneChildren = nullptr;
+138 -47
View File
@@ -31,28 +31,55 @@
namespace xrpl {
void
namespace {
/**
* Whether a depth is at or past the deepest an inner node may occupy.
*
* Nibbles run out at SHAMap::kLeafDepth, so only a leaf may sit there. True for
* every deeper position too, which lies past the end of a key.
*
* @param depth The depth to judge.
* @return Whether an inner node at that depth makes the map impossible.
*/
[[nodiscard]] bool
isLeafDepth(unsigned int depth)
{
return depth >= SHAMap::kLeafDepth;
}
} // namespace
bool
SHAMap::visitLeaves(
std::function<void(boost::intrusive_ptr<SHAMapItem const> const& item)> const& leafFunction)
const
{
visitNodes([&leafFunction](SHAMapTreeNode& node) {
return visitNodes([&leafFunction](SHAMapTreeNode& node) {
if (!node.isInner())
leafFunction(safeDowncast<SHAMapLeafNode&>(node).peekItem());
return true;
});
}
void
bool
SHAMap::visitNodes(std::function<bool(SHAMapTreeNode&)> const& function) const
{
if (!root_)
return;
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::SHAMap::visitNodes : non-null root");
return true;
// LCOV_EXCL_STOP
}
function(*root_);
// The root is readable by definition, so a stop here still reports a complete walk.
if (!function(*root_))
return true;
// A map whose root is a leaf holds one node, and this walk just visited it.
if (!root_->isInner())
return;
return true;
using StackEntry = std::pair<unsigned int, intr_ptr::SharedPtr<SHAMapInnerNode>>;
std::stack<StackEntry, std::vector<StackEntry>> stack;
@@ -67,8 +94,17 @@ SHAMap::visitNodes(std::function<bool(SHAMapTreeNode&)> const& function) const
if (!node->isEmptyBranch(pos))
{
SHAMapTreeNodePtr const child = descendNoStore(*node, pos);
if (!child)
{
// No caller can use a partial walk, so end it here rather than pay
// for the rest of the map. The callers that act on the result log
// it at a level an operator reads, so keep this one at debug.
JLOG(journal_.debug())
<< "visitNodes: unreadable child node " << node->getChildHash(pos);
return false;
}
if (!function(*child))
return;
return true;
if (child->isLeaf())
{
@@ -103,6 +139,8 @@ SHAMap::visitNodes(std::function<bool(SHAMapTreeNode&)> const& function) const
std::tie(pos, node) = stack.top();
stack.pop();
}
return true;
}
void
@@ -143,13 +181,10 @@ SHAMap::visitDifferences(
if (!function(*node))
return;
// Nibbles run out at kLeafDepth, so only a leaf belongs there. A well-formed map never
// holds an inner node at that depth: addKnownNode marks the map invalid rather than hooking
// one in, and fetch-pack data is hash-verified against a validated root, so reaching this
// means a defect or a corrupt store, not something a peer can provoke. Report the node
// anyway - the wire form carries no depth, and the recipient hooks blobs in by hash - but
// skip the children rather than letting getChildNodeID throw on them.
if (nodeID.getDepth() >= kLeafDepth)
// Only a leaf belongs at kLeafDepth. The node is still reported, since the wire form
// carries no depth and the recipient hooks blobs by hash. Its children are skipped, since
// getChildNodeID has no answer past kLeafDepth.
if (isLeafDepth(nodeID.getDepth()))
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::SHAMap::visitDifferences : inner node at leaf depth");
@@ -207,7 +242,12 @@ SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se)
// we already know this child node is missing
fullBelow = false;
}
else if (!backed_ || !f_.getFullBelowCache()->touchIfExists(childHash.asUInt256()))
// The depth test precedes the cache lookup for the same reason it does in addKnownNode():
// the cache is keyed by node hash and shared across maps, and a hash covers a node's
// children but not its depth. Skipping the shortcut forgoes an optimization only.
else if (
!backed_ || isLeafDepth(nodeID.getDepth() + 1) ||
!f_.getFullBelowCache()->touchIfExists(childHash.asUInt256()))
{
bool pending = false;
auto d = descendAsync(
@@ -238,6 +278,17 @@ SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se)
if (--mn.max <= 0)
return;
}
else if (d->isInner() && isLeafDepth(nodeID.getDepth() + 1))
{
// Only a leaf belongs that deep (see isLeafDepth and SHAMap::addKnownNode). A node
// resolved locally reaches the walk without passing through addKnownNode(), so the
// walk reaches this verdict itself. Ordered ahead of the full-below test below,
// which canonicalization shares across maps.
JLOG(journal_.warn()) << "Inner node at branch " << branch << " below " << nodeID
<< " makes the map invalid";
setInvalid();
return;
}
else if (d->isInner() && !safeDowncast<SHAMapInnerNode*>(d)->isFullBelow(mn.generation))
{
mn.stack.push(se);
@@ -301,6 +352,8 @@ SHAMap::gmnProcessDeferredReads(MissingNodes& mn)
}
else if ((mn.max > 0) && mn.missingHashes.insert(nodeHash).second)
{
// getChildNodeID() is safe here: gmnProcessNodes refuses to descend into an inner node
// at kLeafDepth, so a deferred parent sits at most one level above it.
mn.missingNodes.emplace_back(parentID.getChildNodeID(branch), nodeHash.asUInt256());
--mn.max;
}
@@ -311,17 +364,20 @@ SHAMap::gmnProcessDeferredReads(MissingNodes& mn)
mn.deferred = 0;
}
/**
* Get a list of node IDs and hashes for nodes that are part of this SHAMap
* but not available locally. The filter can hold alternate sources of
* nodes that are not permanently stored locally
*/
std::vector<std::pair<SHAMapNodeID, UInt256>>
SHAMap::getMissingNodes(int max, SHAMapSyncFilter const* filter)
{
XRPL_ASSERT(root_->getHash().isNonZero(), "xrpl::SHAMap::getMissingNodes : nonzero root hash");
XRPL_ASSERT(max > 0, "xrpl::SHAMap::getMissingNodes : valid max input");
if (!isValid())
{
// The root node's own hash, since getHash() unshares the tree on a zero hash.
JLOG(journal_.warn()) << "getMissingNodes called on an invalid map, root hash "
<< root_->getHash() << " seq " << ledgerSeq();
return {};
}
MissingNodes mn(
max,
filter,
@@ -354,6 +410,11 @@ SHAMap::getMissingNodes(int max, SHAMapSyncFilter const* filter)
{
gmnProcessNodes(mn, pos);
// The walk just invalidated the map. The loop stops descending here but falls through
// to the drain below, since every posted read must be drained while `mn` is alive.
if (!isValid())
break;
if (mn.max <= 0)
break;
@@ -383,6 +444,11 @@ SHAMap::getMissingNodes(int max, SHAMapSyncFilter const* filter)
if (mn.deferred != 0)
gmnProcessDeferredReads(mn);
// Reads are drained, so the map can be abandoned. What was collected belongs to a tree
// that cannot exist.
if (!isValid())
return {};
if (mn.max <= 0)
return std::move(mn.missingNodes);
@@ -416,6 +482,11 @@ SHAMap::getMissingNodes(int max, SHAMapSyncFilter const* filter)
} while (node != nullptr);
// addKnownNode() on another thread can write the verdict after the loop's own test, so the map
// is judged once more before the result is returned.
if (!isValid())
return {}; // LCOV_EXCL_LINE: only that other thread reaches this, so no test does
if (mn.missingNodes.empty())
clearSynching();
@@ -527,11 +598,19 @@ SHAMap::addRootNode(
XRPL_ASSERT(cowid_ >= 1, "xrpl::SHAMap::addRootNode : valid cowid");
XRPL_ASSERT(rootNode, "xrpl::SHAMap::addRootNode : non-null root node");
// we already have a root_ node
// A map syncs against one hash and installs a root once, so a root already held is a duplicate
// only once it hashes to the hash asked for.
if (root_->getHash().isNonZero())
{
JLOG(journal_.trace()) << "Got root node, already have one";
XRPL_ASSERT(root_->getHash() == hash, "xrpl::SHAMap::addRootNode : valid hash");
if (root_->getHash() != hash)
{
JLOG(journal_.warn()) << "Root node offered under hash " << hash
<< ", but the map holds " << root_->getHash();
return SHAMapAddNode::invalid();
}
return SHAMapAddNode::duplicate();
}
@@ -555,7 +634,7 @@ SHAMap::addRootNode(
Serializer s;
root_->serializeWithPrefix(s);
filter->gotNode(
false, root_->getHash(), ledgerSeq_, std::move(s.modData()), root_->getType());
false, root_->getHash(), ledgerSeq(), std::move(s.modData()), root_->getType());
}
return SHAMapAddNode::useful();
@@ -598,7 +677,13 @@ SHAMap::addKnownNode(
}
auto childHash = inner->getChildHash(branch);
if (f_.getFullBelowCache()->touchIfExists(childHash.asUInt256()))
// Depth before the cache: the cache is keyed by node hash and shared across the family, and
// a hash covers a node's children but not its depth, so the same subtree can be cached as
// complete at one depth and reached at another. Skipping the shortcut only forgoes an
// optimization.
if (!isLeafDepth(currNodeID.getDepth() + 1) &&
f_.getFullBelowCache()->touchIfExists(childHash.asUInt256()))
{
return SHAMapAddNode::duplicate();
}
@@ -616,25 +701,32 @@ SHAMap::addKnownNode(
return SHAMapAddNode::invalid();
}
// Inner nodes must be at a level strictly less than 64
// but leaf nodes (while notionally at level 64) can be
// at any depth up to and including 64:
if ((currNodeID.getDepth() > kLeafDepth) ||
(treeNode->isInner() && currNodeID.getDepth() == kLeafDepth))
// Only leaves may sit at kLeafDepth (see isLeafDepth), so an inner node there makes the map
// impossible. The node is reported as bad data.
//
// Every node from the root down hash-verified to get here, so it is the requested root hash
// itself that commits to a shape no valid tree can have. The verdict belongs to that hash
// rather than to our copy of the tree.
bool const badDepth = treeNode->isInner() && isLeafDepth(currNodeID.getDepth());
SOMETIMES(badDepth, "xrpl::SHAMap::addKnownNode : map is invalid");
if (badDepth)
{
// Map is provably invalid
state_ = SHAMapState::Invalid;
return SHAMapAddNode::useful();
JLOG(journal_.warn()) << "Node " << nodeID << " makes the map invalid at "
<< currNodeID;
setInvalid();
return SHAMapAddNode::invalid();
}
if (currNodeID != nodeID)
// The data hashes to the child at currNodeID but claims to belong at nodeID, so it is not
// the node that was asked for. Only the label is wrong, so the map stays sound and the node
// is still obtainable from another sender.
bool const badPosition = (currNodeID != nodeID);
SOMETIMES(badPosition, "xrpl::SHAMap::addKnownNode : node ID does not match its position");
if (badPosition)
{
// Either this node is broken or we didn't request it (yet)
JLOG(journal_.warn()) << "unable to hook node " << nodeID;
JLOG(journal_.info()) << " stuck at " << currNodeID;
JLOG(journal_.info()) << "got depth=" << nodeID.getDepth()
<< ", walked to= " << currNodeID.getDepth();
return SHAMapAddNode::useful();
JLOG(journal_.warn()) << "Unable to hook node " << nodeID << ", stuck at "
<< currNodeID;
return SHAMapAddNode::invalid();
}
if (backed_)
@@ -647,7 +739,7 @@ SHAMap::addKnownNode(
Serializer s;
treeNode->serializeWithPrefix(s);
filter->gotNode(
false, childHash, ledgerSeq_, std::move(s.modData()), treeNode->getType());
false, childHash, ledgerSeq(), std::move(s.modData()), treeNode->getType());
}
return SHAMapAddNode::useful();
@@ -766,7 +858,7 @@ SHAMap::hasLeafNode(UInt256 const& tag, SHAMapHash const& targetNodeHash) const
// Same kLeafDepth hazard as in visitDifferences above. That guard bounds the caller's own
// traversal, not the map queried here, and the loop below descends from this map's root
// independently, so this check is what keeps a malformed map from reaching getChildNodeID.
if (nodeID.getDepth() >= kLeafDepth)
if (isLeafDepth(nodeID.getDepth()))
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::SHAMap::hasLeafNode : inner node at leaf depth");
@@ -849,8 +941,8 @@ SHAMap::verifyProofPath(UInt256 const& rootHash, UInt256 const& key, std::vector
// there. These nodes come off the wire, so a peer can still claim an inner one;
// reject it rather than passing this depth to selectBranch.
SOMETIMES(
depth >= kLeafDepth, "xrpl::SHAMap::verifyProofPath : inner at leaf depth");
if (depth >= kLeafDepth)
isLeafDepth(depth), "xrpl::SHAMap::verifyProofPath : inner at leaf depth");
if (isLeafDepth(depth))
return false;
auto const nodeId = SHAMapNodeID::createID(depth, key);
@@ -859,10 +951,9 @@ SHAMap::verifyProofPath(UInt256 const& rootHash, UInt256 const& key, std::vector
}
else
{
// The hash chain up to rootHash only proves this leaf sits where the path claims,
// not that it is the leaf for `key`: a peer could substitute any other leaf whose
// subtree hashes to the same value at every level above it. Checking the terminal
// leaf's own key is what ties the proof to `key` specifically.
// The hash chain up to rootHash proves this leaf sits where the path claims. Any
// leaf whose subtree hashes the same at every level above satisfies that chain, so
// the terminal leaf's own key is what ties the proof to `key`.
if (leafKey(*node) != key)
return false;
+19 -3
View File
@@ -7,12 +7,14 @@
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/Rate.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
@@ -485,6 +487,7 @@ ValidVault::finalize(
{
bool const enforce = view.rules().enabled(featureSingleAssetVault);
bool const fix340Enabled = view.rules().enabled(fixCleanup3_4_0);
bool const fix350Enabled = view.rules().enabled(fixCleanup3_5_0);
if (!isTesSuccess(ret))
return true; // Do not perform checks
@@ -1295,6 +1298,18 @@ ValidVault::finalize(
auto const localPseudoDeltaAssets =
roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
auto const rate = transferRate(view, vaultAsset);
bool const feeAdjustedWithdrawal = fix350Enabled && distinctDestination &&
!vaultAsset.native() && tx[sfAccount] != vaultAsset.getIssuer() &&
rate != kParityRate;
auto expectedDestinationDelta = localPseudoDeltaAssets * -1;
if (feeAdjustedWithdrawal)
{
expectedDestinationDelta =
subtractTransferFee(
STAmount{vaultAsset, expectedDestinationDelta}, rate)
.value();
}
// For IOU assets near a precision boundary the destination's STAmount
// exponent can shift, making part of the sent value unrepresentable at
// the receiver's new scale — that portion is irreversibly absorbed by the
@@ -1303,7 +1318,8 @@ ValidVault::finalize(
// the destination's scale. Floor rounding is used so that values exactly
// at the step boundary are not mistakenly dismissed. Any representable
// discrepancy indicates a real accounting bug and must be caught.
auto const destroyedIsSubUlp = tolerateZeroDelta &&
auto const destroyedIsSubUlp = !feeAdjustedWithdrawal &&
tolerateZeroDelta &&
roundToAsset(
vaultAsset,
vaultDeltaAssets.delta * -1 - destinationDelta.delta,
@@ -1311,11 +1327,11 @@ ValidVault::finalize(
Number::RoundingMode::Downward) == kZero;
bool const withdrawAddsUp = fix340Enabled
? agreesWithinOneUnit(
localPseudoDeltaAssets * -1,
expectedDestinationDelta,
roundedDestinationDelta,
vaultAsset,
localMinScale)
: localPseudoDeltaAssets * -1 == roundedDestinationDelta;
: expectedDestinationDelta == roundedDestinationDelta;
if (!destroyedIsSubUlp && !withdrawAddsUp)
{
JLOG(j.fatal()) << "Invariant failed: " << //
@@ -270,6 +270,7 @@ LoanBrokerCoverWithdraw::doApply()
brokerPseudoID,
preFeeBalance_,
amount,
amount,
j_);
}
+190 -37
View File
@@ -3,6 +3,7 @@
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/View.h>
@@ -129,25 +130,133 @@ LoanManage::preclaim(PreclaimContext const& ctx)
return tecNO_PERMISSION;
}
if (auto const vault = ctx.view.read(keylet::vault(loanBrokerSle->at(sfVaultID)));
vault && getVaultVersion(vault) == VaultVersion::FixedPrecision)
{
// FixedPrecision Vaults are not yet supported by LoanManage.
return tecNO_PERMISSION;
}
return tesSUCCESS;
}
STAmount
LoanManage::calculateDefaultCover(SLE::Ref loanSle, SLE::Ref brokerSle, SLE::Ref vaultSle)
{
auto const vaultAsset = vaultSle->at(sfAsset);
Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding);
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
TenthBips32 const coverRateLiquidation{brokerSle->at(sfCoverRateLiquidation)};
Number const rawCover = [&] {
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
// Compute minimum cover
Number const minimumCover =
tenthBipsOfValue(brokerSle->at(sfDebtTotal).value(), coverRateMinimum);
// Apply coverRateLiquidation
return std::min(tenthBipsOfValue(minimumCover, coverRateLiquidation), principalOutstanding);
}();
// rawCover is a product of multiplication and can carry more digits than STAmount's. Keep the
// cap as an exact Number: rounding it to 16 digits first could drop digits that change
// floor16(AssetsAvailable + cover).
Number const cappedCover = std::min(rawCover, *brokerSle->at(sfCoverAvailable));
if (Asset{vaultAsset}.integral())
{
// Fix the mode so the STAmount conversion truncates the fractional part.
NumberRoundModeGuard const mg(Number::RoundingMode::TowardsZero);
return STAmount{vaultAsset, cappedCover};
}
// Floor AssetsAvailable + cover at its posterior grid so the Vault side is exact, unless
// AssetsAvailable has digits finer than the 16-digit cover can carry. The LoanBroker absorbs
// the rounding: CoverAvailable - cover may need finer digits than its posterior grid and
// rounds to nearest, as the broker's trust line does. Downward also keeps the 19-digit
// AssetsAvailable + cover sum from rounding up past cappedCover.
NumberRoundModeGuard const mg(Number::RoundingMode::Downward);
return creditToPosteriorAvailableScale(vaultSle, cappedCover, Number::RoundingMode::Downward);
}
namespace {
// Defaults a Loan on a FixedPrecision Vault. Cover is the raw XLS-66
// First-Loss Capital amount, capped at the LoanBroker's CoverAvailable and
// floored so AssetsAvailable + cover lands exactly on its posterior grid (a
// no-op for integral assets). AssetsAvailable rises by cover through the cash writer;
// AssetsDeployed drops by the Loan's full PrincipalOutstanding exactly, and
// AssetsTotal is re-derived from AssetsAvailable + AssetsDeployed by the
// writer's sync -- this function never touches sfAssetsTotal directly.
[[nodiscard]] TER
defaultLoanFixedPrecision(
ApplyView& view,
SLE::Ref loanSle,
SLE::Ref brokerSle,
SLE::Ref vaultSle,
beast::Journal j)
{
Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding);
Number const scheduledInterest = loanSle->at(sfTotalValueOutstanding) - principalOutstanding -
loanSle->at(sfManagementFeeOutstanding);
STAmount const coverAmount = LoanManage::calculateDefaultCover(loanSle, brokerSle, vaultSle);
// The broker's CoverAvailable decrease and the broker-to-vault transfer
// both use this amount. AssetsAvailable + cover is exact unless
// AssetsAvailable has digits finer than the 16-digit cover can carry; then
// adjustVaultBalances rounds the sum. CoverAvailable - cover may need finer
// digits than the broker's posterior grid; it rounds to nearest, as the
// broker's trust line does.
Number const loss = loanSle->isFlag(lsfLoanImpaired) ? -principalOutstanding : 0;
if (auto const ter = adjustVaultBalances(
vaultSle,
{
.cash = coverAmount,
.deployed = -principalOutstanding,
.yield = -scheduledInterest,
.loss = loss,
},
j);
!isTesSuccess(ter))
return ter; // LCOV_EXCL_LINE
view.update(vaultSle);
brokerSle->at(sfDebtTotal) -= principalOutstanding;
brokerSle->at(sfCoverAvailable) -= coverAmount;
view.update(brokerSle);
loanSle->setFlag(lsfLoanDefault);
loanSle->at(sfTotalValueOutstanding) = 0;
loanSle->at(sfPaymentRemaining) = 0;
loanSle->at(sfPrincipalOutstanding) = 0;
loanSle->at(sfManagementFeeOutstanding) = 0;
loanSle->at(sfNextPaymentDueDate) = 0;
view.update(loanSle);
if (coverAmount == beast::kZero)
return tesSUCCESS;
return accountSend(
view,
brokerSle->at(sfAccount),
vaultSle->at(sfAccount),
coverAmount,
j,
{},
WaiveTransferFee::Yes);
}
} // namespace
TER
LoanManage::defaultLoan(
ApplyView& view,
SLE::Ref loanSle,
SLE::Ref brokerSle,
SLE::Ref vaultSle,
Asset const& vaultAsset,
beast::Journal j)
{
if (getVaultVersion(vaultSle) == VaultVersion::FixedPrecision)
return defaultLoanFixedPrecision(view, loanSle, brokerSle, vaultSle, j);
auto const vaultAsset = *vaultSle->at(sfAsset);
// Calculate the amount of the Default that First-Loss Capital covers:
std::int32_t const loanScale = loanSle->at(sfLoanScale);
@@ -184,7 +293,7 @@ LoanManage::defaultLoan(
// The vault may be at a different scale than the loan. Reduce rounding
// errors during the accounting by rounding some of the values to that
// scale.
auto const vaultScale = getAssetsTotalScale(vaultSle);
auto const vaultScale = getVaultScale(vaultSle);
{
// Decrease the Total Value of the Vault:
@@ -307,22 +416,39 @@ LoanManage::impairLoan(
return tecTOO_SOON;
}
bool const fixedPrecision = getVaultVersion(vaultSle) == VaultVersion::FixedPrecision;
Number const lossUnrealized = loanVaultExposure(vaultSle, loanSle);
// The vault may be at a different scale than the loan. Reduce rounding
// errors during the accounting by rounding some of the values to that
// scale.
auto const vaultScale = getAssetsTotalScale(vaultSle);
// Update the Vault object(set "paper loss")
auto const vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
adjustImpreciseNumber(vaultLossUnrealizedProxy, lossUnrealized, vaultAsset, vaultScale);
if (vaultLossUnrealizedProxy > vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable))
// Update the Vault object (set "paper loss")
if (fixedPrecision)
{
// Having a loss greater than the vault's unavailable assets
// will leave the vault in an invalid / inconsistent state.
JLOG(j.warn()) << "Vault unrealized loss is too large, and will corrupt the vault.";
return tecLIMIT_EXCEEDED;
// adjustVaultBalances's own LU' > AD' check covers the comparison
// against AssetsDeployed.
if (auto const ter = adjustVaultBalances(vaultSle, {.loss = lossUnrealized}, j);
!isTesSuccess(ter))
{
// LCOV_EXCL_START
JLOG(j.warn()) << "Vault unrealized loss is too large, and will corrupt the vault.";
return ter;
// LCOV_EXCL_STOP
}
}
else
{
auto const vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
// The vault may be at a different scale than the loan. Reduce rounding
// errors during the accounting by rounding some of the values to that
// scale.
auto const vaultScale = getVaultScale(vaultSle);
adjustImpreciseNumber(vaultLossUnrealizedProxy, lossUnrealized, vaultAsset, vaultScale);
if (vaultLossUnrealizedProxy >
vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable))
{
// Having a loss greater than the vault's unavailable assets
// will leave the vault in an invalid / inconsistent state.
JLOG(j.warn()) << "Vault unrealized loss is too large, and will corrupt the vault.";
return tecLIMIT_EXCEEDED; // LCOV_EXCL_LINE
}
}
view.update(vaultSle);
@@ -351,23 +477,32 @@ LoanManage::unimpairLoan(
Asset const& vaultAsset,
beast::Journal j)
{
// The vault may be at a different scale than the loan. Reduce rounding
// errors during the accounting by rounding some of the values to that
// scale.
auto const vaultScale = getAssetsTotalScale(vaultSle);
// Update the Vault object(clear "paper loss")
auto const vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
// Update the Vault object (clear "paper loss")
bool const fixedPrecision = getVaultVersion(vaultSle) == VaultVersion::FixedPrecision;
Number const lossReversed = loanVaultExposure(vaultSle, loanSle);
if (vaultLossUnrealizedProxy < lossReversed)
if (fixedPrecision)
{
// LCOV_EXCL_START
JLOG(j.warn()) << "Vault unrealized loss is less than the amount to be cleared";
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
// adjustVaultBalances's own LU' < 0 check covers the comparison.
if (auto const ter = adjustVaultBalances(vaultSle, {.loss = -lossReversed}, j);
!isTesSuccess(ter))
return ter; // LCOV_EXCL_LINE
}
else
{
auto const vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
if (vaultLossUnrealizedProxy < lossReversed)
{
// LCOV_EXCL_START
JLOG(j.warn()) << "Vault unrealized loss is less than the amount to be cleared";
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
// The vault may be at a different scale than the loan. Reduce rounding
// errors during the accounting by rounding some of the values to that
// scale.
auto const vaultScale = getVaultScale(vaultSle);
adjustImpreciseNumber(vaultLossUnrealizedProxy, -lossReversed, vaultAsset, vaultScale);
}
// Reverse the "paper loss"
adjustImpreciseNumber(vaultLossUnrealizedProxy, -lossReversed, vaultAsset, vaultScale);
view.update(vaultSle);
@@ -422,7 +557,7 @@ LoanManage::doApply()
// Valid flag combinations are checked in preflight. No flags is valid -
// just a noop.
if (tx.isFlag(tfLoanDefault))
return defaultLoan(view, loanSle, brokerSle, vaultSle, vaultAsset, j_);
return defaultLoan(view, loanSle, brokerSle, vaultSle, j_);
if (tx.isFlag(tfLoanImpair))
return impairLoan(view, loanSle, vaultSle, vaultAsset, j_);
if (tx.isFlag(tfLoanUnimpair))
@@ -435,9 +570,27 @@ LoanManage::doApply()
// path. Post-amendment, we call associateAsset on all successful paths.
if (view.rules().enabled(fixCleanup3_1_3) && isTesSuccess(result))
{
bool const fixedPrecision = getVaultVersion(vaultSle) == VaultVersion::FixedPrecision;
[[maybe_unused]] bool const assetsEqualBeforeStore =
fixedPrecision && vaultSle->at(sfAssetsAvailable) == vaultSle->at(sfAssetsTotal);
associateAsset(*loanSle, vaultAsset);
associateAsset(*brokerSle, vaultAsset);
associateAsset(*vaultSle, vaultAsset);
if (fixedPrecision)
{
[[maybe_unused]] Number const assetsAvailableAfter = *vaultSle->at(sfAssetsAvailable);
[[maybe_unused]] Number const assetsTotalAfter = *vaultSle->at(sfAssetsTotal);
XRPL_ASSERT_PARTS(
assetsAvailableAfter <= assetsTotalAfter,
"xrpl::LoanManage::doApply",
"assets available must not be greater than assets outstanding");
XRPL_ASSERT_PARTS(
!assetsEqualBeforeStore || assetsAvailableAfter == assetsTotalAfter,
"xrpl::LoanManage::doApply",
"equal assets remain equal after storage");
}
}
return result;
+178 -121
View File
@@ -46,14 +46,12 @@ isPaymentAmountInvalid(STAmount const& amount)
return amount <= beast::kZero;
}
// Returns the account's true, unclamped balance in `asset`, for use only in
// fund-conservation checks. accountHolds(..., SpendableHandling::FullBalance)
// cannot be used for this: for XRP it always defers to xrpLiquid, which
// subtracts the account's reserve, so a payee sitting below its own reserve
// would appear to receive nothing even though its raw ledger balance grew.
// That mismatch is exactly what a conservation check must not see.
// Returns the balance stored on the ledger for a holder of `asset`: the raw
// XRP balance (not xrpLiquid, which subtracts the reserve), or the signed
// trust line or MPToken balance with no trust limit added. Not meaningful for
// the issuer, which holds no balance of its own.
STAmount
conservationBalance(ReadView const& view, AccountID const& id, Asset const& asset, beast::Journal j)
storedBalance(ReadView const& view, AccountID const& id, Asset const& asset, beast::Journal j)
{
if (isXRP(asset))
{
@@ -62,6 +60,25 @@ conservationBalance(ReadView const& view, AccountID const& id, Asset const& asse
return STAmount{asset}; // LCOV_EXCL_LINE
return view.balanceHookIOU(id, xrpAccount(), sle->getFieldAmount(sfBalance));
}
return accountHolds(
view,
id,
asset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j,
SpendableHandling::SimpleBalance);
}
// Returns the account's balance in `asset`, for use only in fund-conservation
// checks. A holder's is its stored balance: a spendable balance would add the
// opposite trust limit, which can hide a small credit in rounding. An issuer
// has no stored balance, so its spendable balance is used instead.
STAmount
conservationBalance(ReadView const& view, AccountID const& id, Asset const& asset, beast::Journal j)
{
if (id != asset.getIssuer())
return storedBalance(view, id, asset, j);
return accountHolds(
view,
id,
@@ -283,11 +300,6 @@ LoanPay::preclaim(PreclaimContext const& ctx)
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
if (getVaultVersion(vaultSle) == VaultVersion::FixedPrecision)
{
// FixedPrecision Vaults are not yet supported by LoanPay.
return tecNO_PERMISSION;
}
auto const asset = vaultSle->at(sfAsset);
auto const vaultPseudoAccount = vaultSle->at(sfAccount);
@@ -362,11 +374,11 @@ LoanPay::doApply()
auto const asset = *vaultSle->at(sfAsset);
// Determine where to send the broker's fee
auto coverAvailableProxy = brokerSle->at(sfCoverAvailable);
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
auto debtTotalProxy = brokerSle->at(sfDebtTotal);
auto const vaultScale = getAssetsTotalScale(vaultSle);
bool const fixedPrecision = getVaultVersion(vaultSle) == VaultVersion::FixedPrecision;
auto const vaultScale = getVaultScale(vaultSle);
// Send the broker fee to the owner if they have sufficient cover available,
// _and_ if the owner can receive funds
@@ -392,7 +404,8 @@ LoanPay::doApply()
return roundToAsset(
asset, tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum), loanScale);
}();
return coverAvailableProxy >= minCover && !isDeepFrozen(view, brokerOwner, asset) &&
return brokerSle->at(sfCoverAvailable) >= minCover &&
!isDeepFrozen(view, brokerOwner, asset) &&
!requireAuth(view, asset, brokerOwner, AuthType::StrongAuth);
}();
@@ -425,6 +438,12 @@ LoanPay::doApply()
}
}
auto const scheduledInterest = [&loanSle] {
return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding) -
loanSle->at(sfManagementFeeOutstanding);
};
Number const scheduledInterestBefore = fixedPrecision ? scheduledInterest() : kNumZero;
LoanPaymentType const paymentType = [&tx] {
// preflight already checked that at most one flag is set.
if (tx.isFlag(tfLoanLatePayment))
@@ -438,7 +457,6 @@ LoanPay::doApply()
std::expected<LoanPaymentParts, TER> const paymentParts =
loanMakePayment(asset, view, loanSle, brokerSle, amount, paymentType, j_);
if (!paymentParts)
{
XRPL_ASSERT_PARTS(
@@ -446,10 +464,12 @@ LoanPay::doApply()
return paymentParts.error();
}
// If the payment computation completed without error, the loanSle object
// has been modified.
// If the payment computation completed without error, the loanSle object has been modified.
view.update(loanSle);
Number const scheduledInterestDelta =
fixedPrecision ? scheduledInterest() - scheduledInterestBefore : kNumZero;
XRPL_ASSERT_PARTS(
// It is possible to pay 0 principal
paymentParts->principalPaid >= 0,
@@ -476,35 +496,49 @@ LoanPay::doApply()
// LCOV_EXCL_STOP
}
auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(vaultSle, *paymentParts);
//------------------------------------------------------
// LoanBroker object state changes
view.update(brokerSle);
Number const assetsAvailableBefore = *vaultSle->at(sfAssetsAvailable);
Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal);
auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid;
auto const deltas = loanPaymentDeltas(vaultSle, *paymentParts);
// FixedPrecision never writes sfAssetsTotal directly, so assetsTotalDelta
// (cash_basis's interestPaid) is unused here and only meaningful for
// Legacy/CashBasis below.
Number const assetsTotalDelta = fixedPrecision ? kNumZero : deltas.assetsTotalDelta;
Number const debtTotalDelta = deltas.debtTotalDelta;
STAmount const totalPaidToVaultRounded = fixedPrecision
// Pass the exact Number sum: rounding it to 16 digits first could drop
// a tail that moves floor16(AssetsAvailable + sum).
? creditToPosteriorAvailableScale(
vaultSle, totalPaidToVaultRaw, Number::RoundingMode::Downward)
: STAmount{
asset,
roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward)};
XRPL_ASSERT_PARTS(
!asset.integral() || totalPaidToVaultRaw == Number(totalPaidToVaultRounded),
"xrpl::LoanPay::doApply",
"rounding does nothing for integral asset");
Number const totalPaidToBrokerRaw = paymentParts->feePaid;
// Fee redirect into cover is an inflow to CoverAvailable, so it must
// floor the sum (see creditToPosteriorBrokerCoverScale's doc), not just
// the delta. Legacy/CashBasis keeps the fee as the original, unrounded
// Number: only the FixedPrecision path is 16-digit-rounded via STAmount.
Number const totalPaidToBroker = fixedPrecision && !sendBrokerFeeToOwner
// The exact fee, for the same reason as totalPaidToVaultRounded.
? Number(creditToPosteriorBrokerCoverScale(
vaultSle, brokerSle, totalPaidToBrokerRaw, Number::RoundingMode::Downward))
: totalPaidToBrokerRaw;
JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid
<< ", interest paid: " << paymentParts->interestPaid
<< ", fee paid: " << paymentParts->feePaid
<< ", assets total delta: " << assetsTotalDelta
<< ", debt total delta: " << debtTotalDelta;
//------------------------------------------------------
// LoanBroker object state changes
view.update(brokerSle);
auto assetsAvailableProxy = vaultSle->at(sfAssetsAvailable);
auto assetsTotalProxy = vaultSle->at(sfAssetsTotal);
auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid;
auto const totalPaidToVaultRounded =
roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward);
XRPL_ASSERT_PARTS(
!asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded,
"xrpl::LoanPay::doApply",
"rounding does nothing for integral asset");
auto const totalPaidToBroker = paymentParts->feePaid;
XRPL_ASSERT_PARTS(
(totalPaidToVaultRaw + totalPaidToBroker) ==
(paymentParts->principalPaid + paymentParts->interestPaid + paymentParts->feePaid),
"xrpl::LoanPay::doApply",
"payments add up");
<< ", debt total delta: " << debtTotalDelta
<< ", scheduled interest delta: " << scheduledInterestDelta;
// Decrease LoanBroker Debt by the amount paid, add the Loan value change
// (which might be negative). debtTotalDelta may be negative, increasing the
@@ -514,45 +548,35 @@ LoanPay::doApply()
"xrpl::LoanPay::doApply",
"debtTotalDelta rounding good");
// Despite our best efforts, it's possible for rounding errors to accumulate
// in the loan broker's debt total. This is because the broker may have more
// than one loan with significantly different scales.
adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale);
// in the loan broker's debt total on Legacy/CashBasis Vaults. This is because
// the broker may have more than one loan with significantly different scales.
adjustBrokerDebtTotal(brokerSle, vaultSle, -debtTotalDelta, vaultScale);
//------------------------------------------------------
// Vault object state changes
view.update(vaultSle);
Number const assetsAvailableBefore = *assetsAvailableProxy;
Number const assetsTotalBefore = *assetsTotalProxy;
#if !NDEBUG
if (fixedPrecision)
{
Number const pseudoAccountBalanceBefore = accountHolds(
view,
vaultPseudoAccount,
asset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_);
XRPL_ASSERT_PARTS(
assetsAvailableBefore == pseudoAccountBalanceBefore,
"xrpl::LoanPay::doApply",
"vault pseudo balance agrees before");
// AssetsDeployed drops by exactly the principal paid this payment,
// taken from the Loan's own payment split rather than the
// broker-side debtTotalDelta.
if (auto const ter = adjustVaultBalances(
vaultSle,
{
.cash = totalPaidToVaultRounded,
.deployed = -paymentParts->principalPaid,
.yield = scheduledInterestDelta,
},
j_);
!isTesSuccess(ter))
return ter;
}
else
{
vaultSle->at(sfAssetsAvailable) += totalPaidToVaultRounded;
vaultSle->at(sfAssetsTotal) += assetsTotalDelta;
}
#endif
assetsAvailableProxy += totalPaidToVaultRounded;
assetsTotalProxy += assetsTotalDelta;
XRPL_ASSERT_PARTS(
*assetsAvailableProxy <= *assetsTotalProxy,
"xrpl::LoanPay::doApply",
"assets available must not be greater than assets outstanding");
JLOG(j_.debug()) << "total paid to vault raw: " << totalPaidToVaultRaw
<< ", total paid to vault rounded: " << totalPaidToVaultRounded
<< ", total paid to broker: " << totalPaidToBroker
<< ", amount from transaction: " << amount;
// Move funds
XRPL_ASSERT_PARTS(
@@ -563,10 +587,11 @@ LoanPay::doApply()
if (!sendBrokerFeeToOwner)
{
// If there is not enough first-loss capital, add the fee to First Loss
// Cover Pool. Note that this moves the entire fee - it does not attempt
// to split it. The broker can Withdraw it later if they want, or leave
// it for future needs.
coverAvailableProxy += totalPaidToBroker;
// Cover Pool. FixedPrecision rounds the redirected fee at the
// posterior cover scale; any sub-unit remainder is forgiven. The
// broker can Withdraw the credited amount later or leave it for future
// needs.
brokerSle->at(sfCoverAvailable) += totalPaidToBroker;
}
associateAsset(*loanSle, asset);
@@ -574,28 +599,48 @@ LoanPay::doApply()
associateAsset(*vaultSle, asset);
// Duplicate some checks after rounding
Number const assetsAvailableAfter = *assetsAvailableProxy;
Number const assetsTotalAfter = *assetsTotalProxy;
Number const assetsAvailableAfter = vaultSle->at(sfAssetsAvailable);
Number const assetsTotalAfter = vaultSle->at(sfAssetsTotal);
XRPL_ASSERT_PARTS(
assetsAvailableAfter <= assetsTotalAfter,
"xrpl::LoanPay::doApply",
"assets available must not be greater than assets outstanding");
if (assetsAvailableAfter == assetsAvailableBefore)
{
// An unchanged assetsAvailable indicates that the amount paid to the
// vault was zero, or rounded to zero. That should be impossible, but I
// can't rule it out for extreme edge cases, so fail gracefully if it
// happens.
//
// LCOV_EXCL_START
JLOG(j_.warn()) << "LoanPay: Vault assets available unchanged after rounding: " //
<< "Before: " << assetsAvailableBefore //
<< ", After: " << assetsAvailableAfter;
return tecPRECISION_LOSS;
// LCOV_EXCL_STOP
// Credit rounded to zero. On FixedPrecision, if this payment also
// closes the Loan (XLS-66 section 3.2.8's terminal exception), let
// it through: AssetsDeployed (vault) and DebtTotal (broker) already
// dropped by the exact remaining principal, and YieldUnrealized was already
// reduced by this payment's scheduled interest, above. No cash moves for the sub-unit
// remainder; the Vault forgives it. A non-terminal zero-credit payment is still rejected:
// letting it through would change the Loan's schedule with no cash movement at all.
if (fixedPrecision && loanSle->at(sfPaymentRemaining) == 0)
{
JLOG(j_.debug()) << "LoanPay: terminal payment closed the Loan with a zero cash "
"credit; the sub-unit remainder is forgiven. Principal: "
<< paymentParts->principalPaid
<< ", Interest: " << paymentParts->interestPaid;
// Nothing moves for this terminal close when the sub-unit remainder is forgiven and
// either no fee is owed or the fee goes to the broker owner who is also the borrower,
// i.e. the owner would pay itself. Return before the transfer and its conservation
// checks, since they assume some transfer happens.
if (totalPaidToVaultRounded == beast::kZero &&
(totalPaidToBroker == beast::kZero || brokerPayee == accountID_))
{
return tesSUCCESS;
}
}
else
{
JLOG(j_.warn()) << "LoanPay: Vault assets available unchanged after rounding: " //
<< "Before: " << assetsAvailableBefore //
<< ", After: " << assetsAvailableAfter //
<< ", Credit: " << totalPaidToVaultRounded //
<< ", Principal: " << paymentParts->principalPaid //
<< ", Interest: " << paymentParts->interestPaid;
return tecPRECISION_LOSS;
}
}
if (assetsTotalDelta != beast::kZero && assetsTotalAfter == assetsTotalBefore)
if (!fixedPrecision && assetsTotalDelta != beast::kZero &&
assetsTotalAfter == assetsTotalBefore)
{
// Non-zero assetsTotalDelta with an unchanged assetsTotal indicates that
// the actual value change rounded to zero. That should be impossible, but
@@ -611,7 +656,8 @@ LoanPay::doApply()
return tecPRECISION_LOSS;
// LCOV_EXCL_STOP
}
if (assetsTotalDelta == beast::kZero && assetsTotalAfter != assetsTotalBefore)
if (!fixedPrecision && assetsTotalDelta == beast::kZero &&
assetsTotalAfter != assetsTotalBefore)
{
// A change in assetsTotal when there was no assetsTotalDelta indicates
// that something really weird happened. That should be flat out
@@ -690,22 +736,6 @@ LoanPay::doApply()
WaiveTransferFee::Yes))
return ter;
#if !NDEBUG
{
Number const pseudoAccountBalanceAfter = accountHolds(
view,
vaultPseudoAccount,
asset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_);
XRPL_ASSERT_PARTS(
assetsAvailableAfter == pseudoAccountBalanceAfter,
"xrpl::LoanPay::doApply",
"vault pseudo balance agrees after");
}
#endif
// Check that funds are conserved
auto const accountBalanceAfter = conservationBalance(view, accountID_, asset, j_);
auto const vaultBalanceAfter = accountID_ == vaultPseudoAccount
@@ -754,10 +784,6 @@ LoanPay::doApply()
}();
// No object changes are made below this point
XRPL_ASSERT_PARTS(
Number::getround() == Number::RoundingMode::ToNearest,
"xrpl::LoanPay::doApply",
"Number rounding ToNearest");
NumberRoundModeGuard const mg(Number::RoundingMode::ToNearest);
auto const accountBalanceBeforeRounded = roundToScale(accountBalanceBefore, balanceScale);
@@ -808,8 +834,26 @@ LoanPay::doApply()
<< ", total " << to_string(totalBalanceChangeRounded) << " ("
<< Number(totalBalanceChange) << ")";
// Each trust line rounds its own change to 16 digits, so a side can move by up to one unit
// of its balance less or more than was sent. Integral assets move exactly.
auto const roundingUnit = [&](STAmount const& before, STAmount const& after) {
Number unit{0};
if (asset.integral())
return unit;
for (auto const& balance : {before, after})
{
if (balance != beast::kZero)
unit = std::max(unit, Number{1, balance.exponent()});
}
return unit;
};
Number const accountUnit = roundingUnit(accountBalanceBefore, accountBalanceAfter);
Number const vaultUnit = roundingUnit(vaultBalanceBefore, vaultBalanceAfter);
Number const brokerUnit = roundingUnit(brokerBalanceBefore, brokerBalanceAfter);
bool const goodRounding = totalBalanceBeforeRounded == totalBalanceAfterRounded ||
totalBalanceChangeRounded == beast::kZero;
totalBalanceChangeRounded == beast::kZero ||
abs(Number(totalBalanceChange)) <= accountUnit + vaultUnit + brokerUnit;
if (totalBalanceBeforeRounded != totalBalanceAfterRounded)
{
JLOG((goodRounding ? j_.debug() : j_.warn()))
@@ -830,11 +874,20 @@ LoanPay::doApply()
goodRounding, "xrpl::LoanPay::doApply", "funds are conserved (with rounding)");
XRPL_ASSERT_PARTS(
accountBalanceAfter < accountBalanceBefore || accountID_ == asset.getIssuer(),
// A payment below one unit of the payer's balance can round away on its trust line. A fee
// the owner-borrower pays to itself does not leave its balance.
accountBalanceAfter < accountBalanceBefore || accountID_ == asset.getIssuer() ||
(accountBalanceAfter == accountBalanceBefore &&
totalPaidToVaultRounded +
(brokerPayee == accountID_ ? Number{0} : totalPaidToBroker) <=
accountUnit),
"xrpl::LoanPay::doApply",
"account balance decreased");
XRPL_ASSERT_PARTS(
vaultBalanceAfter >= beast::kZero && brokerBalanceAfter >= beast::kZero,
// A broker owner may owe the issuer on a reciprocal IOU trust line, so
// its stored balance can be negative. Pseudo-accounts cannot.
vaultBalanceAfter >= beast::kZero &&
(brokerBalanceAfter >= beast::kZero || (sendBrokerFeeToOwner && !asset.integral())),
"xrpl::LoanPay::doApply",
"non-negative vault and broker balances");
XRPL_ASSERT_PARTS(
@@ -846,7 +899,11 @@ LoanPay::doApply()
"xrpl::LoanPay::doApply",
"broker balance did not decrease");
XRPL_ASSERT_PARTS(
vaultBalanceAfter > vaultBalanceBefore || brokerBalanceAfter > brokerBalanceBefore,
// An IOU issuer's spendable balance does not change when it is paid, and a credit below
// one unit of the payee's balance can round away on its trust line.
vaultBalanceAfter > vaultBalanceBefore || brokerBalanceAfter > brokerBalanceBefore ||
brokerPayee == asset.getIssuer() ||
(Number(totalPaidToVaultRounded) <= vaultUnit && totalPaidToBroker <= brokerUnit),
"xrpl::LoanPay::doApply",
"vault and/or broker balance increased");
+51 -15
View File
@@ -12,6 +12,7 @@
#include <xrpl/ledger/entries/FeeSettingsEntry.h>
#include <xrpl/ledger/entries/NegativeUNLEntry.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Fees.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SField.h>
@@ -124,12 +125,23 @@ Change::preclaim(PreclaimContext const& ctx)
ctx.tx.isFieldPresent(sfReserveIncrementDrops))
return temDISABLED;
}
// The ttFEE transaction format defines these fields as optional,
// but they are unconditionally forbidden until FeeVoteImpl is
// updated to populate them (SmartEscrow behavioral port).
if (ctx.tx.isFieldPresent(sfGasLimit) || ctx.tx.isFieldPresent(sfBytecodeSizeLimit) ||
ctx.tx.isFieldPresent(sfGasPrice))
return temDISABLED;
if (ctx.view.rules().enabled(featureSmartEscrow))
{
if (!ctx.tx.isFieldPresent(sfGasLimit) ||
!ctx.tx.isFieldPresent(sfBytecodeSizeLimit) ||
!ctx.tx.isFieldPresent(sfGasPrice))
return temMALFORMED;
if (ctx.tx[sfGasLimit] > kMaxGasLimit ||
ctx.tx[sfBytecodeSizeLimit] > kMaxBytecodeSizeLimit ||
ctx.tx[sfGasPrice] < kMinGasPrice)
return temBAD_FEE;
}
else
{
if (ctx.tx.isFieldPresent(sfGasLimit) ||
ctx.tx.isFieldPresent(sfBytecodeSizeLimit) || ctx.tx.isFieldPresent(sfGasPrice))
return temDISABLED;
}
return tesSUCCESS;
case ttAMENDMENT:
case ttUNL_MODIFY:
@@ -164,6 +176,28 @@ Change::preCompute()
XRPL_ASSERT(accountID_ == beast::kZero, "xrpl::Change::preCompute : zero account");
}
void
Change::initializeVMFees()
{
FeeSettingsEntryW feeObject(view());
if (!feeObject)
{
feeObject.newSLE();
feeObject.insert();
}
// Compile-time constants, never `FeeSetup`: every node applies this
// pseudo-transaction, so reading local config here would diverge.
feeObject->at(sfGasLimit) = kDefaultGasLimit;
feeObject->at(sfBytecodeSizeLimit) = kDefaultBytecodeSizeLimit;
feeObject->at(sfGasPrice) = kDefaultGasPrice;
feeObject.update();
JLOG(j_.info()) << "Feature Extension fees initialized on SmartEscrow activation";
}
TER
Change::applyAmendment()
{
@@ -240,6 +274,9 @@ Change::applyAmendment()
<< " activated: server blocked.";
ctx_.registry.get().getOPs().setAmendmentBlocked();
}
if (amendment == featureSmartEscrow)
initializeVMFees();
}
if (newMajorities.empty())
@@ -266,14 +303,12 @@ Change::applyFee()
feeObject.newSLE();
feeObject.insert();
}
auto const set = [](FeeSettingsEntryW& feeObject, STTx const& tx, auto const& field) {
feeObject->at(field) = tx[field];
auto const set = [](FeeSettingsEntryW& feeObject, STTx const& tx, auto const&... fields) {
((feeObject->at(fields) = tx[fields]), ...);
};
if (view().rules().enabled(featureXRPFees))
{
set(feeObject, ctx_.tx, sfBaseFeeDrops);
set(feeObject, ctx_.tx, sfReserveBaseDrops);
set(feeObject, ctx_.tx, sfReserveIncrementDrops);
set(feeObject, ctx_.tx, sfBaseFeeDrops, sfReserveBaseDrops, sfReserveIncrementDrops);
// Ensure the old fields are removed
feeObject->makeFieldAbsent(sfBaseFee);
feeObject->makeFieldAbsent(sfReferenceFeeUnits);
@@ -282,10 +317,11 @@ Change::applyFee()
}
else
{
set(feeObject, ctx_.tx, sfBaseFee);
set(feeObject, ctx_.tx, sfReferenceFeeUnits);
set(feeObject, ctx_.tx, sfReserveBase);
set(feeObject, ctx_.tx, sfReserveIncrement);
set(feeObject, ctx_.tx, sfBaseFee, sfReferenceFeeUnits, sfReserveBase, sfReserveIncrement);
}
if (view().rules().enabled(featureSmartEscrow))
{
set(feeObject, ctx_.tx, sfGasLimit, sfBytecodeSizeLimit, sfGasPrice);
}
feeObject.update();
@@ -214,7 +214,7 @@ ConfidentialMPTClawback::doApply()
// proof using the corresponding stale private key. The mirrors are updated
// to the current epoch during execution.
if (view().rules().enabled(featureConfidentialMPTKeyRotation))
setMirrorEpochs(*sleIssuance, *sleHolderMPToken);
setMirrorsCurrent(*sleIssuance, *sleHolderMPToken);
// Decrease Global Confidential Outstanding Amount
auto const oldCOA = (*sleIssuance)[sfConfidentialOutstandingAmount];
@@ -345,7 +345,7 @@ ConfidentialMPTConvert::doApply()
// Initialize key epochs when registering the keys.
if (view().rules().enabled(featureConfidentialMPTKeyRotation))
setMirrorEpochs(*sleIssuance, *sleMptoken);
setMirrorsCurrent(*sleIssuance, *sleMptoken);
// Spending balance starts at zero. Must use canonical zero encryption
// (deterministic ciphertext) so the ledger state is reproducible.
@@ -85,6 +85,9 @@ ConfidentialMPTHolderKeyUpdate::preflight(PreflightContext const& ctx)
if (hasInbox && ctx.tx[sfConfidentialBalanceInbox].length() != kEcGamalEncryptedTotalLength)
return temBAD_CIPHERTEXT;
// TODO: Check the sfZKProof length for each mode once the proof sizes are available from
// mpt-crypto.
if (!isValidCompressedECPoint(ctx.tx[sfHolderEncryptionKey]))
return temMALFORMED;
@@ -143,6 +146,9 @@ ConfidentialMPTHolderKeyUpdate::preclaim(PreclaimContext const& ctx)
if (ctx.tx.isFlag(tfHolderKeyRecovery) && sleMptoken->isFieldPresent(sfRecoveryKey))
return tecNO_PERMISSION;
// TODO: Verify sfZKProof. Rotation mode proves the new spending and inbox ciphertexts hold the
// same amounts as the current ones under the new key; Recovery mode proves knowledge of the
// private key for the new sfHolderEncryptionKey.
return tesSUCCESS;
}
@@ -201,6 +201,9 @@ ConfidentialMPTMirrorUpdate::preclaim(PreclaimContext const& ctx)
return tecNO_PERMISSION;
}
// TODO: Verify sfZKProof. Issuer mode must anchor on sfIssuerEncryptedBalance under the
// key that encrypted it; Holder mode anchors on sfConfidentialBalanceSpending under
// sfHolderEncryptionKey.
return tesSUCCESS;
}
@@ -241,7 +244,7 @@ ConfidentialMPTMirrorUpdate::doApply()
if (ctx_.tx.isFieldPresent(sfIssuerEncryptedAmount))
{
(*sleMptoken)[sfIssuerEncryptedBalance] = ctx_.tx[sfIssuerEncryptedAmount];
setIssuerMirrorEpoch(*sleIssuance, *sleMptoken);
setIssuerMirrorCurrent(*sleIssuance, *sleMptoken);
}
if (ctx_.tx.isFieldPresent(sfAuditorEncryptedAmount))
@@ -453,10 +453,7 @@ MPTokenIssuanceSet::doApply()
return tecINTERNAL; // LCOV_EXCL_LINE
// NOTE: presence must be checked before the key is overwritten below.
bool const isRotation = sle->isFieldPresent(keyField);
sle->setFieldVL(keyField, *pubKey);
if (isRotation)
if (sle->isFieldPresent(keyField))
{
// Preclaim rejects overwriting an existing key unless the amendment is
// enabled.
@@ -479,9 +476,20 @@ MPTokenIssuanceSet::doApply()
// LCOV_EXCL_STOP
}
// Proofs over sfIssuerEncryptedBalance must be verified against the issuer key
// that encrypted the mirror balance.
//
// - Mirrors migrated after an issuer key rotation store their new key in
// MPToken's sfIssuerMirrorEncryptionKey.
// - Mirrors that were never migrated carry no record and remain under the original key.
//
// Save the original key here in MPTokenIssuance object during the first key rotation
if (keyField == sfIssuerEncryptionKey && !sle->isFieldPresent(epochField))
(*sle)[sfInitialIssuerEncryptionKey] = (*sle)[keyField];
(*sle)[epochField] = epoch + 1;
}
(*sle)[keyField] = *pubKey;
return tesSUCCESS;
};
@@ -12,12 +12,15 @@
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/Rate.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTakesAsset.h>
@@ -49,6 +52,26 @@ shouldWaiveWithdrawal(ReadView const& view, AccountID const& account, SLE::Const
: WaiveUnrealizedLoss::No;
}
static Rate
withdrawalTransferRate(
ReadView const& view,
AccountID const& account,
AccountID const& destination,
Asset const& asset)
{
// Pre-fixCleanup3_5_0: every vault withdrawal waives the transfer fee.
// Post-fixCleanup3_5_0: the fee applies only when a holder other than the issuer withdraws to
// another such holder.
if (!view.rules().enabled(fixCleanup3_5_0))
return kParityRate;
if (asset.native() || destination == account || account == asset.getIssuer() ||
destination == asset.getIssuer())
return kParityRate;
return transferRate(view, asset);
}
NotTEC
VaultWithdraw::preflight(PreflightContext const& ctx)
{
@@ -170,11 +193,13 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
if (!maybeAssets)
return tefINTERNAL; // LCOV_EXCL_LINE
auto const rate = withdrawalTransferRate(ctx.view, account, dstAcct, vaultAsset);
auto const amountToReceive = subtractTransferFee(*maybeAssets, rate);
if (auto const ret = canWithdraw(
ctx.view,
account,
dstAcct,
*maybeAssets,
amountToReceive,
ctx.tx.isFieldPresent(sfDestinationTag),
ctx.tx[~sfCredentialIDs]))
return ret;
@@ -302,6 +327,8 @@ VaultWithdraw::doApply()
auto const amount = ctx_.tx[sfAmount];
Asset const vaultAsset = vault->at(sfAsset);
auto const dstAcct = ctx_.tx[~sfDestination].value_or(accountID_);
auto const rate = withdrawalTransferRate(view(), accountID_, dstAcct, vaultAsset);
MPTIssue const share{mptIssuanceID};
STAmount sharesRedeemed = {share};
@@ -330,7 +357,11 @@ VaultWithdraw::doApply()
view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes : TruncateShares::No;
{
auto const maybeShares = assetsToSharesWithdraw(
vault, sleIssuance, amount, truncate, waiveUnrealizedLoss);
vault,
sleIssuance,
addTransferFee(amount, rate),
truncate,
waiveUnrealizedLoss);
if (!maybeShares)
return tecINTERNAL; // LCOV_EXCL_LINE
sharesRedeemed = *maybeShares;
@@ -523,6 +554,13 @@ VaultWithdraw::doApply()
assetsWithdrawn = allAvailable;
}
auto const assetsDelivered = subtractTransferFee(assetsWithdrawn, rate);
if (assetsWithdrawn > beast::kZero && assetsDelivered == beast::kZero)
{
JLOG(j_.debug()) << "VaultWithdraw: transfer fee reduces the payout to zero";
return tecPRECISION_LOSS;
}
if (finalPayout && getVaultVersion(vault) != VaultVersion::FixedPrecision)
{
// Do not let dust accumulate in the Vault.
@@ -573,9 +611,15 @@ VaultWithdraw::doApply()
associateAsset(*vault, vaultAsset);
auto const dstAcct = ctx_.tx[~sfDestination].value_or(accountID_);
return doWithdraw(
applyViewContext, accountID_, dstAcct, vaultAccount, preFeeBalance_, assetsWithdrawn, j_);
applyViewContext,
accountID_,
dstAcct,
vaultAccount,
preFeeBalance_,
assetsDelivered,
assetsWithdrawn,
j_);
}
void
+365
View File
@@ -0,0 +1,365 @@
#pragma once
#include <test/jtx/PeerStub.h>
#include <xrpld/app/ledger/ConsensusTransSetSF.h>
#include <xrpld/overlay/Peer.h>
#include <xrpld/overlay/PeerSet.h>
#include <xrpl/basics/SHAMapHash.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/resource/Charge.h>
#include <xrpl/shamap/SHAMapAddNode.h>
#include <xrpl/shamap/SHAMapNodeID.h>
#include <xrpl/shamap/SHAMapTreeNode.h>
#include <tests/libxrpl/shamap/DeepChain.h>
#include <xrpl.pb.h>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <thread>
#include <utility>
#include <vector>
namespace xrpl::test {
// The chain builder needs only libxrpl, so it is shared with the gtest suites; see the header for
// what keeps the protobuf reply builder below in this tree.
using tests::DeepChain;
// A smallest-possible leaf plus its 4-byte HashPrefix lands one byte short of the floor
// ConsensusTransSetSF::gotNode() parses at, so a chain's leaf stays below the parse threshold.
static_assert(
sizeof(std::uint32_t) + DeepChain::kLeafItemBytes < ConsensusTransSetSF::kMinTxNodeBytesToParse,
"a smallest-possible leaf must stay below the resubmission floor");
/**
* A peer that records what it was charged. Every other method comes from
* PeerStub.
*
* One instance per packet keeps charges() unambiguous about which packet was
* charged what.
*
* charges_ is unguarded: charging happens on the packet path, so every charge
* lands on the thread that fed the packet in.
*/
class ChargeRecordingPeer : public PeerStub
{
public:
/**
* @param hasTxSet What hasTxSet() reports, which is how an acquisition
* decides whether this peer is worth asking. Defaults to true, so a
* peer handed straight to takeNodes() needs no argument.
*/
explicit ChargeRecordingPeer(bool hasTxSet = true) : PeerStub(nextId()), hasTxSet_(hasTxSet)
{
}
void
charge(resource::Charge const& fee, std::string const&) override
{
charges_.push_back(fee);
}
/**
* @return Every fee this peer has been charged, in the order charged.
*/
[[nodiscard]] std::vector<resource::Charge> const&
charges() const
{
return charges_;
}
// PeerStub returns false for both, and an acquisition asks the peers reporting true.
[[nodiscard]] bool
hasTxSet(UInt256 const&) const override
{
return hasTxSet_;
}
[[nodiscard]] bool
hasLedger(UInt256 const&, std::uint32_t) const override
{
return true;
}
private:
/**
* The next id to hand out, distinct per instance because
* RequestCountingPeerSet dedups by tracked id and PeerStub's own default is
* zero for every instance.
*
* @return The id.
*/
[[nodiscard]] static ID
nextId()
{
static std::atomic<ID> next{1};
return next++;
}
std::vector<resource::Charge> charges_;
bool hasTxSet_;
};
/**
* A peer set that counts the requests an acquisition makes through it. Offers
* peers to a hasItem/onPeerAdded callback pair, hard-filtered by hasItem (which
* only scores in the real peer set) and deduped by tracked id.
*
* A count is a call, not a delivery: a request naming no peer reaches nobody
* while this set tracks none. requests() and broadcasts() are counted apart.
*
* Every write here is guarded, since the retry timer drives addPeers() and
* sendRequest() from a job thread while the test reads the results.
*/
class RequestCountingPeerSet : public PeerSet
{
public:
/**
* @param candidates The peers addPeers() may offer, in the order they are
* considered. Fixed at construction. Empty offers no one.
*/
explicit RequestCountingPeerSet(std::vector<std::shared_ptr<Peer>> candidates = {})
: candidates_(std::move(candidates))
{
}
/**
* Offer the candidates to the caller, the way the real peer set offers the
* peers the overlay is tracking.
*
* @param limit The most peers to add, recorded for firstLimit().
* @param hasItem Hard-filters the candidates worth asking, where the real
* peer set only scores with it.
* @param onPeerAdded Called for each selected candidate.
*/
void
addPeers(
std::size_t limit,
std::function<bool(std::shared_ptr<Peer> const&)> hasItem,
std::function<void(std::shared_ptr<Peer> const&)> onPeerAdded) override
{
std::vector<std::shared_ptr<Peer>> selected;
{
std::scoped_lock const lock(mutex_);
if (!firstLimit_)
firstLimit_ = limit;
for (auto const& candidate : candidates_)
{
if (selected.size() >= limit)
break;
// Dedup by tracked id, like the real peer set: onPeerAdded runs once per candidate.
if (hasItem(candidate) && addedPeers_.insert(candidate->id()).second)
selected.push_back(candidate);
}
}
// Outside the lock: onPeerAdded() reenters this object through sendRequest().
for (auto const& peer : selected)
onPeerAdded(peer);
}
/**
* Record the request, and record it in the broadcast count as well when it
* names no peer.
*
* @param peer The peer to ask, or null to ask every tracked peer.
*/
void
sendRequest(
::google::protobuf::Message const&,
protocol::MessageType,
std::shared_ptr<Peer> const& peer) override
{
std::scoped_lock const lock(mutex_);
++requests_;
if (!peer)
++broadcasts_;
}
/**
* The ids of every peer addPeers() has selected, which is what an
* acquisition takes for the peers it is tracking.
*
* Unguarded: every caller of this and of addPeers() is an acquisition
* holding its own mtx_, so the ids stay fixed while a caller iterates. A
* test thread reads addedPeers() instead.
* InboundLedger::getPeerCount() reports zero for these, since it resolves
* ids through the overlay, while these peers live only in this harness.
*
* @return The ids.
*/
[[nodiscard]] std::set<Peer::ID> const&
getPeerIds() const override
{
return addedPeers_;
}
/**
* @return How many requests have been sent through this peer set, counting
* a broadcast as one.
*/
[[nodiscard]] int
requests() const
{
std::scoped_lock const lock(mutex_);
return requests_;
}
/**
* How many of those requests carried no peer of their own.
*
* @return The count.
*/
[[nodiscard]] int
broadcasts() const
{
std::scoped_lock const lock(mutex_);
return broadcasts_;
}
/**
* The limit the first addPeers() call asked for, which is init()'s, since
* onTimer() keeps calling addPeers(1) for as long as an acquisition runs.
*
* @return The limit, or nullopt if addPeers() has not been called.
*/
[[nodiscard]] std::optional<std::size_t>
firstLimit() const
{
std::scoped_lock const lock(mutex_);
return firstLimit_;
}
/**
* A set, because onTimer() keeps re-offering the same candidates.
*
* @return The ids of every peer addPeers() has selected so far.
*/
[[nodiscard]] std::set<Peer::ID>
addedPeers() const
{
std::scoped_lock const lock(mutex_);
return addedPeers_;
}
private:
std::vector<std::shared_ptr<Peer>> const candidates_;
mutable std::mutex mutex_;
int requests_{0};
int broadcasts_{0};
std::optional<std::size_t> firstLimit_;
std::set<Peer::ID> addedPeers_;
};
/**
* The given nodes of a chain as a TMLedgerData, so a test can go through the
* real dispatch. Not in DeepChain, since the protobuf types are xrpld and that
* header is shared with the libxrpl-only gtest binary.
*
* @param chain The chain the nodes came from, which names the reply by default.
* @param data The nodes to include, each with its claimed position.
* @param type The reply type, which selects which map the receiver applies it
* to.
* @param ledgerHash The hash the reply claims to be about, defaulting to the
* chain root for a TX set. A ledger acquisition wants its header hash
* here, since the chain root is only that ledger's account hash.
* @param ledgerSeq The sequence to name in the reply.
* @return The reply packet.
*/
[[nodiscard]] inline std::shared_ptr<protocol::TMLedgerData>
packetFor(
DeepChain const& chain,
std::vector<std::pair<SHAMapNodeID, SHAMapTreeNodePtr>> const& data,
protocol::TMLedgerInfoType type = protocol::liTS_CANDIDATE,
std::optional<UInt256> const& ledgerHash = std::nullopt,
std::uint32_t ledgerSeq = 0)
{
auto packet = std::make_shared<protocol::TMLedgerData>();
auto const hash = ledgerHash.value_or(chain.rootHash.asUInt256());
packet->set_ledgerhash(hash.data(), UInt256::size());
packet->set_ledgerseq(ledgerSeq);
packet->set_type(type);
for (auto const& [nodeID, node] : data)
{
Serializer s;
node->serializeForWire(s);
auto* const ledgerNode = packet->add_nodes();
ledgerNode->set_nodedata(s.peekData().data(), s.peekData().size());
// A leaf carries its own key, so the receiver rebuilds its position from that plus a
// depth. An inner node has no key and needs the full ID. The two fields are a oneof.
if (node->isLeaf())
{
ledgerNode->set_depth(nodeID.getDepth());
}
else
{
ledgerNode->set_id(nodeID.getRawString());
}
}
return packet;
}
/**
* Poll until the condition holds, or give up. An acquisition's own timer and
* the jobs it hands finished work to both run on other threads.
*
* @param condition What to wait for.
* @param deadline The longest to wait.
* @return Whether the condition held before the deadline.
*/
[[nodiscard]] inline bool
waitFor(
std::function<bool()> const& condition,
std::chrono::steady_clock::duration deadline = std::chrono::seconds{10})
{
auto const giveUp = std::chrono::steady_clock::now() + deadline;
while (std::chrono::steady_clock::now() < giveUp)
{
if (condition())
return true;
std::this_thread::sleep_for(std::chrono::milliseconds{10});
}
return condition();
}
/**
* Whether a batch verdict carries exactly the given counts.
*
* The counts, since get() is a log format. It is pinned once, in the
* SHAMapAddNode tests, and is what to pass BEAST_EXPECTS() as the reason a
* check here failed.
*
* @param san The verdict to check.
* @param good How many nodes the batch should have hooked in.
* @param bad How many it should have rejected.
* @param duplicate How many it should have already held.
* @return Whether the verdict matches.
*/
[[nodiscard]] inline bool
tallyIs(SHAMapAddNode const& san, int good, int bad, int duplicate)
{
return san.getGood() == good && san.getBad() == bad && san.getDuplicate() == duplicate;
}
} // namespace xrpl::test
-2
View File
@@ -3309,8 +3309,6 @@ class Batch_test : public beast::unit_test::Suite
{
testcase("loan");
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
features = features - featureLendingProtocolV1_2;
bool const lendingBatchEnabled = features[featureLendingProtocolV1_2];
using namespace test::jtx;
@@ -3156,6 +3156,174 @@ class ConfidentialMPTKeyRotation_test : public ConfidentialTransferTestBase
}
}
void
testInitialIssuerEncryptionKey(FeatureBitset features)
{
testcase("MPTokenIssuanceSet initial issuer encryption key");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const auditor("auditor");
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(auditor);
// First-time registration of both keys is not a rotation, so no
// initial issuer key is captured.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor),
});
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
BEAST_EXPECT(mptAlice.checkInitialIssuerKey(std::nullopt));
// Rotating the auditor key alone must not capture an initial issuer
// key: only an issuer key rotation writes sfInitialIssuerEncryptionKey.
mptAlice.generateKeyPair(auditor);
mptAlice.set({.account = alice, .auditorPubKey = mptAlice.getPubKey(auditor)});
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 1u));
BEAST_EXPECT(mptAlice.checkInitialIssuerKey(std::nullopt));
// The first issuer rotation preserves the epoch 0 issuer key (alice's)
// as sfInitialIssuerEncryptionKey and bumps the issuer epoch to 1.
mptAlice.generateKeyPair(alice);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, 1u));
BEAST_EXPECT(mptAlice.checkInitialIssuerKey(mptAlice.getPubKey(alice, 0)));
// A second issuer rotation bumps the epoch again but leaves the initial
// issuer key unchanged: it remains alice's epoch 0 key.
mptAlice.generateKeyPair(alice);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
BEAST_EXPECT(mptAlice.checkKeyEpochs(2u, 1u));
BEAST_EXPECT(mptAlice.checkInitialIssuerKey(mptAlice.getPubKey(alice, 0)));
// Rotating both keys in one transaction bumps both epochs, and the
// initial issuer key still remains alice's epoch 0 key.
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(auditor);
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor),
});
BEAST_EXPECT(mptAlice.checkKeyEpochs(3u, 2u));
BEAST_EXPECT(mptAlice.checkInitialIssuerKey(mptAlice.getPubKey(alice, 0)));
}
void
testIssuerMirrorEncryptionKey(FeatureBitset features)
{
testcase("ConfidentialMPT issuer mirror encryption key");
using namespace test::jtx;
std::uint64_t const amount = 100;
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const auditor("auditor");
// Convert path: setIssuerMirrorCurrent runs inside ConfidentialMPTConvert.
{
Env env{*this, features};
MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
setupConfidentialIssuance(mptAlice, alice, {bob, carol}, {auditor});
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor),
});
// bob converts while the issuer key sits at epoch 0, so his issuer
// mirror epoch and key are both left absent.
mptAlice.convert({
.account = bob,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(bob),
});
BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
BEAST_EXPECT(mptAlice.checkIssuerMirrorEncryptionKey(std::nullopt, bob));
// Rotate the issuer key to epoch 3, then carol converts: her issuer
// mirror is stamped at epoch 3 under the issuance's current issuer
// key.
for (int i = 0; i < 3; ++i)
{
mptAlice.generateKeyPair(alice);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
}
BEAST_EXPECT(mptAlice.checkKeyEpochs(3u, std::nullopt));
mptAlice.convert({
.account = carol,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(carol),
});
BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, 3u, std::nullopt));
BEAST_EXPECT(mptAlice.checkIssuerMirrorEncryptionKey(mptAlice.getPubKey(alice), carol));
}
// Migration path: setIssuerMirrorCurrent runs inside
// ConfidentialMPTMirrorUpdate, refreshing the mirror key on each
// migration to the issuance's then-current issuer key.
{
Env env{*this, features};
ConfidentialEnv ct{
env,
alice,
{{.account = bob}},
tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
auditor};
// bob converted at issuer epoch 0 during setup, so his issuer
// mirror key starts absent.
BEAST_EXPECT(ct.mpt.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
BEAST_EXPECT(ct.mpt.checkIssuerMirrorEncryptionKey(std::nullopt, bob));
// Rotate the issuer key twice (epoch 0 -> 2), leaving bob stale.
for (int i = 0; i < 2; ++i)
{
ct.mpt.generateKeyPair(alice);
ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(alice)});
}
// Migrate bob's issuer mirror to epoch 2 under alice's epoch 2 key.
Buffer const cipher2 = ct.mpt.encryptAmount(alice, amount, generateBlindingFactor());
ct.mpt.mirrorUpdate({
.account = alice,
.holder = bob,
.issuerEncryptedAmount = cipher2,
});
BEAST_EXPECT(ct.mpt.checkMirrorEpochs(bob, 2u, std::nullopt));
BEAST_EXPECT(ct.mpt.checkIssuerMirrorEncryptionKey(ct.mpt.getPubKey(alice, 2), bob));
// Rotate once more (epoch 3) and migrate again: the mirror key
// refreshes to the new issuance issuer key.
ct.mpt.generateKeyPair(alice);
ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(alice)});
Buffer const cipher3 = ct.mpt.encryptAmount(alice, amount, generateBlindingFactor());
ct.mpt.mirrorUpdate({
.account = alice,
.holder = bob,
.issuerEncryptedAmount = cipher3,
});
BEAST_EXPECT(ct.mpt.checkMirrorEpochs(bob, 3u, std::nullopt));
BEAST_EXPECT(ct.mpt.checkIssuerMirrorEncryptionKey(ct.mpt.getPubKey(alice, 3), bob));
}
}
public:
void
testMPTokenIssuanceSetWithFeats(FeatureBitset features)
@@ -3179,6 +3347,9 @@ public:
testMPTokenIssuanceSetWithFeats(all);
testMPTokenIssuanceSetWithFeats(all - featureConfidentialMPTKeyRotation);
testInitialIssuerEncryptionKey(all);
testIssuerMirrorEncryptionKey(all);
testConfidentialMPTConvertEpoch(all);
testConfidentialMPTConvertEpoch(all - featureConfidentialMPTKeyRotation);
testConfidentialMPTSendEpoch(all);
File diff suppressed because it is too large. Load diff
+694
View File
@@ -0,0 +1,694 @@
#include <test/app/AcquireTestHelpers.h>
#include <test/jtx/Env.h>
#include <xrpld/app/ledger/InboundLedger.h>
#include <xrpld/app/ledger/InboundLedgers.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/ledger/Ledger.h>
#include <xrpl/nodestore/NodeObject.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/LedgerHeader.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/shamap/SHAMapNodeID.h>
#include <xrpl.pb.h>
#include <chrono>
#include <memory>
#include <mutex>
#include <set>
#include <utility>
#include <vector>
namespace xrpl::test {
/**
* An acquisition that exposes the entry points its bases keep protected, so a
* case can reach them through this subclass alone.
*/
struct TestableInboundLedger final : InboundLedger
{
using InboundLedger::InboundLedger;
/**
* Look for the ledger locally, ask the peers being tracked for the rest,
* and arm the timer, as InboundLedgers::acquire() does. That caller holds
* its collection lock across init(), which releases it, so this stands in
* with a lock of its own. The mutex is declared first, so it outlives the
* lock.
*/
void
startAcquire()
{
std::recursive_mutex collectionMutex;
ScopedLockType collectionLock(collectionMutex);
init(collectionLock);
}
/**
* Hold the acquisition's own lock. The timer chain takes this lock in
* invokeOnTimer(), so holding it keeps the chain from running until the
* lock is released. The lock is recursive, so init() can take it again.
*
* @return The held lock.
*/
[[nodiscard]] ScopedLockType
lock() const
{
return ScopedLockType(mtx_);
}
/**
* Ask for more nodes, or judge what has been collected, as a fresh
* acquisition does.
*/
void
triggerAdded()
{
trigger(nullptr, TriggerReason::Added);
}
/**
* Record that every part has been fetched.
*/
void
markComplete()
{
ScopedLockType const sl(mtx_);
complete_ = true;
}
/**
* Settle the acquisition and signal whatever is waiting on it.
*/
void
signalDone()
{
ScopedLockType const sl(mtx_);
done();
}
};
/**
* The ledger an acquisition is assembling, as a pointer that can modify it.
*
* @param acquire The acquisition to read from.
* @return The ledger, or nullptr if there is none to report.
*/
[[nodiscard]] static std::shared_ptr<Ledger>
mutableLedger(InboundLedger const& acquire)
{
// Sound because the acquisition holds a non-const ledger and only hands out a const view.
return std::const_pointer_cast<Ledger>(acquire.getLedger());
}
struct InboundLedger_test : public beast::unit_test::Suite
{
/**
* A retry interval short enough that a whole timeout chain costs a fraction
* of a second. TimeoutCounter refuses anything at or below 10ms.
*/
static constexpr auto kFastRetry = std::chrono::milliseconds{20};
/**
* A seed unique to this chain within the suite.
*
* The Env below is shared, and its node store, fetch packs and remembered
* failures are all keyed by hash. A fresh seed per chain gives every chain
* distinct hashes, so each case resolves only its own nodes.
*
* @return The seed.
*/
[[nodiscard]] unsigned int
nextSeed()
{
return ++seed_;
}
/**
* A ledger header naming the given map roots, with a hash derived from the
* fields, so an acquisition accepts the header as its own however the roots
* are chosen.
*
* @param txHash The transaction map root. Zero means no transactions.
* @param accountHash The state map root. Zero is a ledger no acquisition
* can finish.
* @return The header, with its hash filled in.
*/
static LedgerHeader
makeHeader(UInt256 const& txHash, UInt256 const& accountHash)
{
LedgerHeader header;
header.seq = 2;
header.parentCloseTime = NetClock::time_point{};
header.closeTime = NetClock::time_point{};
header.closeTimeResolution = NetClock::duration{10};
header.closeFlags = 0;
header.txHash = txHash;
header.accountHash = accountHash;
header.hash = calculateLedgerHash(header);
return header;
}
/**
* The common shape: no transactions, so only the state map is in play.
*
* @param chain The chain whose root to name as the state hash.
* @return The header, with its hash filled in.
*/
static LedgerHeader
makeHeader(DeepChain const& chain)
{
return makeHeader(UInt256{}, chain.rootHash.asUInt256());
}
/**
* Put the header in the local store, which is the first place tryDB()
* looks. The store keeps its entries, where a fetch pack hands each out
* once, so more than one acquisition of the same ledger can find it.
*
* @param env The environment whose node store to seed.
* @param header The header to store, keyed by its own hash.
*/
static void
storeHeader(jtx::Env& env, LedgerHeader const& header)
{
Serializer s;
s.add32(HashPrefix::LedgerMaster);
addRaw(header, s);
env.app().getNodeFamily().db().store(
NodeObjectType::Ledger, std::move(s.modData()), header.hash, header.seq);
}
/**
* Put every node of a chain in the local store, so a state-map walk
* resolves the whole map from the store.
*
* @param env The environment whose node store to seed.
* @param header The header whose sequence the nodes are stored under.
* @param chain The chain supplying the nodes.
* @param maxDepth The deepest node to store, so a caller can leave a walk
* something to ask for.
*/
static void
storeStateNodes(
jtx::Env& env,
LedgerHeader const& header,
DeepChain const& chain,
unsigned int maxDepth)
{
auto& db = env.app().getNodeFamily().db();
for (auto depth = 0u; depth <= maxDepth; ++depth)
{
db.store(
NodeObjectType::AccountNode,
chain.prefixedNodeAt(depth),
chain.nodeAt(depth)->getHash().asUInt256(),
header.seq);
}
}
/**
* The header alone as a liBASE reply, so takeHeader() alone judges the
* packet.
*
* @param header The header to carry, serialized without a hash prefix, as
* takeHeader() expects.
* @return The reply packet.
*/
static std::shared_ptr<protocol::TMLedgerData>
headerPacket(LedgerHeader const& header)
{
Serializer s;
addRaw(header, s);
auto packet = std::make_shared<protocol::TMLedgerData>();
packet->set_ledgerhash(header.hash.data(), UInt256::size());
packet->set_ledgerseq(header.seq);
packet->set_type(protocol::liBASE);
packet->add_nodes()->set_nodedata(s.peekData().data(), s.peekData().size());
return packet;
}
/**
* An acquisition started with nothing in the store for its hash, so it
* waits on its peers.
*
* @param env The environment to run in.
* @param header The header of the ledger to acquire.
* @return The acquisition, started.
*/
static std::shared_ptr<TestableInboundLedger>
startPeerAcquire(jtx::Env& env, LedgerHeader const& header)
{
auto acquire = std::make_shared<TestableInboundLedger>(
env.app(),
header.hash,
header.seq,
InboundLedger::Reason::GENERIC,
stopwatch(),
std::make_unique<RequestCountingPeerSet>());
acquire->startAcquire();
return acquire;
}
/**
* A ledger whose maps all resolve locally finishes on the spot, and the
* finished ledger is immutable and handed on.
*
* Both entry points into tryDB() are driven: InboundLedgers::acquire(), the
* only caller of init(), and checkLocal(), which reaches done().
*
* @param env The environment to run in.
*/
void
testLocalLedgerCompletesAcquire(jtx::Env& env)
{
testcase("A ledger found locally completes the acquire");
// A chain ending in a real leaf, so the state map is complete rather than merely rooted.
// No transactions, so only the state map is in play.
auto const chain = DeepChain::toLeaf(2, nextSeed());
auto const header = makeHeader(chain);
storeHeader(env, header);
storeStateNodes(env, header, chain, chain.deepestDepth);
// acquire() returns the ledger only once the acquisition is complete and unfailed, so a
// non-null result shows tryDB() found it in the store.
auto const acquired = env.app().getInboundLedgers().acquire(
header.hash, header.seq, InboundLedger::Reason::GENERIC);
BEAST_EXPECT(acquired != nullptr);
if (acquired)
{
BEAST_EXPECT(acquired->isImmutable());
BEAST_EXPECT(acquired->header().hash == header.hash);
}
// init() hands a ledger it completed to LedgerMaster itself.
BEAST_EXPECT(env.app().getLedgerMaster().getLedgerByHash(header.hash) != nullptr);
// Nothing on init()'s path records a failure, since init() never reaches done(). This is
// the baseline for the check after checkLocal() below: an entry found there came from
// done().
BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(header.hash));
// The reason decides where a settled ledger goes: HISTORY counts it toward the fetch rate
// instead of handing it to LedgerMaster.
{
auto const historyChain = DeepChain::toLeaf(2, nextSeed());
auto const historyHeader = makeHeader(historyChain);
storeHeader(env, historyHeader);
storeStateNodes(env, historyHeader, historyChain, historyChain.deepestDepth);
// onLedgerFetched() is the only writer of this rate, and done() calls it before it
// posts any work, so the read below is not racing the job queue.
auto const rateBefore = env.app().getInboundLedgers().fetchRate();
auto const history = std::make_shared<InboundLedger>(
env.app(),
historyHeader.hash,
historyHeader.seq,
InboundLedger::Reason::HISTORY,
stopwatch(),
std::make_unique<RequestCountingPeerSet>());
BEAST_EXPECT(history->checkLocal());
BEAST_EXPECT(history->isComplete());
BEAST_EXPECT(!history->isFailed());
// The switch the rate check reads is reached only once the ledger has been settled.
auto const historyLedger = history->getLedger();
BEAST_EXPECT(historyLedger != nullptr);
if (historyLedger)
BEAST_EXPECT(historyLedger->isImmutable());
BEAST_EXPECT(env.app().getInboundLedgers().fetchRate() > rateBefore);
}
// The same ledger through checkLocal(), which unlike init() reaches done().
auto const again = std::make_shared<InboundLedger>(
env.app(),
header.hash,
header.seq,
InboundLedger::Reason::GENERIC,
stopwatch(),
std::make_unique<RequestCountingPeerSet>());
// True because the acquisition ended, which it reports only after done() has run.
BEAST_EXPECT(again->checkLocal());
BEAST_EXPECT(again->isComplete());
BEAST_EXPECT(!again->isFailed());
auto const settled = again->getLedger();
BEAST_EXPECT(settled != nullptr);
if (settled)
BEAST_EXPECT(settled->isImmutable());
// done() records a failure only inside the AcqDone job it queues, and checkLocal() returns
// before that job runs. Wait for the job, so the list is read after done() has had its say.
env.app().getJobQueue().rendezvous();
BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(header.hash));
}
/**
* A ledger whose map goes invalid on the way to being settled must be
* discarded rather than delivered.
*
* done() settles the ledger before it logs or acts on the outcome, and an
* abandoned map makes settling refuse, so the acquisition records a
* failure.
*
* @param env The environment to run in.
*/
void
testInvalidatedLedgerFailsInDone(jtx::Env& env)
{
testcase("A ledger invalidated on its way to being settled fails");
// The fabricated chain, so feeding it to the state map invalidates the map.
DeepChain const chain{nextSeed()};
// Only the header is local, so the acquisition holds a ledger with an empty state map.
auto const header = makeHeader(chain);
storeHeader(env, header);
auto const acquire = std::make_shared<TestableInboundLedger>(
env.app(),
header.hash,
header.seq,
InboundLedger::Reason::GENERIC,
stopwatch(),
std::make_unique<RequestCountingPeerSet>());
BEAST_EXPECT(!acquire->checkLocal());
BEAST_EXPECT(!acquire->isFailed());
BEAST_EXPECT(!acquire->isComplete());
auto const ledger = mutableLedger(*acquire);
BEAST_EXPECT(ledger != nullptr);
if (!ledger)
return;
// The state of affairs done() is handed: every part fetched, as far as the caller can tell.
acquire->markComplete();
// And the walk that has since reached the verdict.
auto& stateMap = ledger->stateMap();
BEAST_EXPECT(stateMap.addRootNode(chain.rootHash, chain.nodeAt(0), nullptr).isGood());
for (auto const& [nodeID, node] : chain.nodesBelowRoot())
stateMap.addKnownNode(nodeID, node, nullptr);
BEAST_EXPECT(!stateMap.isValid());
acquire->signalDone();
// complete_ is withdrawn alongside the failure, or every guard that checks it before
// failed_ keeps treating this ledger as delivered.
BEAST_EXPECT(!acquire->isComplete());
BEAST_EXPECT(acquire->isFailed());
// This failure leaves ledger_ set, so only the failed_ guard in getLedger() withholds it.
BEAST_EXPECT(acquire->getLedger() == nullptr);
// getLedgerByHash answers null for the hash, and the hash is remembered as a failure,
// which defers re-acquisition.
BEAST_EXPECT(env.app().getLedgerMaster().getLedgerByHash(header.hash) == nullptr);
BEAST_EXPECT(waitFor([&] { return env.app().getInboundLedgers().isFailure(header.hash); }));
}
/**
* An acquisition that fails on local data must still signal.
*
* Both entry points that reach tryDB() are covered. done() is what
* signals, runs logFailure(), and lands the hash in recentFailures_,
* which stops the next round asking for the same ledger.
* recentFailures_ is what the assertions watch, since it is the
* caller-visible consequence of having signaled.
*
* @param env The environment to run in.
*/
void
testLocalFailureSignalsDone(jtx::Env& env)
{
testcase("An acquisition that fails locally still signals");
// A zero account hash is a ledger no acquisition can ever finish, and tryDB() says so as
// soon as it has the header.
auto const header = makeHeader(UInt256{}, UInt256{});
storeHeader(env, header);
BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(header.hash));
// acquire() is the only caller of init(), and returns nullptr for a failed acquisition.
BEAST_EXPECT(
env.app().getInboundLedgers().acquire(
header.hash, header.seq, InboundLedger::Reason::GENERIC) == nullptr);
// The failure reached recentFailures_, which is what stops the next round asking again.
BEAST_EXPECT(waitFor([&] { return env.app().getInboundLedgers().isFailure(header.hash); }));
// The other way tryDB() judges a ledger unobtainable: the header it found is not the one
// asked for. A non-zero account hash, so the route above cannot be what fails this one.
auto const strayHeader = makeHeader(UInt256{}, UInt256{2});
storeHeader(env, strayHeader);
BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(strayHeader.hash));
// acquire() hands the sequence to the acquisition unscreened, so one past the stored
// header's is enough to make tryDB() reject what it reads.
auto const wrongSeq = strayHeader.seq + 1;
BEAST_EXPECT(
env.app().getInboundLedgers().acquire(
strayHeader.hash, wrongSeq, InboundLedger::Reason::GENERIC) == nullptr);
// tryDB() discards the ledger it built before it gives up, so done() runs here with none
// to report. The failure is recorded all the same, which is what the call is for.
BEAST_EXPECT(
waitFor([&] { return env.app().getInboundLedgers().isFailure(strayHeader.hash); }));
// The other route into tryDB(): a trigger() on an acquisition that has no header yet. A
// hash of its own, so this drives a fresh entry.
auto const otherHeader = makeHeader(UInt256{1}, UInt256{});
storeHeader(env, otherHeader);
auto const viaTrigger = std::make_shared<TestableInboundLedger>(
env.app(),
otherHeader.hash,
otherHeader.seq,
InboundLedger::Reason::GENERIC,
stopwatch(),
std::make_unique<RequestCountingPeerSet>());
BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(otherHeader.hash));
viaTrigger->triggerAdded();
// Failed before any header state was published: no ledger, and have_header false.
BEAST_EXPECT(viaTrigger->isFailed());
BEAST_EXPECT(!viaTrigger->isComplete());
BEAST_EXPECT(viaTrigger->getLedger() == nullptr);
BEAST_EXPECT(!viaTrigger->getJson(0)[jss::have_header].asBool());
BEAST_EXPECT(
waitFor([&] { return env.app().getInboundLedgers().isFailure(otherHeader.hash); }));
}
/**
* A peer-supplied header whose account hash is zero fails the acquisition
* and signals, as a local one does. The peer is not charged, and
* recentFailures_ is what shows done() ran.
*
* @param env The environment to run in.
*/
void
testPeerZeroAccountHashFails(jtx::Env& env)
{
testcase("A peer header with a zero account hash fails the acquire");
// A real transaction root and a zero state root. Nothing is stored for this hash, so only a
// peer can supply the header.
auto const chain = DeepChain::toLeaf(2, nextSeed());
auto const header = makeHeader(chain.rootHash.asUInt256(), UInt256{});
BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(header.hash));
auto const acquire = startPeerAcquire(env, header);
BEAST_EXPECT(!acquire->isFailed());
BEAST_EXPECT(!acquire->getJson(0)[jss::have_header].asBool());
auto const peer = std::make_shared<ChargeRecordingPeer>();
BEAST_EXPECT(acquire->gotData(peer, headerPacket(header)));
acquire->runData();
// Failed, not complete, and the header is dropped, as tryDB() leaves one.
BEAST_EXPECT(acquire->isFailed());
BEAST_EXPECT(!acquire->isComplete());
BEAST_EXPECT(acquire->getLedger() == nullptr);
BEAST_EXPECT(!acquire->getJson(0)[jss::have_header].asBool());
// The header is the one asked for, so the sender is not to blame.
BEAST_EXPECT(peer->charges().empty());
// done() ran, so the hash reached recentFailures_, which stops the next round asking again.
BEAST_EXPECT(waitFor([&] { return env.app().getInboundLedgers().isFailure(header.hash); }));
}
/**
* A peer-supplied header with a zero transaction hash is taken, since that
* is an empty transaction set, and the acquisition finishes once the peer
* supplies the state map.
*
* @param env The environment to run in.
*/
void
testPeerHeaderWithoutTransactionsCompletes(jtx::Env& env)
{
testcase("A peer header with no transactions completes the acquire");
// No transactions, and a state map that ends in a real leaf, so the acquisition can finish.
auto const chain = DeepChain::toLeaf(2, nextSeed());
auto const header = makeHeader(chain);
auto const acquire = startPeerAcquire(env, header);
auto const peer = std::make_shared<ChargeRecordingPeer>();
BEAST_EXPECT(acquire->gotData(peer, headerPacket(header)));
acquire->runData();
// The header is taken, the empty transaction map counts as fetched, and the state map is
// what remains.
BEAST_EXPECT(!acquire->isFailed());
BEAST_EXPECT(!acquire->isComplete());
auto const json = acquire->getJson(0);
BEAST_EXPECT(json[jss::have_header].asBool());
BEAST_EXPECT(json[jss::have_transactions].asBool());
BEAST_EXPECT(!json[jss::have_state].asBool());
// The whole state map as state-node replies, root first, which completes the acquisition.
// The second gotData() answers false, since the first already asked for a dispatch.
BEAST_EXPECT(acquire->gotData(
peer,
packetFor(
chain,
{{SHAMapNodeID{}, chain.nodeAt(0)}},
protocol::liAS_NODE,
header.hash,
header.seq)));
BEAST_EXPECT(!acquire->gotData(
peer,
packetFor(
chain, chain.nodesBelowRoot(), protocol::liAS_NODE, header.hash, header.seq)));
acquire->runData();
BEAST_EXPECT(acquire->isComplete());
BEAST_EXPECT(!acquire->isFailed());
BEAST_EXPECT(peer->charges().empty());
auto const settled = acquire->getLedger();
BEAST_EXPECT(settled != nullptr);
if (settled)
BEAST_EXPECT(settled->isImmutable());
BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(header.hash));
}
/**
* The retry timer re-asks, then gives up and signals.
*
* The only case that drives onTimer() rather than trigger() directly,
* which is what covers the give-up: past kLedgerTimeoutRetriesMax the
* acquisition fails itself and done() records that, so the same
* doomed ledger is not asked for again on the next round. It is also
* what the retry interval is a constructor parameter for, since the
* chain runs past kLedgerTimeoutRetriesMax ticks of three seconds
* apiece in production.
*
* The store is empty for this hash and no reply arrives, so every tick
* counts a timeout and the count climbs to the limit. A hash of its own,
* so this case owns its entry in the failure list.
*
* @param env The environment to run in.
*/
void
testTimerRetriesThenGivesUp(jtx::Env& env)
{
testcase("The retry timer re-asks, then gives up");
UInt256 const kUnknownLedger{8};
// One candidate, which onTimer() re-offers on every tick.
auto const candidate = std::make_shared<ChargeRecordingPeer>();
auto peerSet =
std::make_unique<RequestCountingPeerSet>(std::vector<std::shared_ptr<Peer>>{candidate});
auto const* const peerSetPtr = peerSet.get();
auto acquire = std::make_shared<TestableInboundLedger>(
env.app(),
kUnknownLedger,
0,
InboundLedger::Reason::GENERIC,
stopwatch(),
std::move(peerSet),
kFastRetry);
BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(kUnknownLedger));
// init() finds the hash absent from the store, so it asks the candidate and queues the
// first tick as a job at once rather than arming the timer. The case counts from here, past
// those first requests. The lock keeps the chain from running until the baseline is
// recorded. Without it the whole chain (kLedgerTimeoutRetriesMax + 1 ticks, the later ones
// at kFastRetry) can finish first, and the count below would have nothing to climb from.
int requestsFromInit = 0;
{
auto const held = acquire->lock();
acquire->startAcquire();
BEAST_EXPECT(!acquire->isFailed());
requestsFromInit = peerSetPtr->requests();
}
BEAST_EXPECT(requestsFromInit > 0);
BEAST_EXPECT(peerSetPtr->addedPeers() == std::set<Peer::ID>{candidate->id()});
// Every tick asks again, and past kLedgerTimeoutRetriesMax (6) the chain gives up.
BEAST_EXPECT(waitFor([&] { return acquire->isFailed(); }));
BEAST_EXPECT(!acquire->isComplete());
BEAST_EXPECT(peerSetPtr->requests() > requestsFromInit);
// done() remembered the hash, which is what stops the next round asking again.
BEAST_EXPECT(
waitFor([&] { return env.app().getInboundLedgers().isFailure(kUnknownLedger); }));
}
void
run() override
{
// One Env for the suite, since building one costs far more than any case here. Safe
// because every chain is seeded through nextSeed(): the node store, the fetch packs and
// the remembered failures are all shared, and all three are keyed by hash.
jtx::Env env{*this};
testLocalLedgerCompletesAcquire(env);
testInvalidatedLedgerFailsInDone(env);
testLocalFailureSignalsDone(env);
testPeerZeroAccountHashFails(env);
testPeerHeaderWithoutTransactionsCompletes(env);
// Last: the only case that waits out a whole timeout chain.
testTimerRetriesThenGivesUp(env);
}
private:
unsigned int seed_{0};
};
BEAST_DEFINE_TESTSUITE(InboundLedger, app, xrpl);
} // namespace xrpl::test
+11 -5
View File
@@ -11,6 +11,7 @@
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/basics/contract.h>
#include <xrpl/beast/insight/NullCollector.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
@@ -24,6 +25,7 @@
#include <cassert>
#include <memory>
#include <stdexcept>
#include <vector>
namespace xrpl::test {
@@ -72,11 +74,15 @@ public:
}
res->unshare();
// Accept ledger
res->setAccepted(
res->header().closeTime,
res->header().closeTimeResolution,
true /* close time correct*/);
// Accept the ledger. Thrown rather than asserted because a refusal leaves res unusable,
// and this helper is static, so BEAST_EXPECT is out of reach.
if (!res->setAccepted(
res->header().closeTime,
res->header().closeTimeResolution,
true /* close time correct*/))
{
Throw<std::runtime_error>("makeLedger: ledger could not be accepted");
}
lh.insert(res, false);
return res;
}
+118
View File
@@ -28,17 +28,28 @@
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/random.h>
#include <xrpl/beast/net/IPAddress.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/xor_shift_engine.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/Ledger.h>
#include <xrpl/nodestore/Database.h>
#include <xrpl/nodestore/NodeObject.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerHeader.h>
#include <xrpl/protocol/RippleLedgerHash.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/server/Handoff.h>
#include <xrpl/shamap/Family.h>
#include <xrpl/shamap/SHAMap.h>
#include <xrpl/shamap/SHAMapItem.h>
#include <xrpl/shamap/SHAMapMissingNode.h> // SHAMapType
#include <xrpl/shamap/SHAMapTreeNode.h>
#include <boost/asio/ip/address.hpp>
@@ -64,6 +75,7 @@
#include <string>
#include <thread>
#include <unordered_set>
#include <utility>
#include <vector>
namespace xrpl::test {
@@ -1090,6 +1102,111 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
}
}
/**
* A ledger whose transaction map cannot be read in full must draw an error reply
* rather than a short transaction list.
*
* The two maps are built in memory and only part of them is written to the store: the
* whole state map, so the ledger loads, and the transaction map's root alone. The
* nodes below that root are never written, so the ledger the handler reads back from
* the store cannot reach them.
*/
void
testReplayDeltaIncompleteTxMap()
{
static constexpr auto kItems = 200;
static constexpr auto kSeed = 0xdeadbeefU;
// Far enough ahead of the server's own history that the missing-node handler finds
// no hash to acquire and returns at once.
static constexpr auto kSeqAhead = 1000;
testcase("ReplayDelta incomplete tx map");
LedgerServer server(*this, {.initLedgers = 1});
auto& family = server.app.getNodeFamily();
// The items only have to be well formed, because the walk stops before the handler
// reads any of them.
beast::XorShiftEngine engine{kSeed};
auto const makeItem = [&engine] {
static constexpr auto kWordsPerItem = 3;
Serializer s;
for (auto word = 0; word < kWordsPerItem; ++word)
s.add32(randInt<std::uint32_t>(engine));
return makeShamapitem(s.getSHA512Half(), s.slice());
};
SHAMap stateMap{SHAMapType::STATE, family};
SHAMap txMap{SHAMapType::TRANSACTION, family};
for (auto i = 0; i < kItems; ++i)
{
stateMap.addItem(SHAMapNodeType::TnAccountState, makeItem());
txMap.addItem(SHAMapNodeType::TnTransactionNm, makeItem());
}
BEAST_EXPECT(stateMap.setImmutable());
BEAST_EXPECT(txMap.setImmutable());
// Read both hashes before storing anything. getHash() is what computes the node
// hashes, through unshare(), so storing first would write every node under a stale
// hash and nothing would be findable afterwards.
LedgerHeader header = server.ledgerMaster.getClosedLedger()->header();
header.seq += kSeqAhead;
header.accountHash = stateMap.getHash().asUInt256();
header.txHash = txMap.getHash().asUInt256();
header.hash = calculateLedgerHash(header);
// Write a map's nodes into the store. visitNodes reports the root first, so
// rootOnly lets that one call through and stops at the next.
auto const storeMap = [&family](SHAMap const& map, bool rootOnly) {
auto stored = 0;
map.visitNodes([&family, rootOnly, &stored](SHAMapTreeNode& node) {
if (rootOnly && stored > 0)
return false;
Serializer s;
node.serializeWithPrefix(s);
family.db().store(
NodeObjectType::AccountNode,
std::move(s.modData()),
node.getHash().asUInt256(),
0);
++stored;
return true;
});
return stored;
};
BEAST_EXPECT(storeMap(stateMap, false) > 1);
BEAST_EXPECT(storeMap(txMap, true) == 1);
bool loaded = false;
auto const holed = std::make_shared<Ledger const>(
header,
loaded,
false,
server.ledgerMaster.getClosedLedger()->rules(),
server.ledgerMaster.getClosedLedger()->fees(),
family,
server.app.getLogs().journal("Ledger"));
// Both roots are readable, so the ledger loads and the handler accepts it.
BEAST_EXPECT(loaded);
BEAST_EXPECT(holed->isImmutable());
server.ledgerMaster.storeLedger(holed);
auto const request = std::make_shared<protocol::TMReplayDeltaRequest>();
request->set_ledgerhash(header.hash.data(), header.hash.size());
auto const reply = server.msgHandler.processReplayDeltaRequest(request);
// The reply names the missing nodes and carries no partial payload.
BEAST_EXPECT(reply.has_error());
BEAST_EXPECT(reply.error() == protocol::TMReplyError::reNO_NODE);
BEAST_EXPECT(reply.transaction_size() == 0);
BEAST_EXPECT(!reply.has_ledgerheader());
}
void
testTaskParameter()
{
@@ -1492,6 +1609,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
{
testProofPath();
testReplayDelta();
testReplayDeltaIncompleteTxMap();
testTruncatedHeader();
testTaskParameter();
testConfig();
+8
View File
@@ -43,6 +43,12 @@ struct PseudoTx_test : public beast::unit_test::Suite
obj[sfReserveIncrement] = 0;
obj[sfReferenceFeeUnits] = 0;
}
if (rules.enabled(featureSmartEscrow))
{
obj[sfGasLimit] = 0;
obj[sfBytecodeSizeLimit] = 0;
obj[sfGasPrice] = 0;
}
});
res.emplace_back(ttAMENDMENT, [&](auto& obj) {
@@ -107,7 +113,9 @@ struct PseudoTx_test : public beast::unit_test::Suite
FeatureBitset const all{testableAmendments()};
FeatureBitset const xrpFees{featureXRPFees};
testPrevented(all - featureXRPFees - featureSmartEscrow);
testPrevented(all - featureXRPFees);
testPrevented(all - featureSmartEscrow);
testPrevented(all);
testAllowed();
}
+1 -1
View File
@@ -102,7 +102,7 @@ class RCLValidations_test : public beast::unit_test::Suite
BEAST_EXPECT(next->read(keylet::feeSettings()));
if (forceHash)
{
next->setImmutable();
BEAST_EXPECT(next->setImmutable());
forceHash = false;
}
+73 -2
View File
@@ -20,6 +20,7 @@
#include <test/jtx/paths.h>
#include <test/jtx/pay.h>
#include <test/jtx/paychan.h>
#include <test/jtx/regkey.h>
#include <test/jtx/sendmax.h>
#include <test/jtx/seq.h>
#include <test/jtx/sig.h>
@@ -414,6 +415,59 @@ public:
sponsor::As(sponsor, spfSponsorReserve),
Sig(sfSponsorSignature, sponsor),
Ter(tesSUCCESS));
// Sponsor authorizes with its regular key -> tesSUCCESS
Account const regKey("regKey");
Account const other("other");
env.fund(XRP(10000), regKey, other);
env.close();
env(regkey(sponsor, regKey));
env.close();
env(noop(alice),
Fee(XRP(1)),
sponsor::As(sponsor, spfSponsorReserve),
Sig(sfSponsorSignature, regKey),
Ter(tesSUCCESS));
// Sponsor slot signed by an unrelated funded account's key -> tefBAD_AUTH
env(noop(alice),
Fee(XRP(1)),
sponsor::As(sponsor, spfSponsorReserve),
Sig(sfSponsorSignature, other),
Ter(tefBAD_AUTH));
// Sponsor with master key disabled signing with its master key
// -> tefMASTER_DISABLED
Account const sponsorDM("sponsorDM");
env.fund(XRP(10000), sponsorDM);
env.close();
env(regkey(sponsorDM, regKey));
env(fset(sponsorDM, asfDisableMaster), Sig(sponsorDM));
env.close();
env(noop(alice),
Fee(XRP(1)),
sponsor::As(sponsorDM, spfSponsorReserve),
Sig(sfSponsorSignature, sponsorDM),
Ter(tefMASTER_DISABLED));
// Sponsor keyed by a pseudo-account that carries a SponsorSignature
// -> tefBAD_AUTH (pseudo-accounts can't authorize signatures)
Account const gw("gw");
env.fund(XRP(1000000), gw);
env.close();
Vault const vault{env};
auto [vtx, vkey] = vault.create({.owner = alice, .asset = gw["IOU"].asset()});
env(vtx);
env.close();
auto const vaultSle = env.le(vkey);
BEAST_EXPECT(vaultSle);
Account const pseudoAcc("vault", vaultSle->getAccountID(sfAccount));
env.memoize(pseudoAcc);
env(noop(alice),
Fee(XRP(1)),
sponsor::As(pseudoAcc, spfSponsorReserve),
Sig(sfSponsorSignature, other),
Ter(tefBAD_AUTH));
}
void
@@ -472,6 +526,24 @@ public:
sponsor::As(sponsor, spfSponsorReserve),
Msig(sfSponsorSignature, {signer1, signer2}),
Ter(tesSUCCESS));
env.close();
// Quorum is 2 but only one signer signs -> tefBAD_QUORUM
env(noop(alice),
Fee(XRP(1)),
sponsor::As(sponsor, spfSponsorReserve),
Msig(sfSponsorSignature, {signer1}),
Ter(tefBAD_QUORUM));
// Signer that is not on the sponsor's signer list -> tefBAD_SIGNATURE
Account const stranger("stranger");
env.fund(XRP(10000), stranger);
env.close();
env(noop(alice),
Fee(XRP(1)),
sponsor::As(sponsor, spfSponsorReserve),
Msig(sfSponsorSignature, {stranger}),
Ter(tefBAD_SIGNATURE));
}
void
@@ -1859,8 +1931,7 @@ public:
{
// existing owner objects that are outside the v1 SponsorshipTransfer
// object allow-list
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env{*this, features - featureLendingProtocolV1_2};
Env env{*this, features};
Account const alice("alice");
Account const sponsor("sponsor");
env.fund(XRP(10000), alice, sponsor);
+525
View File
@@ -0,0 +1,525 @@
#include <test/app/AcquireTestHelpers.h>
#include <test/jtx/Env.h>
#include <xrpld/app/ledger/InboundTransactions.h>
#include <xrpld/app/ledger/detail/TransactionAcquire.h>
#include <xrpld/overlay/Peer.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/resource/Fees.h>
#include <xrpl/shamap/SHAMap.h>
#include <xrpl/shamap/SHAMapAddNode.h>
#include <xrpl/shamap/SHAMapNodeID.h>
#include <xrpl.pb.h>
#include <chrono>
#include <memory>
#include <set>
#include <utility>
#include <vector>
namespace xrpl::test {
/**
* An acquisition that exposes state its bases keep protected.
*/
struct TestableTransactionAcquire final : TransactionAcquire
{
using TransactionAcquire::TransactionAcquire;
/**
* Whether the set being acquired is still structurally coherent.
*
* @return Whether the map is still valid.
*/
[[nodiscard]] bool
isMapValid() const
{
// Under the lock: a batch on another thread can reach the verdict.
ScopedLockType const sl(mtx_);
return map_->isValid();
}
/**
* Whether a batch has advanced the set since the flag was last cleared.
*
* @return Whether progress has been recorded.
*/
[[nodiscard]] bool
madeProgress() const
{
// Under the lock: a timer tick clears the flag on a job thread.
ScopedLockType const sl(mtx_);
return progress_;
}
/**
* Forget any recorded progress.
*/
void
clearProgress()
{
ScopedLockType const sl(mtx_);
progress_ = false;
}
};
struct TransactionAcquire_test : public beast::unit_test::Suite
{
/**
* A retry interval short enough that a whole timeout chain costs a fraction
* of a second.
*
* TimeoutCounter refuses anything at or below 10ms. At this interval the
* window between the first retry (four timeouts in) and giving up (twenty)
* is still a third of a second, which is what the one case that watches
* both needs.
*/
static constexpr auto kFastRetry = std::chrono::milliseconds{20};
/**
* A seed no other chain in this suite has used.
*
* The Env below is shared, and ConsensusTransSetSF::gotNode() puts
* every node it accepts into the application-wide NodeCache while
* InboundTransactions keys its acquisitions by set hash. A fresh seed
* per chain gives every chain distinct hashes, so each case resolves
* and revives only its own.
*
* @return The seed.
*/
[[nodiscard]] unsigned int
nextSeed()
{
return ++seed_;
}
/**
* Whether takeNodes() declined to look at the data at all.
*
* TimeoutCounter::complete_ and failed_ are both protected, so this
* stands in for either: a done acquisition returns a verdict with every
* count at zero.
*
* @param san The verdict a takeNodes() call returned.
* @return Whether that verdict shows the data was never looked at.
*/
static bool
wasIgnored(SHAMapAddNode const& san)
{
return tallyIs(san, 0, 0, 0);
}
/**
* Wait for a finished set to reach InboundTransactions.
*
* done() hands the map over through a job, so a delivered set is what
* separates completion from a stop.
*
* @param env The environment whose InboundTransactions to watch.
* @param setHash The set to wait for.
* @return The delivered map, or nullptr if none arrived.
*/
[[nodiscard]] static std::shared_ptr<SHAMap>
waitForDeliveredSet(jtx::Env& env, UInt256 const& setHash)
{
auto& inbound = env.app().getInboundTransactions();
// acquire=false keeps this to a lookup rather than registering an acquisition. The job is
// queued before takeNodes() returns, so a few seconds is a generous deadline.
std::shared_ptr<SHAMap> delivered;
if (!waitFor(
[&] { return (delivered = inbound.getSet(setHash, false)) != nullptr; },
std::chrono::seconds{5}))
return nullptr;
return delivered;
}
/**
* A chain ending in a leaf completes the acquisition, which stops the
* asking and hands the map to InboundTransactions. Later replies for it
* are then left alone.
*
* @param env The environment to run in.
*/
void
testHappyPathCompletesAcquisition(jtx::Env& env)
{
testcase("A chain ending in a leaf completes the acquire");
auto const chain = DeepChain::toLeaf(3, nextSeed());
auto peerSet = std::make_unique<RequestCountingPeerSet>();
auto const* const peerSetPtr = peerSet.get();
UInt256 const setHash = chain.rootHash.asUInt256();
auto const acquire =
std::make_shared<TransactionAcquire>(env.app(), setHash, std::move(peerSet));
auto const peer = std::make_shared<ChargeRecordingPeer>();
// The root alone leaves the set incomplete, so accepting it asks for the level below.
auto const rootResult = acquire->takeNodes({{SHAMapNodeID{}, chain.nodeAt(0)}}, peer);
BEAST_EXPECT(rootResult.isUseful());
int const requestsWhileIncomplete = peerSetPtr->requests();
BEAST_EXPECT(requestsWhileIncomplete > 0);
// The rest of the chain, ending in the leaf.
auto const result = acquire->takeNodes(chain.nodesBelowRoot(), peer);
BEAST_EXPECT(result.isUseful());
BEAST_EXPECT(!result.isInvalid());
// The request count holds steady, so the asking has stopped.
BEAST_EXPECT(peerSetPtr->requests() == requestsWhileIncomplete);
auto const delivered = waitForDeliveredSet(env, setHash);
BEAST_EXPECT(delivered != nullptr);
if (delivered)
{
BEAST_EXPECT(delivered->getHash() == chain.rootHash);
BEAST_EXPECT(delivered->isValid());
}
// A reply arriving after the set is finished is left alone, and the asking stays stopped.
// init() stayed uncalled, so the set above is what settled the acquisition.
BEAST_EXPECT(wasIgnored(acquire->takeNodes({{SHAMapNodeID{}, chain.nodeAt(0)}}, peer)));
BEAST_EXPECT(peerSetPtr->requests() == requestsWhileIncomplete);
}
/**
* Two peers each answering with a different missing piece are both accepted
* without penalty, and the set completes from their combined replies.
*
* Driven through InboundTransactions::gotData(), so the leaf goes over the
* real dispatch, which derives a leaf's position from its own key.
*
* @param env The environment to run in.
*/
void
testTwoPeersEachSupplyPartOfTheSet(jtx::Env& env)
{
testcase("Two peers each supplying part of a set are both accepted without penalty");
auto const chain = DeepChain::toLeaf(3, nextSeed());
auto& inbound = env.app().getInboundTransactions();
// acquire=true registers the TransactionAcquire that gotData() looks up by hash.
UInt256 const setHash = chain.rootHash.asUInt256();
BEAST_EXPECT(inbound.getSet(setHash, true) == nullptr);
// The first peer answers with the root only.
auto const peerA = std::make_shared<ChargeRecordingPeer>();
inbound.gotData(setHash, peerA, packetFor(chain, {{SHAMapNodeID{}, chain.nodeAt(0)}}));
BEAST_EXPECT(peerA->charges().empty());
// The second answers with everything the first left out, and finishes the set.
auto const peerB = std::make_shared<ChargeRecordingPeer>();
inbound.gotData(setHash, peerB, packetFor(chain, chain.nodesBelowRoot()));
BEAST_EXPECT(peerB->charges().empty());
auto const delivered = waitForDeliveredSet(env, setHash);
BEAST_EXPECT(delivered != nullptr);
if (delivered)
BEAST_EXPECT(delivered->getHash() == chain.rootHash);
}
/**
* A root that does not hash to the set we asked for is a plain mismatch,
* and has to leave the acquisition able to try another peer.
*
* @param env The environment to run in.
*/
void
testBadRootKeepsAcquireAlive(jtx::Env& env)
{
testcase("A mismatched root leaves the acquire recoverable");
DeepChain const chain{nextSeed()};
// Acquire an unrelated hash, so the chain's root cannot match it.
auto const acquire = std::make_shared<TestableTransactionAcquire>(
env.app(), UInt256{42}, std::make_unique<RequestCountingPeerSet>());
auto const peer = std::make_shared<ChargeRecordingPeer>();
auto const result = acquire->takeNodes({{SHAMapNodeID{}, chain.nodeAt(0)}}, peer);
BEAST_EXPECTS(tallyIs(result, 0, 1, 0), result.get());
// A mismatched root tells the acquisition only that this peer's answer is wrong, so the
// map keeps its state.
BEAST_EXPECT(acquire->isMapValid());
// Still alive: the next packet is examined rather than waved through.
BEAST_EXPECT(!wasIgnored(acquire->takeNodes({{SHAMapNodeID{}, chain.nodeAt(0)}}, peer)));
}
/**
* A second reply carrying a root we already have must stay free.
*
* This is what an honest second responder to the initial fan-out sends:
* trigger() broadcasts to every tracked peer, so several answer the same
* request and all but the first carry data the map already holds, so they
* go uncharged.
*
* Covers the root specifically, which takeNodes() short-circuits on
* haveRoot_ without consulting the map. A repeated non-root node takes the
* other route, through addKnownNode() - see
* testDuplicateNonRootReplyIsFree().
*
* @param env The environment to run in.
*/
void
testDuplicateRootReplyIsFree(jtx::Env& env)
{
testcase("A reply of a root we already have is free");
DeepChain const chain{nextSeed()};
auto& inbound = env.app().getInboundTransactions();
// acquire=true registers the TransactionAcquire that gotData() looks up by hash.
UInt256 const setHash = chain.rootHash.asUInt256();
BEAST_EXPECT(inbound.getSet(setHash, true) == nullptr);
auto const rootPacket = packetFor(chain, {{SHAMapNodeID{}, chain.nodeAt(0)}});
// The first responder supplies the root, which is genuinely useful.
auto const firstPeer = std::make_shared<ChargeRecordingPeer>();
inbound.gotData(setHash, firstPeer, rootPacket);
BEAST_EXPECT(firstPeer->charges().empty());
// The second sends the same root. It answered what we asked, so it goes uncharged.
auto const secondPeer = std::make_shared<ChargeRecordingPeer>();
inbound.gotData(setHash, secondPeer, rootPacket);
BEAST_EXPECT(secondPeer->charges().empty());
}
/**
* A repeated non-root node stays free too.
*
* The counterpart to testDuplicateRootReplyIsFree(), covering the route
* that consults the map: addKnownNode() reports a node it already holds as
* a duplicate, which isGood() counts as success.
*
* @param env The environment to run in.
*/
void
testDuplicateNonRootReplyIsFree(jtx::Env& env)
{
testcase("A repeated non-root node is free");
auto const chain = DeepChain::toLeaf(3, nextSeed());
auto& inbound = env.app().getInboundTransactions();
UInt256 const setHash = chain.rootHash.asUInt256();
BEAST_EXPECT(inbound.getSet(setHash, true) == nullptr);
auto const rootPeer = std::make_shared<ChargeRecordingPeer>();
inbound.gotData(setHash, rootPeer, packetFor(chain, {{SHAMapNodeID{}, chain.nodeAt(0)}}));
BEAST_EXPECT(rootPeer->charges().empty());
// Depth 1 alone, so the set stays incomplete and the acquisition keeps examining data
// rather than waving the second copy through as a late reply.
auto const level1 = packetFor(chain, {{chain.idAt(1), chain.nodeAt(1)}});
auto const firstPeer = std::make_shared<ChargeRecordingPeer>();
inbound.gotData(setHash, firstPeer, level1);
BEAST_EXPECT(firstPeer->charges().empty());
auto const secondPeer = std::make_shared<ChargeRecordingPeer>();
inbound.gotData(setHash, secondPeer, level1);
BEAST_EXPECT(secondPeer->charges().empty());
}
/**
* A reply whose node data cannot be deserialized is charged for.
*
* gotData() rejects the packet before the acquisition is handed
* anything, so this pins the charge on the dispatch layer rather than
* on takeNodes(). It also gives this suite's "was not charged"
* assertions their teeth: a harness that recorded no charge at all
* would satisfy all of them and fail only here.
*
* @param env The environment to run in.
*/
void
testUndeserializableNodeIsCharged(jtx::Env& env)
{
testcase("A reply with undeserializable node data is charged");
DeepChain const chain{nextSeed()};
auto& inbound = env.app().getInboundTransactions();
UInt256 const setHash = chain.rootHash.asUInt256();
BEAST_EXPECT(inbound.getSet(setHash, true) == nullptr);
// getTreeNode() rejects this before the acquisition is handed anything.
auto const packet = std::make_shared<protocol::TMLedgerData>();
packet->set_ledgerhash(setHash.data(), UInt256::size());
packet->set_ledgerseq(0);
packet->set_type(protocol::liTS_CANDIDATE);
auto* const node = packet->add_nodes();
node->set_nodedata("\xff", 1);
node->set_id(SHAMapNodeID{}.getRawString());
auto const peer = std::make_shared<ChargeRecordingPeer>();
inbound.gotData(setHash, peer, packet);
BEAST_EXPECT(peer->charges() == std::vector{resource::kFeeInvalidData});
}
/**
* A well-formed reply whose root hashes to a different set is charged for.
*
* The counterpart to testUndeserializableNodeIsCharged(), which gotData()
* rejects before the acquisition sees it. This reply deserializes cleanly
* and reaches takeNodes(), whose invalid verdict gotData() turns into the
* charge. testBadRootKeepsAcquireAlive() reads that verdict directly, so
* this is the case that pins the charge made from it.
*
* @param env The environment to run in.
*/
void
testMismatchedRootThroughGotDataIsCharged(jtx::Env& env)
{
testcase("A well-formed reply with a mismatched root is charged");
// The set being acquired, and an unrelated chain whose root cannot hash to it.
DeepChain const chain{nextSeed()};
DeepChain const stranger{nextSeed()};
auto& inbound = env.app().getInboundTransactions();
// acquire=true registers the TransactionAcquire that gotData() looks up by hash.
UInt256 const setHash = chain.rootHash.asUInt256();
BEAST_EXPECT(inbound.getSet(setHash, true) == nullptr);
// The reply names the set we asked for, but carries the stranger's root.
auto const packet = packetFor(
stranger, {{SHAMapNodeID{}, stranger.nodeAt(0)}}, protocol::liTS_CANDIDATE, setHash);
auto const peer = std::make_shared<ChargeRecordingPeer>();
inbound.gotData(setHash, peer, packet);
BEAST_EXPECT(peer->charges() == std::vector{resource::kFeeInvalidData});
}
/**
* init() passes hasTxSet() to addPeers() as its candidate filter.
*
* init() hands addPeers() hasTxSet(hash_) as its hasItem callback and
* trigger() as its onPeerAdded callback. The harness selects on that
* callback while the real peer set only scores with it, so this pins which
* callbacks the acquisition supplies, not how many peers production asks.
*
* @param env The environment to run in.
*/
void
testInitFiltersCandidatesByHasTxSet(jtx::Env& env)
{
testcase("init() passes hasTxSet as its candidate filter");
DeepChain const chain{nextSeed()};
// Ordered with the useless peer first, so a filter that is ignored altogether shows up as
// the wrong peer being asked rather than as one extra request.
auto const withoutSet = std::make_shared<ChargeRecordingPeer>(false);
auto const withSet = std::make_shared<ChargeRecordingPeer>(true);
auto peerSet = std::make_unique<RequestCountingPeerSet>(
std::vector<std::shared_ptr<Peer>>{withoutSet, withSet});
auto const* const peerSetPtr = peerSet.get();
auto const acquire = std::make_shared<TransactionAcquire>(
env.app(), chain.rootHash.asUInt256(), std::move(peerSet));
static constexpr int kStartPeers = 2;
acquire->init(kStartPeers);
// Stop the retry loop, which keeps offering the same candidates while the acquisition runs.
acquire->cancel();
BEAST_EXPECT(peerSetPtr->firstLimit() == kStartPeers);
BEAST_EXPECT(peerSetPtr->addedPeers() == std::set<Peer::ID>{withSet->id()});
// The peer that was added is also asked, rather than merely tracked.
BEAST_EXPECT(peerSetPtr->requests() >= 1);
}
/**
* The retry timer broadcasts with no peer of its own, then gives up on
* its own.
*
* The only case that reaches onTimer(). Pins the two behaviors, not the
* thresholds they trip at: bounding those means asserting on wall clock.
* The broadcast count is read after the give-up, which the failed_ guard
* in trigger() makes safe.
*
* The acquisition tracks no peer, so the broadcast this case counts reaches
* nobody in production either. What is pinned is that onTimer() issues it.
*
* @param env The environment to run in.
*/
void
testTimerBroadcastsThenGivesUp(jtx::Env& env)
{
testcase("The retry timer broadcasts, then gives up");
DeepChain const chain{nextSeed()};
auto peerSet = std::make_unique<RequestCountingPeerSet>();
auto const* const peerSetPtr = peerSet.get();
// An unrelated hash, so the probe below is always rejected.
auto const acquire = std::make_shared<TransactionAcquire>(
env.app(), UInt256{42}, std::move(peerSet), kFastRetry);
// No candidates, so nothing goes out until onTimer() decides to broadcast.
acquire->init(1);
BEAST_EXPECT(peerSetPtr->requests() == 0);
// A root that cannot hash to this acquisition's set is rejected without recording progress,
// so polling with it does not postpone the timeout. A fresh peer each time keeps the
// rejections from piling up on one.
auto const probe = [&] {
return wasIgnored(acquire->takeNodes(
{{SHAMapNodeID{}, chain.nodeAt(0)}}, std::make_shared<ChargeRecordingPeer>()));
};
// kNormTimeouts (4) intervals in, onTimer() broadcasts with no peer of its own to ask. Past
// kMaxTimeouts (20) it fails itself, and stops examining data.
BEAST_EXPECT(waitFor(probe));
// Read as a broadcast rather than as a request, since a request here would mean a peer was
// named. Reading it after the failure is safe. trigger() returns before sending once
// failed_ is set, and both run under the acquisition's mutex, so any counted broadcast
// went out while the acquisition was still alive.
BEAST_EXPECT(peerSetPtr->broadcasts() > 0);
}
void
run() override
{
// One Env for the suite, which is safe only because every chain is seeded through
// nextSeed(): cases sharing an Env share a NodeCache, so they must not share a hash.
jtx::Env env{*this};
testHappyPathCompletesAcquisition(env);
testTwoPeersEachSupplyPartOfTheSet(env);
testBadRootKeepsAcquireAlive(env);
testDuplicateRootReplyIsFree(env);
testDuplicateNonRootReplyIsFree(env);
testUndeserializableNodeIsCharged(env);
testMismatchedRootThroughGotDataIsCharged(env);
testInitFiltersCandidatesByHasTxSet(env);
// Last: the only case that waits out a whole timeout chain.
testTimerBroadcastsThenGivesUp(env);
}
private:
unsigned int seed_{0};
};
BEAST_DEFINE_TESTSUITE(TransactionAcquire, app, xrpl);
} // namespace xrpl::test
+219 -30
View File
@@ -62,10 +62,7 @@ namespace xrpl::test {
class InvariantsVault_test : public InvariantsBase
{
FeatureBitset const all_{test::jtx::testableAmendments()};
// testableAmendments() leaves featureLendingProtocolV1_2 off until the
// LoanManage PR; enable it explicitly for FixedPrecision rows.
FeatureBitset const fixedPrecision_{all_ | featureLendingProtocolV1_2};
FeatureBitset const all_{test::jtx::testableAmendments() | featureLendingProtocolV1_2};
void
testVault() // NOLINT(readability-function-size)
@@ -1018,8 +1015,11 @@ class InvariantsVault_test : public InvariantsBase
// circuits and returns success without inspecting the loan or the
// vault. The same state that trips the principal-outstanding check
// under V1_1 must be silently accepted here.
//
// V1_2 is disabled too: it alone makes VaultCreate pick FixedPrecision,
// whose AssetsTotal-sync invariant would fire on this scenario.
doInvariantCheck(
makeEnv(all_ - featureLendingProtocolV1_1),
makeEnv(all_ - featureLendingProtocolV1_1 - featureLendingProtocolV1_2),
{},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
@@ -1446,6 +1446,7 @@ class InvariantsVault_test : public InvariantsBase
// valid positive dust while moving the posterior AssetsTotal to a much
// finer scale. The dust must be bounded by the former scale rather than
// compared with one unit at the posterior scale.
//
// CashBasis, not FixedPrecision: AssetsTotal is set independently of
// AssetsAvailable plus the vault's AssetsDeployed, which the FixedPrecision
// sync invariant rejects.
@@ -1597,9 +1598,7 @@ class InvariantsVault_test : public InvariantsBase
// (here the neutral ttACCOUNT_SET) is a violation, even while the
// loan still has outstanding obligations: the transaction-type check
// fires before the not-fully-paid-off check.
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
doInvariantCheck(
makeEnv(all_ - featureLendingProtocolV1_2),
{"Loan deleted by a transaction other than LoanDelete"},
eraseLoan,
XRPAmount{},
@@ -1943,7 +1942,6 @@ class InvariantsVault_test : public InvariantsBase
// FixedPrecision-only: a new vault must have AssetsDeployed == 0.
doInvariantCheck(
makeEnv(fixedPrecision_),
{
"created vault must be empty",
"updated zero sized vault must have no AssetsDeployed",
@@ -1970,7 +1968,6 @@ class InvariantsVault_test : public InvariantsBase
// FixedPrecision-only: a new vault must have YieldUnrealized == 0.
doInvariantCheck(
makeEnv(fixedPrecision_),
{
"created vault must be empty",
"create operation must not have updated a vault",
@@ -2299,14 +2296,13 @@ class InvariantsVault_test : public InvariantsBase
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseXrp);
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
doInvariantCheck(
makeEnv(all_ - featureLendingProtocolV1_2),
{
"deposit must increase vault balance",
"deposit must decrease depositor balance",
"deposit must change vault and depositor balance by equal amount",
"deposit and assets outstanding must add up",
// FixedPrecision: only AssetsAvailable's delta is compared; the
// stored AssetsTotal is a derived cache.
"deposit and assets available must add up",
},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
@@ -2401,7 +2397,8 @@ class InvariantsVault_test : public InvariantsBase
precloseXrp,
TxAccount::A2);
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
// AssetsTotal alone is wrong. FixedPrecision does not compare it per
// transaction, so this exercises the Legacy/CashBasis path.
doInvariantCheck(
makeEnv(all_ - featureLendingProtocolV1_2),
{"deposit and assets outstanding must add up"},
@@ -2427,11 +2424,9 @@ class InvariantsVault_test : public InvariantsBase
precloseXrp,
TxAccount::A2);
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
doInvariantCheck(
makeEnv(all_ - featureLendingProtocolV1_2),
// FixedPrecision does not compare AssetsTotal's delta.
{
"deposit and assets outstanding must add up",
"deposit and assets available must add up",
},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
@@ -2493,14 +2488,12 @@ class InvariantsVault_test : public InvariantsBase
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseXrp);
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
doInvariantCheck(
makeEnv(all_ - featureLendingProtocolV1_2),
{
"withdrawal must change vault and destination balance by equal amount",
"withdrawal must decrease vault balance",
"withdrawal must increase destination balance",
"withdrawal and assets outstanding must add up",
// FixedPrecision does not compare AssetsTotal's delta.
"withdrawal and assets available must add up",
},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
@@ -2592,11 +2585,9 @@ class InvariantsVault_test : public InvariantsBase
precloseXrp,
TxAccount::A2);
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
doInvariantCheck(
makeEnv(all_ - featureLendingProtocolV1_2),
// FixedPrecision does not compare AssetsTotal's delta.
{
"withdrawal and assets outstanding must add up",
"withdrawal and assets available must add up",
},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
@@ -2612,7 +2603,8 @@ class InvariantsVault_test : public InvariantsBase
precloseXrp,
TxAccount::A2);
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
// AssetsTotal alone is wrong. FixedPrecision does not compare it per
// transaction, so this exercises the Legacy/CashBasis path.
doInvariantCheck(
makeEnv(all_ - featureLendingProtocolV1_2),
{"withdrawal and assets outstanding must add up"},
@@ -2785,12 +2777,10 @@ class InvariantsVault_test : public InvariantsBase
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseMpt);
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
doInvariantCheck(
makeEnv(all_ - featureLendingProtocolV1_2),
{
"clawback must change holder and vault shares by equal amount",
"clawback and assets outstanding must add up",
// FixedPrecision does not compare AssetsTotal's delta.
"clawback and assets available must add up",
},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
@@ -3222,8 +3212,7 @@ class InvariantsVault_test : public InvariantsBase
for (auto const withFix : {false, true})
{
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
FeatureBitset amendments = all_ - featureLendingProtocolV1_2;
FeatureBitset amendments = all_;
if (!withFix)
amendments = amendments - fixCleanup3_4_0;
@@ -3443,6 +3432,71 @@ class InvariantsVault_test : public InvariantsBase
}
}
// Closed-ended FixedPrecision vault with one open loan. Closed-ended
// because LoanBrokerSet::preclaim only accepts those under
// featureLendingProtocolV1_1.
static Keylet
makeFixedPrecisionVaultWithLoan(
test::jtx::Account const& owner,
test::jtx::Account const& borrower,
test::jtx::Account const& issuer,
test::jtx::Env& env)
{
using namespace test::jtx;
env.fund(XRP(1'000'000), issuer, borrower);
env.close();
PrettyAsset const usd = issuer["USD"];
env(trust(owner, usd(1'000'000)));
env(trust(borrower, usd(1'000'000)));
env.close();
env(pay(issuer, owner, usd(100'000)));
env(pay(issuer, borrower, usd(1'000)));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded({
.owner = owner,
.asset = usd,
.subscriptionOffset = std::chrono::seconds{60},
.investmentWindow = std::chrono::seconds{10ull * 365ull * 24ull * 60ull * 60ull},
});
env(vaultTx);
env.close();
env(vault.deposit(
{.depositor = owner, .id = vaultKeylet.key, .amount = usd(1'000).value()}));
env.close();
auto const brokerKeylet =
keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
{
using namespace loan_broker;
env(set(owner, vaultKeylet.key), Fee(env.current()->fees().base * 2));
env.close();
}
// LoanSet is gated on Investment; advance out of Subscription.
vault.closePastSubscription(subscriptionDate);
{
using namespace loan;
env(set(borrower, brokerKeylet.key, usd(100).value()),
kCounterparty(owner),
kInterestRate(TenthBips32{0}),
kPaymentTotal(3),
kPaymentInterval(86400u),
kGracePeriod(3600u),
Sig(sfCounterpartySignature, owner),
Fee(env.current()->fees().base * 2));
env.close();
}
return vaultKeylet;
}
static void
addToVaultField(std::shared_ptr<SLE> const& sle, SF_NUMBER const& field, Number const& delta)
{
@@ -3463,6 +3517,53 @@ class InvariantsVault_test : public InvariantsBase
TER secondTer = tecINVARIANT_FAILED;
};
void
checkLoanVaultRow(AssetsDeployedRow const& row)
{
using namespace test::jtx;
testcase(row.name);
// Create the vault before building the transaction, so the transaction
// carries the real VaultID.
auto env = makeEnv(all_);
Account const owner{"A1"};
Account const other{"A2"};
Account const issuer{"issuer"};
Account const borrower{"borrower"};
env.fund(XRP(1000), owner, other);
auto const vaultKeylet = makeFixedPrecisionVaultWithLoan(owner, borrower, issuer, env);
if (!BEAST_EXPECT(env.le(vaultKeylet)))
return;
env.close();
STTx tx{row.txType, [&](STObject& obj) {
if (row.withVaultId)
{
obj.setFieldH256(sfVaultID, vaultKeylet.key);
}
}};
tx.setAccountID(sfAccount, owner.id());
doInvariantCheck(
std::move(env),
owner,
other,
row.logs,
[&](Account const&, Account const&, ApplyContext& ac) -> bool {
auto const sle = ac.view().peek(vaultKeylet);
if (!sle || !row.mutate(ac, sle))
{
return false;
}
ac.view().update(sle);
return true;
},
XRPAmount{},
tx,
{tecINVARIANT_FAILED, row.secondTer});
}
void
checkXrpVaultRow(AssetsDeployedRow const& row)
{
@@ -3484,7 +3585,7 @@ class InvariantsVault_test : public InvariantsBase
};
doInvariantCheck(
makeEnv(fixedPrecision_),
makeEnv(all_),
row.logs,
[&](Account const& a1, Account const&, ApplyContext& ac) {
auto const sle =
@@ -3506,6 +3607,94 @@ class InvariantsVault_test : public InvariantsBase
void
testVaultAssetsDeployedInvariants()
{
// ttLOAN_PAY and ttLOAN_MANAGE may change AssetsDeployed and LossUnrealized
// respectively, which isolates each check from the "vault transaction
// must not change ..." checks.
AssetsDeployedRow const loanRows[] = {
{
.name = "FixedPrecision vault: AssetsDeployed drift from AssetsTotal",
.logs =
{
"stored AssetsTotal must equal AssetsAvailable plus AssetsDeployed, "
"rounded "
"Downward",
},
.mutate =
[](ApplyContext&, std::shared_ptr<SLE> const& sle) {
addToVaultField(sle, sfAssetsDeployed, Number{1});
return true;
},
.txType = ttLOAN_PAY,
},
{
.name = "FixedPrecision vault: stored AssetsTotal != Downward16(AA + AD)",
.logs =
{
"stored AssetsTotal must equal AssetsAvailable plus AssetsDeployed, "
"rounded "
"Downward",
},
.mutate =
[](ApplyContext&, std::shared_ptr<SLE> const& sle) {
addToVaultField(sle, sfAssetsTotal, Number{1});
return true;
},
.txType = ttLOAN_PAY,
},
// FixedPrecision compares LU with AssetsDeployed directly; the message
// is shared with the Legacy/CashBasis "LU <= AT - AA" check.
{
.name = "FixedPrecision vault: LossUnrealized exceeds AssetsDeployed",
.logs =
{
"loss unrealized must not exceed the difference between assets "
"outstanding and available",
},
.mutate =
[](ApplyContext&, std::shared_ptr<SLE> const& sle) {
Number const assetsDeployed = sle->at(sfAssetsDeployed);
(*sle)[sfLossUnrealized] = assetsDeployed + Number{1};
return true;
},
.txType = ttLOAN_MANAGE,
},
// AssetsTotal moves too so the sync check stays quiet.
{
.name = "FixedPrecision vault: AssetsDeployed changed by a non-lending transaction",
.logs = {"vault transaction must not change AssetsDeployed"},
.mutate =
[](ApplyContext&, std::shared_ptr<SLE> const& sle) {
addToVaultField(sle, sfAssetsDeployed, Number{1});
addToVaultField(sle, sfAssetsTotal, Number{1});
return true;
},
.withVaultId = true,
},
// On-ledger mirror of VaultWithdraw's tecHAS_OBLIGATIONS guard.
{
.name = "FixedPrecision vault: non-zero AssetsDeployed after shares return to zero",
.logs = {"updated zero sized vault must have no AssetsDeployed"},
.mutate =
[](ApplyContext& ac, std::shared_ptr<SLE> const& sle) {
auto const issuance =
ac.view().peek(keylet::mptokenIssuance(sle->at(sfShareMPTID)));
if (!issuance)
{
return false;
}
issuance->setFieldU64(sfOutstandingAmount, 0);
ac.view().update(issuance);
return true;
},
.withVaultId = true,
.secondTer = tefINVARIANT_FAILED,
},
};
for (auto const& row : loanRows)
{
checkLoanVaultRow(row);
}
AssetsDeployedRow const xrpRows[] = {
{
.name = "FixedPrecision vault: negative AssetsDeployed",
@@ -3607,7 +3796,7 @@ class InvariantsVault_test : public InvariantsBase
};
doInvariantCheck(
makeEnv(fixedPrecision_),
makeEnv(all_),
{"withdrawal and assets available must add up"},
[&vaultKeylet](Account const& a1, Account const&, ApplyContext& ac) -> bool {
auto const sleVault = ac.view().peek(vaultKeylet);
+12 -16
View File
@@ -837,23 +837,19 @@ public:
{
// Batch rejects Vault and Lending inners before LendingProtocolV1_2.
FeatureBitset const lendingBatch{all_ | featureLendingProtocolV1_2};
testVaultLifecycle(lendingBatch);
testArbitrage(lendingBatch);
testArbitrageRollback(lendingBatch);
testImpairAndCoverDeposit(lendingBatch);
testDefaultAndCoverWithdraw(lendingBatch);
testIndependentVaultChain(lendingBatch);
testIndependentLoanChain(lendingBatch);
for (auto const& features : {lendingBatch, lendingBatch | featureLendingProtocolV1_1})
{
testVaultLifecycle(features);
testArbitrage(features);
testArbitrageRollback(features);
testImpairAndCoverDeposit(features);
testDefaultAndCoverWithdraw(features);
testIndependentVaultChain(features);
testIndependentLoanChain(features);
}
testClosedEndedVaultLifecycle(lendingBatch | featureLendingProtocolV1_1);
testLoanLifecycleOpenEndedVault(lendingBatch);
// Temporary while the FixedPrecision Vault stack lands: with
// featureLendingProtocolV1_1 these Vaults are FixedPrecision, whose
// lending transactions are enabled in later PRs. Only the Vault-only
// cases run here; the rest are restored in the LoanManage PR.
FeatureBitset const fixedPrecision{lendingBatch | featureLendingProtocolV1_1};
testVaultLifecycle(fixedPrecision);
testIndependentVaultChain(fixedPrecision);
testClosedEndedVaultLifecycle(fixedPrecision);
testLoanLifecycleClosedEndedVault(lendingBatch | featureLendingProtocolV1_1);
}
};
+1 -2
View File
@@ -1964,8 +1964,7 @@ public:
Account const lender{"lender"};
Account const borrower{"borrower"};
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env{*this, testableAmendments() - featureLendingProtocolV1_2};
Env env{*this};
Vault const vault{env};
env.fund(XRP(10'000), lender, borrower);
env.close();
@@ -301,8 +301,7 @@ private:
for (bool const deepFreeze : {true, false})
{
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(*this, testableAmendments() - featureLendingProtocolV1_2);
Env env(*this);
auto const getCoverBalance = [&](BrokerInfo const& brokerInfo,
auto const& accountField) {
@@ -386,8 +385,7 @@ private:
Account const borrower{"borrower"};
auto const iou = issuer["IOU"];
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(*this, testableAmendments() - featureLendingProtocolV1_2);
Env env(*this);
env.fund(XRP(1'000), lender, issuer, borrower);
env(trust(lender, iou(10'000'000)));
env(pay(issuer, lender, iou(5'000'000)));
@@ -442,8 +440,7 @@ private:
Account const lender{"lender"};
Account const borrower{"borrower"};
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(*this, testableAmendments() - featureLendingProtocolV1_2);
Env env(*this);
env.fund(XRP(1'000'000), issuer, lender, borrower);
env.close();
@@ -514,8 +511,7 @@ private:
Account const borrower{"borrower"};
auto const iou = issuer["IOU"];
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(*this, testableAmendments() - featureLendingProtocolV1_2);
Env env(*this);
env.fund(XRP(1'000), lender, issuer, borrower);
env(trust(lender, iou(10'000'000)));
env(pay(issuer, lender, iou(5'000'000)));
+2 -4
View File
@@ -289,8 +289,7 @@ private:
Account const issuer("issuer");
Account const borrower = issuer;
Account const lender("lender");
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(*this, testableAmendments() - featureLendingProtocolV1_2);
Env env(*this);
env.fund(XRP(1'000), issuer, lender);
@@ -330,8 +329,7 @@ private:
Account const depositor{"depositor"};
auto const testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) {
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(*this, testableAmendments() - featureLendingProtocolV1_2);
Env env(*this);
Vault const vault(env);
if (borrower == broker)
@@ -0,0 +1,68 @@
#pragma once
#include <test/app/lending/LoanPayFixedPrecisionBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/TxFlags.h>
#include <chrono>
#include <cstdint>
namespace xrpl {
// LoanManage-tier scaffolding on top of LoanPayFixedPrecisionBase: the
// default/impair helpers needed by LoanManageFixedPrecision only.
class LoanManageFixedPrecisionBase : public LoanPayFixedPrecisionBase
{
protected:
// Advance the clock one second past the loan's due date (late, in grace) or
// past its grace period (defaultable).
void
closePastDue(test::jtx::Env& env, Keylet const& loanKeylet)
{
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
}
void
closePastGrace(test::jtx::Env& env, Keylet const& loanKeylet)
{
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
std::uint32_t const grace = loanSle->at(sfGracePeriod);
env.close(
NetClock::time_point{NetClock::duration{dueDate + grace}} + std::chrono::seconds{1});
}
void
impairWhenLate(test::jtx::Env& env, test::jtx::Account const& owner, Keylet const& loanKeylet)
{
closePastDue(env, loanKeylet);
env(test::jtx::loan::manage(owner, loanKeylet.key, tfLoanImpair));
env.close();
}
void
defaultAfterGrace(
test::jtx::Env& env,
test::jtx::Account const& owner,
Keylet const& loanKeylet)
{
closePastGrace(env, loanKeylet);
env(test::jtx::loan::manage(owner, loanKeylet.key, tfLoanDefault));
env.close();
}
};
} // namespace xrpl
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,340 @@
#pragma once
#include <test/app/lending/LoanFixedPrecisionBase.h>
#include <test/app/vault/VaultFixedPrecisionBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/fee.h>
#include <test/jtx/sig.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <cassert>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace xrpl {
// LoanPay-tier scaffolding on top of LoanFixedPrecisionBase: the
// coarsened-vault fixture (repeated late interest pushes a FixedPrecision
// vault's live Scale past its base grid) and the helpers built on it. Shared
// by LoanPayFixedPrecision and, via LoanManageFixedPrecisionBase, by
// LoanManageFixedPrecision.
class LoanPayFixedPrecisionBase : public LoanFixedPrecisionBase
{
protected:
// Brings the Keylet overload into scope alongside the CoarsenedVault
// overload added below; otherwise the derived overload would hide it.
using LoanFixedPrecisionBase::checkVaultLoanSums;
using VaultFixedPrecisionBase::expectCoarsened;
// Coarsens a FixedPrecision vault through paid late interest, which the
// LoanSet Open-zone guard does not bound. Deposits near the Open-zone ceiling,
// originates a zero-interest loan for most of it, lets the first payment go
// very late at the maximum LateInterestRate, and pays it late.
//
// extraLoans are originated before the coarsening loan goes late, because a
// coarsened vault refuses further LoanSet. loanKeylets[0] is the coarsening
// loan; loanKeylets[1..] are the extras in order.
struct CoarsenedVault
{
LendingFixture fixture;
std::vector<Keylet> loanKeylets;
};
// An extra loan originated before the vault coarsens, on top of the shared
// zero-interest / 1-day-interval / 60s-grace defaults.
struct ExtraLoan
{
Number principal;
std::uint32_t flags = 0;
std::uint32_t paymentTotal = 5;
// Override with a long interval to keep the first payment from being overdue
// once the clock advances to coarsen the vault.
std::uint32_t paymentInterval = 24 * 60 * 60;
// Defaults to the shared depositor. A dust-sized loan needs its own borrower
// with a small balance: a sub-1e-9 debit from a multi-million balance does
// not fit STAmount's 16 digits and rounds away.
std::optional<test::jtx::Account> borrower = std::nullopt;
// A flat per-payment service fee to the broker owner, giving a terminal
// payment a zero vault-credit leg but a non-zero fee leg.
Number serviceFee{0};
};
void
checkVaultLoanSums(
test::jtx::Env& env,
CoarsenedVault const& coarsened,
std::string const& label = {})
{
checkVaultLoanSums(env, coarsened.fixture, coarsened.loanKeylets, label);
}
struct CoarsenParams
{
std::uint8_t scale = 10;
Number depositAmount{899'999};
Number loanPrincipal{700'000};
// The explicit initializer avoids the missing-field-initializers warning when a
// designated initializer omits it.
std::vector<ExtraLoan> extraLoans = // NOLINT(readability-redundant-member-init)
{};
TenthBips32 coverRateMinimum = TenthBips32(0);
TenthBips32 coverRateLiquidation = TenthBips32(0);
Number coverDeposit{0};
// 800 days pushes AssetsAvailable itself past its 16-digit budget, so cash
// flows floor at a coarser grid. 200 days coarsens only getVaultScale()
// (AA + AssetsDeployed), leaving AA's own grid at the base grid.
int overdueDays = 800;
};
void
expectCoarsened(test::jtx::Env const& env, CoarsenedVault const& coarsened)
{
expectCoarsened(env, coarsened.fixture.vaultKeylet);
}
// IOU setup for the coarsened-vault scenarios: the depositor holds 10M USD.
static AssetAccounts
setupCoarsenIou(test::jtx::Env& env, bool clawback = false)
{
return setupIou(
env,
{.clawback = clawback, .depositorTrust = 30'000'000, .depositorFunds = 10'000'000});
}
enum class Debit { Withdraw, Clawback };
// Submits a VaultWithdraw (depositor) or VaultClawback (issuer) of amount and
// returns how much AssetsAvailable dropped.
Number
debitAvailable(
test::jtx::Env& env,
CoarsenedVault const& coarsened,
Debit kind,
test::jtx::Account const& issuer,
test::jtx::Account const& depositor,
PrettyAsset const& asset,
Number const& amount)
{
auto const& vault = coarsened.fixture.vault;
auto const& vaultKeylet = coarsened.fixture.vaultKeylet;
auto const before = snapshotVault(env, vaultKeylet, asset);
if (kind == Debit::Withdraw)
{
env(vault.withdraw(
{.depositor = depositor, .id = vaultKeylet.key, .amount = asset(amount)}));
}
else
{
env(vault.clawback({
.issuer = issuer,
.id = vaultKeylet.key,
.holder = depositor,
.amount = asset(amount).value(),
}));
}
env.close();
return before.available - snapshotVault(env, vaultKeylet, asset).available;
}
CoarsenedVault
coarsenVault(
test::jtx::Env& env,
test::jtx::Account const& owner,
test::jtx::Account const& depositor,
PrettyAsset const& asset)
{
return coarsenVault(env, owner, depositor, asset, CoarsenParams{});
}
CoarsenedVault
coarsenVault(
test::jtx::Env& env,
test::jtx::Account const& owner,
test::jtx::Account const& depositor,
PrettyAsset const& asset,
CoarsenParams const& params)
{
using namespace test::jtx;
using namespace loan;
using namespace loan_broker;
using namespace lending;
// A 15-year investment window, so extra loans with long payment intervals
// still fit before RedemptionDate.
std::chrono::seconds const investmentWindow{15 * 365 * 24 * 60 * 60};
auto fixture = setupLendingVault(
env,
owner,
depositor,
asset,
params.depositAmount,
investmentWindow,
params.scale,
params.coverRateMinimum,
params.coverRateLiquidation);
if (params.coverDeposit != beast::kZero)
{
env(coverDeposit(owner, fixture.brokerKeylet.key, asset(params.coverDeposit).value()));
env.close();
}
std::uint32_t const gracePeriod = 60;
std::vector<Keylet> loanKeylets;
std::vector<ExtraLoan> allLoans{ExtraLoan{.principal = params.loanPrincipal}};
allLoans.insert(allLoans.end(), params.extraLoans.begin(), params.extraLoans.end());
for (std::size_t i = 0; i < allLoans.size(); ++i)
{
auto const loanKeylet =
keylet::loan(fixture.brokerKeylet.key, SeqProxy::rawSequence(i + 1));
auto const& loan = allLoans[i];
auto const submit = [&](auto&&... extra) {
env(set(loan.borrower.value_or(depositor),
fixture.brokerKeylet.key,
loan.principal,
loan.flags),
kInterestRate(TenthBips32(0)),
kLoanServiceFee(loan.serviceFee),
kGracePeriod(gracePeriod),
kPaymentInterval(loan.paymentInterval),
kPaymentTotal(loan.paymentTotal),
Sig(sfCounterpartySignature, owner),
Fee(env.current()->fees().base * 2),
extra...);
};
if (i == 0)
{
// Only the coarsening loan needs a non-zero LateInterestRate.
submit(kLateInterestRate(kMaxLateInterestRate));
}
else
{
submit();
}
env.close();
loanKeylets.push_back(loanKeylet);
}
auto const loanSle = env.le(loanKeylets[0]);
BEAST_EXPECT(loanSle);
std::uint32_t const dueDate = loanSle ? loanSle->at(sfNextPaymentDueDate) : 0;
// Leave the first payment overdue long enough that penalty interest pushes
// AssetsAvailable itself, not just AA + AssetsDeployed, past its 16-digit budget at
// the base scale; only then do cash credits floor.
env.close(
NetClock::time_point{NetClock::duration{dueDate + gracePeriod}} +
std::chrono::seconds{params.overdueDays * 24 * 60 * 60});
// Generously covers the coarsening loan's principal plus accrued late
// interest.
Number const latePaymentAmount = params.loanPrincipal * 10;
env(pay(
depositor, loanKeylets[0].key, asset(latePaymentAmount).value(), tfLoanLatePayment));
env.close();
return {.fixture = fixture, .loanKeylets = loanKeylets};
}
// Reads an integral-asset vault Number field (AssetsAvailable, AssetsDeployed,
// LossUnrealized, ... are STNumber fields, not plain UInt64 SFields) as a
// std::uint64_t for plain integer arithmetic and search loops. Number
// itself does not normalize to the asset's own zero exponent, so the value
// is round-tripped through an STAmount of the vault's (integral) asset
// first, matching how STAmount::mpt()/xrp() extract an exact int64.
template <class T>
static std::uint64_t
toU64(SLE::ConstRef vault, TypedField<T> const& field)
{
Asset const asset = vault->at(sfAsset);
STAmount const amount{asset, Number(vault->at(field))};
assert(amount.exponent() == 0);
return static_cast<std::uint64_t>(amount.mantissa());
}
// Doubles AssetsAvailable roughly cycles times via an origination/
// repayment loop: originate a single-payment, kMaxInterestRate (100%),
// one-year loan for the vault's entire current AssetsAvailable, then pay
// it off in full at the due date. AssetsDeployed returns to zero after every
// cycle, and the depositor's share count is never touched, so this grows
// the per-share NAV purely through externally-funded interest -- the
// "originate, repay, and delete...loans that grow the vault" step shared
// by the sole-holder clawback and final-withdrawal scenarios below. The
// depositor needs enough of their own funds to cover the interest leg of
// every cycle (the principal itself round-trips: it is lent back out,
// then repaid).
void
compoundVaultBySoleHolderLoanCycles(
test::jtx::Env& env,
LendingFixture const& fixture,
test::jtx::Account const& depositor,
PrettyAsset const& asset,
int cycles)
{
using namespace test::jtx;
using namespace loan;
for (int i = 0; i < cycles; ++i)
{
auto const vaultSle = env.le(fixture.vaultKeylet);
if (!BEAST_EXPECT(vaultSle))
return;
Number const principal = vaultSle->at(sfAssetsAvailable);
if (!BEAST_EXPECT(principal > beast::kZero))
return;
std::uint32_t const oneYearInterval = 365 * 24 * 60 * 60;
auto const loanKeylet = openLoan(
env,
fixture,
principal,
/* paymentTotal */ 1,
percentageToTenthBips(100),
/* flags */ 0,
oneYearInterval);
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
Number const periodicPayment = loanSle->at(sfPeriodicPayment);
std::int32_t const loanScale = loanSle->at(sfLoanScale);
Number const paymentDue = roundPeriodicPayment(asset, periodicPayment, loanScale);
// The clock is advanced to just past the due date, so the payment
// must be flagged late (LateInterestRate is 0 by default, so this
// adds no penalty -- it only satisfies the overdue check in
// LendingHelpers' loanMakePayment).
env(pay(depositor, loanKeylet.key, asset(paymentDue).value(), tfLoanLatePayment));
env.close();
}
}
};
} // namespace xrpl
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -452,8 +452,7 @@ private:
// Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
// accepts closed-ended vaults, so build one and advance past
// SubscriptionDate before creating the broker and the loan.
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(*this, testableAmendments() - featureLendingProtocolV1_2);
Env env(*this);
Vault const vault(env);
env.fund(XRP(10'000), lender, issuer, borrower, depositor);
+1 -4
View File
@@ -817,10 +817,7 @@ private:
// `all_`), so callers must opt in. Closed-ended vaults are gated on this amendment; without
// it VaultCreate returns temDISABLED and every follow-on txn sees tecNO_ENTRY.
auto const withEnv = [&, this](auto&& body) {
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(
*this,
(testableAmendments() | featureLendingProtocolV1_1) - featureLendingProtocolV1_2);
Env env(*this, testableAmendments() | featureLendingProtocolV1_1);
env.fund(XRP(1'000'000'000), issuer, lender, borrower);
env.close();
PrettyAsset const asset{xrpIssue(), 1'000'000};
+1 -4
View File
@@ -195,10 +195,7 @@ protected:
// most of this file's tests assert instant-interest-recognition-specific expected values
// for those fields. Tests that specifically exercise the amendment opt
// it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`).
// featureLendingProtocolV1_2 is also excluded: it changes vault precision.
FeatureBitset const all_{
jtx::testableAmendments() - featureLendingProtocolV1_1 - featureLendingProtocolV1_2,
};
FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1};
std::string const iouCurrency_{"IOU"};
struct BrokerParameters
+3 -6
View File
@@ -105,8 +105,7 @@ private:
auto const iou = issuer["IOU"];
auto const testWrapper = [&](auto&& test) {
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(*this, testableAmendments() - featureLendingProtocolV1_2);
Env env(*this);
env.fund(XRP(1'000), lender, issuer, borrower, sponsor);
env(trust(lender, iou(10'000'000)));
env(pay(issuer, lender, iou(5'000'000)));
@@ -291,8 +290,7 @@ private:
auto const iou = issuer["IOU"];
// preclaim
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(*this, testableAmendments() - featureLendingProtocolV1_2);
Env env(*this);
env.fund(XRP(1'000), lender, issuer, borrower);
env(trust(lender, iou(10'000'000)));
env(pay(issuer, lender, iou(5'000'000)));
@@ -373,8 +371,7 @@ private:
Account const lender{"lender"};
Account const issuer{"issuer"};
Account const borrower{"borrower"};
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(*this, testableAmendments() - featureLendingProtocolV1_2);
Env env(*this);
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
@@ -0,0 +1,311 @@
#include <test/app/lending/LoanPayFixedPrecisionBase.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/ter.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <chrono>
#include <string>
namespace xrpl {
// Vault transactions (withdraw, clawback, deposit, ValidVault) on a
// FixedPrecision Vault that LoanPay late interest has coarsened past its base
// grid. The coarsening needs FixedPrecision LoanPay, so these live with it.
class VaultCoarsenedFixedPrecision_test : public LoanPayFixedPrecisionBase
{
void
testCoarsenedVaultWithdrawClawbackKeepAvailableGridDigit()
{
using namespace test::jtx;
testcase(
"Lending: VaultWithdraw and VaultClawback of an amount with a digit on AA's grid "
"but not AT's succeed exactly");
Env env(*this, features());
auto const [issuer, owner, depositor, asset] = setupCoarsenIou(env, true);
auto const coarsened = coarsenVault(env, owner, depositor, asset, {.overdueDays = 200});
auto const& vaultKeylet = coarsened.fixture.vaultKeylet;
expectCoarsened(env, coarsened);
// Withdrawals need the Redemption phase.
env.close(coarsened.fixture.redemptionDate + std::chrono::seconds{1});
struct Row
{
Debit kind = Debit::Withdraw;
char const* label = nullptr;
// Off-grid at AA's base grid (1e-10).
Number amount;
};
Row const rows[] = {
{.kind = Debit::Withdraw, .label = "withdraw", .amount = Number{123'0000000007LL, -10}},
{.kind = Debit::Clawback, .label = "clawback", .amount = Number{111'0000000003LL, -10}},
};
for (auto const& row : rows)
{
Number const delta =
debitAvailable(env, coarsened, row.kind, issuer, depositor, asset, row.amount);
// The share round-trip can trim the delta, but it must keep AA's finer grid
// precision, not collapse to the AssetsTotal grid (-9).
BEAST_EXPECT(delta > beast::kZero);
BEAST_EXPECT(delta <= row.amount);
BEAST_EXPECT(delta != roundToAsset(asset, delta, -9, Number::RoundingMode::Downward));
auto const after = snapshotVault(env, vaultKeylet, asset);
BEAST_EXPECT(after.available <= after.total);
checkVaultLoanSums(
env,
coarsened,
std::string{"testCoarsenedVaultWithdrawClawbackKeepAvailableGridDigit ("} +
row.label + ")");
}
}
void
testCoarsenedVaultSubUnitWithdrawClawbackSucceeds()
{
using namespace test::jtx;
testcase(
"Lending: VaultWithdraw and VaultClawback smaller than half a unit of the "
"AssetsTotal cache's grid still succeed");
Env env(*this, features());
auto const [issuer, owner, depositor, asset] = setupCoarsenIou(env, true);
auto const coarsened = coarsenVault(env, owner, depositor, asset, {.overdueDays = 200});
auto const vaultSle0 = env.le(coarsened.fixture.vaultKeylet);
if (!BEAST_EXPECT(vaultSle0))
return;
// The AssetsTotal cache grid (-9) is coarser than the base grid, and AA
// itself still sits on the base grid (-10).
BEAST_EXPECT(getVaultScale(vaultSle0) == -9);
BEAST_EXPECT(getVaultBaseScale(vaultSle0) == -10);
// Withdrawals need the Redemption phase.
env.close(coarsened.fixture.redemptionDate + std::chrono::seconds{1});
struct Row
{
Debit kind = Debit::Withdraw;
char const* label = nullptr;
// On AA's base grid (-10) but under half a unit of AssetsTotal's grid (5e-10).
Number amount;
};
Row const rows[] = {
{.kind = Debit::Withdraw, .label = "withdraw", .amount = Number{2, -10}},
{.kind = Debit::Clawback, .label = "clawback", .amount = Number{3, -10}},
};
for (auto const& row : rows)
{
// Rounding Downward at -9 gives exactly zero.
BEAST_EXPECT(
roundToAsset(asset, row.amount, -9, Number::RoundingMode::Downward) ==
beast::kZero);
Number const delta =
debitAvailable(env, coarsened, row.kind, issuer, depositor, asset, row.amount);
// The delta may be trimmed by the share round-trip. It must stay strictly
// positive and sub-(AT-grid); the bug rejected it outright.
BEAST_EXPECT(delta > beast::kZero);
BEAST_EXPECT(delta <= row.amount);
BEAST_EXPECT(
roundToAsset(asset, delta, -9, Number::RoundingMode::Downward) == beast::kZero);
checkVaultLoanSums(
env,
coarsened,
std::string{"testCoarsenedVaultSubUnitWithdrawClawbackSucceeds ("} + row.label +
")");
}
}
void
testCoarsenedVaultDebitCrossesPowerOfTenDownward()
{
using namespace test::jtx;
testcase(
"Lending: VaultWithdraw and VaultClawback debits that cross a power of ten keep "
"AssetsAvailable at 16 digits");
Env env(*this, features());
auto const [issuer, owner, depositor, asset] = setupCoarsenIou(env, true);
auto const coarsened = coarsenVault(env, owner, depositor, asset, {.overdueDays = 200});
auto const& vaultKeylet = coarsened.fixture.vaultKeylet;
expectCoarsened(env, coarsened);
// Withdrawals need the Redemption phase.
env.close(coarsened.fixture.redemptionDate + std::chrono::seconds{1});
// A margin, since the share round-trip can trim the delta short of the
// requested amount.
Number const margin{100};
// The clawback runs on the post-withdraw vault, crossing a fresh boundary.
struct Row
{
Debit kind = Debit::Withdraw;
char const* label = nullptr;
};
Row const rows[] = {
{.kind = Debit::Withdraw, .label = "withdraw"},
{.kind = Debit::Clawback, .label = "clawback"},
};
for (auto const& row : rows)
{
Number const availableBefore = snapshotVault(env, vaultKeylet, asset).available;
// The largest power of ten below AssetsAvailable; crossing it downward drops
// a digit from the integer part.
Number const powerOfTen = largestPowerOfTenAtMost(availableBefore);
// One transaction spans the whole gap; the AssetsAvailable-delta check is
// exact against the real transfer.
Number const amount = availableBefore - powerOfTen + margin;
BEAST_EXPECT(amount > beast::kZero);
BEAST_EXPECT(amount < availableBefore);
debitAvailable(env, coarsened, row.kind, issuer, depositor, asset, amount);
BEAST_EXPECT(expectAvailableAtSixteenDigits(env, vaultKeylet, asset) < powerOfTen);
checkVaultLoanSums(
env,
coarsened,
std::string{"testCoarsenedVaultDebitCrossesPowerOfTenDownward ("} + row.label +
")");
}
}
void
testValidVaultLargeSingleDebitAcrossAssetsTotalGridDoesNotFalsePositive()
{
using namespace test::jtx;
testcase(
"Lending: a single large VaultWithdraw/VaultClawback across the AssetsTotal grid "
"boundary succeeds (ValidVault false-positive regression)");
Number const margin{1'000};
Number const powerOfTen{100'000};
struct Row
{
Debit kind = Debit::Withdraw;
char const* label = nullptr;
};
// Each debit runs on a fresh vault so AssetsTotal starts above its boundary
// (after a shrink it would be dominated by AssetsDeployed).
Row const rows[] = {
{.kind = Debit::Withdraw, .label = "withdraw"},
{.kind = Debit::Clawback, .label = "clawback"},
};
for (auto const& row : rows)
{
Env env(*this, features());
auto const [issuer, owner, depositor, asset] = setupCoarsenIou(env, true);
// Setup from the original report: AssetsAvailable 723562.1976154236,
// AssetsDeployed 560000.
auto const coarsened = coarsenVault(env, owner, depositor, asset, {.overdueDays = 200});
auto const& vaultKeylet = coarsened.fixture.vaultKeylet;
expectCoarsened(env, coarsened);
env.close(coarsened.fixture.redemptionDate + std::chrono::seconds{1});
auto const before = snapshotVault(env, vaultKeylet, asset);
// The stored AssetsTotal crosses its own power-of-ten boundary, not
// necessarily AssetsAvailable's, since AssetsDeployed adds magnitude.
Number const powerOfTenAT = largestPowerOfTenAtMost(before.total);
BEAST_EXPECT(before.total >= powerOfTenAT);
// One debit takes AssetsAvailable below its boundary and carries AssetsTotal
// across its own boundary in the same transaction.
debitAvailable(
env,
coarsened,
row.kind,
issuer,
depositor,
asset,
before.available - powerOfTen + margin);
BEAST_EXPECT(snapshotVault(env, vaultKeylet, asset).total < powerOfTenAT);
checkVaultLoanSums(
env,
coarsened,
std::string{
"testValidVaultLargeSingleDebitAcrossAssetsTotalGridDoesNotFalsePositive ("} +
row.label + ")");
}
}
void
testDepositRefusedOnDebtCoarsenedVault()
{
using namespace test::jtx;
testcase(
"Lending: VaultDeposit is refused on a vault coarsened via AssetsDeployed while "
"AssetsAvailable is still uncoarsened -- always via the Investment-phase gate, "
"not the precision-loss check");
Env env(*this, features());
auto const [issuer, owner, depositor, asset] = setupCoarsenIou(env);
auto const coarsened = coarsenVault(env, owner, depositor, asset, {.overdueDays = 200});
auto const& vaultKeylet = coarsened.fixture.vaultKeylet;
auto const vaultSle = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultSle))
return;
BEAST_EXPECT(getVaultScale(vaultSle) == -9);
BEAST_EXPECT(getVaultBaseScale(vaultSle) == -10);
// Off-grid at the coarsened live scale (-9) but on AA's base grid (-10).
Number const depositAmount{2, -10};
BEAST_EXPECT(
roundToAsset(asset, depositAmount, -9, Number::RoundingMode::Downward) == beast::kZero);
// An AssetsDeployed-coarsened vault always has a LoanBroker, so it is closed-ended
// and in the Investment phase while debt is outstanding. VaultDeposit refuses
// there before the precision-loss check, so this amount fails with tecEXPIRED,
// not tecPRECISION_LOSS. See the VaultBalance.* gtest cases for the bare-SLE
// precision check.
env(coarsened.fixture.vault.deposit(
{.depositor = depositor, .id = vaultKeylet.key, .amount = asset(depositAmount)}),
Ter(tecEXPIRED));
env.close();
checkVaultLoanSums(env, coarsened, "testDepositRefusedOnDebtCoarsenedVault");
}
public:
void
run() override
{
testCoarsenedVaultWithdrawClawbackKeepAvailableGridDigit();
testCoarsenedVaultSubUnitWithdrawClawbackSucceeds();
testCoarsenedVaultDebitCrossesPowerOfTenDownward();
testValidVaultLargeSingleDebitAcrossAssetsTotalGridDoesNotFalsePositive();
testDepositRefusedOnDebtCoarsenedVault();
}
};
BEAST_DEFINE_TESTSUITE(VaultCoarsenedFixedPrecision, app, xrpl);
} // namespace xrpl
+81 -63
View File
@@ -50,6 +50,12 @@ namespace xrpl {
class VaultBugs_test : public VaultTestBase
{
// The IOU precision-boundary bugs in this suite probe the STAmount
// 16-digit mantissa cliff (~1e16). FixedPrecision's Open-zone cap
// (9e(15-Scale)) makes that value unreachable at any Scale, so these
// scenarios cannot be reproduced under V1.2 by construction.
FeatureBitset const cashBasisAll_{all_ - featureLendingProtocolV1_2};
private:
// Bug: the equality check (vault outflow == destination inflow) was
// skipped whenever the destination delta rounded to zero at localMinScale,
@@ -127,13 +133,13 @@ private:
testcase(
"bug: VaultWithdraw to destination at IOU precision boundary fires "
"invariant (pre-fixCleanup3_2_0)");
runScenario(all_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw to destination at IOU precision boundary succeeds "
"when destroyed amount is sub-ULP (post-fixCleanup3_2_0)");
runScenario(all_, tesSUCCESS);
runScenario(cashBasisAll_, tesSUCCESS);
}
}
@@ -192,13 +198,13 @@ private:
testcase(
"bug: VaultDeposit by issuer at IOU edge fires "
"tecINVARIANT_FAILED at finalize (pre-fixCleanup3_2_0)");
runScenario(all_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultDeposit by issuer at IOU edge rejects with "
"tecPRECISION_LOSS proactively (post-fixCleanup3_2_0)");
runScenario(all_, tecPRECISION_LOSS);
runScenario(cashBasisAll_, tecPRECISION_LOSS);
}
}
@@ -271,13 +277,13 @@ private:
testcase(
"bug: VaultDeposit across IOU scale boundary fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(all_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultDeposit across IOU scale boundary succeeds "
"(post-fixCleanup3_2_0)");
runScenario(all_, tecPRECISION_LOSS);
runScenario(cashBasisAll_, tecPRECISION_LOSS);
}
}
@@ -344,13 +350,13 @@ private:
testcase(
"bug: VaultWithdraw across IOU scale boundary fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(all_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw across IOU scale boundary succeeds "
"(post-fixCleanup3_2_0)");
runScenario(all_, tesSUCCESS);
runScenario(cashBasisAll_, tesSUCCESS);
}
}
@@ -436,13 +442,13 @@ private:
// Also remove fixCleanup3_4_0 so the VaultDeposit clamp
// introduced by that amendment does not short-circuit this
// pre-fixCleanup3_2_0 scenario with tecPRECISION_LOSS.
runScenario(all_ - fixCleanup3_2_0 - fixCleanup3_4_0, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_2_0 - fixCleanup3_4_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultDeposit below Vault precision canonicalized to zero "
"(post-fixCleanup3_2_0)");
runScenario(all_, tecPRECISION_LOSS);
runScenario(cashBasisAll_, tecPRECISION_LOSS);
}
}
@@ -580,7 +586,7 @@ private:
// pattern that only makes sense on open-ended vaults. The gate
// added by LP V1.1 is unrelated to the truncation bug asserted
// here.
auto const legacy = all_ - featureLendingProtocolV1_1;
auto const legacy = cashBasisAll_ - featureLendingProtocolV1_1;
{
testcase(
"bug: VaultDeposit share truncation lets depositor debit "
@@ -696,25 +702,25 @@ private:
testcase(
"bug: VaultWithdraw to third-party at IOU edge fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(all_ - fixCleanup3_2_0, DestKind::ThirdParty, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_2_0, DestKind::ThirdParty, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw to third-party at IOU edge succeeds "
"(post-fixCleanup3_2_0)");
runScenario(all_, DestKind::ThirdParty, tesSUCCESS);
runScenario(cashBasisAll_, DestKind::ThirdParty, tesSUCCESS);
}
{
testcase(
"bug: VaultWithdraw to self at IOU edge fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(all_ - fixCleanup3_2_0, DestKind::Self, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_2_0, DestKind::Self, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw to self at IOU edge succeeds "
"(post-fixCleanup3_2_0)");
runScenario(all_, DestKind::Self, tesSUCCESS);
runScenario(cashBasisAll_, DestKind::Self, tesSUCCESS);
}
}
@@ -786,9 +792,9 @@ private:
};
testcase("bug: VaultClawback dust debit fires invariant (pre-fixCleanup3_4_0)");
runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
testcase("bug: VaultClawback dust debit rejected cleanly (post-fixCleanup3_4_0)");
runScenario(all_, tecPRECISION_LOSS);
runScenario(cashBasisAll_, tecPRECISION_LOSS);
}
{
@@ -810,9 +816,9 @@ private:
};
testcase("bug: VaultWithdraw dust debit fires invariant (pre-fixCleanup3_4_0)");
runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
testcase("bug: VaultWithdraw dust debit rejected cleanly (post-fixCleanup3_4_0)");
runScenario(all_, tecPRECISION_LOSS);
runScenario(cashBasisAll_, tecPRECISION_LOSS);
}
}
@@ -878,12 +884,12 @@ private:
testcase(
"bug: VaultDeposit overcredits across an IOU scale boundary "
"(pre-fixCleanup3_4_0)");
runScenario(all_ - fixCleanup3_2_0 - fixCleanup3_4_0, true);
runScenario(cashBasisAll_ - fixCleanup3_2_0 - fixCleanup3_4_0, true);
testcase(
"bug: VaultDeposit no longer overcredits across an IOU scale boundary "
"(post-fixCleanup3_4_0)");
runScenario(all_, false);
runScenario(cashBasisAll_, false);
}
// 1e17 IOU at scale 0. Withdraw all-but-one, then the last share:
@@ -931,11 +937,11 @@ private:
testcase(
"bug: VaultWithdraw permanently locks a large IOU vault "
"(pre-fixCleanup3_4_0)");
runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
testcase(
"bug: VaultWithdraw no longer locks a large IOU vault "
"(post-fixCleanup3_4_0)");
runScenario(all_, tesSUCCESS);
runScenario(cashBasisAll_, tesSUCCESS);
}
// VaultDeposit::preclaim uses accountHolds(..., SpendableHandling::
@@ -1006,14 +1012,14 @@ private:
"IOU vault deposit exceeding depositor's balance but "
"within counterparty's trust limit, pre-fixCleanup3_2_0 "
"(tefINTERNAL)");
runTest(all_ - fixCleanup3_2_0, tefINTERNAL);
runTest(cashBasisAll_ - fixCleanup3_2_0, tefINTERNAL);
}
{
testcase(
"IOU vault deposit exceeding depositor's balance but "
"within counterparty's trust limit, post-fixCleanup3_2_0 "
"(tesSUCCESS)");
runTest(all_, tesSUCCESS);
runTest(cashBasisAll_, tesSUCCESS);
}
}
@@ -1025,7 +1031,7 @@ private:
using namespace test::jtx;
testcase("Bug6 - limit bypass with share-denominated withdrawal");
auto const allAmendments = all_ | featureSingleAssetVault;
auto const allAmendments = cashBasisAll_ | featureSingleAssetVault;
for (auto const& features : {allAmendments, allAmendments - fixCleanup3_1_3})
{
@@ -1285,13 +1291,13 @@ private:
testcase(
"bug: VaultClawback round-trip overshoot lets issuer recover "
"more than requested (pre-fixCleanup3_4_0)");
runScenario(all_ - fixCleanup3_4_0, false);
runScenario(cashBasisAll_ - fixCleanup3_4_0, false);
}
{
testcase(
"bug: VaultClawback round-trip overshoot is clamped so "
"assetsRecovered <= clawbackAmount (post-fixCleanup3_4_0)");
runScenario(all_, true);
runScenario(cashBasisAll_, true);
}
}
@@ -1349,13 +1355,13 @@ private:
testcase(
"bug: VaultWithdraw round-trip overshoot delivers more than "
"requested (pre-fixCleanup3_4_0)");
runScenario(all_ - fixCleanup3_4_0, false);
runScenario(cashBasisAll_ - fixCleanup3_4_0, false);
}
{
testcase(
"bug: VaultWithdraw round-trip overshoot is clamped so "
"assetsWithdrawn <= requested (post-fixCleanup3_4_0)");
runScenario(all_, true);
runScenario(cashBasisAll_, true);
}
}
@@ -1553,12 +1559,12 @@ private:
BEAST_EXPECT(tokenAfter->getFieldU64(sfMPTAmount) == expectedSharesAfter);
};
runSole(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
runSole(all_, tesSUCCESS);
runSole(cashBasisAll_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
runSole(cashBasisAll_, tesSUCCESS);
testcase("VaultClawback after impaired loan, non-sole holder");
{
Env env(*this, all_);
Env env(*this, cashBasisAll_);
auto const maybeSetup = makeImpairedLoanVault(env, 1'000);
if (!maybeSetup)
{
@@ -1770,30 +1776,42 @@ private:
};
runScenario(
all_, false /* removeAssetToken */, false /* withdrawAllAliceShares */, tesSUCCESS);
cashBasisAll_,
false /* removeAssetToken */,
false /* withdrawAllAliceShares */,
tesSUCCESS);
runScenario(
all_, false /* removeAssetToken */, true /* withdrawAllAliceShares */, tesSUCCESS);
cashBasisAll_,
false /* removeAssetToken */,
true /* withdrawAllAliceShares */,
tesSUCCESS);
runScenario(
all_, true /* removeAssetToken */, false /* withdrawAllAliceShares */, tesSUCCESS);
cashBasisAll_,
true /* removeAssetToken */,
false /* withdrawAllAliceShares */,
tesSUCCESS);
runScenario(
all_, true /* removeAssetToken */, true /* withdrawAllAliceShares */, tesSUCCESS);
cashBasisAll_,
true /* removeAssetToken */,
true /* withdrawAllAliceShares */,
tesSUCCESS);
runScenario(
all_ - fixCleanup3_4_0,
cashBasisAll_ - fixCleanup3_4_0,
false /* removeAssetToken */,
false /* withdrawAllAliceShares */,
tecINVARIANT_FAILED);
runScenario(
all_ - fixCleanup3_4_0,
cashBasisAll_ - fixCleanup3_4_0,
false /* removeAssetToken */,
true /* withdrawAllAliceShares */,
tecINVARIANT_FAILED);
runScenario(
all_ - fixCleanup3_4_0,
cashBasisAll_ - fixCleanup3_4_0,
true /* removeAssetToken */,
false /* withdrawAllAliceShares */,
tecINVARIANT_FAILED);
runScenario(
all_ - fixCleanup3_4_0,
cashBasisAll_ - fixCleanup3_4_0,
true /* removeAssetToken */,
true /* withdrawAllAliceShares */,
tecINVARIANT_FAILED);
@@ -1813,7 +1831,7 @@ private:
using namespace loan;
using namespace std::chrono_literals;
Env env(*this, all_);
Env env(*this, cashBasisAll_);
Account const issuer{"issuer"};
Account const owner{"owner"};
@@ -2053,8 +2071,8 @@ private:
BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == asset(0).value());
};
runScenario(all_, tesSUCCESS);
runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
runScenario(cashBasisAll_, tesSUCCESS);
runScenario(cashBasisAll_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
}
// addEmptyHolding() used to check isGlobalFrozen(issuer) and
@@ -2349,63 +2367,63 @@ private:
"bug: VaultWithdraw to self fails with tecINTERNAL after issuer "
"clears asfDefaultRipple even though the trust line exists "
"(pre-fixCleanup3_4_0)");
runExistingLine(all_ - fixCleanup3_4_0, tecINTERNAL);
runExistingLine(cashBasisAll_ - fixCleanup3_4_0, tecINTERNAL);
testcase(
"bug: VaultWithdraw to self succeeds after issuer clears "
"asfDefaultRipple when the trust line exists (post-fixCleanup3_4_0)");
runExistingLine(all_, tesSUCCESS);
runExistingLine(cashBasisAll_, tesSUCCESS);
testcase(
"bug: VaultWithdraw to self with an existing line still gets "
"tecFROZEN under asfGlobalFreeze (post-fixCleanup3_4_0)");
runExistingLine(all_, tecFROZEN, true);
runExistingLine(cashBasisAll_, tecFROZEN, true);
testcase(
"bug: VaultWithdraw to self fails with tecINTERNAL after issuer "
"clears asfDefaultRipple and the trust line was deleted "
"(pre-fixCleanup3_4_0)");
runDeletedLine(all_ - fixCleanup3_4_0, tecINTERNAL);
runDeletedLine(cashBasisAll_ - fixCleanup3_4_0, tecINTERNAL);
testcase(
"bug: VaultWithdraw to self fails with terNO_RIPPLE after issuer "
"clears asfDefaultRipple and the trust line was deleted "
"(post-fixCleanup3_4_0)");
runDeletedLine(all_, terNO_RIPPLE);
runDeletedLine(cashBasisAll_, terNO_RIPPLE);
testcase(
"bug: LoanBrokerCoverWithdraw to self fails with tecINTERNAL after "
"issuer clears asfDefaultRipple even though the trust line exists "
"(pre-fixCleanup3_4_0)");
runCoverWithdraw(all_ - fixCleanup3_4_0, tecINTERNAL);
runCoverWithdraw(cashBasisAll_ - fixCleanup3_4_0, tecINTERNAL);
testcase(
"bug: LoanBrokerCoverWithdraw to self succeeds after issuer clears "
"asfDefaultRipple when the trust line exists (post-fixCleanup3_4_0)");
runCoverWithdraw(all_, tesSUCCESS);
runCoverWithdraw(cashBasisAll_, tesSUCCESS);
testcase(
"bug: LoanBrokerCoverWithdraw to self fails with tecINTERNAL after "
"issuer clears asfDefaultRipple and the trust line was deleted "
"(pre-fixCleanup3_4_0)");
runDeletedCoverWithdraw(all_ - fixCleanup3_4_0, tecINTERNAL);
runDeletedCoverWithdraw(cashBasisAll_ - fixCleanup3_4_0, tecINTERNAL);
testcase(
"bug: LoanBrokerCoverWithdraw to self fails with terNO_RIPPLE after "
"issuer clears asfDefaultRipple and the trust line was deleted "
"(post-fixCleanup3_4_0)");
runDeletedCoverWithdraw(all_, terNO_RIPPLE);
runDeletedCoverWithdraw(cashBasisAll_, terNO_RIPPLE);
testcase(
"bug: private VaultWithdraw to self fails with tecINTERNAL after "
"issuer clears asfDefaultRipple even though the trust line exists "
"(pre-fixCleanup3_4_0)");
runPrivateVault(all_ - fixCleanup3_4_0, tecINTERNAL);
runPrivateVault(cashBasisAll_ - fixCleanup3_4_0, tecINTERNAL);
testcase(
"bug: private VaultWithdraw to self succeeds after issuer clears "
"asfDefaultRipple when the trust line exists (post-fixCleanup3_4_0)");
runPrivateVault(all_, tesSUCCESS);
runPrivateVault(cashBasisAll_, tesSUCCESS);
}
// Bug 1: a sponsored XRP VaultWithdraw to a distinct destination is
@@ -2497,12 +2515,12 @@ private:
"bug: sponsored XRP withdrawal to a distinct destination misreads a "
"touched-but-zero sender delta as a second recipient "
"(pre-fixCleanup3_4_0)");
runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
testcase(
"bug: sponsored XRP withdrawal to a distinct destination succeeds "
"(post-fixCleanup3_4_0)");
runScenario(all_, tesSUCCESS);
runScenario(cashBasisAll_, tesSUCCESS);
}
// Bug 2: a co-signed fee sponsor named as the withdrawal's own
@@ -2576,12 +2594,12 @@ private:
"bug: co-signed sponsor named as withdrawal destination has its "
"own fee debit misread as breaking the payout equality "
"(pre-fixCleanup3_4_0)");
runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
runScenario(cashBasisAll_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
testcase(
"bug: co-signed sponsor named as withdrawal destination succeeds "
"(post-fixCleanup3_4_0)");
runScenario(all_, tesSUCCESS);
runScenario(cashBasisAll_, tesSUCCESS);
}
// Pre-funded fee sponsorship draws the fee from ltSponsorship.sfFeeAmount,
@@ -2678,18 +2696,18 @@ private:
testcase(
"pre-funded fee XRP withdrawal to a distinct destination succeeds "
"(post-fixCleanup3_4_0)");
runScenario(all_, tesSUCCESS, false);
runScenario(cashBasisAll_, tesSUCCESS, false);
testcase(
"bug: pre-funded sponsor named as withdrawal destination misreads "
"the sender's touched-but-zero delta as a second recipient "
"(pre-fixCleanup3_4_0)");
runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED, true);
runScenario(cashBasisAll_ - fixCleanup3_4_0, tecINVARIANT_FAILED, true);
testcase(
"bug: pre-funded sponsor named as withdrawal destination receives "
"the full payout (post-fixCleanup3_4_0)");
runScenario(all_, tesSUCCESS, true);
runScenario(cashBasisAll_, tesSUCCESS, true);
}
// Unsponsored third-party XRP withdrawal: the sender's AccountRoot moves
@@ -2704,7 +2722,7 @@ private:
"unsponsored XRP withdrawal to a distinct destination succeeds "
"(pre-fixCleanup3_4_0)");
Env env{*this, all_ - fixCleanup3_4_0};
Env env{*this, cashBasisAll_ - fixCleanup3_4_0};
Account const owner{"owner"};
Account const holder{"holder"};
Account const destination{"destination"};
+3 -10
View File
@@ -3,7 +3,6 @@
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/envconfig.h>
#include <test/jtx/escrow.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
@@ -51,13 +50,7 @@ private:
using namespace test::jtx;
using namespace loan_broker;
using namespace loan;
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(
*this,
envconfig(),
testableAmendments() - featureLendingProtocolV1_2,
nullptr,
beast::Severity::Warning);
Env env(*this, beast::Severity::Warning);
auto const vaultAssetBalance = [&](Keylet const& vaultKeylet) {
auto const sleVault = env.le(vaultKeylet);
@@ -332,8 +325,7 @@ private:
using namespace test::jtx;
using namespace loan_broker;
using namespace loan;
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env(*this, testableAmendments() - featureLendingProtocolV1_2);
Env env(*this);
env.enableFeature(fixCleanup3_1_3);
// Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
@@ -849,6 +841,7 @@ private:
Env legacyEnv(*this, all_);
legacyEnv.disableFeature(fixCleanup3_1_3);
legacyEnv.disableFeature(featureLendingProtocolV1_2);
legacyEnv.fund(XRP(10000), issuer, owner, depositor);
legacyEnv.close();
+10 -13
View File
@@ -555,8 +555,7 @@ private:
using namespace loan_broker;
using namespace loan;
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env{*this, testableAmendments() - featureLendingProtocolV1_2};
Env env{*this, testableAmendments()};
Account const owner{"owner"};
Account const depositor{"depositor"};
Account const borrower{"borrower"};
@@ -634,8 +633,7 @@ private:
using namespace loan_broker;
using namespace loan;
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env{*this, testableAmendments() - featureLendingProtocolV1_2};
Env env{*this, testableAmendments()};
Account const owner{"owner"};
Account const alice{"alice"};
Account const bob{"bob"};
@@ -762,12 +760,13 @@ private:
balancesEq(XRP(140).value(), XRP(200).value());
// bob has 200 XRP-worth of shares but only 140 XRP is available (the remaining 60 XRP
// sits in the outstanding loan). A full 200 XRP withdrawal fails against the
// AssetsAvailable cap; bob redeems 140 XRP instead and is left holding 60M shares backed
// by the loan receivable — the realistic outcome when capital is still deployed at
// Redemption.
// sits in the outstanding loan). Requesting the full 200 XRP is a final withdrawal
// (it would burn all of bob's remaining shares), and FixedPrecision refuses to empty
// the vault while AssetsDeployed is non-zero, ahead of the ordinary insufficient-funds
// guard; bob redeems 140 XRP instead and is left holding 60M shares backed by the loan
// receivable — the realistic outcome when capital is still deployed at Redemption.
env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}),
Ter{tecINSUFFICIENT_FUNDS});
Ter{tecHAS_OBLIGATIONS});
env.close();
env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(140).value()}));
env.close();
@@ -796,8 +795,7 @@ private:
using namespace loan_broker;
using namespace loan;
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env{*this, testableAmendments() - featureLendingProtocolV1_2};
Env env{*this, testableAmendments()};
Account const owner{"owner"};
Account const alice{"alice"};
Account const borrower{"borrower"};
@@ -862,8 +860,7 @@ private:
using namespace loan_broker;
using namespace loan;
// Temporary: FixedPrecision lending is incomplete until the LoanManage PR.
Env env{*this, testableAmendments() - featureLendingProtocolV1_2};
Env env{*this, testableAmendments()};
Account const owner{"owner"};
Account const alice{"alice"};
Account const bob{"bob"};
+616 -48
View File
@@ -50,6 +50,39 @@ namespace xrpl {
class VaultLifecycle_test : public VaultTestBase
{
private:
struct FundedVault
{
Keylet keylet;
test::jtx::Account account;
test::jtx::PrettyAsset shares;
};
/**
* Creates a vault owned by `owner` and deposits `amount` into it from `depositor`.
*/
static FundedVault
createFundedVault(
test::jtx::Env& env,
test::jtx::Vault& vault,
test::jtx::Account const& owner,
test::jtx::Account const& depositor,
STAmount const& amount)
{
auto [tx, keylet] = vault.create({.owner = owner, .asset = amount.asset()});
env(tx);
env.close();
env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount}));
env.close();
auto const sle = env.le(keylet);
test::jtx::Account const account{"vault", sle->at(sfAccount)};
env.memoize(account);
return {
.keylet = keylet,
.account = account,
.shares = test::jtx::PrettyAsset{MPTIssue{sle->at(sfShareMPTID)}},
};
}
void
testSequences()
{
@@ -573,6 +606,7 @@ private:
bool enableClawback = true;
bool requireAuth = true;
int initialXRP = 1000;
std::uint16_t transferFee = 0;
FeatureBitset features = testableAmendments();
};
@@ -597,6 +631,7 @@ private:
MPTTester mptt{env, issuer, kMptInitNoFund};
auto const kNone = LedgerSpecificFlags(0);
mptt.create({
.transferFee = args.transferFee,
.flags = tfMPTCanTransfer | tfMPTCanLock |
(args.enableClawback ? tfMPTCanClawback : kNone) |
(args.requireAuth ? tfMPTRequireAuth : kNone),
@@ -616,6 +651,324 @@ private:
test(env, issuer, owner, depositor, asset, vault, mptt);
};
// The MPT charges a 25% transfer fee, so 100 gross delivers 80 net.
auto const feeArgs = CaseArgs{.transferFee = 25'000};
auto const testTransferFeeGate = [&](FeatureBitset const& features) {
testCase(
[this, features](
Env& env,
Account const&,
Account const& owner,
Account const& depositor,
PrettyAsset const& asset,
Vault& vault,
MPTTester& mptt) {
bool const feeCharged = features[fixCleanup3_5_0];
testcase(
feeCharged ? "MPT transfer fee on third-party withdrawal"
: "MPT transfer fee waived pre-fixCleanup3_5_0");
auto const funded = createFundedVault(env, vault, owner, depositor, asset(100));
auto tx = vault.withdraw({
.depositor = depositor,
.id = funded.keylet.key,
.amount = funded.shares(100),
});
tx[sfDestination] = owner.human();
env(tx);
env.close();
auto const expected = feeCharged ? 80 : 100;
BEAST_EXPECT(mptt.checkMPTokenAmount(depositor, 900));
BEAST_EXPECT(mptt.checkMPTokenAmount(owner, expected));
BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(900 + expected));
BEAST_EXPECT(
env.balance(funded.account, asset.raw().get<MPTIssue>()) == asset(0));
env(vault.del({.owner = owner, .id = funded.keylet.key}));
env.close();
},
CaseArgs{.transferFee = 25'000, .features = features});
};
testTransferFeeGate(testableAmendments() - fixCleanup3_5_0);
testTransferFeeGate(testableAmendments());
testCase(
[this](
Env& env,
Account const&,
Account const& owner,
Account const& depositor,
PrettyAsset const& asset,
Vault& vault,
MPTTester& mptt) {
testcase("MPT transfer fee on fixed-asset third-party withdrawal");
auto const funded = createFundedVault(env, vault, owner, depositor, asset(100));
auto const mptIssue = asset.raw().get<MPTIssue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
auto tx = vault.withdraw(
{.depositor = depositor, .id = funded.keylet.key, .amount = asset(40)});
tx[sfDestination] = owner.human();
env(tx);
env.close();
// The destination receives the requested 40; the gross 50 leaves the vault.
BEAST_EXPECT(mptt.checkMPTokenAmount(owner, 40));
BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(50));
BEAST_EXPECT(env.balance(depositor, shareIssue) == funded.shares(50));
BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(990));
},
feeArgs);
testCase(
[this](
Env& env,
Account const&,
Account const& owner,
Account const& depositor,
PrettyAsset const& asset,
Vault& vault,
MPTTester& mptt) {
testcase("MPT transfer fee on fixed-share third-party withdrawal");
auto const funded = createFundedVault(env, vault, owner, depositor, asset(100));
auto const mptIssue = asset.raw().get<MPTIssue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
auto tx = vault.withdraw(
{.depositor = depositor, .id = funded.keylet.key, .amount = funded.shares(25)});
tx[sfDestination] = owner.human();
env(tx);
env.close();
// The 25 shares redeem 25 assets; the destination receives 20 net.
BEAST_EXPECT(mptt.checkMPTokenAmount(owner, 20));
BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(75));
BEAST_EXPECT(env.balance(depositor, shareIssue) == funded.shares(75));
BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(995));
},
feeArgs);
testCase(
[this](
Env& env,
Account const& issuer,
Account const& owner,
Account const& depositor,
PrettyAsset const& asset,
Vault& vault,
MPTTester& mptt) {
testcase("MPT no transfer fee on withdrawal to issuer");
auto const funded = createFundedVault(env, vault, owner, depositor, asset(100));
auto const mptIssue = asset.raw().get<MPTIssue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
auto tx = vault.withdraw(
{.depositor = depositor, .id = funded.keylet.key, .amount = asset(10)});
tx[sfDestination] = issuer.human();
env(tx);
env.close();
BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(90));
BEAST_EXPECT(env.balance(depositor, shareIssue) == funded.shares(90));
BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(990));
},
feeArgs);
testCase(
[this](
Env& env,
Account const&,
Account const& owner,
Account const& depositor,
PrettyAsset const& asset,
Vault& vault,
MPTTester& mptt) {
testcase("MPT no transfer fee on self-withdrawal");
auto const funded = createFundedVault(env, vault, owner, depositor, asset(100));
auto const mptIssue = asset.raw().get<MPTIssue>();
env(vault.withdraw(
{.depositor = depositor, .id = funded.keylet.key, .amount = asset(10)}));
env.close();
BEAST_EXPECT(mptt.checkMPTokenAmount(depositor, 910));
BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(90));
BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(1000));
},
feeArgs);
testCase(
[this](
Env& env,
Account const&,
Account const& owner,
Account const& depositor,
PrettyAsset const& asset,
Vault& vault,
MPTTester& mptt) {
testcase("MPT transfer fee must fit in the vault position");
auto const funded = createFundedVault(env, vault, owner, depositor, asset(100));
auto const mptIssue = asset.raw().get<MPTIssue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
// Delivering 81 costs 102 gross, more than the 100 shares held.
auto tx = vault.withdraw(
{.depositor = depositor, .id = funded.keylet.key, .amount = asset(81)});
tx[sfDestination] = owner.human();
env(tx, Ter{tecINSUFFICIENT_FUNDS});
env.close();
// Delivering 80 costs exactly the 100 shares held.
tx = vault.withdraw(
{.depositor = depositor, .id = funded.keylet.key, .amount = asset(80)});
tx[sfDestination] = owner.human();
env(tx);
env.close();
BEAST_EXPECT(mptt.checkMPTokenAmount(owner, 80));
BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(0));
BEAST_EXPECT(env.balance(depositor, shareIssue) == funded.shares(0));
BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(980));
env(vault.del({.owner = owner, .id = funded.keylet.key}));
env.close();
},
feeArgs);
// The payout rounds down whether or not MPTokensV2 is enabled.
auto const testTransferFeeRoundsDown = [&](FeatureBitset const& features) {
testCase(
[this, features](
Env& env,
Account const&,
Account const& owner,
Account const& depositor,
PrettyAsset const& asset,
Vault& vault,
MPTTester& mptt) {
testcase(
features[featureMPTokensV2]
? "MPT transfer fee rounds the payout down to zero"
: "MPT transfer fee rounds the payout down to zero without "
"MPTokensV2");
auto const funded = createFundedVault(env, vault, owner, depositor, asset(1));
auto const mptIssue = asset.raw().get<MPTIssue>();
auto tx = vault.withdraw({
.depositor = depositor,
.id = funded.keylet.key,
.amount = funded.shares(1),
});
tx[sfDestination] = owner.human();
env(tx, Ter{tecPRECISION_LOSS});
env.close();
// A self-withdrawal pays no fee, so the same share still redeems.
env(vault.withdraw({
.depositor = depositor,
.id = funded.keylet.key,
.amount = funded.shares(1),
}));
env.close();
BEAST_EXPECT(mptt.checkMPTokenAmount(depositor, 1000));
BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(0));
env(vault.del({.owner = owner, .id = funded.keylet.key}));
env.close();
},
CaseArgs{.transferFee = 25'000, .features = features});
};
testTransferFeeRoundsDown(testableAmendments());
testTransferFeeRoundsDown(testableAmendments() - featureMPTokensV2);
testCase(
[this](
Env& env,
Account const& issuer,
Account const& owner,
Account const& depositor,
PrettyAsset const& asset,
Vault& vault,
MPTTester& mptt) {
testcase("MPT transfer fee leaves other depositors whole");
Account const charlie{"charlie"};
env.fund(XRP(1000), charlie);
env.close();
mptt.authorize({.account = charlie});
mptt.authorize({.account = issuer, .holder = charlie});
env(pay(issuer, owner, asset(100)));
env.close();
auto const funded = createFundedVault(env, vault, owner, depositor, asset(100));
env(vault.deposit(
{.depositor = owner, .id = funded.keylet.key, .amount = asset(100)}));
env.close();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
auto const vaultBefore = env.le(funded.keylet);
Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
Number const assetsAvailableBefore = vaultBefore->at(sfAssetsAvailable);
// Delivering 40 to charlie costs the depositor 50 gross.
auto tx = vault.withdraw(
{.depositor = depositor, .id = funded.keylet.key, .amount = asset(40)});
tx[sfDestination] = charlie.human();
env(tx);
env.close();
BEAST_EXPECT(mptt.checkMPTokenAmount(charlie, 40));
auto const vaultAfter = env.le(funded.keylet);
BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore - 50);
BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == assetsAvailableBefore - 50);
// The owner's 100 shares still redeem for the 100 it deposited.
BEAST_EXPECT(env.balance(owner, shareIssue) == funded.shares(100));
env(vault.withdraw(
{.depositor = owner, .id = funded.keylet.key, .amount = funded.shares(100)}));
env.close();
BEAST_EXPECT(mptt.checkMPTokenAmount(owner, 100));
},
feeArgs);
testCase(
[this](
Env& env,
Account const& issuer,
Account const& owner,
Account const& depositor,
PrettyAsset const& asset,
Vault& vault,
MPTTester& mptt) {
testcase("MPT no transfer fee on issuer withdrawal to a third party");
auto const funded = createFundedVault(env, vault, owner, issuer, asset(100));
auto const mptIssue = asset.raw().get<MPTIssue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
auto tx = vault.withdraw(
{.depositor = issuer, .id = funded.keylet.key, .amount = asset(40)});
tx[sfDestination] = depositor.human();
env(tx);
env.close();
BEAST_EXPECT(mptt.checkMPTokenAmount(depositor, 1040));
BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(60));
BEAST_EXPECT(env.balance(issuer, shareIssue) == funded.shares(60));
BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(1100));
},
feeArgs);
testCase([this](
Env& env,
Account const& issuer,
@@ -1499,66 +1852,281 @@ private:
env.close();
});
// The issuer charges a 25% transfer fee, so 100 gross delivers 80 net.
auto const feeArgs = CaseArgs{.transferRate = 1.25};
auto const testTransferFeeGate = [&](FeatureBitset const& features) {
testCase(
[this, features](
Env& env,
Account const& owner,
Account const& issuer,
Account const& charlie,
auto,
Vault& vault,
PrettyAsset const& asset,
auto&&...) {
bool const feeCharged = features[fixCleanup3_5_0];
testcase(
feeCharged ? "IOU transfer fee on third-party withdrawal"
: "IOU transfer fee waived pre-fixCleanup3_5_0");
auto const funded = createFundedVault(env, vault, owner, owner, asset(100));
auto const issue = asset.raw().get<Issue>();
// Deposits are fee-free.
BEAST_EXPECT(env.balance(owner, issue) == asset(100));
BEAST_EXPECT(env.balance(funded.account, issue) == asset(100));
env(vault.clawback({
.issuer = issuer,
.id = funded.keylet.key,
.holder = owner,
.amount = asset(50),
}));
env.close();
// Clawbacks are fee-free.
BEAST_EXPECT(env.balance(owner, issue) == asset(100));
BEAST_EXPECT(env.balance(funded.account, issue) == asset(50));
env(vault.withdraw({
.depositor = owner,
.id = funded.keylet.key,
.amount = funded.shares(20'000'000),
}));
env.close();
// Self-withdrawals are fee-free.
BEAST_EXPECT(env.balance(owner, issue) == asset(120));
BEAST_EXPECT(env.balance(funded.account, issue) == asset(30));
auto tx = vault.withdraw({
.depositor = owner,
.id = funded.keylet.key,
.amount = funded.shares(30'000'000),
});
tx[sfDestination] = charlie.human();
env(tx);
env.close();
auto const expected = feeCharged ? asset(24) : asset(30);
BEAST_EXPECT(env.balance(owner, issue) == asset(120));
BEAST_EXPECT(env.balance(charlie, issue) == expected);
BEAST_EXPECT(env.balance(funded.account, issue) == asset(0));
env(vault.del({.owner = owner, .id = funded.keylet.key}));
env.close();
},
CaseArgs{.transferRate = 1.25, .features = features});
};
testTransferFeeGate(testableAmendments() - fixCleanup3_5_0);
testTransferFeeGate(testableAmendments());
testCase(
[&, this](
[this](
Env& env,
Account const& owner,
Account const&,
Account const& charlie,
auto,
Vault& vault,
PrettyAsset const& asset,
auto&&...) {
testcase("IOU transfer fee on fixed-asset third-party withdrawal");
auto const funded = createFundedVault(env, vault, owner, owner, asset(100));
auto const issue = asset.raw().get<Issue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
auto tx = vault.withdraw(
{.depositor = owner, .id = funded.keylet.key, .amount = asset(40)});
tx[sfDestination] = charlie.human();
env(tx);
env.close();
// The destination receives the requested 40; the gross 50 leaves the vault.
BEAST_EXPECT(env.balance(charlie, issue) == asset(40));
BEAST_EXPECT(env.balance(funded.account, issue) == asset(50));
BEAST_EXPECT(env.balance(owner, shareIssue) == funded.shares(50'000'000));
BEAST_EXPECT(env.balance(owner, issue) == asset(100));
},
feeArgs);
testCase(
[this](
Env& env,
Account const& owner,
Account const&,
Account const& charlie,
auto,
Vault& vault,
PrettyAsset const& asset,
auto&&...) {
testcase("IOU transfer fee on fixed-share third-party withdrawal");
auto const funded = createFundedVault(env, vault, owner, owner, asset(100));
auto const issue = asset.raw().get<Issue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
auto tx = vault.withdraw({
.depositor = owner,
.id = funded.keylet.key,
.amount = funded.shares(25'000'000),
});
tx[sfDestination] = charlie.human();
env(tx);
env.close();
// The shares redeem 25 assets; the destination receives 20 net.
BEAST_EXPECT(env.balance(charlie, issue) == asset(20));
BEAST_EXPECT(env.balance(funded.account, issue) == asset(75));
BEAST_EXPECT(env.balance(owner, shareIssue) == funded.shares(75'000'000));
},
feeArgs);
testCase(
[this](
Env& env,
Account const& owner,
Account const& issuer,
Account const&,
auto,
Vault& vault,
PrettyAsset const& asset,
auto&&...) {
testcase("IOU no transfer fee on withdrawal to issuer");
auto const funded = createFundedVault(env, vault, owner, owner, asset(100));
auto const issue = asset.raw().get<Issue>();
auto tx = vault.withdraw(
{.depositor = owner, .id = funded.keylet.key, .amount = asset(10)});
tx[sfDestination] = issuer.human();
env(tx);
env.close();
BEAST_EXPECT(env.balance(funded.account, issue) == asset(90));
BEAST_EXPECT(env.balance(owner, issue) == asset(100));
},
feeArgs);
testCase(
[this](
Env& env,
Account const& owner,
Account const&,
Account const&,
auto,
Vault& vault,
PrettyAsset const& asset,
auto&&...) {
testcase("IOU no transfer fee on self-withdrawal");
auto const funded = createFundedVault(env, vault, owner, owner, asset(100));
auto const issue = asset.raw().get<Issue>();
env(vault.withdraw(
{.depositor = owner, .id = funded.keylet.key, .amount = asset(10)}));
env.close();
BEAST_EXPECT(env.balance(owner, issue) == asset(110));
BEAST_EXPECT(env.balance(funded.account, issue) == asset(90));
},
feeArgs);
testCase(
[this](
Env& env,
Account const& owner,
Account const&,
Account const& charlie,
auto,
Vault& vault,
PrettyAsset const& asset,
auto&&...) {
testcase("IOU transfer fee must fit in the vault position");
auto const funded = createFundedVault(env, vault, owner, owner, asset(100));
auto const issue = asset.raw().get<Issue>();
// Delivering 81 costs 101.25 gross, more than the vault position.
auto tx = vault.withdraw(
{.depositor = owner, .id = funded.keylet.key, .amount = asset(81)});
tx[sfDestination] = charlie.human();
env(tx, Ter{tecINSUFFICIENT_FUNDS});
env.close();
// Delivering 80 costs exactly the 100 held.
tx = vault.withdraw(
{.depositor = owner, .id = funded.keylet.key, .amount = asset(80)});
tx[sfDestination] = charlie.human();
env(tx);
env.close();
BEAST_EXPECT(env.balance(charlie, issue) == asset(80));
BEAST_EXPECT(env.balance(funded.account, issue) == asset(0));
env(vault.del({.owner = owner, .id = funded.keylet.key}));
env.close();
},
feeArgs);
testCase(
[this](
Env& env,
Account const& owner,
Account const& issuer,
Account const& charlie,
auto vaultAccount,
auto,
Vault& vault,
PrettyAsset const& asset,
auto issuanceId) {
testcase("IOU transfer fees not applied");
auto&&...) {
testcase("IOU no transfer fee on issuer withdrawal to a third party");
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
auto const funded = createFundedVault(env, vault, owner, issuer, asset(100));
auto const issue = asset.raw().get<Issue>();
auto tx = vault.withdraw(
{.depositor = issuer, .id = funded.keylet.key, .amount = asset(40)});
tx[sfDestination] = charlie.human();
env(tx);
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
env.close();
auto const issue = asset.raw().get<Issue>();
Asset const share = Asset(issuanceId(keylet));
// transfer fees ignored on deposit
BEAST_EXPECT(env.balance(owner, issue) == asset(100));
BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(100));
{
auto const tx = vault.clawback(
{.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
env(tx);
env.close();
}
// transfer fees ignored on clawback
BEAST_EXPECT(env.balance(owner, issue) == asset(100));
BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(50));
env(vault.withdraw(
{.depositor = owner, .id = keylet.key, .amount = share(20'000'000)}));
// transfer fees ignored on withdraw
BEAST_EXPECT(env.balance(owner, issue) == asset(120));
BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(30));
{
auto tx = vault.withdraw(
{.depositor = owner, .id = keylet.key, .amount = share(30'000'000)});
tx[sfDestination] = charlie.human();
env(tx);
}
// transfer fees ignored on withdraw to 3rd party
BEAST_EXPECT(env.balance(owner, issue) == asset(120));
BEAST_EXPECT(env.balance(charlie, issue) == asset(30));
BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(0));
env(vault.del({.owner = owner, .id = keylet.key}));
env.close();
BEAST_EXPECT(env.balance(charlie, issue) == asset(40));
BEAST_EXPECT(env.balance(funded.account, issue) == asset(60));
},
CaseArgs{.transferRate = 1.25});
feeArgs);
testCase(
[this](
Env& env,
Account const& owner,
Account const&,
Account const& charlie,
auto,
Vault& vault,
PrettyAsset const& asset,
auto&&...) {
testcase("IOU receiver limit applies to the net amount");
// The limit fits the 40 delivered but not the 50 gross.
env(trust(charlie, asset(40)));
env.close();
auto const funded = createFundedVault(env, vault, owner, owner, asset(100));
auto const issue = asset.raw().get<Issue>();
auto tx = vault.withdraw(
{.depositor = owner, .id = funded.keylet.key, .amount = asset(40)});
tx[sfDestination] = charlie.human();
env(tx);
env.close();
BEAST_EXPECT(env.balance(charlie, issue) == asset(40));
BEAST_EXPECT(env.balance(funded.account, issue) == asset(50));
},
feeArgs);
testCase([&, this](
Env& env,
+9 -3
View File
@@ -49,6 +49,12 @@ namespace xrpl {
class VaultScale_test : public VaultTestBase
{
// The IOU precision-boundary bugs in this suite probe the STAmount
// 16-digit mantissa cliff (~1e16). FixedPrecision's Open-zone cap
// (9e(15-Scale)) makes that value unreachable at any Scale, so these
// scenarios cannot be reproduced under V1.2 by construction.
FeatureBitset const cashBasisAll_{all_ - featureLendingProtocolV1_2};
private:
void
testScaleIOU()
@@ -77,7 +83,7 @@ private:
// attaching a loan broker). featureLendingProtocolV1_1 adds a
// closed-ended vault gate on LoanBrokerSet::preclaim and is
// orthogonal to what this suite asserts, so strip it here.
Env env{*this, all_ - featureLendingProtocolV1_1};
Env env{*this, cashBasisAll_ - featureLendingProtocolV1_1};
Account const owner{"owner"};
Account const issuer{"issuer"};
Account const depositor{"depositor"};
@@ -1377,8 +1383,8 @@ public:
run() override
{
testScaleIOU();
testAssetsMaximum(all_ - fixCleanup3_5_0);
testAssetsMaximum(all_);
testAssetsMaximum(cashBasisAll_ - fixCleanup3_5_0);
testAssetsMaximum(cashBasisAll_);
}
};
+1 -7
View File
@@ -114,13 +114,7 @@ protected:
return {.vault = vault, .keylet = keylet, .sub = sub, .red = red};
}
// The IOU precision-boundary bugs in VaultBugs_test.cpp probe the
// STAmount 16-digit mantissa cliff (~1e16). FixedPrecision's Open-zone
// cap (9e(15-Scale)) makes that value unreachable at any Scale, so
// these scenarios cannot be reproduced under V1.2 by construction.
// Tests for the fixed-precision protocol enable featureLendingProtocolV1_2
// explicitly.
FeatureBitset const all_{test::jtx::testableAmendments() - featureLendingProtocolV1_2};
FeatureBitset const all_{test::jtx::testableAmendments()};
std::string const iouCurrency_{"IOU"};
};
+7 -3
View File
@@ -1202,9 +1202,13 @@ private:
Vault const vault{env};
// Without the fix, rounding an out-of-range AssetsMaximum to the vault
// asset throws.
TER const createErr = withFix ? TER{temMALFORMED} : TER{tefEXCEPTION};
TER const setErr = withFix ? TER{tecPRECISION_LOSS} : TER{tefEXCEPTION};
// asset throws. FixedPrecision Vaults check AssetsMaximum against the
// base grid in preclaim first, which rejects it without throwing.
bool const fixedPrecision = features[featureLendingProtocolV1_1] &&
features[featureLendingProtocolV1_2] && features[fixCleanup3_4_0];
TER const preFixErr = fixedPrecision ? TER{tecPRECISION_LOSS} : TER{tefEXCEPTION};
TER const createErr = withFix ? TER{temMALFORMED} : preFixErr;
TER const setErr = withFix ? TER{tecPRECISION_LOSS} : preFixErr;
auto const test = [&](Asset const& asset, Number const& maxValid, Number const& overMax) {
{
+26
View File
@@ -8,6 +8,7 @@
#include <xrpl/config/BasicConfig.h>
#include <xrpl/config/Constants.h>
#include <xrpl/protocol/SystemParameters.h> // IWYU pragma: keep
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/server/Port.h>
#include <boost/lexical_cast/bad_lexical_cast.hpp>
@@ -1753,6 +1754,30 @@ r.ripple.com:51235
}
}
void
testToFees()
{
testcase("toFees");
FeeSetup setup;
setup.referenceFee = XRPAmount{17};
setup.accountReserve = XRPAmount{3'000'017};
setup.ownerReserve = XRPAmount{700'017};
setup.gasLimit = 123'457;
setup.bytecodeSizeLimit = 23'457;
setup.gasPrice = 7'654'321;
auto const fees = setup.toFees();
BEAST_EXPECT(fees.base == setup.referenceFee);
BEAST_EXPECT(fees.reserve == setup.accountReserve);
BEAST_EXPECT(fees.increment == setup.ownerReserve);
BEAST_EXPECT(fees.gasLimit == setup.gasLimit);
BEAST_EXPECT(fees.bytecodeSizeLimit == setup.bytecodeSizeLimit);
BEAST_EXPECT(fees.gasPrice == setup.gasPrice);
}
void
run() override
{
@@ -1772,6 +1797,7 @@ r.ripple.com:51235
testAmendment();
testOverlay();
testNetworkID();
testToFees();
}
};
+3 -2
View File
@@ -21,8 +21,9 @@ void
setupConfigForUnitTests(Config& cfg)
{
using namespace jtx;
// Default fees to old values, so tests don't have to worry about changes in
// Config.h
// Reserves held at pre-2021 mainnet values; many tests carry balance
// expectations built on them. Extension limits are deliberately unset --
// tests inherit the FeeSetup defaults, or set [voting].
cfg.fees.referenceFee = UNIT_TEST_REFERENCE_FEE;
cfg.fees.accountReserve = XRP(200).value().xrp().drops();
cfg.fees.ownerReserve = XRP(50).value().xrp().drops();
+30
View File
@@ -827,6 +827,36 @@ MPTTester::checkEncryptionKeys(
});
}
bool
MPTTester::checkEncryptionKey(
std::optional<Buffer> const& expected,
SF_VL const& field,
std::optional<Account> const& holder) const
{
return forObject(
[&](SLEP const& sle) -> bool {
auto const encryptionKey = (*sle)[~field];
if (!expected)
return !encryptionKey;
return encryptionKey && Slice(*encryptionKey) == Slice(*expected);
},
holder);
}
bool
MPTTester::checkInitialIssuerKey(std::optional<Buffer> const& expected) const
{
return checkEncryptionKey(expected, sfInitialIssuerEncryptionKey);
}
bool
MPTTester::checkIssuerMirrorEncryptionKey(
std::optional<Buffer> const& expected,
Account const& holder) const
{
return checkEncryptionKey(expected, sfIssuerMirrorEncryptionKey, holder);
}
void
MPTTester::pay(
Account const& src,
+13
View File
@@ -739,6 +739,13 @@ public:
std::optional<Account> const& issuerKeyOwner,
std::optional<Account> const& auditorKeyOwner) const;
[[nodiscard]] bool
checkInitialIssuerKey(std::optional<Buffer> const& expected) const;
[[nodiscard]] bool
checkIssuerMirrorEncryptionKey(std::optional<Buffer> const& expected, Account const& holder)
const;
[[nodiscard]] Account const&
issuer() const
{
@@ -965,6 +972,12 @@ private:
json::Value& jv,
Account const& account,
std::uint64_t const amount) const;
[[nodiscard]] bool
checkEncryptionKey(
std::optional<Buffer> const& expected,
SF_VL const& field,
std::optional<Account> const& holder = std::nullopt) const;
};
} // namespace xrpl::test::jtx
+8 -2
View File
@@ -13,6 +13,7 @@
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/basics/contract.h>
#include <xrpl/beast/hash/uhash.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
@@ -194,7 +195,7 @@ public:
metadata->add(*metaSerializer);
ledger->rawTxInsert(UInt256{1}, txSerializer, metaSerializer);
ledger->setImmutable();
BEAST_EXPECT(ledger->setImmutable());
ledger->setValidated();
try
@@ -277,7 +278,12 @@ public:
ledger->rawTxInsert(UInt256{seq}, txSerializer, metaSerializer);
}
ledger->setImmutable();
// Thrown rather than asserted because a refusal leaves the ledger unusable, and this
// helper is static, so BEAST_EXPECT is out of reach.
if (!ledger->setImmutable())
{
Throw<std::runtime_error>("bookChangesFor: ledger could not be made immutable");
}
ledger->setValidated();
return xrpl::rpc::computeBookChanges(std::static_pointer_cast<Ledger const>(ledger));
+15
View File
@@ -392,6 +392,21 @@ public:
"badCredentials");
}
{
testcase(
"deposit_authorized with credentials failure: zero "
"credential index");
for (auto const& zero : {std::string("0"), std::string(64, '0')})
{
auto const args = depositAuthArgs(alice, becky, "validated", {zero});
auto const jv = env.rpc("json", "deposit_authorized", args.toStyledString());
checkCredentialsResponse(
jv[jss::result], alice, becky, false, {zero}, "badCredentials");
}
}
{
testcase(
"deposit_authorized with credentials not authorized: "
+38 -2
View File
@@ -32,6 +32,7 @@
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Fees.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
@@ -437,7 +438,19 @@ public:
{
using namespace jtx;
Env env{*this, singleThreadIo(envconfig(validator, "")), features};
// doValidation only writes a fee field when its target differs from the
// ledger's current value, so a node with nothing to change emits
// nothing. Vote for limits other than the ones already in force, so
// the extension fields actually appear on the flag ledger.
auto const voteDifferentLimits = [](std::unique_ptr<Config> cfg) {
auto& voting = cfg->section(Sections::kVoting);
voting.set(Keys::kGasLimit, std::to_string(kDefaultGasLimit - 1000));
voting.set(Keys::kBytecodeSizeLimit, std::to_string(kDefaultBytecodeSizeLimit - 1000));
voting.set(Keys::kGasPrice, std::to_string(kDefaultGasPrice - 1000));
return cfg;
};
Env env{*this, singleThreadIo(voteDifferentLimits(envconfig(validator, ""))), features};
auto& cfg = env.app().config();
if (!BEAST_EXPECT(cfg.section(Sections::kValidationSeed).empty()))
return;
@@ -520,6 +533,28 @@ public:
if (jv.isMember(jss::reserve_inc) != isFlagLedger)
return false;
if (env.closed()->rules().enabled(featureSmartEscrow))
{
if (jv.isMember(jss::gas_limit) != isFlagLedger)
return false;
if (jv.isMember(jss::bytecode_size_limit) != isFlagLedger)
return false;
if (jv.isMember(jss::gas_price) != isFlagLedger)
return false;
}
else
{
if (jv.isMember(jss::gas_limit))
return false;
if (jv.isMember(jss::bytecode_size_limit))
return false;
if (jv.isMember(jss::gas_price))
return false;
}
return true;
};
@@ -2466,7 +2501,8 @@ public:
testTransactionsAPIv1();
testTransactionsAPIv2();
testManifests();
testValidations(all - xrpFees);
testValidations(all - featureXRPFees - featureSmartEscrow);
testValidations(all - featureSmartEscrow);
testValidations(all);
testSubErrors(true);
testSubErrors(false);
-123
View File
@@ -1,123 +0,0 @@
#pragma once
#include <xrpl/basics/ByteUtilities.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/basics/contract.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/config/BasicConfig.h>
#include <xrpl/config/Constants.h>
#include <xrpl/nodestore/Database.h>
#include <xrpl/nodestore/DummyScheduler.h>
#include <xrpl/nodestore/Manager.h>
#include <xrpl/shamap/Family.h>
#include <xrpl/shamap/FullBelowCache.h>
#include <xrpl/shamap/TreeNodeCache.h>
#include <cstdint>
#include <memory>
#include <stdexcept>
namespace xrpl::test {
/**
* Test implementation of Family for unit tests.
*
* Uses an in-memory NodeStore database and simple caches.
* The missingNode methods throw since tests shouldn't encounter missing nodes.
*/
class TestFamily : public Family
{
private:
std::unique_ptr<node_store::Database> db_;
TestStopwatch clock_;
std::shared_ptr<FullBelowCache> fbCache_;
std::shared_ptr<TreeNodeCache> tnCache_;
node_store::DummyScheduler scheduler_;
beast::Journal j_;
public:
explicit TestFamily(beast::Journal j)
: fbCache_(std::make_shared<FullBelowCache>("TestFamily full below cache", clock_, j))
, tnCache_(
std::make_shared<TreeNodeCache>(
"TestFamily tree node cache",
65536,
std::chrono::minutes{1},
clock_,
j))
, j_(j)
{
Section config;
config.set(Keys::kType, "memory");
config.set(Keys::kPath, "TestFamily");
db_ = node_store::Manager::instance().makeDatabase(megabytes(4), scheduler_, 1, config, j);
}
node_store::Database&
db() override
{
return *db_;
}
[[nodiscard]] node_store::Database const&
db() const override
{
return *db_;
}
beast::Journal const&
journal() override
{
return j_;
}
std::shared_ptr<FullBelowCache>
getFullBelowCache() override
{
return fbCache_;
}
std::shared_ptr<TreeNodeCache>
getTreeNodeCache() override
{
return tnCache_;
}
void
sweep() override
{
fbCache_->sweep();
tnCache_->sweep();
}
void
missingNodeAcquireBySeq(std::uint32_t refNum, UInt256 const& nodeHash) override
{
Throw<std::runtime_error>("TestFamily: missing node (by seq)");
}
void
missingNodeAcquireByHash(UInt256 const& refHash, std::uint32_t refNum) override
{
Throw<std::runtime_error>("TestFamily: missing node (by hash)");
}
void
reset() override
{
(*fbCache_).reset();
(*tnCache_).reset();
}
/**
* Access the test clock for time manipulation in tests.
*/
TestStopwatch&
clock()
{
return clock_;
}
};
} // namespace xrpl::test
@@ -12,8 +12,8 @@
#include <boost/asio/io_context.hpp>
#include <helpers/TestFamily.h>
#include <helpers/TestSink.h>
#include <shamap/common.h>
#include <cstdint>
#include <memory>
@@ -73,7 +73,7 @@ class TestServiceRegistry : public ServiceRegistry
{
TestLogs logs_{beast::Severity::Warning};
boost::asio::io_context ioContext_;
TestFamily family_{logs_.journal("TestFamily")};
tests::TestNodeFamily family_{logs_.journal("TestNodeFamily")};
LoadFeeTrack feeTrack_{logs_.journal("LoadFeeTrack")};
TestNetworkIDService networkIDService_;
HashRouter hashRouter_{HashRouter::Setup{}, stopwatch()};
+11 -2
View File
@@ -69,7 +69,13 @@ TxTest::TxTest(std::optional<FeatureBitset> features)
rules_.emplace(featureSet_);
// Default fees for testing
Fees const fees{XRPAmount{10}, XRPAmount{10000000}, XRPAmount{2000000}};
Fees const fees{
XRPAmount{10},
XRPAmount{10000000},
XRPAmount{2000000},
kDefaultGasLimit,
kDefaultBytecodeSizeLimit,
kDefaultGasPrice};
// Create a genesis ledger as the base
closedLedger_ = std::make_shared<Ledger>(
@@ -209,7 +215,10 @@ TxTest::close()
accum.apply(*newLedger);
}
newLedger->setAccepted(ledgerCloseTime, newLedger->header().closeTimeResolution, true);
if (!newLedger->setAccepted(ledgerCloseTime, newLedger->header().closeTimeResolution, true))
{
Throw<std::runtime_error>("TxTest::close: ledger has an invalid map");
}
closedLedger_ = newLedger;
+186
View File
@@ -0,0 +1,186 @@
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/hash/uhash.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/Ledger.h>
#include <xrpl/nodestore/NodeObject.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Fees.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/XRPAmount.h>
#include <gtest/gtest.h>
#include <shamap/common.h>
#include <cstdint>
#include <memory>
#include <unordered_set>
#include <vector>
namespace xrpl::test {
namespace {
// Unlike every protocol constant, so an assertion cannot pass by chance.
constexpr std::uint32_t kLocalGasLimit{123'457};
constexpr std::uint32_t kLocalBytecodeSizeLimit{23'457};
constexpr std::uint32_t kLocalGasPrice{7'654'321};
// Stands in for FeeSetup::toFees(): local configuration.
Fees
localFees()
{
return Fees{
XRPAmount{10},
XRPAmount{10'000'000},
XRPAmount{2'000'000},
kLocalGasLimit,
kLocalBytecodeSizeLimit,
kLocalGasPrice};
}
} // namespace
struct ExtensionFees : ::testing::Test
{
std::unordered_set<UInt256, beast::Uhash<>> presets;
tests::TestNodeFamily family{beast::Journal{beast::Journal::getNullSink()}};
std::shared_ptr<Ledger>
makeGenesis(std::vector<UInt256> const& amendments)
{
presets.insert(amendments.begin(), amendments.end());
return std::make_shared<Ledger>(
kCreateGenesis, Rules{presets}, localFees(), amendments, family);
}
// Closed successor with FeeSettings rewritten by `edit`; setImmutable runs
// Ledger::setup, so fees() is what the resolution produced. Its state is
// flushed to the node store, so it can be reloaded.
template <typename F>
std::shared_ptr<Ledger>
successorWithFeeSettings(Ledger const& parent, F&& edit)
{
auto ledger = std::make_shared<Ledger>(parent, NetClock::time_point{});
auto const sle = std::make_shared<SLE>(*ledger->read(keylet::feeSettings()));
edit(*sle);
ledger->rawReplace(sle);
ledger->stateMap().flushDirty(NodeObjectType::AccountNode);
EXPECT_TRUE(ledger->setImmutable());
return ledger;
}
// Whether `ledger` loads back from the node store. Loading is the one
// place the result of Ledger::setup is reported.
bool
reloads(Ledger const& ledger)
{
bool loaded = false;
Ledger const reloaded(
ledger.header(),
loaded,
false,
Rules{presets},
localFees(),
family,
beast::Journal{beast::Journal::getNullSink()});
return loaded;
}
};
// test how Ledger::setup decides the three extension fee fields
// Genesis is the one place local configuration legitimately reaches the ledger.
TEST_F(ExtensionFees, genesis_writes_the_configured_values)
{
auto const ledger = makeGenesis({featureSmartEscrow});
EXPECT_EQ(ledger->fees().gasLimit, kLocalGasLimit);
EXPECT_EQ(ledger->fees().bytecodeSizeLimit, kLocalBytecodeSizeLimit);
EXPECT_EQ(ledger->fees().gasPrice, kLocalGasPrice);
}
// Absent must resolve to the protocol constant, never to the constructed Fees,
// which every caller builds from local configuration.
TEST_F(ExtensionFees, absent_fields_resolve_to_the_protocol_constants)
{
auto const genesis = makeGenesis({featureSmartEscrow});
auto const ledger = successorWithFeeSettings(*genesis, [](SLE& sle) {
sle.makeFieldAbsent(sfGasLimit);
sle.makeFieldAbsent(sfBytecodeSizeLimit);
sle.makeFieldAbsent(sfGasPrice);
});
ASSERT_TRUE(ledger->rules().enabled(featureSmartEscrow));
EXPECT_EQ(ledger->fees().gasLimit, kDefaultGasLimit);
EXPECT_EQ(ledger->fees().bytecodeSizeLimit, kDefaultBytecodeSizeLimit);
EXPECT_EQ(ledger->fees().gasPrice, kDefaultGasPrice);
EXPECT_NE(ledger->fees().gasLimit, kLocalGasLimit);
EXPECT_NE(ledger->fees().bytecodeSizeLimit, kLocalBytecodeSizeLimit);
EXPECT_NE(ledger->fees().gasPrice, kLocalGasPrice);
}
// The fallback only fills gaps; a present field still governs.
TEST_F(ExtensionFees, present_fields_are_taken_from_the_ledger)
{
constexpr std::uint32_t kVotedGasLimit{987'654};
constexpr std::uint32_t kVotedBytecodeSizeLimit{87'654};
constexpr std::uint32_t kVotedGasPrice{76'543};
auto const genesis = makeGenesis({featureSmartEscrow});
auto const ledger = successorWithFeeSettings(*genesis, [&](SLE& sle) {
sle.at(sfGasLimit) = kVotedGasLimit;
sle.at(sfBytecodeSizeLimit) = kVotedBytecodeSizeLimit;
sle.at(sfGasPrice) = kVotedGasPrice;
});
EXPECT_EQ(ledger->fees().gasLimit, kVotedGasLimit);
EXPECT_EQ(ledger->fees().bytecodeSizeLimit, kVotedBytecodeSizeLimit);
EXPECT_EQ(ledger->fees().gasPrice, kVotedGasPrice);
}
// An explicit zero is the kill switch, not absence.
TEST_F(ExtensionFees, an_explicit_zero_is_not_treated_as_absent)
{
auto const genesis = makeGenesis({featureSmartEscrow});
auto const ledger = successorWithFeeSettings(*genesis, [](SLE& sle) {
sle.at(sfGasLimit) = 0u;
sle.at(sfBytecodeSizeLimit) = 0u;
});
EXPECT_EQ(ledger->fees().gasLimit, 0u);
EXPECT_EQ(ledger->fees().bytecodeSizeLimit, 0u);
}
// FeeSettings may carry the gas settings only once the amendment is enabled.
TEST_F(ExtensionFees, gas_settings_before_the_amendment_fail_to_load)
{
auto const genesis = makeGenesis({});
auto const ledger =
successorWithFeeSettings(*genesis, [](SLE& sle) { sle.at(sfGasLimit) = kDefaultGasLimit; });
ASSERT_FALSE(ledger->rules().enabled(featureSmartEscrow));
EXPECT_FALSE(reloads(*ledger));
}
TEST_F(ExtensionFees, gas_settings_after_the_amendment_load)
{
auto const genesis = makeGenesis({featureSmartEscrow});
auto const ledger =
successorWithFeeSettings(*genesis, [](SLE& sle) { sle.at(sfGasLimit) = kDefaultGasLimit; });
ASSERT_TRUE(ledger->rules().enabled(featureSmartEscrow));
EXPECT_TRUE(reloads(*ledger));
}
} // namespace xrpl::test
+50
View File
@@ -0,0 +1,50 @@
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Rate.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/UintTypes.h>
#include <gtest/gtest.h>
using namespace xrpl;
namespace {
// A 25% transfer fee.
Rate const kRate125{1'250'000'000};
AccountID const kIssuer{1};
} // namespace
TEST(TokenHelpers, add_transfer_fee)
{
Issue const usd{toCurrency("USD"), kIssuer};
MPTIssue const mpt{makeMptID(1, kIssuer)};
EXPECT_EQ(addTransferFee(STAmount{usd, 80}, kRate125), STAmount(usd, 100));
EXPECT_EQ(addTransferFee(STAmount{usd, 80}, kParityRate), STAmount(usd, 80));
// Integral MPT: the fee on 1 unit rounds up to a whole unit.
EXPECT_EQ(addTransferFee(STAmount{mpt, 80}, kRate125), STAmount(mpt, 100));
EXPECT_EQ(addTransferFee(STAmount{mpt, 1}, kRate125), STAmount(mpt, 2));
EXPECT_EQ(addTransferFee(STAmount{mpt, 1}, kParityRate), STAmount(mpt, 1));
}
TEST(TokenHelpers, subtract_transfer_fee)
{
Issue const usd{toCurrency("USD"), kIssuer};
MPTIssue const mpt{makeMptID(1, kIssuer)};
EXPECT_EQ(subtractTransferFee(STAmount{usd, 100}, kRate125), STAmount(usd, 80));
EXPECT_EQ(subtractTransferFee(STAmount{usd, 100}, kParityRate), STAmount(usd, 100));
// Integral MPT: the payout rounds down, to zero for a single unit.
EXPECT_EQ(subtractTransferFee(STAmount{mpt, 31}, kRate125), STAmount(mpt, 24));
EXPECT_EQ(subtractTransferFee(STAmount{mpt, 1}, kRate125), STAmount(mpt));
EXPECT_EQ(subtractTransferFee(STAmount{mpt, 1}, kParityRate), STAmount(mpt, 1));
}
@@ -0,0 +1,386 @@
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/tx/transactors/lending/LoanManage.h>
#include <gtest/gtest.h>
#include <helpers/Account.h>
#include <protocol/VaultTestHelpers.h>
#include <algorithm>
#include <cstdint>
#include <memory>
#include <random>
#include <sstream>
namespace xrpl {
namespace {
// Minimal bare LoanBroker SLE carrying the fields calculateDefaultCover reads:
// CoverAvailable, DebtTotal, and the two cover rates. Not a valid ledger entry
// on its own -- just enough for the function's own field reads.
std::shared_ptr<SLE>
makeBroker(
Asset const& asset,
Number const& coverAvailable,
Number const& debtTotal,
TenthBips32 coverRateMinimum,
TenthBips32 coverRateLiquidation)
{
auto broker = std::make_shared<SLE>(keylet::loanBroker(uint256(2)));
broker->at(sfCoverAvailable) = coverAvailable;
broker->at(sfDebtTotal) = debtTotal;
broker->at(sfCoverRateMinimum) = coverRateMinimum.value();
broker->at(sfCoverRateLiquidation) = coverRateLiquidation.value();
associateAsset(*broker, asset);
return broker;
}
// Minimal bare Loan SLE carrying the only field calculateDefaultCover reads:
// PrincipalOutstanding.
std::shared_ptr<SLE>
makeLoan(Asset const& asset, Number const& principalOutstanding)
{
auto loan = std::make_shared<SLE>(keylet::loan(uint256(3)));
loan->at(sfPrincipalOutstanding) = principalOutstanding;
associateAsset(*loan, asset);
return loan;
}
// Replicates calculateDefaultCover's uncapped XLS-66 raw cover computation
// (same Upward rounding), so tests can independently verify the
// CoverAvailable/PrincipalOutstanding cap without duplicating
// calculateDefaultCover itself.
Number
rawCoverFor(
Number const& debtTotal,
TenthBips32 coverRateMinimum,
TenthBips32 coverRateLiquidation,
Number const& principalOutstanding)
{
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
Number const minimumCover = tenthBipsOfValue(debtTotal, coverRateMinimum);
return std::min(tenthBipsOfValue(minimumCover, coverRateLiquidation), principalOutstanding);
}
struct CoverCase
{
Number assetsAvailable;
Number coverAvailable;
Number debtTotal;
TenthBips32 coverRateMinimum;
TenthBips32 coverRateLiquidation;
Number principalOutstanding;
};
// Builds the ledger entries for one case, calls calculateDefaultCover, and
// checks every invariant defaultLoanFixedPrecision itself asserts right after
// the call: cover is non-negative, never exceeds the uncapped XLS-66 amount
// or the broker's CoverAvailable, leaves AssetsAvailable + cover exactly
// representable (STAmount round-trips to the same Number) whenever the cover
// can carry AssetsAvailable's digits, and rounds CoverAvailable - cover by at
// most half an ulp.
void
checkCoverCase(Asset const& asset, std::uint8_t scale, CoverCase const& in)
{
auto const vault = makeVault(
asset,
in.assetsAvailable,
VaultVersion::FixedPrecision,
scale,
Number{0},
in.assetsAvailable);
auto const broker = makeBroker(
asset, in.coverAvailable, in.debtTotal, in.coverRateMinimum, in.coverRateLiquidation);
auto const loan = makeLoan(asset, in.principalOutstanding);
STAmount const cover = LoanManage::calculateDefaultCover(loan, broker, vault);
Number const rawCover = rawCoverFor(
in.debtTotal, in.coverRateMinimum, in.coverRateLiquidation, in.principalOutstanding);
std::ostringstream context;
context << "scale=" << int(scale) << " assetsAvailable=" << in.assetsAvailable
<< " coverAvailable=" << in.coverAvailable << " debtTotal=" << in.debtTotal
<< " coverRateMinimum=" << in.coverRateMinimum.value()
<< " coverRateLiquidation=" << in.coverRateLiquidation.value()
<< " principalOutstanding=" << in.principalOutstanding << " rawCover=" << rawCover
<< " cover=" << Number(cover);
EXPECT_GE(Number(cover), Number{0}) << "cover < 0: " << context.str();
EXPECT_LE(Number(cover), rawCover) << "cover exceeds uncapped XLS-66 amount: " << context.str();
EXPECT_LE(Number(cover), in.coverAvailable)
<< "cover exceeds CoverAvailable: " << context.str();
// AssetsAvailable + cover is exact unless AssetsAvailable has digits finer
// than the 16-digit cover can carry; then no cover cancels them, and the
// sum rounds by at most half an ulp of its posterior grid.
Number const vaultSum = in.assetsAvailable + Number(cover);
STAmount const vaultRounded{asset, vaultSum};
bool const coverCanCarryAssetsAvailable =
roundToAsset(
asset,
in.assetsAvailable,
xrpl::scale(Number(cover), asset),
Number::RoundingMode::TowardsZero) == in.assetsAvailable;
if (coverCanCarryAssetsAvailable)
{
EXPECT_EQ(Number(vaultRounded), vaultSum)
<< "AssetsAvailable + cover not exactly 16-digit representable: " << context.str();
}
else
{
Number const vaultRoundingError = abs(Number(vaultRounded) - vaultSum);
EXPECT_LE(vaultRoundingError, Number(5, xrpl::scale(Number(vaultRounded), asset) - 1))
<< "AssetsAvailable + cover rounding error exceeds half a posterior ulp: "
<< context.str();
}
// The LoanBroker absorbs the rounding: CoverAvailable - cover rounds to
// nearest, so the error stays within half an ulp of its posterior grid.
Number const brokerSum = in.coverAvailable - Number(cover);
STAmount const brokerRounded{asset, brokerSum};
Number const brokerRoundingError = abs(Number(brokerRounded) - brokerSum);
EXPECT_LE(brokerRoundingError, Number(5, xrpl::scale(Number(brokerRounded), asset) - 1))
<< "CoverAvailable - cover rounding error exceeds half a posterior ulp: " << context.str();
}
} // namespace
TEST(LoanDefaultCover, hand_picked_cases)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
std::uint8_t const scale = 6;
TenthBips32 const halfRate = percentageToTenthBips(50);
TenthBips32 const fullRate = lending::kMaxCoverRate;
// Open zone: AssetsAvailable and CoverAvailable both well under the Open
// limit (9 * 10^9 at Scale 6); every grid involved stays at the fine
// (scale-6) exponent.
checkCoverCase(
iou,
scale,
{
.assetsAvailable = Number{1000},
.coverAvailable = Number{5000},
.debtTotal = Number{10000},
.coverRateMinimum = halfRate,
.coverRateLiquidation = halfRate,
.principalOutstanding = Number{2000},
});
// CoverAvailable coarsened above the Open limit (20,000,000,000.00000,
// 16 significant digits); AssetsAvailable stays small and fine.
Number const coarseCoverAvailable{2'000'000'000'000'000, -5};
checkCoverCase(
iou,
scale,
{
.assetsAvailable = Number{100},
.coverAvailable = coarseCoverAvailable,
.debtTotal = Number{1000},
.coverRateMinimum = fullRate,
.coverRateLiquidation = fullRate,
.principalOutstanding = Number{500},
});
// AssetsAvailable one unit below a power of ten (9,999,999,999.999999,
// 16 significant digits already); CoverAvailable stays small and fine.
Number const coarseAssetsAvailable{9'999'999'999'999'999, -6};
checkCoverCase(
iou,
scale,
{
.assetsAvailable = coarseAssetsAvailable,
.coverAvailable = Number{50},
.debtTotal = Number{1000},
.coverRateMinimum = fullRate,
.coverRateLiquidation = fullRate,
.principalOutstanding = Number{30},
});
// Both AssetsAvailable and CoverAvailable coarsened.
checkCoverCase(
iou,
scale,
{
.assetsAvailable = coarseAssetsAvailable,
.coverAvailable = coarseCoverAvailable,
.debtTotal = Number{1000},
.coverRateMinimum = fullRate,
.coverRateLiquidation = fullRate,
.principalOutstanding = Number{900},
});
// Cover capped by CoverAvailable: the uncapped XLS-66 amount vastly
// exceeds the broker's available cover.
checkCoverCase(
iou,
scale,
{
.assetsAvailable = Number{1000},
.coverAvailable = Number{1},
.debtTotal = Number{1'000'000},
.coverRateMinimum = fullRate,
.coverRateLiquidation = fullRate,
.principalOutstanding = Number{1'000'000},
});
// Cover capped by PrincipalOutstanding: DebtTotal times both rates would
// exceed the Loan's own outstanding principal.
checkCoverCase(
iou,
scale,
{
.assetsAvailable = Number{1000},
.coverAvailable = Number{1'000'000},
.debtTotal = Number{1'000'000},
.coverRateMinimum = fullRate,
.coverRateLiquidation = fullRate,
.principalOutstanding = Number{5},
});
}
// The raw cover carries more than 16 digits and is large enough that its last
// 16-digit place is coarser than AssetsAvailable's grid. The sum must be floored
// from the exact raw cover: truncating the cover to 16 digits first could cost
// the Vault one posterior unit.
TEST(LoanDefaultCover, uses_exact_raw_cover)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
std::uint8_t const scale = 6;
Number const assetsAvailable{5, -6};
Number const coverAvailable{2, 11};
Number const debtTotal{1'234'567'890'123'457LL, -4};
TenthBips32 const rate = percentageToTenthBips(33);
Number const principalOutstanding{2, 11};
auto const vault = makeVault(
iou, assetsAvailable, VaultVersion::FixedPrecision, scale, Number{0}, assetsAvailable);
auto const broker = makeBroker(iou, coverAvailable, debtTotal, rate, rate);
auto const loan = makeLoan(iou, principalOutstanding);
Number const rawCover = rawCoverFor(debtTotal, rate, rate, principalOutstanding);
// The case only means something if truncating the raw cover to 16 digits
// loses digits that its sum with AssetsAvailable would keep.
ASSERT_NE(Number(STAmount{iou, rawCover}), rawCover);
STAmount const cover = LoanManage::calculateDefaultCover(loan, broker, vault);
// Floor the exact sum, then take the 16-digit credit, also rounded down.
STAmount const expected = [&] {
NumberRoundModeGuard const mg(Number::RoundingMode::Downward);
Number const sum = assetsAvailable + rawCover;
Number const flooredSum =
roundToAsset(iou, sum, xrpl::scale(sum, iou), Number::RoundingMode::Downward);
return STAmount{iou, flooredSum - assetsAvailable};
}();
EXPECT_EQ(cover, expected) << "rawCover=" << rawCover << " cover=" << Number(cover);
}
TEST(LoanDefaultCover, integral_asset_cases)
{
test::Account const issuer{"issuer"};
TenthBips32 const fullRate = lending::kMaxCoverRate;
for (Asset const asset : {Asset{xrpIssue()}, Asset{MPTIssue{makeMptID(1, issuer.id())}}})
{
// Integral assets have no fractional grid (Scale is unused), so the
// same cover arithmetic must still land exactly for whole-unit
// amounts.
checkCoverCase(
asset,
0,
{
.assetsAvailable = Number{1000},
.coverAvailable = Number{200},
.debtTotal = Number{900},
.coverRateMinimum = fullRate,
.coverRateLiquidation = fullRate,
.principalOutstanding = Number{150},
});
}
}
TEST(LoanDefaultCover, randomized_sweep)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
// NOLINTNEXTLINE(bugprone-random-generator-seed): fixed seed for reproducible test
std::mt19937 rng{20261002u};
constexpr int kIterations = 5000;
auto const pickScale = [&rng] -> std::uint8_t {
std::uniform_int_distribution<int> dist(0, kFixedPrecisionVaultMaximumIouScale);
return static_cast<std::uint8_t>(dist(rng));
};
auto const pickRate = [&rng] -> TenthBips32 {
std::uniform_int_distribution<std::uint32_t> dist(0, lending::kMaxCoverRate.value());
return TenthBips32{dist(rng)};
};
// Mantissa with 1 to 16 digits, biased toward landing exactly on a power
// of ten or one unit below it (all nines) -- the cusps where coarsening
// and carries are most likely to surface a bug. Always positive, since
// these stand in for ledger balances.
auto const pickMantissa = [&rng] -> std::int64_t {
std::uniform_int_distribution<int> digitsDist(1, 16);
int const digits = digitsDist(rng);
std::int64_t powerOfTen = 1;
for (int i = 0; i < digits - 1; ++i)
powerOfTen *= 10;
std::uniform_int_distribution<int> shapeDist(0, 9);
int const shape = shapeDist(rng);
if (shape == 0)
return powerOfTen; // bare power of ten
if (shape == 1)
return (powerOfTen * 10) - 1; // all nines, one digit up
std::uniform_int_distribution<std::int64_t> mantissaDist(powerOfTen, (powerOfTen * 10) - 1);
return mantissaDist(rng);
};
// A FixedPrecision Vault never holds a balance finer than its own Scale
// (exponent must be >= -scale); minExponent enforces that so every
// generated case is a state the real system could actually reach.
auto const pickExponent = [&rng](int minExponent) -> int {
std::uniform_int_distribution<int> dist(minExponent, minExponent + 25);
return dist(rng);
};
auto const pickNumber = [&](int minExponent) -> Number {
return Number{pickMantissa(), pickExponent(minExponent)};
};
for (int i = 0; i < kIterations; ++i)
{
std::uint8_t const scale = pickScale();
int const minExponent = -static_cast<int>(scale);
CoverCase const in{
.assetsAvailable = pickNumber(minExponent),
.coverAvailable = pickNumber(minExponent),
.debtTotal = pickNumber(minExponent),
.coverRateMinimum = pickRate(),
.coverRateLiquidation = pickRate(),
.principalOutstanding = pickNumber(minExponent),
};
checkCoverCase(iou, scale, in);
}
}
} // namespace xrpl
+87
View File
@@ -0,0 +1,87 @@
#include <xrpl/protocol/STAmount.h>
#include <xrpl/basics/Number.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/Quality.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/XRPAmount.h>
#include <gtest/gtest.h>
#include <cstdint>
#include <stdexcept>
using namespace xrpl;
namespace {
// A 25% transfer fee.
constexpr std::uint32_t kTransferRate125 = 1'250'000'000;
AccountID const kUsdIssuer{1};
} // namespace
TEST(STAmount, mul_ratio_iou)
{
Issue const usd{toCurrency("USD"), kUsdIssuer};
EXPECT_EQ(
mulRatio(STAmount{usd, 100}, QUALITY_ONE, kTransferRate125, Number::RoundingMode::Downward),
STAmount(usd, 80));
EXPECT_EQ(
mulRatio(STAmount{usd, 80}, kTransferRate125, QUALITY_ONE, Number::RoundingMode::Upward),
STAmount(usd, 100));
// 1 / 1.7 does not fit in a 16-digit mantissa, so the rounding direction
// decides the last digit.
STAmount const one{usd, 1};
EXPECT_LT(
mulRatio(one, QUALITY_ONE, 1'700'000'000, Number::RoundingMode::Downward),
mulRatio(one, QUALITY_ONE, 1'700'000'000, Number::RoundingMode::Upward));
STAmount const full{usd, std::uint64_t{9'999'999'999'999'999}, -5};
EXPECT_EQ(mulRatio(full, QUALITY_ONE, QUALITY_ONE, Number::RoundingMode::Downward), full);
}
TEST(STAmount, mul_ratio_mpt)
{
MPTIssue const mpt{makeMptID(1, kUsdIssuer)};
EXPECT_EQ(
mulRatio(STAmount{mpt, 31}, QUALITY_ONE, kTransferRate125, Number::RoundingMode::Downward),
STAmount(mpt, 24));
EXPECT_EQ(
mulRatio(STAmount{mpt, 31}, QUALITY_ONE, kTransferRate125, Number::RoundingMode::Upward),
STAmount(mpt, 25));
EXPECT_EQ(
mulRatio(STAmount{mpt, 1}, QUALITY_ONE, kTransferRate125, Number::RoundingMode::Downward),
STAmount(mpt));
EXPECT_EQ(
mulRatio(STAmount{mpt, 1}, QUALITY_ONE, kTransferRate125, Number::RoundingMode::Upward),
STAmount(mpt, 1));
STAmount const max{mpt, kMaxMpTokenAmount};
EXPECT_EQ(mulRatio(max, QUALITY_ONE, QUALITY_ONE, Number::RoundingMode::Downward), max);
EXPECT_NO_THROW(
(void)mulRatio(max, QUALITY_ONE, kTransferRate125, Number::RoundingMode::Downward));
EXPECT_THROW(
(void)mulRatio(max, kTransferRate125, QUALITY_ONE, Number::RoundingMode::Upward),
std::overflow_error);
}
TEST(STAmount, mul_ratio_xrp)
{
EXPECT_EQ(
mulRatio(
STAmount{XRPAmount{31}}, QUALITY_ONE, kTransferRate125, Number::RoundingMode::Downward),
STAmount{XRPAmount{24}});
EXPECT_EQ(
mulRatio(
STAmount{XRPAmount{31}}, QUALITY_ONE, kTransferRate125, Number::RoundingMode::Upward),
STAmount{XRPAmount{25}});
}
@@ -546,6 +546,22 @@ TEST_F(VaultBalance, credit_to_posterior_scale_ignores_ambient_rounding_mode)
EXPECT_EQ(Number(credit), exactDownwardCredit);
}
TEST_F(VaultBalance, credit_to_posterior_scale_just_under_power_of_ten_is_not_negative)
{
// reference is all nines on the scale -10 grid and raw is just under one
// unit of it. The exact sum is just under 10^6, but rounds up to it to
// nearest. The grid must come from the sum that is floored, so the credit
// is zero, not negative.
Number const reference{9'999'999'999'999'999LL, -10};
Number const raw{9'970'000'001LL, -20};
int const baseScale = -10;
NumberRoundModeGuard const ambient(Number::RoundingMode::ToNearest);
STAmount const credit = detail::creditToPosteriorScale(
iou_, reference, baseScale, raw, Number::RoundingMode::Downward);
EXPECT_EQ(credit, STAmount(iou_, 0));
}
// creditToPosteriorAvailableScale returns
// floor16(AssetsAvailable + raw) - AssetsAvailable, never finer than -Scale.
@@ -38,6 +38,7 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
auto const auditorEncryptionKeyValue = canonical_VL();
auto const issuerKeyEpochValue = canonical_UINT32();
auto const auditorKeyEpochValue = canonical_UINT32();
auto const initialIssuerEncryptionKeyValue = canonical_VL();
auto const confidentialOutstandingAmountValue = canonical_UINT64();
MPTokenIssuanceBuilder builder{
@@ -61,6 +62,7 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
builder.setAuditorEncryptionKey(auditorEncryptionKeyValue);
builder.setIssuerKeyEpoch(issuerKeyEpochValue);
builder.setAuditorKeyEpoch(auditorKeyEpochValue);
builder.setInitialIssuerEncryptionKey(initialIssuerEncryptionKeyValue);
builder.setConfidentialOutstandingAmount(confidentialOutstandingAmountValue);
builder.setLedgerIndex(index);
@@ -204,6 +206,14 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
EXPECT_TRUE(entry.hasAuditorKeyEpoch());
}
{
auto const& expected = initialIssuerEncryptionKeyValue;
auto const actualOpt = entry.getInitialIssuerEncryptionKey();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfInitialIssuerEncryptionKey");
EXPECT_TRUE(entry.hasInitialIssuerEncryptionKey());
}
{
auto const& expected = confidentialOutstandingAmountValue;
auto const actualOpt = entry.getConfidentialOutstandingAmount();
@@ -243,6 +253,7 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
auto const auditorEncryptionKeyValue = canonical_VL();
auto const issuerKeyEpochValue = canonical_UINT32();
auto const auditorKeyEpochValue = canonical_UINT32();
auto const initialIssuerEncryptionKeyValue = canonical_VL();
auto const confidentialOutstandingAmountValue = canonical_UINT64();
auto sle = std::make_shared<SLE>(MPTokenIssuance::entryType, index);
@@ -265,6 +276,7 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
sle->at(sfAuditorEncryptionKey) = auditorEncryptionKeyValue;
sle->at(sfIssuerKeyEpoch) = issuerKeyEpochValue;
sle->at(sfAuditorKeyEpoch) = auditorKeyEpochValue;
sle->at(sfInitialIssuerEncryptionKey) = initialIssuerEncryptionKeyValue;
sle->at(sfConfidentialOutstandingAmount) = confidentialOutstandingAmountValue;
MPTokenIssuanceBuilder builderFromSle{sle};
@@ -492,6 +504,19 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
expectEqualField(expected, *fromBuilderOpt, "sfAuditorKeyEpoch");
}
{
auto const& expected = initialIssuerEncryptionKeyValue;
auto const fromSleOpt = entryFromSle.getInitialIssuerEncryptionKey();
auto const fromBuilderOpt = entryFromBuilder.getInitialIssuerEncryptionKey();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfInitialIssuerEncryptionKey");
expectEqualField(expected, *fromBuilderOpt, "sfInitialIssuerEncryptionKey");
}
{
auto const& expected = confidentialOutstandingAmountValue;
@@ -593,6 +618,8 @@ TEST(MPTokenIssuanceTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(entry.getIssuerKeyEpoch().has_value());
EXPECT_FALSE(entry.hasAuditorKeyEpoch());
EXPECT_FALSE(entry.getAuditorKeyEpoch().has_value());
EXPECT_FALSE(entry.hasInitialIssuerEncryptionKey());
EXPECT_FALSE(entry.getInitialIssuerEncryptionKey().has_value());
EXPECT_FALSE(entry.hasConfidentialOutstandingAmount());
EXPECT_FALSE(entry.getConfidentialOutstandingAmount().has_value());
}
@@ -34,6 +34,7 @@ TEST(MPTokenTests, BuilderSettersRoundTrip)
auto const auditorEncryptedBalanceValue = canonical_VL();
auto const issuerKeyMirrorEpochValue = canonical_UINT32();
auto const auditorKeyMirrorEpochValue = canonical_UINT32();
auto const issuerMirrorEncryptionKeyValue = canonical_VL();
auto const holderEncryptionKeyValue = canonical_VL();
auto const recoveryKeyValue = canonical_VL();
@@ -54,6 +55,7 @@ TEST(MPTokenTests, BuilderSettersRoundTrip)
builder.setAuditorEncryptedBalance(auditorEncryptedBalanceValue);
builder.setIssuerKeyMirrorEpoch(issuerKeyMirrorEpochValue);
builder.setAuditorKeyMirrorEpoch(auditorKeyMirrorEpochValue);
builder.setIssuerMirrorEncryptionKey(issuerMirrorEncryptionKeyValue);
builder.setHolderEncryptionKey(holderEncryptionKeyValue);
builder.setRecoveryKey(recoveryKeyValue);
@@ -168,6 +170,14 @@ TEST(MPTokenTests, BuilderSettersRoundTrip)
EXPECT_TRUE(entry.hasAuditorKeyMirrorEpoch());
}
{
auto const& expected = issuerMirrorEncryptionKeyValue;
auto const actualOpt = entry.getIssuerMirrorEncryptionKey();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfIssuerMirrorEncryptionKey");
EXPECT_TRUE(entry.hasIssuerMirrorEncryptionKey());
}
{
auto const& expected = holderEncryptionKeyValue;
auto const actualOpt = entry.getHolderEncryptionKey();
@@ -211,6 +221,7 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip)
auto const auditorEncryptedBalanceValue = canonical_VL();
auto const issuerKeyMirrorEpochValue = canonical_UINT32();
auto const auditorKeyMirrorEpochValue = canonical_UINT32();
auto const issuerMirrorEncryptionKeyValue = canonical_VL();
auto const holderEncryptionKeyValue = canonical_VL();
auto const recoveryKeyValue = canonical_VL();
@@ -230,6 +241,7 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip)
sle->at(sfAuditorEncryptedBalance) = auditorEncryptedBalanceValue;
sle->at(sfIssuerKeyMirrorEpoch) = issuerKeyMirrorEpochValue;
sle->at(sfAuditorKeyMirrorEpoch) = auditorKeyMirrorEpochValue;
sle->at(sfIssuerMirrorEncryptionKey) = issuerMirrorEncryptionKeyValue;
sle->at(sfHolderEncryptionKey) = holderEncryptionKeyValue;
sle->at(sfRecoveryKey) = recoveryKeyValue;
@@ -409,6 +421,19 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip)
expectEqualField(expected, *fromBuilderOpt, "sfAuditorKeyMirrorEpoch");
}
{
auto const& expected = issuerMirrorEncryptionKeyValue;
auto const fromSleOpt = entryFromSle.getIssuerMirrorEncryptionKey();
auto const fromBuilderOpt = entryFromBuilder.getIssuerMirrorEncryptionKey();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfIssuerMirrorEncryptionKey");
expectEqualField(expected, *fromBuilderOpt, "sfIssuerMirrorEncryptionKey");
}
{
auto const& expected = holderEncryptionKeyValue;
@@ -515,6 +540,8 @@ TEST(MPTokenTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(entry.getIssuerKeyMirrorEpoch().has_value());
EXPECT_FALSE(entry.hasAuditorKeyMirrorEpoch());
EXPECT_FALSE(entry.getAuditorKeyMirrorEpoch().has_value());
EXPECT_FALSE(entry.hasIssuerMirrorEncryptionKey());
EXPECT_FALSE(entry.getIssuerMirrorEncryptionKey().has_value());
EXPECT_FALSE(entry.hasHolderEncryptionKey());
EXPECT_FALSE(entry.getHolderEncryptionKey().has_value());
EXPECT_FALSE(entry.hasRecoveryKey());
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more