mirror of
https://github.com/XRPLF/rippled.git
synced 2026-07-23 07:00:21 +00:00
Compare commits
42 Commits
develop
...
FN-68-loan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d60fc44a37 | ||
|
|
846064de36 | ||
|
|
896c83ee13 | ||
|
|
e3716bfd9e | ||
|
|
da51750be2 | ||
|
|
1a4fbadf7c | ||
|
|
2c4d50265c | ||
|
|
da6345f429 | ||
|
|
beb8a1872d | ||
|
|
24db40e56c | ||
|
|
da4513d096 | ||
|
|
2e2fddefe9 | ||
|
|
db997ecad9 | ||
|
|
bbcce301f0 | ||
|
|
68e4fbdf2b | ||
|
|
bb0a09ae21 | ||
|
|
d94232007f | ||
|
|
df8bfbe5af | ||
|
|
347d1a19ef | ||
|
|
d65fab27a1 | ||
|
|
b5d25c5ab1 | ||
|
|
7222150095 | ||
|
|
a67da5c2ed | ||
|
|
d2f23b2f5b | ||
|
|
4067e5025f | ||
|
|
ed4330a7d6 | ||
|
|
feba605998 | ||
|
|
b322097529 | ||
|
|
e159d27373 | ||
|
|
ba53026006 | ||
|
|
34773080df | ||
|
|
3c3bd75991 | ||
|
|
7459fe454d | ||
|
|
106bf48725 | ||
|
|
74c968d4e3 | ||
|
|
167147281c | ||
|
|
ba60306610 | ||
|
|
6674500896 | ||
|
|
c5d7ebe93d | ||
|
|
d0b5ca9dab | ||
|
|
5e51893e9b | ||
|
|
3422c11d02 |
@@ -216,7 +216,6 @@ words:
|
||||
- Nyffenegger
|
||||
- onlatest
|
||||
- ostr
|
||||
- oxalica
|
||||
- pargs
|
||||
- partitioner
|
||||
- paychan
|
||||
@@ -341,7 +340,6 @@ words:
|
||||
- unsquelch
|
||||
- unsquelched
|
||||
- unsquelching
|
||||
- unsuffixed
|
||||
- unvalidated
|
||||
- unveto
|
||||
- unvetoed
|
||||
|
||||
8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
@@ -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.
|
||||
-->
|
||||
|
||||
2
.github/workflows/build-nix-images.yml
vendored
2
.github/workflows/build-nix-images.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -81,9 +81,6 @@ DerivedData
|
||||
# Python
|
||||
__pycache__
|
||||
|
||||
# Rust build artifacts.
|
||||
target/
|
||||
|
||||
# Direnv's directory
|
||||
/.direnv
|
||||
|
||||
|
||||
23
flake.lock
generated
23
flake.lock
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
17
flake.nix
17
flake.nix
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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}"
|
||||
|
||||
14
nix/docker/test_files/rust/proc_macro/Cargo.lock
generated
14
nix/docker/test_files/rust/proc_macro/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["echo_macro", "test_macro"]
|
||||
@@ -1,8 +0,0 @@
|
||||
[package]
|
||||
name = "echo_macro"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
@@ -1,6 +0,0 @@
|
||||
use proc_macro::TokenStream;
|
||||
|
||||
#[proc_macro]
|
||||
pub fn define_echo(item: TokenStream) -> TokenStream {
|
||||
format!("fn echo() -> u32 {{ {item} }}").parse().unwrap()
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
[package]
|
||||
name = "test_macro"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
echo_macro = { path = "../echo_macro" }
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user