Compare commits

..

42 Commits

Author SHA1 Message Date
Timur Yalymov
d60fc44a37 Merge branch 'develop' into FN-68-loanpay-impaired-late-bypass 2026-07-22 10:18:49 +01:00
Timur Ialymov
846064de36 test: Add LoanPay regression tests for impaired overdue loans (FN-68)
Cover the LoanPay side of the impairment fix from FN-9 (#6557):
- with featureLendingProtocolV1_1, a plain LoanPay on an impaired,
  overdue loan is rejected with tecEXPIRED and only tfLoanLatePayment is
  accepted, clearing impairment with no residual vault LossUnrealized;
- without the amendment, the original bypass reproduces (tesSUCCESS),
  proving the amendment is what closes it.
2026-07-21 14:58:09 +00:00
Vito
896c83ee13 fix: replace fixCleanup3_2_0 gating for impairment to featureLendingProtocolV1_1 2026-07-21 15:30:59 +02:00
Vito
e3716bfd9e fix: clang-tidy 2026-07-21 15:16:51 +02:00
Vito
da51750be2 Merge remote-tracking branch 'origin/develop' into tapanito/lending-impairment 2026-07-21 15:14:31 +02:00
Vito
1a4fbadf7c Merge remote-tracking branch 'origin/develop' into tapanito/lending-impairment
# Conflicts:
#	include/xrpl/protocol/detail/features.macro
#	src/libxrpl/ledger/helpers/LendingHelpers.cpp
#	src/libxrpl/tx/transactors/vault/VaultDelete.cpp
#	src/test/app/Vault_test.cpp
2026-07-01 15:25:58 +02:00
Vito
2c4d50265c minor code cleanup 2026-06-08 14:02:50 +02:00
Vito
da6345f429 Merge tapanito/lending-fix-amendment into tapanito/lending-impairment 2026-06-08 14:02:43 +02:00
Vito
beb8a1872d fix: Regenerate protocol autogenerated files 2026-06-08 12:07:19 +02:00
Vito
24db40e56c fix: remove unnecessary tests & clang-tidy 2026-06-08 11:57:42 +02:00
Vito
da4513d096 post-merge cleanup 2026-06-08 11:47:11 +02:00
Vito
2e2fddefe9 Merge remote-tracking branch 'origin/develop' into tapanito/lending-fix-amendment
# Conflicts:
#	include/xrpl/ledger/helpers/LendingHelpers.h
#	include/xrpl/protocol/STAmount.h
#	include/xrpl/protocol/detail/features.macro
#	include/xrpl/protocol/detail/transactions.macro
#	src/libxrpl/tx/invariants/VaultInvariant.cpp
#	src/test/app/Invariants_test.cpp
#	src/test/app/LoanBroker_test.cpp
#	src/test/app/Loan_test.cpp
#	src/test/app/Vault_test.cpp
2026-06-08 11:28:35 +02:00
Vito
db997ecad9 chore: Reset VaultInvariant to develop state before merge
VaultInvariant changes parked in /tmp/vault-invariant-changes.patch
for later re-evaluation.
2026-06-08 11:19:05 +02:00
Vito
bbcce301f0 fix: Prevent early loan impairment and due-date manipulation
Stop impairLoan and unimpairLoan from rewriting
sfNextPaymentDueDate when the amendment is active. Previously a
colluding broker could repeatedly impair and unimpair an overdue
loan to keep pushing the due date forward, permanently blocking
default eligibility and suppressing late-interest / late-fee
accrual.
2026-05-06 18:51:13 +02:00
Vito
68e4fbdf2b Merge remote-tracking branch 'origin/develop' into tapanito/lending-fix-amendment 2026-03-31 10:00:59 +02:00
Vito
bb0a09ae21 Merge remote-tracking branch 'origin/develop' into tapanito/lending-fix-amendment 2026-03-26 17:16:49 +01:00
Vito
d94232007f fix: updates autogen files 2026-03-24 14:34:54 +01:00
Vito
df8bfbe5af fix: errors introduced post-merge 2026-03-24 12:37:06 +01:00
Vito
347d1a19ef Merge remote-tracking branch 'origin/develop' into tapanito/lending-fix-amendment 2026-03-24 12:35:50 +01:00
Vito Tumas
d65fab27a1 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-21 14:39:10 +01:00
Vito Tumas
b5d25c5ab1 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-18 18:39:43 +01:00
Vito Tumas
7222150095 refactor: Rename fixLendingProtocolV1_1 to featureLendingProtocolV1_1 (#6527)
Use XRPL_FEATURE macro instead of XRPL_FIX since
LendingProtocolV1_1 is a feature amendment, not a fix.
Update all references in VaultDelete and related tests.
2026-03-16 09:26:57 +01:00
Vito
a67da5c2ed Merge remote-tracking branch 'origin/develop' into tapanito/lending-fix-amendment 2026-03-09 11:34:59 +01:00
Vito Tumas
d2f23b2f5b Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-05 14:29:35 +01:00
Vito Tumas
4067e5025f Add rounding to Vault invariants (#6217)
Co-authored-by: Ed Hennis <ed@ripple.com>
2026-03-05 10:38:42 +01:00
Vito Tumas
ed4330a7d6 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-04 11:26:33 +01:00
Vito Tumas
feba605998 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-03 15:38:14 +01:00
Vito
b322097529 fixes formatting errors 2026-03-03 13:51:15 +01:00
Vito Tumas
e159d27373 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-03 13:48:37 +01:00
Vito Tumas
ba53026006 adds sfMemoData field to VaultDelete transaction (#6356)
* adds sfMemoData field to VaultDelete transaction
2026-02-26 14:13:29 +01:00
Vito Tumas
34773080df Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-25 13:44:20 +01:00
Vito Tumas
3c3bd75991 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-24 14:40:31 +01:00
Vito Tumas
7459fe454d Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-23 12:17:17 +01:00
Vito
106bf48725 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-18 18:29:08 +01:00
Vito Tumas
74c968d4e3 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-17 13:51:08 +01:00
Vito
167147281c Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-12 15:22:30 +01:00
Vito Tumas
ba60306610 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-11 17:46:20 +01:00
Vito Tumas
6674500896 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-10 11:48:23 +01:00
Vito
c5d7ebe93d restores missing linebreak 2026-02-05 10:24:14 +01:00
Ed Hennis
d0b5ca9dab Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-04 18:21:55 -04:00
Vito
5e51893e9b fixes a typo 2026-02-04 11:31:58 +01:00
Vito
3422c11d02 adds lending v1.1 fix amendment 2026-02-04 11:30:41 +01:00
27 changed files with 587 additions and 293 deletions

View File

@@ -216,7 +216,6 @@ words:
- Nyffenegger
- onlatest
- ostr
- oxalica
- pargs
- partitioner
- paychan
@@ -341,7 +340,6 @@ words:
- unsquelch
- unsquelched
- unsquelching
- unsuffixed
- unvalidated
- unveto
- unvetoed

View File

@@ -1,10 +1,10 @@
<!--
This PR template helps you write a good pull request description.
This PR template helps you to write a good pull request description.
Please feel free to include additional useful information even beyond what is requested below.
If your branch is on a personal fork and has a name that allows it to
run CI build/test jobs (e.g. "ci/foo"), remember to rename it BEFORE
opening the PR. This avoids redundant test runs. Renaming
opening the PR. This avoids unnecessary redundant test runs. Renaming
the branch after opening the PR will close the PR.
https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-branches-in-your-repository/renaming-a-branch
-->
@@ -15,7 +15,7 @@ https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-
Please include a summary of the changes.
This may be a direct input to the release notes.
If too broad, please consider splitting into multiple PRs.
If there is a relevant task or issue, please link it here.
If a relevant task or issue, please link it here.
-->
### Context of Change
@@ -65,5 +65,5 @@ This section may not be needed if your change includes thoroughly commented unit
<!--
## Future Tasks
For future tasks related to this PR.
For future tasks related to PR.
-->

View File

@@ -8,7 +8,6 @@ on:
- ".github/workflows/build-nix-images.yml"
- "flake.nix"
- "flake.lock"
- "rust-toolchain.toml"
- "nix/**"
- "!nix/docker/README.md"
- "!nix/devshell.nix"
@@ -19,7 +18,6 @@ on:
- ".github/workflows/build-nix-images.yml"
- "flake.nix"
- "flake.lock"
- "rust-toolchain.toml"
- "nix/**"
- "!nix/docker/README.md"
- "!nix/devshell.nix"

3
.gitignore vendored
View File

@@ -81,9 +81,6 @@ DerivedData
# Python
__pycache__
# Rust build artifacts.
target/
# Direnv's directory
/.direnv

23
flake.lock generated
View File

@@ -36,28 +36,7 @@
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"nixpkgs-custom-glibc": "nixpkgs-custom-glibc",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784611586,
"narHash": "sha256-OfqgY+0hp/zseZB7uyH0U8kIDPS4scZZCyAurEplvG0=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "14f58845249f3552a89b07772626b8d3c632fa86",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
"nixpkgs-custom-glibc": "nixpkgs-custom-glibc"
}
}
},

View File

@@ -10,25 +10,12 @@
url = "github:NixOS/nixpkgs/9cd98386a38891d1074fc18036b842dc4416f562";
flake = false;
};
# Pinned Rust toolchains, delivered from the Nix store. Lets the Nix CI
# image and dev shell honour the single `rust-toolchain.toml` pin (shared
# with the rustup-based non-Nix runners) while staying hermetic — the
# toolchain lands in the image's Nix closure and is locked by flake.lock.
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
nixpkgs,
nixpkgs-custom-glibc,
rust-overlay,
...
}:
{ nixpkgs, nixpkgs-custom-glibc, ... }:
let
forEachSystem = import ./nix/utils.nix { inherit nixpkgs nixpkgs-custom-glibc rust-overlay; };
forEachSystem = import ./nix/utils.nix { inherit nixpkgs nixpkgs-custom-glibc; };
in
{
devShells = forEachSystem (import ./nix/devshell.nix);

View File

@@ -286,6 +286,10 @@ computeFullPaymentInterest(
std::uint32_t startDate,
TenthBips32 closeInterestRate);
// Returns true if the loan's next payment due date has passed.
[[nodiscard]] bool
isPaymentLate(ReadView const& view, SLE::const_ref loanSle);
namespace detail {
// These classes and functions should only be accessed by LendingHelper
// functions and unit tests

View File

@@ -7,10 +7,8 @@ let
inherit (import ./packages.nix { inherit pkgs; })
commonPackages
gccPackage
gccVersion
llvmPackages
llvmVersion
mkVersionedToolLinks
;
# Underlying compiler toolchains to wrap (versions pinned in packages.nix).
@@ -129,25 +127,6 @@ in
customGcov
customClangForCiEnv
customBinutils
(mkVersionedToolLinks {
name = "gcc";
package = customGcc;
version = gccVersion;
tools = [
"gcc"
"g++"
"cpp"
];
})
(mkVersionedToolLinks {
name = "clang";
package = customClang;
version = llvmVersion;
tools = [
"clang"
"clang++"
];
})
# CA certificate bundle so HTTPS clients (git, curl, conan) can verify
# TLS connections without ca-certificates being installed in the system.
pkgs.cacert

View File

@@ -3,9 +3,7 @@ let
inherit (import ./packages.nix { inherit pkgs; })
commonPackages
gccVersion
llvmVersion
llvmPackages
mkVersionedToolLinks
;
# Plain nixpkgs stdenvs — no custom glibc, unlike ci-env.nix.
@@ -17,8 +15,6 @@ let
{
stdenv,
compilerName,
version ? null,
versionedTools ? [ ],
}:
let
compilerVersion =
@@ -29,15 +25,9 @@ let
echo "Compiler: "
${compilerName} --version
'';
versionedLinks = pkgs.lib.optional (version != null) (mkVersionedToolLinks {
name = compilerName;
package = stdenv.cc;
inherit version;
tools = versionedTools;
});
in
(pkgs.mkShell.override { inherit stdenv; }) {
packages = commonPackages ++ versionedLinks;
packages = commonPackages;
shellHook = ''
echo "Welcome to xrpld development shell";
${compilerVersion}
@@ -51,22 +41,11 @@ rec {
gcc = makeShell {
stdenv = gccStdenv;
compilerName = "gcc";
version = gccVersion;
versionedTools = [
"gcc"
"g++"
"cpp"
];
};
clang = makeShell {
stdenv = clangStdenv;
compilerName = "clang";
version = llvmVersion;
versionedTools = [
"clang"
"clang++"
];
};
# Nix provides no compiler; use the one from your system (e.g. Apple Clang).

View File

@@ -12,7 +12,6 @@ COPY nix/packages.nix /tmp/build/nix/packages.nix
COPY nix/utils.nix /tmp/build/nix/utils.nix
COPY flake.nix /tmp/build/
COPY flake.lock /tmp/build/
COPY rust-toolchain.toml /tmp/build/
WORKDIR /tmp/build
FROM builder-source AS builder

View File

@@ -47,9 +47,7 @@ work without `ca-certificates` being installed in the base image.
[`test_files/cpp/sources/`](./test_files/cpp/sources) with both `g++` and
`clang++`, and sanitizers, and
- compiles the Rust test programs in
[`test_files/rust/sources/`](./test_files/rust/sources) with `rustc`, and
builds the [`test_files/rust/proc_macro/`](./test_files/rust/proc_macro)
workspace with `cargo` to exercise proc-macro dylib loading.
[`test_files/rust/sources/`](./test_files/rust/sources) with `rustc`.
3. **`tester`** — Start again from a clean `BASE_IMAGE` (no Nix toolchain),
install only the sanitizer runtime libraries
([`install-sanitizer-libs.sh`](./install-sanitizer-libs.sh)), and run the
@@ -78,23 +76,20 @@ toolchain being present at runtime. Two pieces make that work:
[`loader-path.sh`](./loader-path.sh) reports the expected loader path for the
current architecture, so we can patch the binaries to use the correct loader.
The build then verifies all of this end to end, and the C++ and Rust programs
go through the same pipeline: each is compiled in `final`, has its `PT_INTERP`
patched to the target loader, and is then run in the clean `tester` stage to
confirm it emits the expected diagnostic on a stock base image. The C++ programs
are in `test_files/cpp/sources/` (a regular binary plus ASan/TSan/UBSan
variants); the Rust programs are in `test_files/rust/sources/` (a hello binary
plus panic and overflow-check variants), plus the `test_files/rust/proc_macro/`
workspace — a crate whose compilation additionally loads a proc-macro dylib, and
whose resulting binary is patched and run like the others.
The build then verifies all of this end to end: the C++ test programs in
`test_files/cpp/sources/` (a regular binary plus ASan/TSan/UBSan variants) and
the Rust test programs in `test_files/rust/sources/` (a hello binary plus panic
and overflow-check variants) are compiled in `final`, their `PT_INTERP` is
patched to the target loader, and they are run in the clean `tester` stage to
confirm each emits the expected diagnostic on a stock base image.
## Files
| File | Purpose |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| [`./Dockerfile`](./Dockerfile) | Multi-stage build described above. |
| [`./loader-path.sh`](./loader-path.sh) | Print the dynamic-linker (`PT_INTERP`) path for the current architecture. |
| [`./test_files/cpp/`](./test_files/cpp) | C++ sanitizer smoke test: sources + compile/run scripts. |
| [`./test_files/rust/`](./test_files/rust) | Rust smoke test: rustc sources + a cargo proc-macro workspace + compile/run scripts. |
| [`/bin/check-tools.sh`](../../bin/check-tools.sh) | Verify every expected tools are present and runnable. |
| [`/bin/install-sanitizer-libs.sh`](../../bin/install-sanitizer-libs.sh) | Install `libasan`/`libtsan`/`libubsan` runtimes on the supported base images. |
| File | Purpose |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| [`./Dockerfile`](./Dockerfile) | Multi-stage build described above. |
| [`./loader-path.sh`](./loader-path.sh) | Print the dynamic-linker (`PT_INTERP`) path for the current architecture. |
| [`./test_files/cpp/`](./test_files/cpp) | C++ sanitizer smoke test: sources + compile/run scripts. |
| [`./test_files/rust/`](./test_files/rust) | Rust rustc smoke test: sources + compile/run scripts. |
| [`/bin/check-tools.sh`](../../bin/check-tools.sh) | Verify every expected tools are present and runnable. |
| [`/bin/install-sanitizer-libs.sh`](../../bin/install-sanitizer-libs.sh) | Install `libasan`/`libtsan`/`libubsan` runtimes on the supported base images. |

View File

@@ -40,29 +40,6 @@ compile hello
compile panic
compile overflow "-C overflow-checks=on"
function compile_proc_macro() {
local proj="${src_dir}/../proc_macro"
echo "=== Building proc-macro workspace (cargo) ==="
cargo build --manifest-path "${proj}/Cargo.toml" --offline
local built="${proj}/target/debug/test_macro"
if [ ! -f "${built}" ]; then
echo "ERROR: built test_macro binary not found at ${built}" >&2
exit 1
fi
local binary="${dst_dir}/proc_macro"
cp "${built}" "${binary}"
echo "=== Patching ${binary} to use ${loader} as PT_INTERP ==="
patchelf --set-interpreter "${loader}" --remove-rpath "${binary}"
rm -rf "${proj}/target"
}
compile_proc_macro
echo "=== All binaries compiled ==="
ls -la "${dst_dir}"

View File

@@ -1,14 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "echo_macro"
version = "0.0.0"
[[package]]
name = "test_macro"
version = "0.0.0"
dependencies = [
"echo_macro",
]

View File

@@ -1,3 +0,0 @@
[workspace]
resolver = "2"
members = ["echo_macro", "test_macro"]

View File

@@ -1,8 +0,0 @@
[package]
name = "echo_macro"
version = "0.0.0"
edition = "2024"
publish = false
[lib]
proc-macro = true

View File

@@ -1,6 +0,0 @@
use proc_macro::TokenStream;
#[proc_macro]
pub fn define_echo(item: TokenStream) -> TokenStream {
format!("fn echo() -> u32 {{ {item} }}").parse().unwrap()
}

View File

@@ -1,8 +0,0 @@
[package]
name = "test_macro"
version = "0.0.0"
edition = "2024"
publish = false
[dependencies]
echo_macro = { path = "../echo_macro" }

View File

@@ -1,9 +0,0 @@
use echo_macro::define_echo;
define_echo!(42);
fn main() {
let a = echo();
println!("proc-macro answer = {a}");
assert_eq!(a, 42, "proc-macro expansion produced the wrong value");
}

View File

@@ -1,7 +1,7 @@
#!/bin/bash
# Run pre-compiled Rust binaries and confirm each emits its expected diagnostic.
# Binaries must already exist in <bins_dir> as <name> for name in
# {hello,panic,overflow,proc_macro}.
# {hello,panic,overflow}.
set -eo pipefail
@@ -54,13 +54,12 @@ declare -A expect=(
[hello]="Hello from main thread"
[panic]="explicit panic from test"
[overflow]="attempt to add with overflow"
[proc_macro]="proc-macro answer = 42"
)
for name in hello panic overflow proc_macro; do
for name in hello panic overflow; do
binary="${bins_dir}/${name}"
if [ "${name}" = "hello" ] || [ "${name}" = "proc_macro" ]; then
if [ "${name}" = "hello" ]; then
expected_rc=0
else
expected_rc=nonzero

View File

@@ -15,55 +15,6 @@ let
runClangTidy = pkgs.writeShellScriptBin "run-clang-tidy" ''
exec ${pkgs.python3}/bin/python3 ${llvmPackages.clang-unwrapped}/bin/run-clang-tidy "$@"
'';
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ../rust-toolchain.toml;
# Nix wraps its toolchain so that binaries are exposed only under unsuffixed
# names (gcc, g++, clang-tidy, ...). Several tools probe for a
# version-suffixed name first and fall back to a system binary on the PATH
# when Nix doesn't provide it:
# - Conan's Boost recipe looks up `g++-<major>` before plain `g++`.
# - bin/pre-commit/clang_tidy_check.py looks up `run-clang-tidy-<v>` and
# `clang-apply-replacements-<v>` before the unsuffixed names.
# On a host that also has the matching system binary (e.g. Ubuntu's
# `/usr/bin/g++-15` or `clang-tidy-22`) the probe escapes Nix and mixes a
# system tool into the Nix environment. Generate version-suffixed symlinks
# next to a package's tools so those probes resolve to the Nix ones.
#
# Compiler links must point at whichever compiler is active in a given
# environment (the plain stdenv compiler in the dev shell, the custom-glibc
# wrappers in ci-env.nix), so those callers pass their own `package`; the
# clang tooling is environment-independent and is linked in commonPackages.
mkVersionedToolLinks =
{
name,
package,
version,
tools,
}:
pkgs.linkFarm "${name}-${toString version}-versioned-links" (
map (tool: {
name = "bin/${tool}-${toString version}";
path = "${package}/bin/${tool}";
}) tools
);
clangToolLinks = mkVersionedToolLinks {
name = "clang-tools";
package = clangTools;
version = llvmVersion;
tools = [
"clang-tidy"
"clang-apply-replacements"
"clang-format"
];
};
runClangTidyLink = mkVersionedToolLinks {
name = "run-clang-tidy";
package = runClangTidy;
version = llvmVersion;
tools = [ "run-clang-tidy" ];
};
in
{
inherit
@@ -71,12 +22,9 @@ in
llvmVersion
gccPackage
llvmPackages
mkVersionedToolLinks
;
commonPackages = with pkgs; [
clangToolLinks
runClangTidyLink
ccache
clangbuildanalyzer
clangTools
@@ -115,10 +63,14 @@ in
vim
zip
# Rust packages
cargo
cargo-audit
cargo-llvm-cov
cargo-nextest
clippy
corrosion
rustToolchain
rust-analyzer
rustc
rustfmt
];
}

View File

@@ -1,8 +1,4 @@
{
nixpkgs,
nixpkgs-custom-glibc,
rust-overlay,
}:
{ nixpkgs, nixpkgs-custom-glibc }:
function:
nixpkgs.lib.genAttrs
[
@@ -14,12 +10,7 @@ nixpkgs.lib.genAttrs
(
system:
function {
# rust-overlay adds `pkgs.rust-bin`, from which we build the pinned Rust
# toolchain (see packages.nix). Consumed by both the CI image and dev shell.
pkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
};
pkgs = import nixpkgs { inherit system; };
# glibc 2.31 — matches the system libc on Ubuntu 20.04 LTS. Sourced
# from the nixpkgs snapshot pinned via the `nixpkgs-custom-glibc`
# flake input, so the build uses the compiler from that snapshot

View File

@@ -1,4 +1,8 @@
# Rust toolchain pin for rustup-based CI runners and local development.
# rustup reads this file and installs the pinned toolchain (see the
# prepare-runner action in XRPLF/actions, which runs `rustup toolchain install`).
# NOTE: the Nix CI image and development shell ignore this file; its rustc comes from flake.lock.
[toolchain]
channel = "1.95"
components = ["rustfmt", "clippy", "rust-analyzer"]
components = ["rustfmt", "clippy"]
profile = "minimal"

View File

@@ -130,6 +130,12 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale)
roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
}
[[nodiscard]] bool
isPaymentLate(ReadView const& view, SLE::const_ref loanSle)
{
return hasExpired(view, loanSle->at(sfNextPaymentDueDate));
}
namespace detail {
void
@@ -354,7 +360,7 @@ loanLatePaymentInterest(
// If the payment is not late by any amount of time, then there's no late
// interest
if (now <= nextPaymentDueDate)
return 0;
return kNumZero;
// Equation (3) from XLS-66 spec, Section A-2 Equation Glossary
auto const secondsOverdue = now - nextPaymentDueDate;
@@ -875,7 +881,7 @@ doOverpayment(
std::expected<ExtendedPaymentComponents, TER>
computeLatePayment(
Asset const& asset,
ApplyView const& view,
ReadView const& view,
SLE::const_ref loan,
ExtendedPaymentComponents const& periodic,
STAmount const& amount,
@@ -968,7 +974,7 @@ computeLatePayment(
std::expected<ExtendedPaymentComponents, TER>
computeFullPayment(
Asset const& asset,
ApplyView& view,
ReadView const& view,
SLE::const_ref loan,
Number const& periodicRate,
STAmount const& amount,
@@ -2110,7 +2116,7 @@ loanMakePayment(
// -------------------------------------------------------------
// A late payment not flagged as late overrides all other options.
if (paymentType != LoanPaymentType::Late && hasExpired(view, nextDueDateProxy))
if (paymentType != LoanPaymentType::Late && isPaymentLate(view, loan))
{
// If the payment is late, and the late flag was not set, it's not
// valid

View File

@@ -304,6 +304,12 @@ LoanManage::impairLoan(
Asset const& vaultAsset,
beast::Journal j)
{
if (view.rules().enabled(featureLendingProtocolV1_1) && !isPaymentLate(view, loanSle))
{
JLOG(j.warn()) << "Cannot impair a loan that is not late";
return tecTOO_SOON;
}
Number const lossUnrealized = owedToVault(loanSle);
// The vault may be at a different scale than the loan. Reduce rounding
@@ -318,20 +324,22 @@ LoanManage::impairLoan(
{
// 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.";
JLOG(j.warn()) << "Vault unrealized loss is too large, and will corrupt the vault.";
return tecLIMIT_EXCEEDED;
}
view.update(vaultSle);
// Update the Loan object
loanSle->setFlag(lsfLoanImpaired);
auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate);
if (!hasExpired(view, loanNextDueProxy))
if (!view.rules().enabled(featureLendingProtocolV1_1))
{
// loan payment is not yet late -
// move the next payment due date to now
loanNextDueProxy = view.parentCloseTime().time_since_epoch().count();
auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate);
if (!isPaymentLate(view, loanSle))
{
// loan payment is not yet late move the next payment due date to now
loanNextDueProxy = view.parentCloseTime().time_since_epoch().count();
}
}
view.update(loanSle);
@@ -368,19 +376,24 @@ LoanManage::unimpairLoan(
// Update the Loan object
loanSle->clearFlag(lsfLoanImpaired);
auto const paymentInterval = loanSle->at(sfPaymentInterval);
auto const normalPaymentDueDate =
std::max(loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) + paymentInterval;
if (!hasExpired(view, normalPaymentDueDate))
if (!view.rules().enabled(featureLendingProtocolV1_1))
{
// loan was unimpaired within the payment interval
loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate;
}
else
{
// loan was unimpaired after the original payment due date
loanSle->at(sfNextPaymentDueDate) =
view.parentCloseTime().time_since_epoch().count() + paymentInterval;
auto const paymentInterval = loanSle->at(sfPaymentInterval);
auto const normalPaymentDueDate =
std::max(loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) +
paymentInterval;
if (!hasExpired(view, normalPaymentDueDate))
{
// loan was unimpaired within the payment interval
loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate;
}
else
{
// loan was unimpaired after the original payment due date
loanSle->at(sfNextPaymentDueDate) =
view.parentCloseTime().time_since_epoch().count() + paymentInterval;
}
}
view.update(loanSle);

View File

@@ -33,6 +33,7 @@ VaultDelete::preflight(PreflightContext const& ctx)
if (ctx.tx.isFieldPresent(sfMemoData) && !ctx.rules.enabled(featureLendingProtocolV1_1))
return temDISABLED;
// The sfMemoData field is an optional field used to record the deletion reason.
if (!validDataLength(ctx.tx[~sfMemoData], kMaxDataPayloadLength))
return temMALFORMED;

View File

@@ -1476,17 +1476,27 @@ protected:
// due
env(manage(lender, keylet.key, tfLoanDefault), Ter(tecTOO_SOON));
// Check the vault
bool const canImpair = canImpairLoan(env, broker, state);
// Impair the loan, if possible
env(manage(lender, keylet.key, tfLoanImpair),
canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
// Unimpair the loan
env(manage(lender, keylet.key, tfLoanUnimpair),
canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION));
auto const nextDueDate = startDate + *loanParams.payInterval;
if (env.enabled(featureLendingProtocolV1_1))
{
// With the amendment, impairment is only allowed when the
// payment is late. Impair/unimpair cycle is tested in the
// toEndOfLife callbacks.
env(manage(lender, keylet.key, tfLoanImpair), Ter(tecTOO_SOON));
}
else
{
// Check the vault
bool const canImpair = canImpairLoan(env, broker, state);
// Impair the loan, if possible
env(manage(lender, keylet.key, tfLoanImpair),
canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
// Unimpair the loan
env(manage(lender, keylet.key, tfLoanUnimpair),
canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION));
}
env.close();
verifyLoanStatus(
@@ -2078,6 +2088,19 @@ protected:
if (impair)
{
auto const paymentDue = tp{d{state.nextPaymentDate}};
bool const alreadyLate = env.now() > paymentDue;
if (env.enabled(featureLendingProtocolV1_1) && !alreadyLate)
{
// With the amendment, impairment requires the
// payment to be late
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecTOO_SOON));
// Advance time past the payment due date
env.close(paymentDue + 1s);
}
// Check the vault
bool const canImpair = canImpairLoan(env, broker, state);
// Impair the loan, if possible
@@ -2087,7 +2110,12 @@ protected:
if (canImpair)
{
state.flags |= tfLoanImpair;
state.nextPaymentDate = env.now().time_since_epoch().count();
if (!env.enabled(featureLendingProtocolV1_1))
{
// Without the amendment, impairment moves the
// due date to now
state.nextPaymentDate = env.now().time_since_epoch().count();
}
// Once the loan is impaired, it can't be impaired again
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
@@ -2096,14 +2124,27 @@ protected:
}
auto const nextDueDate = tp{d{state.nextPaymentDate}};
auto const gracePeriod = [&]() {
auto const loanSle = env.le(loanKeylet);
return loanSle ? loanSle->at(sfGracePeriod) : 60;
}();
auto const defaultableTime = nextDueDate + std::chrono::seconds{gracePeriod};
// Can't default the loan yet. The grace period hasn't
// expired
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON));
if (env.now() <= defaultableTime)
{
// Can't default the loan yet. The grace period hasn't
// expired
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON));
// Let some time pass so that the loan can be
// defaulted
env.close(nextDueDate + 60s);
// Let some time pass so that the loan can be
// defaulted
env.close(defaultableTime);
}
else
{
// Grace period already expired
env.close();
}
auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker);
@@ -2707,7 +2748,10 @@ protected:
auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
if (canImpairLoan(env, broker, state))
// With the amendment, on-time payments can't be
// preceded by impairment (payment is not late)
if (!env.enabled(featureLendingProtocolV1_1) &&
canImpairLoan(env, broker, state))
{
// Making a payment will unimpair the loan
env(manage(lender, loanKeylet.key, tfLoanImpair));
@@ -7157,6 +7201,415 @@ protected:
BEAST_EXPECT(afterSecondCoverAvailable == 0);
}
// Verify that with featureLendingProtocolV1_1:
// 1. A loan cannot be impaired before its payment is late.
// 2. Impairing a late loan does not change sfNextPaymentDueDate.
// 3. The unimpair operation does not change sfNextPaymentDueDate.
void
testImpairmentPaymentDateUnchanged()
{
using namespace jtx;
using namespace loan;
using namespace std::chrono_literals;
testcase("Impairment does not change payment due date");
Env env(*this, all_);
BEAST_EXPECT(env.enabled(featureLendingProtocolV1_1));
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000'000), lender, borrower);
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
auto const broker = createVaultAndBroker(env, xrpAsset, lender);
// Create a loan
auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
if (!BEAST_EXPECT(sleBroker))
return;
auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence));
Number const principalRequest{1, 3};
env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
Sig(sfCounterpartySignature, lender),
kPaymentTotal(12),
kPaymentInterval(600),
Fee(env.current()->fees().base * 2));
env.close();
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
auto const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
BEAST_EXPECT(originalNextDueDate > 0);
// 1. Impairment must fail when payment is not yet late
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecTOO_SOON));
// Verify the due date was not changed by the failed impairment
{
auto const loan = env.le(loanKeylet);
BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
}
// Advance time past the payment due date
env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
// 2. Impairment succeeds when payment is late
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
// Verify sfNextPaymentDueDate is unchanged after impairment
{
auto const loan = env.le(loanKeylet);
if (!BEAST_EXPECT(loan))
return;
BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
}
// 3. Unimpair also does not change sfNextPaymentDueDate
env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
{
auto const loan = env.le(loanKeylet);
if (!BEAST_EXPECT(loan))
return;
BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
}
}
// Verify that without featureLendingProtocolV1_1, the pre-amendment
// impair/unimpair behaviour is preserved:
// 1. Impairing a loan before its payment is late moves
// sfNextPaymentDueDate to "now".
// 2a. Unimpair within the original payment interval restores
// sfNextPaymentDueDate to StartDate + PaymentInterval.
// 2b. Unimpair after the original due date sets
// sfNextPaymentDueDate to now + PaymentInterval.
void
testImpairmentPaymentDatePreAmendment()
{
using namespace jtx;
using namespace loan;
using namespace std::chrono_literals;
testcase("Pre-amendment impair/unimpair date restoration");
Env env(*this, all_ - featureLendingProtocolV1_1);
BEAST_EXPECT(!env.enabled(featureLendingProtocolV1_1));
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000'000), lender, borrower);
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
auto const broker = createVaultAndBroker(env, xrpAsset, lender);
// Helper to create a loan and return its keylet
Number const principalRequest{1, 3};
auto createLoan = [&]() {
auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
BEAST_EXPECT(sleBroker);
auto const lk = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence));
env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
Sig(sfCounterpartySignature, lender),
kPaymentTotal(12),
kPaymentInterval(600),
Fee(env.current()->fees().base * 2));
env.close();
return lk;
};
// Helper to default + delete a loan and replenish first-loss capital
// so the broker is ready for the next loan.
auto cleanupLoan = [&](Keylet const& loanKeylet, std::uint32_t dueDate) {
env.close(NetClock::time_point{NetClock::duration{dueDate + 60}} + 1s);
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
env.close();
auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
if (!BEAST_EXPECT(brokerSle))
return;
auto const coverNeeded =
broker.asset(broker.params.coverDeposit).value() - brokerSle->at(sfCoverAvailable);
if (coverNeeded > 0)
{
env(loanBroker::coverDeposit(
lender, broker.brokerID, STAmount{broker.asset, coverNeeded}));
env.close();
}
env(del(lender, loanKeylet.key));
env.close();
};
// ---- Case A: impair before late, unimpair within original interval ----
{
auto const loanKeylet = createLoan();
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
auto const startDate = loanSle->at(sfStartDate);
auto const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
BEAST_EXPECT(originalNextDueDate == startDate + 600);
// Payment is not late yet — impair succeeds and moves due date
// to now (pre-amendment allows immediate impairment)
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
{
auto const loan = env.le(loanKeylet);
if (!BEAST_EXPECT(loan))
return;
BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
// Due date was moved to parentCloseTime
auto const movedDueDate = loan->at(sfNextPaymentDueDate);
BEAST_EXPECT(movedDueDate != originalNextDueDate);
BEAST_EXPECT(movedDueDate < originalNextDueDate);
}
// Unimpair while still within the original payment interval.
// The normal due date (startDate + 600) has not yet expired,
// so it should be restored.
env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
{
auto const loan = env.le(loanKeylet);
if (!BEAST_EXPECT(loan))
return;
BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
}
cleanupLoan(loanKeylet, originalNextDueDate);
}
// ---- Case B: impair before late, unimpair after original due date ----
{
auto const loanKeylet = createLoan();
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
auto const startDate = loanSle->at(sfStartDate);
auto const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
BEAST_EXPECT(originalNextDueDate == startDate + 600);
// Payment is not late yet — impair moves due date to now
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
// Advance time past the original due date before unimpair
env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 10s);
auto const timeBeforeUnimpair =
env.current()->header().parentCloseTime.time_since_epoch().count();
env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
{
auto const loan = env.le(loanKeylet);
if (!BEAST_EXPECT(loan))
return;
BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
// Unimpaired after the original due date, so the new due
// date should be approximately now + paymentInterval
auto const newDueDate = loan->at(sfNextPaymentDueDate);
BEAST_EXPECT(newDueDate > originalNextDueDate);
BEAST_EXPECT(newDueDate == timeBeforeUnimpair + 600);
}
}
}
void
testImpairedOverdueLoanPayRequiresLateFlag()
{
using namespace jtx;
using namespace loan;
using namespace std::chrono_literals;
// FN-68: a borrower must not be able to bypass late-payment charges by
// paying an impaired, overdue loan with a plain LoanPay. Under
// featureLendingProtocolV1_1 impairment no longer moves the due date,
// so the payment logic sees the real (overdue) date: a regular payment
// is rejected with tecEXPIRED, and only a tfLoanLatePayment (which
// charges the late fee + late interest) is accepted.
testcase("Impaired overdue LoanPay requires late-payment flag");
Env env(*this, all_);
BEAST_EXPECT(env.enabled(featureLendingProtocolV1_1));
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000'000), lender, borrower);
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
auto const broker = createVaultAndBroker(env, xrpAsset, lender);
auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
if (!BEAST_EXPECT(sleBroker))
return;
auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence));
// Loan with non-zero late-payment terms, so the late path carries a
// real penalty that the exploit would otherwise avoid.
Number const principalRequest{1, 3};
env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
Sig(sfCounterpartySignature, lender),
kPaymentTotal(12),
kPaymentInterval(600),
kLatePaymentFee(broker.asset(3).number()),
kLateInterestRate(TenthBips32{30322}),
Fee(env.current()->fees().base * 2));
env.close();
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
auto const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
auto const paymentsBefore = loanSle->at(sfPaymentRemaining);
BEAST_EXPECT(originalNextDueDate > 0);
// Advance past the due date so the loan is overdue, then impair it
// (impairment is only allowed once the payment is late).
env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
env.close();
// The loan is impaired and overdue, and its due date was NOT moved.
{
auto const loan = env.le(loanKeylet);
if (!BEAST_EXPECT(loan))
return;
BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
}
auto const payAmount = broker.asset(500).value();
// The exploit: a plain LoanPay (Flags = 0) on an impaired, overdue loan
// must be rejected. Before FN-9 the auto-unimpair pushed the due date
// into the future and this returned tesSUCCESS, letting the borrower
// skip the late fee and late interest.
env(pay(borrower, loanKeylet.key, payAmount), Ter(tecEXPIRED));
env.close();
// The failed payment changed nothing: still impaired, still overdue,
// no payment applied.
{
auto const loan = env.le(loanKeylet);
if (!BEAST_EXPECT(loan))
return;
BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore);
BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
}
// The same payment WITH the late flag is accepted, clears impairment,
// and advances the schedule by one period.
env(pay(borrower, loanKeylet.key, payAmount, tfLoanLatePayment), Ter(tesSUCCESS));
env.close();
{
auto const loan = env.le(loanKeylet);
if (!BEAST_EXPECT(loan))
return;
BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore - 1);
}
// A loan that is no longer impaired must leave no unrealized loss on
// the vault.
{
auto const vaultSle = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSle))
return;
BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
}
}
void
testImpairedOverdueLoanPayBypassPreAmendment()
{
using namespace jtx;
using namespace loan;
using namespace std::chrono_literals;
// FN-68 (pre-amendment): documents the original vulnerability. Without
// featureLendingProtocolV1_1, impairing moves the due date and LoanPay
// auto-unimpair pushes it into the future before the late check, so a
// plain (Flags = 0) LoanPay on an impaired, overdue loan is accepted as
// on-time (tesSUCCESS) and the borrower dodges the late-payment charges.
// This is what testImpairedOverdueLoanPayRequiresLateFlag closes once
// the amendment is enabled.
testcase("Impaired overdue LoanPay bypass (pre-amendment)");
Env env(*this, all_ - featureLendingProtocolV1_1);
BEAST_EXPECT(!env.enabled(featureLendingProtocolV1_1));
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000'000), lender, borrower);
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
auto const broker = createVaultAndBroker(env, xrpAsset, lender);
auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
if (!BEAST_EXPECT(sleBroker))
return;
auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence));
Number const principalRequest{1, 3};
env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
Sig(sfCounterpartySignature, lender),
kPaymentTotal(12),
kPaymentInterval(600),
kLatePaymentFee(broker.asset(3).number()),
kLateInterestRate(TenthBips32{30322}),
Fee(env.current()->fees().base * 2));
env.close();
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
auto const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
BEAST_EXPECT(originalNextDueDate > 0);
// Pre-amendment: a loan can be impaired before it is late, which moves
// the due date to "now".
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
env.close();
// Advance past the ORIGINAL due date so the loan is genuinely overdue.
env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
{
auto const loan = env.le(loanKeylet);
if (!BEAST_EXPECT(loan))
return;
BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
}
auto const payAmount = broker.asset(500).value();
// The bug: a plain LoanPay is accepted as on-time and clears the loan's
// impaired flag, so the late fee / late interest are never charged.
env(pay(borrower, loanKeylet.key, payAmount), Ter(tesSUCCESS));
env.close();
{
auto const loan = env.le(loanKeylet);
if (!BEAST_EXPECT(loan))
return;
BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
}
}
void
testYieldTheftRounding(std::uint32_t flags)
{
@@ -7378,6 +7831,15 @@ protected:
Ter(tesSUCCESS));
env.close();
// Advance time past the payment due date so the loan can be impaired
{
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
auto const nextDueDate = loanSle->at(sfNextPaymentDueDate);
env.close(NetClock::time_point{NetClock::duration{nextDueDate}} + 1s);
}
// Impair the loan to create unrealized loss
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
env.close();
@@ -8564,6 +9026,10 @@ protected:
testLendingCanTradeDisabledNoImpact();
testBugOverpaymentPrincipalChange();
testBugOverpayUnroundedAmount();
testImpairmentPaymentDateUnchanged();
testImpairmentPaymentDatePreAmendment();
testImpairedOverdueLoanPayRequiresLateFlag();
testImpairedOverdueLoanPayBypassPreAmendment();
for (auto const flags : {0u, tfLoanOverpayment})
testYieldTheftRounding(flags);

View File

@@ -28,6 +28,7 @@
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
@@ -6108,6 +6109,7 @@ class Vault_test : public beast::unit_test::Suite
{
using namespace loan;
using namespace std::chrono_literals;
env(set(f.borrower, f.brokerID, kStuckPrincipal),
Sig(sfCounterpartySignature, f.lender),
kPaymentTotal(kStuckPayTotal),
@@ -6115,6 +6117,15 @@ class Vault_test : public beast::unit_test::Suite
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
// Impairment requires the payment to be late, so advance past
// the due date before impairing.
auto const loanSle = env.le(*f.loanKeylet);
if (!BEAST_EXPECT(loanSle))
return f;
std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
env.close(NetClock::time_point{NetClock::duration{dueDate}} + 1s);
env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
env.close();
}
@@ -6395,7 +6406,14 @@ class Vault_test : public beast::unit_test::Suite
BEAST_EXPECT(retainedShares == f.sharesLender - 750'018'750);
// Borrower repays the loan in full (pays more than the outstanding
// total; the loan transactor caps the receivable).
// total each time; the loan transactor caps the receivable). The
// loan is still overdue from the impairment setup, so the first
// (and only remaining, since kStuckPayTotal == 2) outstanding
// installment must be caught up with a late payment before the
// final regular payment can close the loan out.
env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2), tfLoanLatePayment),
Ter(tesSUCCESS));
env.close();
env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
env.close();