From 42502e4263f7011158264ddcdaed29c2e969ef53 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Wed, 26 Aug 2026 13:42:19 +0000 Subject: [PATCH 01/40] ci: Update CI image (#8121) --- .github/scripts/strategy-matrix/linux.json | 2 +- .github/workflows/build-nix-images.yml | 2 ++ .github/workflows/cargo-audit.yml | 2 +- .github/workflows/pre-commit.yml | 2 +- .github/workflows/publish-docs.yml | 2 +- .github/workflows/reusable-clang-tidy.yml | 2 +- .github/workflows/reusable-rust.yml | 6 +++--- .github/workflows/reusable-upload-recipe.yml | 2 +- nix/check-tools/nix-ubuntu-amd64.txt | 20 ++++++++++---------- nix/check-tools/nix-ubuntu-arm64.txt | 20 ++++++++++---------- 10 files changed, 31 insertions(+), 29 deletions(-) diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 8450c3079e..14d1c725d7 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -1,5 +1,5 @@ { - "image_tag": "sha-a0074f8", + "image_tag": "sha-473fe44", "configs": { "ubuntu": [ { diff --git a/.github/workflows/build-nix-images.yml b/.github/workflows/build-nix-images.yml index 813edd8aff..a528786dd4 100644 --- a/.github/workflows/build-nix-images.yml +++ b/.github/workflows/build-nix-images.yml @@ -12,6 +12,7 @@ on: - "nix/**" - "!nix/docker/README.md" - "!nix/devshell.nix" + - "!nix/check-tools/*.txt" - "bin/check-tools.sh" - "bin/default-loader-path.sh" - "bin/install-sanitizer-libs.sh" @@ -24,6 +25,7 @@ on: - "nix/**" - "!nix/docker/README.md" - "!nix/devshell.nix" + - "!nix/check-tools/*.txt" - "bin/check-tools.sh" - "bin/default-loader-path.sh" - "bin/install-sanitizer-libs.sh" diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index d167e52e61..6ddc6cdac9 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -34,7 +34,7 @@ permissions: jobs: audit: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 permissions: contents: read # Needed to open an issue on scheduled failures. diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 905e910591..1f6b69f087 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -17,4 +17,4 @@ jobs: uses: XRPLF/actions/.github/workflows/pre-commit.yml@f1952595d212e86169935135efc66294b4574131 with: runs_on: ubuntu-latest - container: '{ "image": "ghcr.io/xrplf/xrpld/pre-commit:sha-f56b79f" }' + container: '{ "image": "ghcr.io/xrplf/xrpld/pre-commit:sha-473fe44" }' diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index b8ca7751ab..8c5d10929c 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -41,7 +41,7 @@ env: jobs: build: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index ac21c83ea0..045d384181 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -34,7 +34,7 @@ jobs: needs: [determine-files] if: ${{ needs.determine-files.outputs.cpp_changed_files != '' || needs.determine-files.outputs.need_full_run == 'true' }} runs-on: ["self-hosted", "Linux", "X64", "heavy"] - container: "ghcr.io/xrplf/xrpld/nix-debian:sha-a0074f8" + container: "ghcr.io/xrplf/xrpld/nix-debian:sha-473fe44" permissions: contents: read issues: write diff --git a/.github/workflows/reusable-rust.yml b/.github/workflows/reusable-rust.yml index 83301f97ad..a0199f0129 100644 --- a/.github/workflows/reusable-rust.yml +++ b/.github/workflows/reusable-rust.yml @@ -27,7 +27,7 @@ permissions: jobs: clippy: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -40,7 +40,7 @@ jobs: coverage: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -66,7 +66,7 @@ jobs: doc: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index 680d95fb97..608a5ea988 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -40,7 +40,7 @@ defaults: jobs: upload: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 env: REMOTE_NAME: ${{ inputs.remote_name }} CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }} diff --git a/nix/check-tools/nix-ubuntu-amd64.txt b/nix/check-tools/nix-ubuntu-amd64.txt index a5857c93f1..28b6c38014 100644 --- a/nix/check-tools/nix-ubuntu-amd64.txt +++ b/nix/check-tools/nix-ubuntu-amd64.txt @@ -114,8 +114,8 @@ Development tooling: Rust toolchain: ✅ cargo - cargo 1.95.0 (f2d3ce0bd 2026-03-21) - /nix/store/85qbwr3vzfs58m7ywnjblz105p8ahbrv-cargo-1.95.0-x86_64-unknown-linux-gnu/bin/cargo + cargo 1.97.1 (c980f4866 2026-06-30) + /nix/store/88abzp43ywyzql1rhf8jh5aj5n5j7xzr-cargo-1.97.1-x86_64-unknown-linux-gnu/bin/cargo ✅ cargo-audit cargo-audit-audit 0.22.1 /nix/store/2w9if868piw98xz057sz97jnjvf7hnvf-cargo-audit-0.22.1/bin/cargo-audit @@ -126,17 +126,17 @@ Rust toolchain: cargo-nextest 0.9.137 /nix/store/jhkr7gwyrchkml33gyns9cy0yn7b57qc-cargo-nextest-0.9.137/bin/cargo-nextest ✅ clippy-driver - clippy 0.1.95 (59807616e1 2026-04-14) - /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/clippy-driver + clippy 0.1.97 (8bab26f4f6 2026-07-14) + /nix/store/40d3mzka7r1ps71l0yv2fs6616nbw85m-rust-minimal-1.97.1/bin/clippy-driver ✅ rust-analyzer - rust-analyzer 1.95.0 (5980761 2026-04-14) - /nix/store/i3cnpngfwa3k4jn431pl6ji1r4qmxky9-rust-analyzer-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rust-analyzer + rust-analyzer 1.97.1 (8bab26f 2026-07-14) + /nix/store/lr3m97p3hx1k22a7c44pb0wa7rbayhfi-rust-analyzer-preview-1.97.1-x86_64-unknown-linux-gnu/bin/rust-analyzer ✅ rustc - rustc 1.95.0 (59807616e 2026-04-14) - /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/rustc + rustc 1.97.1 (8bab26f4f 2026-07-14) + /nix/store/40d3mzka7r1ps71l0yv2fs6616nbw85m-rust-minimal-1.97.1/bin/rustc ✅ rustfmt - rustfmt 1.9.0-stable (59807616e1 2026-04-14) - /nix/store/366hhk2dgwxmnf4hgrj4b8llhjr3hf0i-rustfmt-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rustfmt + rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14) + /nix/store/6f1icmb2za20kxn30pgmbv5jq9fnbf4z-rustfmt-preview-1.97.1-x86_64-unknown-linux-gnu/bin/rustfmt GCC toolchain: ✅ gcc diff --git a/nix/check-tools/nix-ubuntu-arm64.txt b/nix/check-tools/nix-ubuntu-arm64.txt index 820c6de086..b3b5885a7f 100644 --- a/nix/check-tools/nix-ubuntu-arm64.txt +++ b/nix/check-tools/nix-ubuntu-arm64.txt @@ -114,8 +114,8 @@ Development tooling: Rust toolchain: ✅ cargo - cargo 1.95.0 (f2d3ce0bd 2026-03-21) - /nix/store/yw1rs50s6qpsw0zyl7j3dpm18swbl0ag-cargo-1.95.0-aarch64-unknown-linux-gnu/bin/cargo + cargo 1.97.1 (c980f4866 2026-06-30) + /nix/store/6hch2qrr86n2sa2m90lrpxrfxxwbkayl-cargo-1.97.1-aarch64-unknown-linux-gnu/bin/cargo ✅ cargo-audit cargo-audit-audit 0.22.1 /nix/store/9rxbrn9aa2r1z96186s69pc7vzizyfch-cargo-audit-0.22.1/bin/cargo-audit @@ -126,17 +126,17 @@ Rust toolchain: cargo-nextest 0.9.137 /nix/store/qb6bcg2fjvm3r9s9j98nmffmf9xwh45s-cargo-nextest-0.9.137/bin/cargo-nextest ✅ clippy-driver - clippy 0.1.95 (59807616e1 2026-04-14) - /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/clippy-driver + clippy 0.1.97 (8bab26f4f6 2026-07-14) + /nix/store/a6p27cg6b8szfixfyvkssx6l0c345zw8-rust-minimal-1.97.1/bin/clippy-driver ✅ rust-analyzer - rust-analyzer 1.95.0 (5980761 2026-04-14) - /nix/store/m1rn67sqfz8s44idcxqallg680ifk71r-rust-analyzer-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rust-analyzer + rust-analyzer 1.97.1 (8bab26f 2026-07-14) + /nix/store/262830dlw2517lnagfx7i7agqgl4fmsd-rust-analyzer-preview-1.97.1-aarch64-unknown-linux-gnu/bin/rust-analyzer ✅ rustc - rustc 1.95.0 (59807616e 2026-04-14) - /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/rustc + rustc 1.97.1 (8bab26f4f 2026-07-14) + /nix/store/a6p27cg6b8szfixfyvkssx6l0c345zw8-rust-minimal-1.97.1/bin/rustc ✅ rustfmt - rustfmt 1.9.0-stable (59807616e1 2026-04-14) - /nix/store/jidfsprj2820glyzjn54ldn3j1fmz8c5-rustfmt-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rustfmt + rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14) + /nix/store/nd8g81wv1smnvdpy4whpcyv2siwjmaan-rustfmt-preview-1.97.1-aarch64-unknown-linux-gnu/bin/rustfmt GCC toolchain: ✅ gcc From f8fba079fe8d20d7a9e1051e44311f2943c40b49 Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:55:04 +0000 Subject: [PATCH 02/40] fix: Refuse a pseudo-account as the vault clawback holder (#8111) Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> --- .../tx/transactors/vault/VaultClawback.cpp | 12 +++ src/test/app/vault/VaultClawback_test.cpp | 83 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp index 7348e1734b..c2c099f14e 100644 --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -95,6 +96,17 @@ VaultClawback::preclaim(PreclaimContext const& ctx) // LCOV_EXCL_STOP } + // A pseudo-account holds no vault shares, so a clawback naming one is a no-op: the vault's own + // pseudo-account issues the shares, and no flow hands them to another one. + // Pre-fixCleanup3_4_0: an implicit amount ends in tecPRECISION_LOSS, an explicit one debits the + // vault and trips the "shares must move" invariant. + // Post-fixCleanup3_4_0: refused here. + if (ctx.view.rules().enabled(fixCleanup3_4_0) && isPseudoAccount(ctx.view, holder)) + { + JLOG(ctx.j.debug()) << "VaultClawback: holder is a pseudo-account."; + return tecPSEUDO_ACCOUNT; + } + Asset const share = MPTIssue{mptIssuanceID}; // Ambiguous case: If Issuer is Owner they must specify the asset diff --git a/src/test/app/vault/VaultClawback_test.cpp b/src/test/app/vault/VaultClawback_test.cpp index 6ce847f9fd..0290b67047 100644 --- a/src/test/app/vault/VaultClawback_test.cpp +++ b/src/test/app/vault/VaultClawback_test.cpp @@ -1129,12 +1129,95 @@ private: } } + // The vault's pseudo-account issues the shares, so it never holds any, and naming it as Holder + // asks for a clawback that cannot move anything. Before the rule an implicit amount resolved to + // zero shares and ended in tecPRECISION_LOSS, while an explicit one debited the vault first and + // was caught by the invariant that shares must move. + void + testClawbackPseudoAccountHolder() + { + using namespace test::jtx; + + auto const runScenario = [this](FeatureBitset features, std::string const& prefix) { + bool const guarded = features[fixCleanup3_4_0]; + Env env{*this, features}; + + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const issuer{"issuer"}; + + env.fund(XRP(1'000), owner, depositor, issuer); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + env.trust(asset(1'000), owner); + env.trust(asset(1'000), depositor); + env(pay(issuer, depositor, asset(200))); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + auto const vaultSle = env.le(keylet); + if (!BEAST_EXPECT(vaultSle)) + return; + Account const pseudo{"vault pseudo-account", vaultSle->at(sfAccount)}; + env.memoize(pseudo); + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)})); + env.close(); + + auto const assetsBefore = [&]() -> Number { + auto const sle = env.le(keylet); + if (!BEAST_EXPECT(sle)) + return Number{}; + return sle->at(sfAssetsTotal); + }(); + + { + testcase("VaultClawback - " + prefix + " pseudo-account holder, implicit amount"); + env(vault.clawback({ + .issuer = issuer, + .id = keylet.key, + .holder = pseudo, + }), + Ter(guarded ? TER{tecPSEUDO_ACCOUNT} : TER{tecPRECISION_LOSS})); + env.close(); + } + + { + testcase("VaultClawback - " + prefix + " pseudo-account holder, explicit amount"); + env(vault.clawback({ + .issuer = issuer, + .id = keylet.key, + .holder = pseudo, + .amount = asset(10).value(), + }), + Ter(guarded ? TER{tecPSEUDO_ACCOUNT} : TER{tecINVARIANT_FAILED})); + env.close(); + } + + // Neither attempt may touch the vault, whichever way it was refused. + auto const sleAfter = env.le(keylet); + BEAST_EXPECT(sleAfter && sleAfter->at(sfAssetsTotal) == assetsBefore); + }; + + runScenario(all_, "post-rule"); + runScenario(all_ - fixCleanup3_4_0, "pre-rule"); + } + public: void run() override { testVaultClawbackBurnShares(); testVaultClawbackAssets(); + testClawbackPseudoAccountHolder(); testVaultEscrowedMPT(); } }; From d83a84510e2ae62c4ea784e0994a4cbed398f627 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Wed, 26 Aug 2026 14:03:01 +0000 Subject: [PATCH 03/40] chore: Bump version to 3.4.0-b2 (#8120) --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index 7f10630581..f1917eccff 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.4.0-b1" +char const* const versionString = "3.4.0-b2" // clang-format on ; From c28d389e0e3b6a59aa8a193b8df8da565cfe7f97 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Wed, 26 Aug 2026 14:04:58 +0000 Subject: [PATCH 04/40] build: Refactor generate.py to make packaging_config part of config (#8115) --- .github/scripts/strategy-matrix/generate.py | 101 ++++++++++++-------- .github/scripts/strategy-matrix/linux.json | 35 ++----- .github/workflows/reusable-package.yml | 4 +- package/README.md | 49 +++++----- 4 files changed, 98 insertions(+), 91 deletions(-) diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 7a3b7a8cf5..65671dbd11 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -23,6 +23,19 @@ _SANITIZER_SUFFIX: dict[str, str] = { } +def config_name( + distro: str, + compiler: str, + build_type: str, + arch: str, + suffix: str = "", + sanitizer: str = "", +) -> str: + """Name a config. Its artifacts are named after it, so packaging reuses this.""" + parts = [s for s in [suffix, _SANITIZER_SUFFIX.get(sanitizer, "")] if s] + return "-".join([f"{distro}-{compiler}-{build_type.lower()}-{arch}", *parts]) + + def get_cmake_args(build_type: str, extra_args: str) -> str: """Get the full list of CMake arguments for a config.""" args = _BASE_CMAKE_ARGS.copy() @@ -37,17 +50,27 @@ def get_cmake_args(build_type: str, extra_args: str) -> str: # Every config must declare 'minimal'. Minimal configs form the reduced matrix -# built for pull requests by default; the full matrix adds the rest. Packaging -# configs declare it too, but packaging is gated in the workflow, not by it. +# built for pull requests by default; the full matrix adds the rest. # -# Configs may also opt into 'benchmark' to smoke-run the benchmarks. Note that -# the flag applies to every entry a config expands into, so only set it on -# configs that expand to a single combination. +# Configs may also opt into 'benchmark' to smoke-run the benchmarks, or carry a +# 'package' map to be packaged as well. Note that either applies to every entry +# a config expands into, so only set them on configs that expand to a single +# combination. + + +@dataclasses.dataclass +class PackageConfig: + """The 'package' map of a config whose binaries are also packaged.""" + + type: str # "deb" or "rpm"; has to match what the image provides + # The packaging container image: a vanilla distro image, not the nix image + # the config itself builds in. + image: str @dataclasses.dataclass class LinuxConfig: - """One entry in linux.json's 'configs' or 'package_configs' arrays.""" + """One entry in a linux.json 'configs' array.""" compiler: list[str] build_type: list[str] @@ -57,9 +80,11 @@ class LinuxConfig: sanitizers: list[str] = dataclasses.field(default_factory=list) suffix: str = "" extra_cmake_args: str = "" - # The two below are only used by package_configs entries. - image: str = "" - package_type: str = "" # "deb" or "rpm"; has to match what image provides + package: PackageConfig | None = None # set to also package this config + + def __post_init__(self) -> None: + if isinstance(self.package, dict): + self.package = PackageConfig(**self.package) @dataclasses.dataclass @@ -68,22 +93,16 @@ class LinuxFile: image_tag: str configs: dict[str, list[LinuxConfig]] # distro → configs - package_configs: dict[str, list[LinuxConfig]] # distro → packaging configs @classmethod def load(cls, path: Path) -> "LinuxFile": data = json.loads(path.read_text()) - - def parse(section: dict) -> dict[str, list[LinuxConfig]]: - return { - distro: [LinuxConfig(**c) for c in cfgs] - for distro, cfgs in section.items() - } - return cls( image_tag=data["image_tag"], - configs=parse(data["configs"]), - package_configs=parse(data.get("package_configs", {})), + configs={ + distro: [LinuxConfig(**c) for c in cfgs] + for distro, cfgs in data["configs"].items() + }, ) @@ -199,13 +218,9 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]: effective_sanitizers, effective_archs.items(), ): - name = f"{distro}-{compiler}-{build_type.lower()}-{arch}" - suffix_parts = [ - s for s in [cfg.suffix, _SANITIZER_SUFFIX.get(sanitizer, "")] if s - ] - if suffix_parts: - name += "-" + "-".join(suffix_parts) - + name = config_name( + distro, compiler, build_type, arch, cfg.suffix, sanitizer + ) entries.append( MatrixEntry( config_name=name, @@ -225,27 +240,33 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]: def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: - """Generate the packaging matrix from a LinuxFile's package_configs section. + """Generate the packaging matrix from the configs that carry a 'package' map. - Packaging uses vanilla distro images (debian:bookworm, almalinux:9) instead of - the nix-based build images, because deb/rpm tooling (debhelper, rpm-build) - is taken from the distro's archive rather than from nixpkgs. Each config - entry carries its own 'image'. + Packaging consumes the binaries that config's build job uploaded, so the + artifact names come from the same config name, and a packaged config is one + that passes -Dvalidator_keys=ON. - The artifact names must match what the build job uploads: one artifact per - binary, each named after the build config. + Packaging itself runs in vanilla distro images (debian:trixie, almalinux:10) + instead of the nix-based build images, because deb/rpm tooling (debhelper, + rpm-build) is taken from the distro's archive rather than from nixpkgs. """ entries = [] - for distro, configs in linux.package_configs.items(): + for distro, configs in linux.configs.items(): for cfg in configs: - for compiler, build_type in itertools.product(cfg.compiler, cfg.build_type): - config_name = f"{distro}-{compiler}-{build_type.lower()}-amd64" + if cfg.package is None: + continue + for compiler, build_type, arch in itertools.product( + cfg.compiler, cfg.build_type, cfg.arch + ): + # The packaging workflow hardcodes an amd64 runner. + assert arch == "amd64", f"cannot package {distro} on {arch}" + name = config_name(distro, compiler, build_type, arch, cfg.suffix) entries.append( PackagingEntry( - xrpld_artifact_name=f"xrpld-{config_name}", - validator_keys_artifact_name=f"validator-keys-{config_name}", - image=cfg.image, - package_type=cfg.package_type, + xrpld_artifact_name=f"xrpld-{name}", + validator_keys_artifact_name=f"validator-keys-{name}", + image=cfg.package.image, + package_type=cfg.package.type, ) ) diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 14d1c725d7..731536a748 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -71,7 +71,11 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "extra_cmake_args": "-Dvalidator_keys=ON" + "extra_cmake_args": "-Dvalidator_keys=ON", + "package": { + "type": "deb", + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-45e4b88" + } } ], @@ -81,30 +85,11 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "extra_cmake_args": "-Dvalidator_keys=ON" - } - ] - }, - "package_configs": { - "debian": [ - { - "compiler": ["gcc"], - "build_type": ["Release"], - "arch": ["amd64"], - "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-45e4b88", - "package_type": "deb" - } - ], - - "rhel": [ - { - "compiler": ["gcc"], - "build_type": ["Release"], - "arch": ["amd64"], - "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-45e4b88", - "package_type": "rpm" + "extra_cmake_args": "-Dvalidator_keys=ON", + "package": { + "type": "rpm", + "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-45e4b88" + } } ] } diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index 4d1968b93c..aa9183be37 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -1,7 +1,7 @@ # Build Linux packages from the pre-built xrpld and validator-keys artifacts: # -# - one job per distro, taken from "package_configs" in linux.json -# - each entry names its container image and the format it builds there +# - one job per config that carries a "package" map in linux.json +# - that map names the container image and the format it builds there # - with 'publish: true' a job also uploads what it built # (see package/publish_pkg.py) # diff --git a/package/README.md b/package/README.md index bacd79efe5..8295a8a38e 100644 --- a/package/README.md +++ b/package/README.md @@ -23,16 +23,16 @@ package/ ## Prerequisites -Packaging targets and their container images are declared in -[`.github/scripts/strategy-matrix/linux.json`](../.github/scripts/strategy-matrix/linux.json) -under `package_configs`, one entry per distro. Today only `linux/amd64` is -emitted. Each entry pins its full container image in an `image` field; to move -to a new image, edit that field and both CI and local builds pick it up. The -entry also declares the format that image builds in a `package_type` field, -which CI passes to `build_pkg.py` as `--package-type`; the two have to stay in -step. +Packaging is declared on the build configs themselves, in +[`.github/scripts/strategy-matrix/linux.json`](../.github/scripts/strategy-matrix/linux.json): +a config that is also packaged carries a `package` map, so its binaries and its +packaging job cannot drift apart. Today only `linux/amd64` is emitted. The map +pins the full container image in `image` — edit that field to move to a new +image and both CI and local builds pick it up — and names the format that image +builds in `type`, which CI passes to `build_pkg.py` as `--package-type`; the two +have to stay in step. -| Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required | +| Package type | Image (`configs.[].package.image` in `linux.json`) | Tools required | | ------------ | ---------------------------------------------------------- | --------------------------------------------------- | | RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild`, `rpmsign` | | DEB | `ghcr.io/xrplf/xrpld/packaging-debian:sha-` | `dpkg-buildpackage`, debhelper with compat level 13 | @@ -50,19 +50,20 @@ To print the full packaging matrix (artifact names and images) for the current Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call `reusable-package.yml`. That workflow generates its own packaging matrix from -`package_configs` in `linux.json` (via `generate.py --packaging`) and fans out -one job per distro. Each job downloads the pre-built `xrpld` and `validator-keys` -binary artifacts and runs in that distro's container, building the format its -`package_type` declares. The packaging script derives the package version from -the downloaded binary's `xrpld --version` output; no CMake configure or build -step is needed inside the packaging job. +the configs that carry a `package` map (via `generate.py --packaging`) and fans +out one job per distro. Each job downloads the pre-built `xrpld` and +`validator-keys` binary artifacts and runs in that distro's container, building +the format `package.type` declares. The packaging script derives the package +version from the downloaded binary's `xrpld --version` output; no CMake +configure or build step is needed inside the packaging job. -The binaries come from the `debian` and `rhel` build configurations in -`linux.json`'s `configs` section, which pass `-Dvalidator_keys=ON` so that the +The binaries come from the `debian` and `rhel` build configs themselves — the +ones carrying the `package` map — which pass `-Dvalidator_keys=ON` so that the build job produces `validator-keys` next to `xrpld` and uploads it as the -`validator-keys-` artifact. The packaging entry for a distro names -both artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`), so a -packaged configuration must keep `-Dvalidator_keys=ON`. +`validator-keys-` artifact. The packaging matrix names both +artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`) after that +same config, so a packaged config must keep `-Dvalidator_keys=ON`. Those configs +are not `minimal`, so `on-pr.yml` only packages once a PR runs the full matrix. `validator-keys` is fetched from an exact commit pinned in [`cmake/XrplValidatorKeys.cmake`](../cmake/XrplValidatorKeys.cmake), so a given @@ -75,10 +76,10 @@ With `xrpld` and `validator-keys` binaries already built at `build/xrpld` and The image tag is derived from `linux.json` so you don't need to hardcode a SHA. ```bash -# From the repo root. Each distro's container image is the `image` field of its -# package_configs entry in linux.json. Example for the rpm-producing image (use -# .package_configs.debian[0].image and --package-type deb for the other one): -IMAGE=$(jq -r '.package_configs.rhel[0].image' .github/scripts/strategy-matrix/linux.json) +# From the repo root. Each distro's container image is the `package.image` field +# of its config in linux.json. Example for the rpm-producing image (use +# .configs.debian[0].package.image and --package-type deb for the other one): +IMAGE=$(jq -r '.configs.rhel[0].package.image' .github/scripts/strategy-matrix/linux.json) PKG_RELEASE=1 From 3c47af779ca7f3b988e6774462a131c0ce826690 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:02:05 +0000 Subject: [PATCH 05/40] fix: Clamp Vault Deposit, Withdraw, and Clawback to assetsTotal grid (#8057) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- include/xrpl/ledger/helpers/VaultHelpers.h | 60 ++- src/libxrpl/ledger/helpers/VaultHelpers.cpp | 61 +++ .../tx/transactors/vault/VaultClawback.cpp | 70 ++- .../tx/transactors/vault/VaultDeposit.cpp | 58 ++- .../tx/transactors/vault/VaultWithdraw.cpp | 118 ++++- src/test/app/vault/VaultBugs_test.cpp | 128 +++++ src/test/app/vault/VaultHelpers_test.cpp | 484 ++++++++++++++++++ src/test/app/vault/VaultPrecisionFixture.h | 16 +- src/test/app/vault/VaultScale_test.cpp | 111 ++++ .../vault/VaultTransactorPrecision_test.cpp | 357 +++++++++++++ 10 files changed, 1394 insertions(+), 69 deletions(-) create mode 100644 src/test/app/vault/VaultHelpers_test.cpp create mode 100644 src/test/app/vault/VaultTransactorPrecision_test.cpp diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index e4ed6de0ef..b42f349b95 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -10,6 +10,7 @@ #include #include +#include #include namespace xrpl { @@ -44,6 +45,32 @@ assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co [[nodiscard]] std::optional sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares); +/** + * Adjusts a requested asset change (`delta`) to match the decimal scale of the + * updated total vault assets. This ensures `sfAssetsTotal`, `sfAssetsAvailable`, + * and the actual asset transfer change by the exact same representable amount. + * + * Rounding strategy: + * - Debits (withdrawals): Rounds down `|delta|` on the new scale to prevent + * paying out more than requested. + * - Credits (deposits): Floors the resulting total asset balance and returns the + * difference from the current total. This prevents crediting the vault with + * more assets than the user deposited. + * + * Key rules: + * - The returned magnitude never exceeds `|delta|`. + * - Returns `tecPRECISION_LOSS` if the change is smaller than 1 ULP of the target scale + * (prevents share operations when totals cannot change). + * - For integer assets (XRP, MPT), rounding is a no-op. + * + * @param vault The vault ledger entry. + * @param delta The requested signed change to sfAssetsTotal. + * @return The rounded, positive magnitude, or `tecPRECISION_LOSS` if the + * change is below representable precision. + */ +[[nodiscard]] std::expected +clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta); + /** * Controls whether to truncate shares instead of rounding. */ @@ -59,33 +86,30 @@ enum class TruncateShares : bool { No = false, Yes = true }; enum class WaiveUnrealizedLoss : bool { No = false, Yes = true }; /** - * Returns the effective total of assets backing outstanding shares for the - * purposes of a withdrawal, i.e. sfAssetsTotal, discounted by sfLossUnrealized - * unless waived. This is the numerator used by both withdraw conversion - * helpers (assetsToSharesWithdraw and sharesToAssetsWithdraw) to compute the - * share/asset exchange rate. + * Returns the assets backing outstanding shares for a withdrawal: + * sfAssetsTotal minus sfLossUnrealized, or sfAssetsTotal alone when the + * unrealized loss is waived. Used by assetsToSharesWithdraw and + * sharesToAssetsWithdraw as the numerator of the share/asset exchange rate. * * @param vault The vault SLE. - * @param waive Whether to waive (i.e. not subtract) the vault's unrealized - * loss. + * @param waive Whether to skip subtracting the unrealized loss. */ [[nodiscard]] Number assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive); /** - * Returns whether debiting `amount` from `total` — the current value of a - * vault's sfAssetsTotal or sfAssetsAvailable field — would canonicalize back - * to the exact same STAmount value it started at. This happens when a - * genuinely non-zero debit is dust relative to a `total` large enough to - * exceed STAmount's significant-digit precision: the shares still move, but - * the stored total doesn't change, which otherwise trips the ValidVault - * invariant after the fact instead of failing cleanly upfront. + * Returns true if debiting `amount` from `total` (the current value of a + * vault's sfAssetsTotal or sfAssetsAvailable) would canonicalize to the + * same STAmount value. This happens when `amount` is non-zero but too small + * to change the stored total at STAmount's precision. Shares would still + * move, so the ValidVault invariant would fail after apply; callers use + * this to reject the transaction upfront instead. * - * @param asset The vault's underlying asset, used to canonicalize both sides - * the same way the ledger will when the field is stored. + * @param asset The vault's underlying asset, used to canonicalize both + * sides the same way the ledger will when the field is stored. * @param total The field's current value. - * @param amount The amount to debit. A value of zero always returns false; - * that case is rejected separately and unconditionally. + * @param amount The amount to debit. Zero always returns false; that case + * is rejected separately. */ [[nodiscard]] bool debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount); diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 7f4a7ac03c..941b94143d 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,7 @@ #include #include +#include #include #include @@ -69,6 +71,65 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co return assets; } +[[nodiscard]] std::expected +clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta) +{ + XRPL_ASSERT( + delta.asset() == vault->at(sfAsset), + "xrpl::clampToAssetsTotalScale : delta and vault asset match"); + + Asset const asset = vault->at(sfAsset); + + STAmount magnitude = delta.negative() ? -delta : delta; + if (asset.integral()) + { + return magnitude; + } + Number const assetsTotal = vault->at(sfAssetsTotal); + + // Calculate the scale after applying the delta using ToNearest rounding. + // This aligns the delta with scale checks used by vault invariants. + int const postScale = [&] { + NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest); + return scale(assetsTotal + delta, asset); + }(); + + STAmount actualDelta; + if (delta.negative()) + { + // For withdrawals (debits), floor the magnitude to the target scale + // to ensure exact grid alignment without paying out extra assets. + actualDelta = roundToScale(magnitude, postScale, Number::RoundingMode::Downward); + } + else + { + // For deposits (credits), derive actualDelta from the floored posterior total. + // This prevents grid alignment issues from crediting the vault more than deposited. + // + // Sum using Downward rounding so intermediate precision doesn't round up + // and exceed the original requested amount. + Number const posterior = [&] { + NumberRoundModeGuard const rg(Number::RoundingMode::Downward); + return assetsTotal + magnitude; + }(); + + Number const roundedPosterior = + roundToAsset(asset, posterior, postScale, Number::RoundingMode::Downward); + actualDelta = STAmount{asset, roundedPosterior - assetsTotal}; + } + + XRPL_ASSERT( + abs(actualDelta) <= abs(delta), + "xrpl::clampToAssetsTotalScale : actual delta smaller or equal to calculated delta"); + + // Reject changes below scale precision (1 ULP) to prevent share balance changes + // without corresponding asset movements. + if (actualDelta <= beast::kZero) + return std::unexpected(tecPRECISION_LOSS); + + return actualDelta; +} + [[nodiscard]] Number assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive) { diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp index c2c099f14e..aabd833c75 100644 --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp @@ -268,10 +268,13 @@ VaultClawback::assetsToClawback( STAmount sharesDestroyed; STAmount assetsRecovered; + // Number arithmetic can throw overflow_error when Scale and totals are large. Caught below. try { if (clawbackAmount == beast::kZero) { + // Zero amount means clawback all shares the holder has; derive the corresponding asset + // amount from the share balance. sharesDestroyed = accountHolds( view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_); auto const maybeAssets = @@ -302,13 +305,11 @@ VaultClawback::assetsToClawback( return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE assetsRecovered = *maybeAssets; } - // Clamp to maximum. + // Clamp assetsRecovered to sfAssetsAvailable, then re-derive shares and assets so the pair + // stays consistent. if (assetsRecovered > *assetsAvailable) { assetsRecovered = *assetsAvailable; - // Note, it is important to truncate the number of shares, - // otherwise the corresponding assets might breach the - // AssetsAvailable { auto const maybeShares = assetsToSharesWithdraw( vault, sleShareIssuance, assetsRecovered, TruncateShares::Yes); @@ -322,6 +323,8 @@ VaultClawback::assetsToClawback( if (!maybeAssets) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE assetsRecovered = *maybeAssets; + // Truncation should guarantee the invariant holds. If it does not, a conversion + // helper is broken; refuse rather than over-recover. if (assetsRecovered > *assetsAvailable) { // LCOV_EXCL_START @@ -330,6 +333,18 @@ VaultClawback::assetsToClawback( // LCOV_EXCL_STOP } } + + // Post-fixCleanup3_4_0: round the recovery down at the posterior sfAssetsTotal scale so all + // rails change by the same representable delta. sharesDestroyed is intentionally NOT + // re-derived here: the holder's shares are burned for their pre-clamp value, so any + // sub-ULP trimmed off stays in the vault for the remaining shareholders. + if (ctx_.view().rules().enabled(fixCleanup3_4_0) && assetsRecovered > beast::kZero) + { + auto const maybeClamped = clampToAssetsTotalScale(vault, -assetsRecovered); + if (!maybeClamped) + return std::unexpected(maybeClamped.error()); + assetsRecovered = *maybeClamped; + } } catch (std::overflow_error const&) { @@ -341,6 +356,8 @@ VaultClawback::assetsToClawback( << ", assetsTotal=" << vault->at(sfAssetsTotal).value() << ", sharesTotal=" << sleShareIssuance->at(sfOutstandingAmount) << ", amount=" << clawbackAmount.value(); + // Overflow means this transaction cannot apply, but ledger state is still consistent. + // Return tecPATH_DRY rather than a hard internal error. return std::unexpected(tecPATH_DRY); } @@ -394,21 +411,44 @@ VaultClawback::doApply() sharesDestroyed = clawbackParts->second; } + // The holder has no shares (or the recovery clamped to zero). Nothing to burn; refuse rather + // than modifying vault state. if (sharesDestroyed == beast::kZero) return tecPRECISION_LOSS; - // A recovered amount can be genuinely non-zero yet still be dust relative to a - // sfAssetsTotal/sfAssetsAvailable large enough to exceed STAmount's significant-digit - // precision: subtracting it below rounds the stored total right back to where it started. - // The shares still move, so ValidVault would fail after the fact with "clawback must - // decrease vault balance" instead of a clean upfront rejection. - if (view().rules().enabled(fixCleanup3_4_0) && - (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsRecovered) || - debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsRecovered))) + // Number arithmetic can throw overflow_error when Scale and totals are large. + if (view().rules().enabled(fixCleanup3_4_0)) { - JLOG(j_.debug()) << "VaultClawback: clawback amount too small to change stored vault" - " balance"; - return tecPRECISION_LOSS; + try + { + // A non-zero recovery can be too small to change the stored sfAssetsTotal at + // STAmount's precision. Shares would still be burned, reject it instead. + if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsRecovered)) + { + // LCOV_EXCL_START + JLOG(j_.debug()) + << "VaultClawback: clawback amount too small to change stored vault" + " balance"; + return tecPRECISION_LOSS; + // LCOV_EXCL_STOP + } + } + // LCOV_EXCL_START + catch (std::overflow_error const&) + { + // It's easy to hit this exception from Number with large enough Scale + // so we avoid spamming the log and only use debug here. + JLOG(j_.debug()) // + << "VaultClawback: overflow error with" + << " scale=" << (int)vault->at(sfScale).value() // + << ", assetsTotal=" << vault->at(sfAssetsTotal).value() + << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount) + << ", amount=" << amount.value(); + // Overflow means this transaction cannot apply, but ledger state is still + // consistent. Return tecPATH_DRY rather than a hard internal error. + return tecPATH_DRY; + } + // LCOV_EXCL_STOP } assetsTotal -= assetsRecovered; diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index 27e590338c..adb8b3f8f2 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -307,6 +307,8 @@ VaultDeposit::doApply() } STAmount sharesCreated = {vault->at(sfShareMPTID)}, assetsDeposited; + + // Number arithmetic can throw overflow_error when Scale and totals are large. Caught below. try { // Compute exchange before transferring any amounts. @@ -316,14 +318,20 @@ VaultDeposit::doApply() return tecINTERNAL; // LCOV_EXCL_LINE sharesCreated = *maybeShares; } + if (sharesCreated == beast::kZero) return tecPRECISION_LOSS; + // Convert shares back to assets so the depositor is debited for the amount actually minted. + // The truncated share count is worth <= amount; without this the difference would be + // credited to the vault for free. auto const maybeAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated); if (!maybeAssets) { return tecINTERNAL; // LCOV_EXCL_LINE } + // The round-trip must never return more than the original amount. If it does, a conversion + // helper is broken. Reject rather than overcharge the depositor. if (*maybeAssets > amount) { // LCOV_EXCL_START @@ -331,13 +339,51 @@ VaultDeposit::doApply() return tecINTERNAL; // LCOV_EXCL_STOP } - // What a deposit transfers is not the requested amount but that amount truncated to a - // whole number of shares and converted back, which can be smaller. Only here is that - // value known rather than recomputed, so this is where it can be checked against the - // depositor's balance before anything moves. - if (fix340Enabled && roundsToZeroForDepositor(view(), accountID_, *maybeAssets, j_)) - return tecPRECISION_LOSS; assetsDeposited = *maybeAssets; + + // Post-fixCleanup3_4_0: round the deposit to the sfAssetsTotal scale so all accounting + // fields (trust line / MPT, sfAssetsAvailable, sfAssetsTotal) change by the same + // representable delta. + if (fix340Enabled) + { + // Round down at the posterior sfAssetsTotal scale so the vault is credited by no more + // than the depositor paid. + auto const maybeClamped = clampToAssetsTotalScale(vault, assetsDeposited); + if (!maybeClamped) + return maybeClamped.error(); + assetsDeposited = *maybeClamped; + + // The pre-clamp share count would over-issue by the trimmed ULP and give the depositor + // more value than they credited. + auto const maybeReShares = assetsToSharesDeposit(vault, sleIssuance, assetsDeposited); + if (!maybeReShares) + return tecINTERNAL; // LCOV_EXCL_LINE + + sharesCreated = *maybeReShares; + + if (sharesCreated == beast::kZero) + return tecPRECISION_LOSS; + + // The re-derived share count would over-issue if it round-trips back to more assets + // than the clamped amount actually paid. Unreachable unless a conversion helper is + // broken. + // LCOV_EXCL_START + auto const maybeReAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated); + if (!maybeReAssets) + return tecINTERNAL; + if (*maybeReAssets > assetsDeposited) + { + JLOG(j_.error()) << "VaultDeposit: would take more than offered."; + return tecINTERNAL; + } + // LCOV_EXCL_STOP + + // The actual deposit amount is truncated to whole shares, converted back to assets, + // and clamped to the sfAssetsTotal scale (post-fixCleanup3_4_0). Check the depositor's + // balance here—after clamping—before making any state changes. + if (roundsToZeroForDepositor(view(), accountID_, assetsDeposited, j_)) + return tecPRECISION_LOSS; + } } catch (std::overflow_error const&) { diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 9e066304ed..697612af3e 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -267,6 +268,7 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) TER VaultWithdraw::doApply() { + bool const fix340Enabled = view().rules().enabled(fixCleanup3_4_0); auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID])); auto applyViewContext = ctx_.getApplyViewContext(); if (!vault) @@ -300,6 +302,7 @@ VaultWithdraw::doApply() // We waive the unrealized-loss subtraction in this case to avoid user withdrawing all of their // shares but keeping future value in the vault. auto const waiveUnrealizedLoss = shouldWaiveWithdrawal(view(), accountID_, sleIssuance); + // Number arithmetic can throw overflow_error when Scale and totals are large. Caught below. try { if (amount.asset() == vaultAsset) @@ -324,8 +327,12 @@ VaultWithdraw::doApply() sharesRedeemed = *maybeShares; } + // Shares are MPT (integer). Small requested amounts truncate to zero; refuse rather + // than burn nothing while paying out assets. if (sharesRedeemed == beast::kZero) return tecPRECISION_LOSS; + // Convert shares back to assets so the payout matches the shares actually burned, not + // the requested amount. The extra would otherwise be paid from the vault for free. auto const maybeAssets = sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss); if (!maybeAssets) @@ -334,7 +341,8 @@ VaultWithdraw::doApply() } else if (amount.asset() == share) { - // Fixed shares, variable assets. + // Fixed shares, variable assets. No round-trip: the share count is exactly what the + // caller specified; only the payout amount is derived. sharesRedeemed = amount; auto const maybeAssets = sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss); @@ -357,6 +365,8 @@ VaultWithdraw::doApply() << ", assetsTotal=" << vault->at(sfAssetsTotal).value() << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount) << ", amount=" << amount.value(); + // Overflow means this transaction cannot apply, but ledger state is still consistent. + // Return tecPATH_DRY rather than a hard internal error. return tecPATH_DRY; } @@ -369,12 +379,11 @@ VaultWithdraw::doApply() auto assetsTotal = vault->at(sfAssetsTotal); auto const lossUnrealized = vault->at(sfLossUnrealized); - if (view().rules().enabled(fixCleanup3_4_0) && !isFinalWithdrawal) + if (fix340Enabled && !isFinalWithdrawal) { - // A withdrawal for a fixed share amount (variable assets) has no requested-asset - // amount to check for rounding, unlike the fixed-assets branch above: a small enough - // share amount can round down to an exact zero even though the vault still holds - // positive effective value backing outstanding shares. + // Fixed-shares path: a small share count can round to zero assets even though the vault has + // backing value. Reject rather than burn shares for a zero payout. The fixed-assets branch + // above has already rejected zero via the sharesRedeemed check. if (amount.asset() == share && assetsWithdrawn == beast::kZero && assetsTotalForWithdrawal(vault, waiveUnrealizedLoss) != beast::kZero) { @@ -382,17 +391,34 @@ VaultWithdraw::doApply() return tecPRECISION_LOSS; } - // assetsWithdrawn can also be genuinely non-zero and still too small to move - // sfAssetsTotal or sfAssetsAvailable once canonicalized to STAmount's precision. Either - // way the shares still move, so ValidVault would otherwise fail after the fact instead - // of a clean upfront rejection. - if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsWithdrawn) || - debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsWithdrawn)) + // Number arithmetic can throw overflow_error when Scale and totals are large. + try { - JLOG(j_.debug()) << "VaultWithdraw: withdrawal amount too small to change stored" - " vault balance"; - return tecPRECISION_LOSS; + // A non-zero payout can be too small to change the stored sfAssetsTotal at + // STAmount's precision. Shares would still be burned, reject it instead. + if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsWithdrawn)) + { + JLOG(j_.debug()) << "VaultWithdraw: withdrawal amount too small to change stored" + " vault balance"; + return tecPRECISION_LOSS; + } } + // LCOV_EXCL_START + catch (std::overflow_error const&) + { + // It's easy to hit this exception from Number with large enough Scale + // so we avoid spamming the log and only use debug here. + JLOG(j_.debug()) // + << "VaultWithdraw: overflow error with" + << " scale=" << (int)vault->at(sfScale).value() // + << ", assetsTotal=" << vault->at(sfAssetsTotal).value() + << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount) + << ", amount=" << amount.value(); + // Overflow means this transaction cannot apply, but ledger state is still consistent. + // Return tecPATH_DRY rather than a hard internal error. + return tecPATH_DRY; + } + // LCOV_EXCL_STOP } // Post-fixCleanup3_3_0: preclaim already validated all freeze conditions @@ -409,6 +435,54 @@ VaultWithdraw::doApply() return tecINSUFFICIENT_FUNDS; } + // Post-fixCleanup3_4_0: round the payout to the sfAssetsTotal scale so all three rails + // (trust line / MPT, sfAssetsAvailable, sfAssetsTotal) change by the same representable delta. + // Skip when assetsWithdrawn is already zero: the earlier fix340 guard above deliberately + // permits fixed-share zero-asset withdrawals in a fully-impaired vault (where + // assetsTotalForWithdrawal == 0), and clamping-then-rejecting would undo that. Also skip on + // the final-withdrawal path, which overwrites assetsWithdrawn with sfAssetsAvailable below. + if (fix340Enabled && !isFinalWithdrawal && assetsWithdrawn > beast::kZero) + { + // Check availability against the unclamped amount first, so a withdrawal that is both + // over the vault's available balance and sub-ULP at the posterior sfAssetsTotal scale + // reports tecINSUFFICIENT_FUNDS rather than tecPRECISION_LOSS. The clamp below only ever + // shrinks assetsWithdrawn, so this check stays valid; the post-clamp check further down + // remains in place to catch the (now smaller) clamped value too. + if (*assetsAvailable < assetsWithdrawn) + { + JLOG(j_.debug()) << "VaultWithdraw: vault doesn't hold enough assets"; + return tecINSUFFICIENT_FUNDS; + } + + // Number arithmetic can throw overflow_error when Scale and totals are large. + try + { + // Round down at the posterior sfAssetsTotal scale so the payout never exceeds the + // value represented by the redeemed shares. sharesRedeemed is intentionally not + // re-derived: any trimmed residue stays with remaining shareholders. + auto const maybeClamped = clampToAssetsTotalScale(vault, -assetsWithdrawn); + if (!maybeClamped) + return maybeClamped.error(); // LCOV_EXCL_LINE + assetsWithdrawn = *maybeClamped; + } + // LCOV_EXCL_START + catch (std::overflow_error const&) + { + // It's easy to hit this exception from Number with large enough Scale + // so we avoid spamming the log and only use debug here. + JLOG(j_.debug()) // + << "VaultWithdraw: overflow error with" + << " scale=" << (int)vault->at(sfScale).value() // + << ", assetsTotal=" << vault->at(sfAssetsTotal).value() + << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount) + << ", amount=" << amount.value(); + // Overflow means this transaction cannot apply, but ledger state is still consistent. + // Return tecPATH_DRY rather than a hard internal error. + return tecPATH_DRY; + } + // LCOV_EXCL_STOP + } + // The vault must have enough assets on hand. if (*assetsAvailable < assetsWithdrawn) { @@ -416,14 +490,12 @@ VaultWithdraw::doApply() return tecINSUFFICIENT_FUNDS; } - // Post-fixCleanup3_2_0 "final withdrawal" rule: - // a transaction that would burn every outstanding share is only permitted when the vault is in - // a clean state — no outstanding receivables and no unrealized loss. Otherwise the resulting - // (shares == 0, assetsTotal > 0) state would violate the zero-sized-vault invariant. + // Post-fixCleanup3_2_0: burning every outstanding share is only allowed when the vault has no + // unrealized loss. Otherwise the resulting (shares == 0, assetsTotal > 0) state would violate + // the zero-sized-vault invariant. // - // When the rule applies, the payout is the remaining sfAssetsAvailable; in a clean vault - // the helper result should already equal that value, and any mismatch is a rounding artifact - // worth logging. + // The payout is set to the remaining sfAssetsAvailable. The helper result should already + // equal that value in a clean vault; any mismatch is a rounding artifact and is logged. if (view().rules().enabled(fixCleanup3_2_0) && isFinalWithdrawal) { // Unreachable: a final withdrawal with lossUnrealized > 0 has @@ -457,6 +529,8 @@ VaultWithdraw::doApply() } else { + // Debit both rails by the same delta so sfAssetsTotal and sfAssetsAvailable stay in step, + // as required by the ValidVault invariant. assetsTotal -= assetsWithdrawn; assetsAvailable -= assetsWithdrawn; } diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index e3e0c40bc2..0ecf6f0e2a 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include // IWYU pragma: keep #include #include #include @@ -425,6 +426,9 @@ private: testcase( "bug: VaultDeposit below Vault precision canonicalized to zero " "(pre-fixCleanup3_2_0)"); + // Also remove fixCleanup3_4_0 so the VaultDeposit clamp + // introduced by that amendment does not short-circuit this + // pre-fixCleanup3_2_0 scenario with tecPRECISION_LOSS. runScenario( testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0, tecINVARIANT_FAILED); } @@ -806,6 +810,128 @@ private: } } + // Scale 15 seed + deposit 5: pre-fix credited > paid; post-fix credited <= paid. + // fixCleanup3_2_0 is off so roundToVaultScale does not shrink the deposit first. + void + testBugVaultDepositOvercreditsAcrossScaleBoundary() + { + using namespace test::jtx; + + auto runScenario = [this](FeatureBitset features, bool expectOvercredit) { + Env env(*this, features); + Account const owner{"owner"}; + Account const issuer{"issuer"}; + Account const depositor{"depositor"}; + env.fund(XRP(1'000'000), owner, issuer, depositor); + env.close(); + + PrettyAsset const usd{issuer["USD"]}; + Number const seed{9'999'999'999'999'999LL, -15}; + Number const deposit{5}; + + env(trust(depositor, usd(1'000'000'000))); + env.close(); + env(pay(issuer, depositor, usd(deposit))); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = usd.raw()}); + tx[sfScale] = 15; + env(tx); + env.close(); + env(vault.deposit({.depositor = issuer, .id = keylet.key, .amount = usd(seed)})); + env.close(); + + Number const totalBefore = env.le(keylet)->at(sfAssetsTotal); + Number const depositorBefore = env.balance(depositor, usd.raw()).number(); + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = usd(deposit)})); + env.close(); + + Number const totalAfter = env.le(keylet)->at(sfAssetsTotal); + Number const depositorAfter = env.balance(depositor, usd.raw()).number(); + Number const paid = depositorBefore - depositorAfter; + Number const credited = totalAfter - totalBefore; + + if (expectOvercredit) + { + BEAST_EXPECTS( + credited > paid, + "AssetsTotal credited " + to_string(credited) + " for a payment of " + + to_string(paid) + ", expected an overcredit"); + } + else + { + BEAST_EXPECTS( + credited <= paid, + "AssetsTotal credited " + to_string(credited) + " for a payment of " + + to_string(paid)); + } + }; + + testcase( + "bug: VaultDeposit overcredits across an IOU scale boundary " + "(pre-fixCleanup3_4_0)"); + runScenario(all_ - fixCleanup3_2_0 - fixCleanup3_4_0, true); + + testcase( + "bug: VaultDeposit no longer overcredits across an IOU scale boundary " + "(post-fixCleanup3_4_0)"); + runScenario(all_, false); + } + + // 1e17 IOU at scale 0. Withdraw all-but-one, then the last share: + // pre-fix tecINVARIANT_FAILED, post-fix tesSUCCESS. + void + testBugVaultLockedByPartialWithdraw() + { + using namespace test::jtx; + + auto runScenario = [this](FeatureBitset features, TER expected) { + Env env(*this, features); + Account const owner{"owner"}; + Account const issuer{"issuer"}; + Account const holder{"holder"}; + env.fund(XRP(1'000'000), owner, issuer, holder); + env.close(); + + PrettyAsset const usd{issuer["USD"]}; + env(trust(holder, usd(Number{1, 18}))); + env.close(); + env(pay(issuer, holder, usd(Number{1, 17}))); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = usd.raw()}); + tx[sfScale] = 0; + env(tx); + env.close(); + env(vault.deposit( + {.depositor = holder, .id = keylet.key, .amount = usd(Number{1, 17})})); + env.close(); + + MPTIssue const share{env.le(keylet)->at(sfShareMPTID)}; + std::int64_t const allButOne = 100'000'000'000'000'000LL - 1; + env(vault.withdraw( + {.depositor = holder, .id = keylet.key, .amount = STAmount{share, allButOne}})); + env.close(); + + env(vault.withdraw( + {.depositor = holder, .id = keylet.key, .amount = STAmount{share, 1}}), + Ter(expected)); + env.close(); + }; + + testcase( + "bug: VaultWithdraw permanently locks a large IOU vault " + "(pre-fixCleanup3_4_0)"); + runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED); + testcase( + "bug: VaultWithdraw no longer locks a large IOU vault " + "(post-fixCleanup3_4_0)"); + runScenario(all_, tesSUCCESS); + } + // VaultDeposit::preclaim uses accountHolds(..., SpendableHandling:: // shFULL_BALANCE), which for an IOU asset adds the counterparty's // LowLimit/HighLimit to the depositor's raw balance (TokenHelpers.cpp: @@ -1346,6 +1472,8 @@ public: testBugDepositShareTruncationSubUlp(); testVaultWithdrawCanonicalizeToZero(); testBugVaultDustDebitCanonicalizesToNoOp(); + testBugVaultDepositOvercreditsAcrossScaleBoundary(); + testBugVaultLockedByPartialWithdraw(); testVaultDepositNegativeBalanceFromOppositeLimit(); testCredentialPinsPseudoAccount(); testCredentialPinOverflow(); diff --git a/src/test/app/vault/VaultHelpers_test.cpp b/src/test/app/vault/VaultHelpers_test.cpp new file mode 100644 index 0000000000..d52b732a60 --- /dev/null +++ b/src/test/app/vault/VaultHelpers_test.cpp @@ -0,0 +1,484 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // IWYU pragma: keep +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +// True unit test of `clampToAssetsTotalScale`. The function under test only +// reads sfAsset and sfAssetsTotal from the vault SLE and never touches a +// ledger view or Rules, so a bare in-memory ltVAULT SLE is enough; there is +// no jtx::Env and no transaction submitted anywhere in this file. +// +// Number regime: this suite relies on the default thread_local Number +// mantissa range, which src/libxrpl/basics/Number.cpp initializes to +// Large330 (19-digit mantissa, post-fixCleanup3_3_0 cusp-rounding behavior): +// +// thread_local std::reference_wrapper Number::kRange = +// MantissaRange::Access::mantissaRange(MantissaRange::MantissaScale::Large330); +// +// Unlike transaction processing, this test never constructs a ledger `Rules` +// object, so `STAmount::operator=(Number const&)` always takes its +// `!getCurrentTransactionRules()` branch and calls `fromNumber`, independent +// of amendment state. testProbeLarge330Regime() below asserts directly on a +// value that only round-trips exactly under Large330, pinning the regime +// rather than merely asserting it by comment. +class VaultHelpers_test : public beast::unit_test::Suite +{ +private: + // A single row of the clampToAssetsTotalScale table. `assetsTotal` and + // `delta` must already be genuine, on-grid STAmount values for `asset`. + struct Case + { + char const* name = nullptr; + Number assetsTotal; + Number delta; + std::optional expected; // nullopt means tecPRECISION_LOSS + }; + + // Builds a bare ltVAULT SLE with only sfAsset and sfAssetsTotal set, + // mirroring what a transactor does: set the STNumber field, then call + // associateAsset() so it is quantized to the asset's STAmount grid, the + // same way VaultDeposit::doApply does for a real vault (see + // src/libxrpl/tx/transactors/vault/VaultDeposit.cpp). + static std::shared_ptr + makeVault(Asset const& asset, Number const& assetsTotal) + { + auto vault = std::make_shared(keylet::vault(uint256(1))); + vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset}); + vault->at(sfAssetsTotal) = assetsTotal; + associateAsset(*vault, asset); + return vault; + } + + // Runs every case in `cases` against `asset`, once per ambient rounding + // mode. The function must give the same answer under all four modes, + // and its answer must match the hand-derived `expected` value. + template + void + runCases(Asset const& asset, std::array const& cases) + { + std::array const modes{ + Number::RoundingMode::ToNearest, + Number::RoundingMode::Downward, + Number::RoundingMode::Upward, + Number::RoundingMode::TowardsZero}; + + for (auto const& c : cases) + { + testcase(c.name); + + auto const vault = makeVault(asset, c.assetsTotal); + BEAST_EXPECTS( + Number(vault->at(sfAssetsTotal)) == c.assetsTotal, + std::string(c.name) + + ": assetsTotal is not a genuine on-grid STAmount value (associateAsset " + "changed it)"); + + STAmount const delta{asset, c.delta}; + BEAST_EXPECTS( + Number(delta) == c.delta, + std::string(c.name) + ": delta is not a genuine on-grid STAmount value"); + + std::optional> reference; + for (auto const mode : modes) + { + NumberRoundModeGuard const rg(mode); + auto const result = clampToAssetsTotalScale(vault, delta); + + // The function must be insensitive to the caller's ambient + // rounding mode: every mode must agree with the first one + // tried. + if (!reference) + { + reference = result; + } + else + { + BEAST_EXPECTS( + result.has_value() == reference->has_value(), + std::string(c.name) + ": result depends on ambient rounding mode"); + if (result.has_value() && reference->has_value()) + { + BEAST_EXPECTS( + *result == **reference, + std::string(c.name) + ": value depends on ambient rounding mode"); + } + else if (!result.has_value() && !reference->has_value()) + { + BEAST_EXPECTS( + result.error() == reference->error(), + std::string(c.name) + ": error depends on ambient rounding mode"); + } + } + + if (!c.expected) + { + BEAST_EXPECTS( + !result.has_value(), + std::string(c.name) + ": expected tecPRECISION_LOSS, got success value " + + (result.has_value() ? result->getText() : std::string())); + if (!result.has_value()) + { + BEAST_EXPECTS( + result.error() == tecPRECISION_LOSS, + std::string(c.name) + ": expected tecPRECISION_LOSS, got " + + transToken(result.error())); + } + continue; + } + + STAmount const expected{asset, *c.expected}; + if (!BEAST_EXPECTS( + result.has_value(), + std::string(c.name) + ": expected success (" + expected.getText() + + "), got " + transToken(result.error()))) + { + continue; + } + + BEAST_EXPECTS( + *result == expected, + std::string(c.name) + ": expected " + expected.getText() + ", got " + + result->getText()); + + // The result must always be positive... + BEAST_EXPECT(Number(*result) > Number{0}); + + // ...and never larger in magnitude than the requested delta. + BEAST_EXPECT(abs(Number(*result)) <= abs(c.delta)); + + // For IOU rows, re-flooring the result on the posterior grid + // must be a no-op: the result is already exactly + // representable at that scale. + // + // For debits this holds directly at postScale, because the + // result IS `roundToScale(magnitude, postScale, Downward)` by + // construction. For credits the result is + // `roundedPosterior - assetsTotal`, where roundedPosterior + // sits exactly on the postScale grid but assetsTotal sits on + // its own (possibly finer) natural grid; the difference of a + // multiple of 10^postScale and a multiple of 10^assetsScale + // is only guaranteed exact at the FINER of the two scales. + // Row 7 below ("overcredit fix across a scale boundary") is + // exactly this case: assetsTotal's own scale (-15) is finer + // than postScale (-14), so checking exactness at postScale + // alone fails even though the implementation is correct. + if (!asset.integral()) + { + bool const isDebit = c.delta.mantissa() < 0; + Number const posterior = + isDebit ? c.assetsTotal - Number(*result) : c.assetsTotal + Number(*result); + int const postScale = scale(posterior, asset); + int const checkScale = + isDebit ? postScale : std::min(postScale, scale(c.assetsTotal, asset)); + STAmount const reFloored = + roundToScale(*result, checkScale, Number::RoundingMode::Downward); + BEAST_EXPECTS( + reFloored == *result, + std::string(c.name) + ": result " + result->getText() + + " is not exact on the posterior grid (scale " + + std::to_string(checkScale) + ")"); + } + } + } + } + + // Pins the Number mantissa regime this suite relies on. Under Large330, + // a 19-digit mantissa (max 10^19-1) is exact where a legacy 16-digit + // ("Small", max 10^16-1) regime would have to round it down to 16 + // significant digits, changing both mantissa and exponent. + void + testProbeLarge330Regime() + { + testcase("probe: default Number regime is Large330 (19-digit mantissa)"); + + BEAST_EXPECT(Number::getMantissaScale() == MantissaRange::MantissaScale::Large330); + + // std::numeric_limits::max(), 19 significant digits. + // This is already inside Large330's [10^18, 10^19-1] range, so + // constructing it is a no-op; under "Small" it would have to lose + // its low 3 digits. + Number const probe{9'223'372'036'854'775'807LL, 0}; + BEAST_EXPECT(probe.mantissa() == 9'223'372'036'854'775'807LL); + BEAST_EXPECT(probe.exponent() == 0); + } + + // ------------------------------------------------------------------- + // IOU debits (delta negative). + // ------------------------------------------------------------------- + void + testIouDebits(Asset const& iou) + { + std::array const cases{ + Case{ + // T = 1000000.000000005, delta = -1e-9. + // Posterior = 1000000.000000004, still 16 significant + // digits at exponent -9 (no rounding, no decade change). + // postScale = -9. magnitude 1e-9 has its own exponent -24 + // (finer than -9), so it must be actually floored: 1e-9 is + // exactly 1 ULP at scale -9, so flooring is a no-op. + .name = "IOU debit: on-grid, same decade", + .assetsTotal = Number{1'000'000'000'000'005LL, -9}, + .delta = Number{-1, -9}, + .expected = Number{1, -9}, + }, + Case{ + // T = 1000000, delta = -7.3e-10. + // Posterior = 999999.99999999927 exactly (17 significant + // digits: 15 nines, then "27"). Rounding to 16 digits + // (ToNearest) rounds the trailing "...92.7" up to + // "...93", giving mantissa 9999999999999993 at exponent + // -10 -- postScale = -10, ONE DIGIT FINER than the naive + // "posterior stays in T's decade at -9" guess, because + // subtracting anything positive from an exact power-of-ten + // total necessarily drops into the next lower decade + // (1000000 has 7 integer digits, 999999.x has 6). + // At scale -10 the ULP is 1e-10, and floor(7.3) = 7, so + // the debit is NOT sub-ULP: it floors to 7e-10, not to + // zero. See discrepancy note in the report. + .name = "IOU debit: sub-ULP at the naive scale, but not at the true postScale", + .assetsTotal = Number{1'000'000, 0}, + .delta = Number{-73, -11}, + .expected = Number{7, -10}, + }, + Case{ + // T = 1000000, delta = -5.3e-9. + // Posterior = 999999.9999999947 exactly -- this needs only + // 16 significant digits (14 nines, then "47"), so it is + // exactly representable with NO rounding at exponent -10. + // postScale = -10 (again one digit finer than T's own -9, + // for the same power-of-ten-boundary reason as the row + // above). At that grid 5.3e-9 is exactly 53 ULPs (integer), + // so it floors to itself, unchanged. + .name = "IOU debit: exact at the true (finer) postScale", + .assetsTotal = Number{1'000'000, 0}, + .delta = Number{-53, -10}, + .expected = Number{53, -10}, + }, + Case{ + // T = 1.000000000000000, delta = -7.3e-16. + // Posterior = 0.99999999999999927 exactly (17 significant + // digits: 15 nines then "27"). Rounding to 16 digits + // (ToNearest) gives mantissa 9999999999999993 at exponent + // -16 -- postScale = -16. At that grid, 7.3e-16 is 7.3 + // ULPs (not integral), so it floors to 7e-16, not to + // itself. See discrepancy note in the report. + .name = "IOU debit: decade-crossing debit, floored (not exact) at finer grid", + .assetsTotal = Number{1, 0}, + .delta = Number{-73, -17}, + .expected = Number{7, -16}, + }, + Case{ + // T = 1000000, delta = -999999.9999999999 (9.999999999999999e5). + // Posterior = 0.0000000001 = 1e-10 exactly. postScale is + // the exponent of 1e-10 as a canonical STAmount, i.e. -25 -- + // far finer than the magnitude's own exponent (-10). + // roundToScale short-circuits ("value.exponent() >= scale") + // and returns the magnitude unchanged. + .name = "IOU debit: near-total debit, unchanged (finer postScale than magnitude)", + .assetsTotal = Number{1'000'000, 0}, + .delta = Number{-9'999'999'999'999'999LL, -10}, + .expected = Number{9'999'999'999'999'999LL, -10}, + }, + }; + + runCases(iou, cases); + } + + // ------------------------------------------------------------------- + // IOU credits (delta positive). + // ------------------------------------------------------------------- + void + testIouCredits(Asset const& iou) + { + std::array const cases{ + Case{ + // T = 1000000, delta = +2e-9. Posterior = 1000000.000000002, + // exactly 16 significant digits at exponent -9 + // (postScale = -9, unchanged from T -- addition never + // crosses below the 1e6 boundary the way subtraction does). + // magnitude is already exact at that scale, so it passes + // through unchanged. + .name = "IOU credit: on-grid", + .assetsTotal = Number{1'000'000, 0}, + .delta = Number{2, -9}, + .expected = Number{2, -9}, + }, + Case{ + // T = 9.999999999999999, delta = +5. + // Exact posterior = 14.999999999999999 (17 significant + // digits: "14" then 15 nines). postScale is computed under + // ToNearest at the Number (19-digit) level: normalized + // mantissa 1499999999999999900 (exponent -17) divided by + // 1000 (to reach 16-digit IOU precision) gives + // 1499999999999999.9, which rounds UP to 1500000000000000 + // -- i.e. exactly 15, at exponent -14. postScale = -14. + // Downward-guarded posterior (exact, no rounding needed + // since 17 digits < 19): 14.999999999999999. Flooring THAT + // to 16 digits at scale -14 (Downward) gives + // 1499999999999999 * 10^-14 = 14.99999999999999 (postScale + // already matches the STAmount's own exponent, so no + // further roundToScale is applied). + // actualDelta = 14.99999999999999 - 9.999999999999999 + // = 4.999999999999991. + // This mirrors testBugVaultDepositOvercreditsAcrossScaleBoundary + // in VaultBugs_test.cpp (same seed/deposit values), which + // asserts post-fix `credited <= paid` rather than an exact + // number; this row pins the exact value. + .name = "IOU credit: overcredit fix across a scale boundary", + .assetsTotal = Number{9'999'999'999'999'999LL, -15}, + .delta = Number{5, 0}, + .expected = Number{4'999'999'999'999'991LL, -15}, + }, + Case{ + // Finding-1 regression: T = 1000000, delta = +9.999999999999999e-10. + // The exact sum needs ~25 significant digits (1000000 at + // position 6, delta's last digit at position -25), far + // beyond Number's 19-digit mantissa. + // + // postScale (computed under ToNearest): the digits of delta + // that land within the 19-digit window (positions -10..-12, + // "999") plus an all-nines remainder below position -12 + // round UP under ToNearest, carrying all the way through + // the intervening zeros: the sum rounds to exactly + // 1000000.000000001, i.e. postScale = -9. + // + // But the credit branch computes the *posterior* under a + // Downward guard, not ToNearest: positions -10..-12 stay + // "999" (no carry), giving posterior = 1000000.000000000999 + // exactly. Flooring that (Downward) to scale -9 truncates + // the "999" entirely, landing back on exactly 1000000 -- + // i.e. the same as T. actualDelta = 0 => tecPRECISION_LOSS. + // This is the ambient-rounding leak the Downward guard on + // the credit-side sum exists to close; this row is a + // regression test that the guard is doing its job. + .name = "IOU credit: Finding-1 regression, ToNearest sum would overcredit", + .assetsTotal = Number{1'000'000, 0}, + .delta = Number{9'999'999'999'999'999LL, -25}, + .expected = std::nullopt, + }, + Case{ + // Same shape as the row above, but delta = +9.995e-10 is a + // 19-digit half-even tie at the position-(-12) cusp: the + // remainder below the retained "999" digits is exactly + // 0.5 ULP, and ToNearest ties-to-even rounds the (odd) "9" + // up, carrying the same way. Downward-guarded posterior + // still truncates to "...000999" and floors back to T, so + // the outcome is identical: tecPRECISION_LOSS. + .name = "IOU credit: Finding-1 regression, 19-digit half-even tie", + .assetsTotal = Number{1'000'000, 0}, + .delta = Number{9'995, -13}, + .expected = std::nullopt, + }, + Case{ + // T = 0, delta = +3.7e-5. Posterior grid is delta's own + // scale (postScale = -20, the canonical exponent of + // 3.7e-5), so the magnitude is trivially unchanged. + .name = "IOU credit: zero-total vault", + .assetsTotal = Number{0}, + .delta = Number{37, -6}, + .expected = Number{37, -6}, + }, + Case{ + // T = 1000000, delta = +4e-10. Exact sum needs 17 + // significant digits (leading "1" at position 6, trailing + // "4" at position -10); rounding to 16 digits drops the "4" + // entirely (0.4 ULP at scale -9 rounds down under both + // ToNearest and Downward), so postScale = -9 and the + // Downward-guarded posterior floors straight back to T. + // actualDelta = 0 => tecPRECISION_LOSS. + .name = "IOU credit: sub-ULP credit", + .assetsTotal = Number{1'000'000, 0}, + .delta = Number{4, -10}, + .expected = std::nullopt, + }, + }; + + runCases(iou, cases); + } + + // ------------------------------------------------------------------- + // Integral assets (XRP, MPT): rounding is a no-op, magnitude is + // returned unchanged and positive regardless of delta's sign. This is + // a regression test for a signed-return bug: the function must not + // hand back a negative delta for a debit. + // ------------------------------------------------------------------- + void + testIntegralAssets(Asset const& mpt, Asset const& xrp) + { + std::array const mptCases{ + Case{ + .name = "MPT debit: magnitude is positive, not the signed delta", + .assetsTotal = Number{1'000'000}, + .delta = Number{-5}, + .expected = Number{5}, + }, + Case{ + .name = "MPT credit: unchanged", + .assetsTotal = Number{1'000'000}, + .delta = Number{7}, + .expected = Number{7}, + }, + }; + runCases(mpt, mptCases); + + std::array const xrpCases{ + Case{ + .name = "XRP debit: magnitude is positive, not the signed delta", + .assetsTotal = Number{100'000}, + .delta = Number{-3}, + .expected = Number{3}, + }, + Case{ + .name = "XRP credit: unchanged", + .assetsTotal = Number{100'000}, + .delta = Number{10}, + .expected = Number{10}, + }, + }; + runCases(xrp, xrpCases); + } + +public: + void + run() override + { + testProbeLarge330Regime(); + + test::jtx::Account const issuer{"issuer"}; + Issue const iou{toCurrency("USD"), issuer.id()}; + MPTIssue const mpt{makeMptID(1, issuer.id())}; + Issue const xrp = xrpIssue(); + + testIouDebits(iou); + testIouCredits(iou); + testIntegralAssets(mpt, xrp); + } +}; + +BEAST_DEFINE_TESTSUITE(VaultHelpers, app, xrpl); + +} // namespace xrpl diff --git a/src/test/app/vault/VaultPrecisionFixture.h b/src/test/app/vault/VaultPrecisionFixture.h index d1067e245d..22a3276fdf 100644 --- a/src/test/app/vault/VaultPrecisionFixture.h +++ b/src/test/app/vault/VaultPrecisionFixture.h @@ -35,14 +35,9 @@ namespace xrpl::test { // Shared fixture for VaultInvariantPrecision_test and // VaultTransactorPrecision_test. -// -// Layout: -// - A-1 (impairAndPaySibling=false): 1000 USD vault + one ordinary loan. -// assetsTotal ~= 1000.353..., assetsAvailable == 993, lossUnrealized == 0. -// - A-3 (impairAndPaySibling=true): add a second loan of principal 11, -// impair the first loan, and pay off the second in full. This drives -// the vault to the lossUnrealized == (assetsTotal - assetsAvailable) -// boundary where the loss invariant used to spuriously fire. +// impairAndPaySibling=false: 1000 USD vault and one ordinary loan. +// impairAndPaySibling=true: a second loan is impaired then a sibling is paid +// off, leaving lossUnrealized at assetsTotal - assetsAvailable. class VaultPrecisionFixture : public LoanTestBase { protected: @@ -84,11 +79,16 @@ protected: { Asset asset; MPTIssue share; + // The {} initializers are not redundant: Number's default constructor is explicit, so + // fields omitted from the designated initializer in read() below would otherwise fail + // copy-list-initialization. + // NOLINTBEGIN(readability-redundant-member-init) Number assetsTotal{}; // sfAssetsTotal Number assetsAvailable{}; // sfAssetsAvailable Number lossUnrealized{}; // sfLossUnrealized Number pseudo{}; // vault pseudo-account balance in the asset Number sharesTotal{}; // sfOutstandingAmount on the share MPT + // NOLINTEND(readability-redundant-member-init) }; static Numbers diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp index b2ce4a5abf..c2858a204d 100644 --- a/src/test/app/vault/VaultScale_test.cpp +++ b/src/test/app/vault/VaultScale_test.cpp @@ -898,6 +898,117 @@ private: BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(400)); } }); + + // peek() writes the open ledger only; do not close() before le(). + auto seedLargeTotal = [](Env& env, + Data& d, + Number const& total, + Number const& available, + std::uint64_t outstanding) { + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(100, 0))}); + env(tx); + env.close(); + d.peek([&](SLE& vault, SLE& shares) -> bool { + vault[sfAssetsTotal] = total; + vault[sfAssetsAvailable] = available; + shares[sfOutstandingAmount] = outstanding; + return true; + }); + }; + + auto expectVault = [this]( + Env& env, + Data const& d, + Number const& total, + Number const& available, + STAmount const& shareBalance) { + auto const sle = env.le(d.keylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsTotal) == total); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == available); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == shareBalance); + }; + + // T-6 is exact after the decade; recover 6. + testCase(0, [&, this](Env& env, Data d) { + testcase("Scale clawback uses posterior scale across decade boundary"); + + Number const midGridTotal{10000000000000005ll}; + Number const available{6}; + seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull); + + auto tx = + d.vault.clawback({.issuer = d.issuer, .id = d.keylet.key, .holder = d.depositor}); + env(tx, Ter(tesSUCCESS)); + expectVault(env, d, midGridTotal - available, Number(0), d.share(94)); + }); + + // T stays on the 10-asset grid; 6 is unrepresentable. + testCase(0, [&, this](Env& env, Data d) { + testcase("Scale clawback rejects amount below posterior scale"); + + Number const midGridTotal{12345678901234567ll}; + Number const available{6}; + seedLargeTotal(env, d, midGridTotal, available, 12345678901234567ull); + + auto tx = + d.vault.clawback({.issuer = d.issuer, .id = d.keylet.key, .holder = d.depositor}); + env(tx, Ter(tecPRECISION_LOSS)); + expectVault(env, d, midGridTotal, available, d.share(100)); + }); + + // A recovery larger than the anterior ULP also lands exactly on the finer posterior grid. + testCase(0, [&, this](Env& env, Data d) { + testcase("Scale clawback preserves exact posterior amount"); + + Number const midGridTotal{10000000000000005ll}; + Number const available{15}; + seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull); + + auto tx = + d.vault.clawback({.issuer = d.issuer, .id = d.keylet.key, .holder = d.depositor}); + env(tx, Ter(tesSUCCESS)); + expectVault(env, d, midGridTotal - available, Number(0), d.share(85)); + }); + + testCase(0, [&, this](Env& env, Data d) { + testcase("Scale deposit rejects amount below posterior scale"); + + Number const midGridTotal{10000000000000005ll}; + Number const available{100}; + seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull); + + auto const assetsBefore = env.balance(d.depositor, d.assets); + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(6))}); + env(tx, Ter(tecPRECISION_LOSS)); + expectVault(env, d, midGridTotal, available, d.share(100)); + BEAST_EXPECT(env.balance(d.depositor, d.assets) == assetsBefore); + }); + + testCase(0, [&, this](Env& env, Data d) { + testcase("Scale withdraw uses posterior scale across decade boundary"); + + Number const midGridTotal{10000000000000005ll}; + Number const available{100}; + seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull); + + auto const assetsBefore = env.balance(d.depositor, d.assets); + auto tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.share, Number(15))}); + env(tx, Ter(tesSUCCESS)); + expectVault(env, d, midGridTotal - Number(15), Number(85), d.share(85)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, assetsBefore.number() + Number(15))); + }); } void diff --git a/src/test/app/vault/VaultTransactorPrecision_test.cpp b/src/test/app/vault/VaultTransactorPrecision_test.cpp new file mode 100644 index 0000000000..e06c87ee68 --- /dev/null +++ b/src/test/app/vault/VaultTransactorPrecision_test.cpp @@ -0,0 +1,357 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +// With fixCleanup3_4_0, deposit/withdraw/clawback apply one amount on the +// sfAssetsTotal grid. These tests require T, A, and the pseudo-account to +// change by the same Number; the invariant suite still allows a one-unit gap. +class VaultTransactorPrecision_test : public VaultPrecisionFixture +{ + jtx::Env + makeEnv() + { + return jtx::Env{*this, jtx::envconfig(), all_, nullptr, beast::Severity::Disabled}; + } + + bool + ready(Fixture const& f) + { + return BEAST_EXPECT(f.asset && f.broker) && f.asset; + } + + void + assertEqualDeltas(Numbers const& before, Numbers const& after, std::string const& tag) + { + Number const tDelta = before.assetsTotal - after.assetsTotal; + Number const aDelta = before.assetsAvailable - after.assetsAvailable; + Number const pDelta = before.pseudo - after.pseudo; + BEAST_EXPECTS(tDelta == aDelta, tag + " tDelta != aDelta"); + BEAST_EXPECTS(tDelta == pDelta, tag + " tDelta != pDelta"); + } + + void + testDeposit() + { + using namespace jtx; + + testcase("deposit clamp does not over-credit"); + + std::array const kAmounts{1, 7, 1'000, 10'000'000}; + + for (auto const amount : kAmounts) + { + Env env = makeEnv(); + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false); + if (!ready(f)) + continue; + // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + jtx::PrettyAsset const& asset = f.asset.value(); + + auto const before = read(env, f); + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(amount).value()}), + Ter(std::ignore)); + env.close(); + + if (env.ter() != tesSUCCESS) + continue; + + auto const after = read(env, f); + Number const tDelta = after.assetsTotal - before.assetsTotal; + Number const requested = asset(amount).number(); + BEAST_EXPECTS( + tDelta <= requested, + "amount=" + std::to_string(amount) + " tDelta exceeds requested"); + + Number const sharesMinted = after.sharesTotal - before.sharesTotal; + if (before.sharesTotal == Number{0}) + continue; + Number const shareValue = (before.assetsTotal * sharesMinted) / before.sharesTotal; + BEAST_EXPECTS( + shareValue <= tDelta, + "amount=" + std::to_string(amount) + " shareValue > assetsTaken"); + } + + { + Env env = makeEnv(); + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false); + if (!ready(f)) + return; + // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + jtx::PrettyAsset const& asset = f.asset.value(); + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(99'000'000).value()}), + Ter(std::ignore)); + env.close(); + + auto const before = read(env, f); + Number const kLowerBound{1, 6}; + BEAST_EXPECT(before.assetsTotal > kLowerBound); + + auto const tinyAmount = asset(Number{1, -10}).value(); + env(v.deposit( + {.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = tinyAmount}), + Ter(std::ignore)); + env.close(); + + BEAST_EXPECTS( + env.ter() == tecPRECISION_LOSS, + std::string{"expected tecPRECISION_LOSS, got "} + transToken(env.ter())); + + auto const after = read(env, f); + BEAST_EXPECT(after.assetsTotal == before.assetsTotal); + BEAST_EXPECT(after.assetsAvailable == before.assetsAvailable); + BEAST_EXPECT(after.sharesTotal == before.sharesTotal); + } + } + + void + testWithdraw() + { + using namespace jtx; + + testcase("withdraw deltas are equal"); + + Env env = makeEnv(); + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false); + if (!ready(f)) + return; + // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + jtx::PrettyAsset const& asset = f.asset.value(); + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(1'000'000).value()}), + Ter(std::ignore)); + env.close(); + + auto checkSuccess = [&](STAmount const& amount, std::string const& tag) { + auto const before = read(env, f); + env(v.withdraw({.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = amount}), + Ter(std::ignore)); + env.close(); + if (env.ter() != tesSUCCESS) + return; + + auto const after = read(env, f); + assertEqualDeltas(before, after, tag); + + Number const sharesBurned = before.sharesTotal - after.sharesTotal; + if (before.sharesTotal == Number{0}) + return; + Number const shareValue = (before.assetsTotal * sharesBurned) / before.sharesTotal; + Number const tDelta = before.assetsTotal - after.assetsTotal; + BEAST_EXPECTS(tDelta <= shareValue, tag + " payout > shareValue"); + }; + + std::array const kShareCounts{99'999u, 333'333u, 1'234'567u}; + for (auto const count : kShareCounts) + { + auto const before = read(env, f); + if (before.sharesTotal < count) + continue; + STAmount const shareAmount{MPTIssue{f.share}, Number{static_cast(count)}}; + checkSuccess(shareAmount, "shares=" + std::to_string(count)); + } + + std::array const kAssetAmounts{1, 7, 99}; + for (auto const amount : kAssetAmounts) + checkSuccess(asset(amount).value(), "assets=" + std::to_string(amount)); + } + + // Withdraw more than sfAssetsAvailable must return tecINSUFFICIENT_FUNDS, + // not tecPRECISION_LOSS. + void + testWithdrawInsufficientFundsPrecedence() + { + using namespace jtx; + + testcase("withdraw over available returns insufficient funds, not precision loss"); + + Env env = makeEnv(); + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false); + if (!ready(f)) + return; + // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + jtx::PrettyAsset const& asset = f.asset.value(); + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(1'000'000).value()}), + Ter(std::ignore)); + env.close(); + + auto const before = read(env, f); + if (!BEAST_EXPECT(before.assetsAvailable > Number{0})) + return; + + STAmount const request = asset(before.assetsAvailable + Number{1}).value(); + env(v.withdraw({.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = request}), + Ter(std::ignore)); + env.close(); + + BEAST_EXPECTS( + env.ter() == tecINSUFFICIENT_FUNDS, + std::string{"expected tecINSUFFICIENT_FUNDS, got "} + transToken(env.ter())); + } + + void + testClawback() + { + using namespace jtx; + + testcase("clawback deltas are equal"); + + Env env = makeEnv(); + auto f = setupSingleLoanVault( + env, + /*impairAndPaySibling=*/false, + /*allowClawback=*/true); + if (!ready(f)) + return; + // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + jtx::PrettyAsset const& asset = f.asset.value(); + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(2'000).value()}), + Ter(std::ignore)); + env.close(); + + auto checkSuccess = [&](std::optional const& amount, std::string const& tag) { + auto const before = read(env, f); + if (before.sharesTotal == Number{0}) + return; + + env(v.clawback( + {.issuer = f.issuer, + .id = f.vaultKeylet.key, + .holder = f.depositor, + .amount = amount}), + Ter(std::ignore)); + env.close(); + if (env.ter() != tesSUCCESS) + return; + + assertEqualDeltas(before, read(env, f), tag); + }; + + std::array const kAmounts{1, 7, 99}; + for (auto const amount : kAmounts) + checkSuccess(asset(amount).value(), "amount=" + std::to_string(amount)); + + checkSuccess(std::nullopt, "sfAmount absent"); + } + + void + testImpairedVault() + { + using namespace jtx; + + testcase("impaired vault loss stays within assetsTotal - assetsAvailable"); + + Env env = makeEnv(); + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true); + if (!ready(f)) + return; + // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + jtx::PrettyAsset const& asset = f.asset.value(); + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(5'000).value()}), + Ter(std::ignore)); + env.close(); + + auto checkInvariant = [&](std::string const& tag) { + TER const actual = env.ter(); + BEAST_EXPECTS(actual != tecINVARIANT_FAILED, tag + " unexpected invariant failure"); + if (actual != tesSUCCESS) + return; + auto const after = read(env, f); + BEAST_EXPECTS( + after.lossUnrealized <= after.assetsTotal - after.assetsAvailable, + tag + " lossUnrealized exceeds assetsTotal - assetsAvailable"); + }; + + std::array const kAmounts{1, 7, 51, 137}; + for (std::size_t i = 0; i + 1 < kAmounts.size(); i += 2) + { + int const depositAmount = kAmounts[i]; + int const withdrawAmount = kAmounts[i + 1]; + + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(depositAmount).value()}), + Ter(std::ignore)); + env.close(); + checkInvariant("deposit=" + std::to_string(depositAmount)); + + env(v.withdraw( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(withdrawAmount).value()}), + Ter(std::ignore)); + env.close(); + checkInvariant("withdraw=" + std::to_string(withdrawAmount)); + } + } + +public: + void + run() override + { + testDeposit(); + testWithdraw(); + testWithdrawInsufficientFundsPrecedence(); + testClawback(); + testImpairedVault(); + } +}; + +BEAST_DEFINE_TESTSUITE(VaultTransactorPrecision, app, xrpl); + +} // namespace xrpl::test From 1e8b136bfb6c3d43ef2e5cbf7daebc51360ceb06 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:35:54 +0000 Subject: [PATCH 06/40] feat: Enable LendingProtocolV1_1 amendment (#8125) --- include/xrpl/protocol/detail/features.macro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index de02fed7d8..ae696a4ea4 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -18,7 +18,7 @@ XRPL_FIX (Cleanup3_4_0, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(Sponsor, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(BatchV1_1, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FEATURE(LendingProtocolV1_1, Supported::No, VoteBehavior::DefaultNo) +XRPL_FEATURE(LendingProtocolV1_1, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(ConfidentialTransfer, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_3_0, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_2_0, Supported::Yes, VoteBehavior::DefaultNo) From dc3bd9cf00b5a6d959a50490517906a6a0e0dc35 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Wed, 26 Aug 2026 18:09:46 +0000 Subject: [PATCH 07/40] fix: Fix MPT/DEX Audit/Attackathon reports (Phase 1) (#7334) --- include/xrpl/ledger/helpers/TokenHelpers.h | 8 + include/xrpl/protocol/MPTAmount.h | 14 + src/libxrpl/ledger/helpers/TokenHelpers.cpp | 10 +- src/libxrpl/protocol/Indexes.cpp | 27 +- src/libxrpl/tx/paths/BookStep.cpp | 17 +- src/libxrpl/tx/paths/MPTEndpointStep.cpp | 189 ++-- .../tx/transactors/dex/AMMWithdraw.cpp | 23 +- .../tx/transactors/dex/OfferCreate.cpp | 22 +- src/test/app/AMMMPT_test.cpp | 61 ++ src/test/app/FlowMPT_test.cpp | 244 +++++ src/test/app/OfferMPT_test.cpp | 902 +++++++++++++++++- src/test/app/PathMPT_test.cpp | 222 +++++ src/test/app/Path_test.cpp | 62 +- src/test/jtx/impl/mpt.cpp | 2 +- src/test/rpc/BookChanges_test.cpp | 212 ++++ src/test/rpc/LedgerRPC_test.cpp | 99 ++ src/xrpld/app/ledger/detail/LedgerToJson.cpp | 1 + src/xrpld/app/misc/NetworkOPs.cpp | 77 +- src/xrpld/rpc/BookChanges.h | 54 +- src/xrpld/rpc/detail/AccountAssets.cpp | 8 +- src/xrpld/rpc/detail/MPT.h | 13 +- src/xrpld/rpc/detail/PathRequest.cpp | 37 +- src/xrpld/rpc/detail/Pathfinder.cpp | 11 +- 23 files changed, 2142 insertions(+), 173 deletions(-) diff --git a/include/xrpl/ledger/helpers/TokenHelpers.h b/include/xrpl/ledger/helpers/TokenHelpers.h index 501101136a..5153b43cb2 100644 --- a/include/xrpl/ledger/helpers/TokenHelpers.h +++ b/include/xrpl/ledger/helpers/TokenHelpers.h @@ -294,6 +294,14 @@ accountFunds( AuthHandling authHandling, beast::Journal j); +/** + * Returns the transfer fee as Rate based on the type of token + * @param view The ledger view + * @param asset The asset being transferred + */ +[[nodiscard]] Rate +transferRate(ReadView const& view, Asset const& asset); + /** * Returns the transfer fee as Rate based on the type of token * @param view The ledger view diff --git a/include/xrpl/protocol/MPTAmount.h b/include/xrpl/protocol/MPTAmount.h index 462092f7dd..68a7926256 100644 --- a/include/xrpl/protocol/MPTAmount.h +++ b/include/xrpl/protocol/MPTAmount.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -174,4 +175,17 @@ mulRatio(MPTAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundU return MPTAmount(r.convert_to()); } +inline std::optional +tryMulRatio(MPTAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundUp) +{ + try + { + return mulRatio(amt, num, den, roundUp); + } + catch (std::overflow_error const&) + { + return std::nullopt; + } +} + } // namespace xrpl diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp index 7ebfa64bcf..aaf99a3c0a 100644 --- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp @@ -535,13 +535,19 @@ accountFunds( } Rate -transferRate(ReadView const& view, STAmount const& amount) +transferRate(ReadView const& view, Asset const& asset) { - return amount.asset().visit( + return asset.visit( [&](Issue const& issue) { return transferRate(view, issue.getIssuer()); }, [&](MPTIssue const& issue) { return transferRate(view, issue.getMptID()); }); } +Rate +transferRate(ReadView const& view, STAmount const& amount) +{ + return transferRate(view, amount.asset()); +} + //------------------------------------------------------------------------------ // // Holding operations diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 66fdfd453b..91ed5c893f 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -123,6 +123,10 @@ getBookBase(Book const& book) { XRPL_ASSERT(isConsistent(book), "xrpl::getBookBase : input is consistent"); + constexpr std::uint8_t kIssueToMPTTag = 0x01; + constexpr std::uint8_t kMPTToIssueTag = 0x02; + constexpr std::uint8_t kMPTToMPTTag = 0x03; + auto getIndexHash = [&book](Args... args) { if (book.domain) return indexHash(std::forward(args)..., *book.domain); @@ -136,19 +140,36 @@ getBookBase(Book const& book) return getIndexHash( LedgerNameSpace::BookDir, in.currency, out.currency, in.account, out.account); } + // The three MPT-involving branches are new under MPTokensV2 and + // each gets a 1-byte discriminator to prevent preimage collisions + // between branches: the (Issue,MPT) and (MPT,Issue) preimages + // are both 64 bytes of raw concatenation, so without a + // per-branch tag chosen Currency / MPTID / AccountID values can + // align byte-for-byte and produce the same BookDir keylet for + // two distinct markets. (Issue,Issue) is left untagged to + // preserve existing mainnet order-book keylets. else if constexpr (std::is_same_v && std::is_same_v) { return getIndexHash( - LedgerNameSpace::BookDir, in.currency, out.getMptID(), in.account); + LedgerNameSpace::BookDir, + kIssueToMPTTag, + in.currency, + out.getMptID(), + in.account); } else if constexpr (std::is_same_v && std::is_same_v) { return getIndexHash( - LedgerNameSpace::BookDir, in.getMptID(), out.currency, out.account); + LedgerNameSpace::BookDir, + kMPTToIssueTag, + in.getMptID(), + out.currency, + out.account); } else { - return getIndexHash(LedgerNameSpace::BookDir, in.getMptID(), out.getMptID()); + return getIndexHash( + LedgerNameSpace::BookDir, kMPTToMPTTag, in.getMptID(), out.getMptID()); } }, book.in.value(), diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp index 2823627108..ae218a4cff 100644 --- a/src/libxrpl/tx/paths/BookStep.cpp +++ b/src/libxrpl/tx/paths/BookStep.cpp @@ -1500,6 +1500,13 @@ template bool BookStep::checkMPTDEX(ReadView const& view, AccountID const& owner) const { + // Offer-owner locks on book_.in and book_.out are handled by the + // liquidity sources before an offer reaches this point. OfferStream + // filters CLOB offers through the assetIn deep-freeze check and the + // assetOut owner-funds check using FreezeHandling::ZeroIfFrozen, while + // AMMLiquidity gets pool balances through ammAccountHolds(), which zeroes + // locked holdings. This method only enforces MPT trade and transfer + // permissions. if (!isTesSuccess(canTrade(view, book_.in)) || !isTesSuccess(canTrade(view, book_.out))) return false; @@ -1513,14 +1520,8 @@ BookStep::checkMPTDEX(ReadView const& view, AccountID const // Offer's owner is an issuer if (asset.getIssuer() == owner) return true; - // The previous step could be MPTEndpointStep with non issuer account or - // BookStep. Fail both if in asset is locked. In the former case it is holder - // to locked holder transfer. In the latter case it is not possible to tell if - // it is issuer to holder or holder to holder transfer. - if (isFrozen(view, owner, book_.in.get())) - return false; - // Previous step is BookStep. BookStep only sends if CanTransfer is - // set and not locked or the offer is owned by an issuer + // Previous BookStep already enforced transferability for the asset + // it sends to this offer. if (prevStep_->bookStepBook()) return true; // Previous step is MPTEndpointStep and offer's owner is not an diff --git a/src/libxrpl/tx/paths/MPTEndpointStep.cpp b/src/libxrpl/tx/paths/MPTEndpointStep.cpp index a47cfa15a5..8fd69d3106 100644 --- a/src/libxrpl/tx/paths/MPTEndpointStep.cpp +++ b/src/libxrpl/tx/paths/MPTEndpointStep.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -89,6 +90,13 @@ protected: void resetCache(DebtDirection dir); + [[nodiscard]] TER + sendWithMPTCreate( + ApplyView& view, + AccountID const& src, + AccountID const& dst, + MPTAmount const& amount); + private: MPTEndpointStep( StrandContext const& ctx, @@ -274,7 +282,7 @@ public: // Not applicable for payment static TER - checkCreateMPT(ApplyView&, DebtDirection) + checkCreateMPT(ApplyView&) { return tesSUCCESS; } @@ -322,7 +330,7 @@ public: // Can be created in rev or fwd (if limiting step) direction. TER - checkCreateMPT(ApplyView& view, DebtDirection srcDebtDir); + checkCreateMPT(ApplyView& view); }; //------------------------------------------------------------------------------ @@ -401,7 +409,7 @@ MPTEndpointOfferCrossingStep::check(StrandContext const& ctx, SLE::const_ref) } TER -MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirection srcDebtDir) +MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view) { // TakerPays is the last step if offer crossing if (isLast_) @@ -412,9 +420,14 @@ MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirectio // crossed. See CreateOffer::applyGuts() for reserve check. if (auto const err = xrpl::checkCreateMPT(view, mptIssue_, dst_, j_); !isTesSuccess(err)) { + // Unreachable: offer-crossing checks reject an offer whose owner + // could fail to create the MPToken. + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::MPTEndpointOfferCrossingStep::checkCreateMPT : create MPToken failed"); JLOG(j_.trace()) << "MPTEndpointStep::checkCreateMPT: failed create MPT"; - resetCache(srcDebtDir); return err; + // LCOV_EXCL_STOP } } return tesSUCCESS; @@ -422,6 +435,30 @@ MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirectio //------------------------------------------------------------------------------ +template +TER +MPTEndpointStep::sendWithMPTCreate( + ApplyView& view, + AccountID const& src, + AccountID const& dst, + MPTAmount const& amount) +{ + // Only offer crossing can fail here (payment checkCreateMPT is a no-op), + // via the unreachable path excluded in checkCreateMPT() above. + if (auto const err = static_cast(this)->checkCreateMPT(view); !isTesSuccess(err)) + return err; // LCOV_EXCL_LINE + + return directSendNoFee( + view, + src, + dst, + toSTAmount(amount, mptIssue_), + /*checkIssuer*/ false, + j_); +} + +//------------------------------------------------------------------------------ + template std::pair MPTEndpointStep::maxPaymentFlow(ReadView const& sb) const @@ -478,8 +515,6 @@ MPTEndpointStep::revImp( auto const [srcQOut, dstQIn] = qualities(sb, srcDebtDir, StrandDirection::Reverse); (void)dstQIn; - MPTIssue const srcToDstIss(mptIssue_); - JLOG(j_.trace()) << "MPTEndpointStep::rev" << " srcRedeems: " << redeems(srcDebtDir) << " outReq: " << to_string(out) << " maxSrcToDst: " << to_string(maxSrcToDst) << " srcQOut: " << srcQOut @@ -492,59 +527,41 @@ MPTEndpointStep::revImp( return {beast::kZero, beast::kZero}; } - if (auto const err = static_cast(this)->checkCreateMPT(sb, srcDebtDir); - !isTesSuccess(err)) - return {beast::kZero, beast::kZero}; + // When a previous step feeds this issuing step, srcQOut is the issuer's + // transfer rate and maxPaymentFlow() returns the issuance maximum rather + // than a real limit, so srcToDst * srcQOut need not be representable. Cap + // srcToDst at the largest amount whose input is; the previous step then + // limits the flow to what the source actually holds. + MPTAmount const maxRepresentable = + mulRatio(MPTAmount(kMaxMpTokenAmount), QUALITY_ONE, srcQOut, /*roundUp*/ false); // Don't have to factor in dstQIn since it is always QUALITY_ONE - MPTAmount const srcToDst = out; + MPTAmount const srcToDst = std::min({out, maxSrcToDst, maxRepresentable}); - if (srcToDst <= maxSrcToDst) - { - MPTAmount const in = mulRatio(srcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true); - cache_.emplace(in, srcToDst, srcToDst, srcDebtDir); - auto const ter = directSendNoFee( - sb, - src_, - dst_, - toSTAmount(srcToDst, srcToDstIss), - /*checkIssuer*/ false, - j_); - if (!isTesSuccess(ter)) - { - JLOG(j_.trace()) << "MPTEndpointStep::rev: error " << ter; - resetCache(srcDebtDir); - return {beast::kZero, beast::kZero}; - } - JLOG(j_.trace()) << "MPTEndpointStep::rev: Non-limiting" - << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in) - << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out); - return {in, out}; - } + // Can't overflow: srcToDst <= kMaxMpTokenAmount * QUALITY_ONE / srcQOut, + // so the rounded up product is at most kMaxMpTokenAmount. + MPTAmount const in = mulRatio(srcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true); - // limiting node - MPTAmount const in = mulRatio(maxSrcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true); - // Don't have to factor in dsqQIn since it's always QUALITY_ONE - MPTAmount const actualOut = maxSrcToDst; - cache_.emplace(in, maxSrcToDst, actualOut, srcDebtDir); + cache_.emplace(in, srcToDst, srcToDst, srcDebtDir); - auto const ter = directSendNoFee( - sb, - src_, - dst_, - toSTAmount(maxSrcToDst, srcToDstIss), - /*checkIssuer*/ false, - j_); + auto const ter = sendWithMPTCreate(sb, src_, dst_, srcToDst); if (!isTesSuccess(ter)) { + // Unreachable: send fails only on funds/auth/overflow, precluded by + // maxPaymentFlow, check() requireAuth, and 2*kMaxMpTokenAmount < 2^64. + // LCOV_EXCL_START + UNREACHABLE("xrpl::MPTEndpointStep::revImp : send failed"); JLOG(j_.trace()) << "MPTEndpointStep::rev: error " << ter; resetCache(srcDebtDir); return {beast::kZero, beast::kZero}; + // LCOV_EXCL_STOP } - JLOG(j_.trace()) << "MPTEndpointStep::rev: Limiting" + + JLOG(j_.trace()) << "MPTEndpointStep::rev: " << (srcToDst < out ? "Limiting" : "Non-limiting") << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in) - << " srcToDst: " << to_string(maxSrcToDst) << " out: " << to_string(out); - return {in, actualOut}; + << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out); + + return {in, srcToDst}; } // The forward pass should never have more liquidity than the reverse @@ -609,8 +626,6 @@ MPTEndpointStep::fwdImp( auto const [srcQOut, dstQIn] = qualities(sb, srcDebtDir, StrandDirection::Forward); (void)dstQIn; - MPTIssue const srcToDstIss(mptIssue_); - JLOG(j_.trace()) << "MPTEndpointStep::fwd" << " srcRedeems: " << redeems(srcDebtDir) << " inReq: " << to_string(in) << " maxSrcToDst: " << to_string(maxSrcToDst) << " srcQOut: " << srcQOut @@ -618,63 +633,81 @@ MPTEndpointStep::fwdImp( if (maxSrcToDst.signum() <= 0) { + // Unreachable: the reverse pass owns dry detection; every path that + // reaches fwdImp (see StrandFlow::flow) has a funded source. + // LCOV_EXCL_START + UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : dry source"); JLOG(j_.trace()) << "MPTEndpointStep::fwd: dry"; resetCache(srcDebtDir); return {beast::kZero, beast::kZero}; + // LCOV_EXCL_STOP } - if (auto const err = static_cast(this)->checkCreateMPT(sb, srcDebtDir); - !isTesSuccess(err)) + auto const maybeSrcToDst = tryMulRatio(in, QUALITY_ONE, srcQOut, /*roundUp*/ false); + if (!maybeSrcToDst) + { + // Unreachable: divides by srcQOut >= QUALITY_ONE, so result <= in <= + // maxMPTAmount and can never overflow int64. + // LCOV_EXCL_START + UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : source to destination overflow"); + JLOG(j_.trace()) << "MPTEndpointStep::fwd: overflow"; + resetCache(srcDebtDir); return {beast::kZero, beast::kZero}; + // LCOV_EXCL_STOP + } - MPTAmount const srcToDst = mulRatio(in, QUALITY_ONE, srcQOut, /*roundUp*/ false); + MPTAmount const srcToDst = *maybeSrcToDst; if (srcToDst <= maxSrcToDst) { // Don't have to factor in dstQIn since it's always QUALITY_ONE MPTAmount const out = srcToDst; setCacheLimiting(in, srcToDst, out, srcDebtDir); - auto const ter = directSendNoFee( - sb, - src_, - dst_, - toSTAmount(cache_->srcToDst, srcToDstIss), - /*checkIssuer*/ false, - j_); - if (!isTesSuccess(ter)) - { - JLOG(j_.trace()) << "MPTEndpointStep::fwd: error " << ter; - resetCache(srcDebtDir); - return {beast::kZero, beast::kZero}; - } + JLOG(j_.trace()) << "MPTEndpointStep::fwd: Non-limiting" << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in) << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out); } else { + // Unreachable: the reverse pass owns all limiting; the forward driver + // (StrandFlow::flow) never re-finds a limit, so srcToDst <= maxSrcToDst. + // LCOV_EXCL_START + UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : forward pass limiting"); // limiting node - MPTAmount const actualIn = mulRatio(maxSrcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true); - // Don't have to factor in dstQIn since it's always QUALITY_ONE - MPTAmount const out = maxSrcToDst; - setCacheLimiting(actualIn, maxSrcToDst, out, srcDebtDir); - auto const ter = directSendNoFee( - sb, - src_, - dst_, - toSTAmount(cache_->srcToDst, srcToDstIss), - /*checkIssuer*/ false, - j_); - if (!isTesSuccess(ter)) + auto const maybeActualIn = tryMulRatio(maxSrcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true); + if (!maybeActualIn) { - JLOG(j_.trace()) << "MPTEndpointStep::fwd: error " << ter; + JLOG(j_.trace()) << "MPTEndpointStep::fwd: overflow"; resetCache(srcDebtDir); return {beast::kZero, beast::kZero}; } + + MPTAmount const actualIn = *maybeActualIn; + + // Don't have to factor in dstQIn since it's always QUALITY_ONE + MPTAmount const out = maxSrcToDst; + setCacheLimiting(actualIn, maxSrcToDst, out, srcDebtDir); + JLOG(j_.trace()) << "MPTEndpointStep::fwd: Limiting" << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(actualIn) << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out); + // LCOV_EXCL_STOP } + + auto const ter = sendWithMPTCreate(sb, src_, dst_, cache_->srcToDst); + if (!isTesSuccess(ter)) + { + // Unreachable: send fails only on funds/auth/overflow, precluded by + // maxPaymentFlow, check() requireAuth, and 2*kMaxMpTokenAmount < 2^64. + // LCOV_EXCL_START + UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : send failed"); + JLOG(j_.trace()) << "MPTEndpointStep::fwd: error " << ter; + resetCache(srcDebtDir); + return {beast::kZero, beast::kZero}; + // LCOV_EXCL_STOP + } + return {cache_->in, cache_->out}; // NOLINTEND(bugprone-unchecked-optional-access) } diff --git a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp index edd2cc2037..7744c128af 100644 --- a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp @@ -669,17 +669,16 @@ AMMWithdraw::withdraw( mptokenKey = std::nullopt; if (!enabledFixAmMv12 || isXRP(asset)) return tesSUCCESS; - bool const isIssue = asset.holds(); - bool const assetNotExists = [&] { - if (isIssue) - return !view.exists(keylet::trustLine(account, asset.get())); - auto const issuanceKey = keylet::mptokenIssuance(asset.get()); - mptokenKey = keylet::mptoken(issuanceKey.key, account); - if (!view.exists(*mptokenKey)) - return true; - mptokenKey = std::nullopt; - return false; - }(); + bool const assetNotExists = asset.visit( + [&](Issue const& issue) { return !view.exists(keylet::trustLine(account, issue)); }, + [&](MPTIssue const& issue) { + auto const issuanceKey = keylet::mptokenIssuance(issue); + mptokenKey = keylet::mptoken(issuanceKey.key, account); + if (!view.exists(*mptokenKey)) + return true; + mptokenKey = std::nullopt; + return false; + }); if (assetNotExists) { auto sleAccount = view.peek(keylet::account(account)); @@ -693,7 +692,7 @@ AMMWithdraw::withdraw( ? XRPAmount(beast::kZero) : accountReserve(view, sleAccount, journal, {.ownerCountDelta = 1})); - auto const balanceAdj = isIssue ? std::max(priorBalance, balance) : priorBalance; + auto const balanceAdj = std::max(priorBalance, balance); if (balanceAdj < reserve) return tecINSUFFICIENT_RESERVE; } diff --git a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp index 0492f9c062..7ab1143d12 100644 --- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp +++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp @@ -672,6 +672,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel) } bool crossed = false; + bool const mptV2 = ctx_.view().rules().enabled(featureMPTokensV2); if (isTesSuccess(result)) { @@ -694,7 +695,12 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel) if (sle && sle->isFieldPresent(sfTickSize)) uTickSize = std::min(uTickSize, (*sle)[sfTickSize]); } - if (uTickSize < Quality::kMaxTickSize) + // Quality's ctor is the same getRate() call that produced uRate, and + // round() maps zero to zero, so an unrepresentable quality would make + // divide() below throw (tefEXCEPTION). Skip the rounding instead: the + // offer still crosses, and any residual is stopped before placement. + bool const unrepresentableRate = mptV2 && uRate == 0; + if (uTickSize < Quality::kMaxTickSize && !unrepresentableRate) { auto const rate = Quality{saTakerGets, saTakerPays}.round(uTickSize).rate(); @@ -841,6 +847,20 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel) return {tesSUCCESS, true}; } + // The remainder rests at uRate, the original pre-crossing rate. A zero + // rate (quality not representable) puts it in the directory whose index + // equals getBookBase(book), and BookTip scans keys strictly greater, so it + // could never be crossed while holding the owner's reserve. Don't place + // it; anything that crossed is kept, and a fully crossed offer has already + // returned above. Gated to preserve pre-amendment behavior. + if (mptV2 && uRate == 0) + { + JLOG(j_.debug()) << "Unrepresentable quality: remainder not placed"; + if (!crossed) + return {tecKILLED, false}; + return {tesSUCCESS, true}; + } + auto const sleCreator = sb.peek(keylet::account(accountID_)); if (!sleCreator) return {tefINTERNAL, false}; diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp index ac9728ede1..37e0ed585d 100644 --- a/src/test/app/AMMMPT_test.cpp +++ b/src/test/app/AMMMPT_test.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -3318,6 +3319,65 @@ private: } } + void + testWithdrawReserveUsesLiveBalance() + { + testcase("Withdraw reserve check uses live balance"); + + using namespace jtx; + + auto const test = [&](auto&& makeToken) { + Env env(*this); + env.fund(XRP(30'000), gw_, alice_, bob_); + env.close(); + + auto const token = makeToken(env); + AMM amm(env, gw_, XRP(100), token(100)); + + // The EUR trustline is an unrelated owner object. The XRP-only + // AMM deposit adds the LP token trustline, so Alice has 2 owners. + env.trust(gw_["EUR"](1), alice_); + amm.deposit(DepositArg{.account = alice_, .asset1In = XRP(10)}); + BEAST_EXPECT(env.ownerCount(alice_) == 2); + env.require(Balance(alice_, token(kNone))); + + // Drain Alice to one drop below the reserve for a third owner + // object, accounting for the fee on the drain payment. + auto const reserveForToken = reserve(env, 3); + auto const targetBalance = reserveForToken - XRPAmount{1}; + auto const baseFee = env.current()->fees().base; + auto const currentBalance = env.balance(alice_).value().xrp(); + auto const drainAmount = currentBalance - targetBalance - baseFee; + BEAST_EXPECT(drainAmount > XRPAmount{0}); + env(pay(alice_, bob_, drops(drainAmount))); + env.close(); + + // AMMWithdraw captures priorBalance before the fee, then the XRP + // leg raises the live sandbox balance before the token leg. + // XRP(2) keeps the integral MPT side positive after rounding. + auto const xrpOut = XRP(2); + auto const tokenOut = token(2); + auto const priorBalance = env.balance(alice_).value().xrp(); + auto const liveBalanceAfterXrpLeg = priorBalance - baseFee + xrpOut.value().xrp(); + BEAST_EXPECT(priorBalance < reserveForToken); + BEAST_EXPECT(liveBalanceAfterXrpLeg > priorBalance); + BEAST_EXPECT(liveBalanceAfterXrpLeg >= reserveForToken); + + // The XRP leg runs first, so the missing IOU trustline or MPToken + // is reserved against the updated sandbox balance. + amm.withdraw( + WithdrawArg{.account = alice_, .asset1Out = xrpOut, .asset2Out = tokenOut}); + + // The withdrawal succeeds only if the missing token holding can be + // reserved from the live balance after the XRP leg. + BEAST_EXPECT(env.ownerCount(alice_) == 3); + BEAST_EXPECT(env.balance(alice_, token).value().signum() > 0); + }; + + test([&](Env&) -> PrettyAsset { return gw_["USD"]; }); + test([&](Env& env) -> PrettyAsset { return MPTTester({.env = env, .issuer = gw_}); }); + } + void testInvalidFeeVote() { @@ -7491,6 +7551,7 @@ private: testDeposit(); testInvalidWithdraw(); testWithdraw(); + testWithdrawReserveUsesLiveBalance(); testInvalidFeeVote(); testFeeVote(); testInvalidBid(); diff --git a/src/test/app/FlowMPT_test.cpp b/src/test/app/FlowMPT_test.cpp index 49e3f9be94..0f88814d4f 100644 --- a/src/test/app/FlowMPT_test.cpp +++ b/src/test/app/FlowMPT_test.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -638,6 +639,149 @@ struct FlowMPT_test : public beast::unit_test::Suite } } + void + testMPTEndpointTransferRateOverflow(FeatureBitset features) + { + testcase("MPT Endpoint transfer rate overflow"); + + using namespace jtx; + + Account const iouGW("iou_gateway"); + Account const mptGW("mpt_gateway"); + Account const alice("alice"); + Account const bob("bob"); + + { + // Control: the same issuer-owned offer path works when the + // transfer-fee-adjusted input amount remains representable. + Env env(*this, features); + + std::int64_t constexpr deliverAmount = 1'000'000'000'000'000'000LL; + std::int64_t constexpr offerAmount = deliverAmount + (deliverAmount / 2); + + env.fund(XRP(10'000), iouGW, mptGW, alice, bob); + env.close(); + + auto const usd = iouGW["USD"]; + env.trust(usd(offerAmount), alice); + env.trust(usd(offerAmount), mptGW); + env(pay(iouGW, alice, usd(offerAmount))); + + MPTTester const mpt( + {.env = env, .issuer = mptGW, .holders = {bob}, .transferFee = kMaxTransferFee}); + + env(offer(mptGW, usd(offerAmount), mpt(offerAmount))); + + env(pay(alice, bob, mpt(deliverAmount)), + Path(~mpt), + Sendmax(usd(offerAmount)), + Txflags(tfNoRippleDirect | tfPartialPayment)); + + env.require(Balance(alice, usd(0)), Balance(bob, mpt(deliverAmount))); + BEAST_EXPECT(!isOffer(env, mptGW, usd(offerAmount), mpt(offerAmount))); + } + { + // Regression: an extreme transfer-fee-adjusted MPT amount used to + // throw from MPTAmount::mulRatio during the endpoint reverse pass. + // The reverse pass now caps srcToDst at the largest amount whose + // transfer-fee-adjusted input is representable, so the offer limits + // the strand and a partial payment goes through. + Env env(*this, features); + + std::int64_t constexpr overflowAmount = 7'000'000'000'000'000'000LL; + // The offer caps the input at overflowAmount, which the maximum + // transfer rate of 1.5 scales down to 7e18 * 2 / 3, rounded down + std::int64_t constexpr deliveredAmount = 4'666'666'666'666'666'666LL; + + env.fund(XRP(10'000), iouGW, mptGW, alice, bob); + env.close(); + + auto const usd = iouGW["USD"]; + env.trust(usd(overflowAmount), alice); + env.trust(usd(overflowAmount), mptGW); + env(pay(iouGW, alice, usd(overflowAmount))); + + MPTTester const mpt( + {.env = env, .issuer = mptGW, .holders = {bob}, .transferFee = kMaxTransferFee}); + + env(offer(mptGW, usd(overflowAmount), mpt(overflowAmount))); + + env(pay(alice, bob, mpt(overflowAmount)), + Path(~mpt), + Sendmax(usd(overflowAmount)), + Txflags(tfNoRippleDirect | tfPartialPayment)); + + env.require(Balance(alice, usd(0)), Balance(bob, mpt(deliveredAmount))); + BEAST_EXPECT(!isOffer(env, mptGW, usd(overflowAmount), mpt(overflowAmount))); + } + } + + void + testMPTEndpointRipplingInputOverflow(FeatureBitset features) + { + // A payment between the holders of an MPT with a transfer fee ripples + // through the issuer, and the issuing step has to charge the transfer + // rate on the amount it receives. maxPaymentFlow() returns the issuance + // maximum for that step, so srcToDst * transferRate is not necessarily + // representable as an MPT amount. The reverse pass must cap the flow at + // the largest representable input instead of declaring the strand dry, + // otherwise a deliverable partial payment fails with tecPATH_DRY. + // + // Same defect as the case above, reached without an offer: holder -> + // issuer -> holder, one case per branch of the pre-fix revImp. + testcase("MPT Endpoint rippling input overflow"); + + using namespace jtx; + + Account const gw("gateway"); + Account const alice("alice"); + Account const bob("bob"); + + // The maximum transfer fee gives a transfer rate of 1.5, so an input of + // kMaxMpTokenAmount covers at most kMaxMpTokenAmount * 2 / 3 of output. + std::int64_t constexpr maxRepresentable = 6'148'914'691'236'517'204LL; + std::int64_t constexpr aliceBalance = 1'000; + // The forward pass rounds the delivered amount down: 1000 / 1.5 + std::int64_t constexpr bobBalance = 666; + + auto const test = + [&](std::uint64_t maxAmt, std::int64_t deliver, std::string const& label) { + Env env(*this, features); + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + auto mpt = MPTTester( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = kMaxTransferFee, + .maxAmt = maxAmt}); + + env(pay(gw, alice, mpt(aliceBalance))); + env.close(); + + // alice asks to deliver more than the transfer rate can scale, + // so the issuing step caps the flow and her balance limits it + // further + env(pay(alice, bob, mpt(deliver)), + Sendmax(mpt(kMaxMpTokenAmount)), + Txflags(tfPartialPayment)); + BEAST_EXPECTS(env.ter() == tesSUCCESS, label); + BEAST_EXPECTS(env.balance(alice, mpt) == mpt(0), label); + BEAST_EXPECTS(env.balance(bob, mpt) == mpt(bobBalance), label); + BEAST_EXPECTS(mpt.checkMPTokenOutstandingAmount(bobBalance), label); + }; + + // The requested amount is below MaximumAmount, so the reverse pass + // takes the non-limiting branch and overflows on the requested amount + test(kMaxMpTokenAmount, maxRepresentable + 1, "non-limiting"); + + // MaximumAmount is below the requested amount but still large enough + // that scaling it by the transfer rate is not representable, so the + // reverse pass takes the limiting branch and overflows on the maximum + test(maxRepresentable + 1, kMaxMpTokenAmount, "limiting"); + } + void testFalseDry(FeatureBitset features) { @@ -2271,6 +2415,103 @@ struct FlowMPT_test : public beast::unit_test::Suite } } + void + testLockedMidPathHolder(FeatureBitset features) + { + // Regression: a cross-currency strand whose second book step + // consumes the offer of a holder that is locked on the step's + // in-asset (an MPT). The strand is XRP -> [book1: XRP/USD] -> + // USD -> [book2: USD/EUR] -> EUR, so book2 has book_.in == USD + // (an MPT) and its previous step is another BookStep. That is + // exactly the checkMPTDEX() branch that trusts the preceding + // BookStep and no longer re-checks isFrozen(owner, book_.in). + // + // The bypass the branch might appear to open does not exist: + // for MPT, isDeepFrozen() == isFrozen() (frozen MPTs can neither + // send nor receive), and OfferStream gates every offer through + // isDeepFrozen(owner, assetIn) before it can reach checkMPTDEX(). + // So a locked mid-path holder's offer is removed by the liquidity + // source and the strand simply finds no liquidity at book2. + testcase("Locked mid-path holder behind a BookStep"); + + using namespace jtx; + + Account const gw("gw"); + Account const alice("alice"); // book1 (XRP/USD) offer owner + Account const mid("mid"); // book2 (USD/EUR) offer owner + Account const sam("sam"); // source + Account const bill("bill"); // destination + + auto const test = [&](bool lock) { + Env env(*this, features); + env.fund(XRP(1'000), gw, alice, mid, sam, bill); + env.close(); + + auto usd = MPTTester( + {.env = env, + .issuer = gw, + .holders = {alice, mid}, + .flags = kMptDexFlags | tfMPTCanLock, + .maxAmt = 1'000}); + auto const eur = gw["EUR"]; + + // alice funds book1 (sells USD for XRP); mid funds book2 + // (sells EUR for USD, i.e. receives the mid-path USD). + env(pay(gw, alice, usd(100))); + env(trust(mid, eur(100))); + env(pay(gw, mid, eur(100))); + env(trust(bill, eur(100))); + env.close(); + + env(offer(alice, XRP(100), usd(100))); // XRP/USD, sells USD + env.close(); + env(offer(mid, usd(100), eur(100))); // USD/EUR, sells EUR + env.close(); + BEAST_EXPECT(expectOffers(env, alice, 1)); + BEAST_EXPECT(expectOffers(env, mid, 1)); + + // Lock mid on USD *after* its offer is already on the book: + // the reviewer's "frozen holder's offer sits behind a + // BookStep" scenario. + if (lock) + { + usd.set({.holder = mid, .flags = tfMPTLock}); + env.close(); + } + + env(pay(sam, bill, eur(100)), + Sendmax(XRP(100)), + Path(~usd, ~eur), + Txflags(tfNoRippleDirect), + // book1 (XRP/USD) still has liquidity, so the strand is + // not fully dry; it just cannot cross book2 once mid's + // offer is removed, hence PARTIAL rather than DRY. + Ter(lock ? TER(tecPATH_PARTIAL) : TER(tesSUCCESS))); + env.close(); + + if (lock) + { + // No liquidity reached book2: mid neither received USD + // nor delivered EUR, so bill received nothing. + BEAST_EXPECT(env.balance(bill, eur) == eur(0)); + BEAST_EXPECT(env.balance(mid, usd) == usd(0)); + } + else + { + // The strand crosses both books: mid receives the + // mid-path USD and bill receives EUR. + BEAST_EXPECT(env.balance(bill, eur) == eur(100)); + BEAST_EXPECT(env.balance(mid, usd) == usd(100)); + BEAST_EXPECT(env.balance(alice, usd) == usd(0)); + BEAST_EXPECT(expectOffers(env, alice, 0)); + BEAST_EXPECT(expectOffers(env, mid, 0)); + } + }; + + test(false); // baseline: unlocked strand succeeds + test(true); // locked mid-path holder: strand finds no liquidity + } + void testWithFeats(FeatureBitset features) { @@ -2282,6 +2523,8 @@ struct FlowMPT_test : public beast::unit_test::Suite testBookStep(features); testOfferOwnerMPTCreation(features); testTransferRate(features); + testMPTEndpointTransferRateOverflow(features); + testMPTEndpointRipplingInputOverflow(features); testSelfPayment1(features); testSelfPayment2(features); testSelfFundedXRPEndpoint(false, features); @@ -2289,6 +2532,7 @@ struct FlowMPT_test : public beast::unit_test::Suite testUnfundedOffer(features); testReExecuteDirectStep(features); testSelfPayLowQualityOffer(features); + testLockedMidPathHolder(features); } void diff --git a/src/test/app/OfferMPT_test.cpp b/src/test/app/OfferMPT_test.cpp index e262954fdf..80541480e8 100644 --- a/src/test/app/OfferMPT_test.cpp +++ b/src/test/app/OfferMPT_test.cpp @@ -24,12 +24,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -3679,6 +3681,26 @@ public: } { + // Companion to the transfer-rate overflow cases above. The taker + // sells TakerPays=MPT(~1.84e18) for TakerGets=XRP(1) against a + // same-magnitude poison offer, forcing BookStep::revImp()'s + // limitStepOut() to strictly reduce and overflow. + // + // The taker's own quality is also unrepresentable here + // (getRate(TakerGets, TakerPays) == 0: a large MPT numerator over + // a small XRP denominator overflows the rate mantissa), but that + // no longer short-circuits the transaction -- crossing is + // attempted, and only a residual that would REST is stopped. So + // this exercises the deeper safety net: + // BookStep::forEachOffer's catch(std::overflow_error), which under + // featureMPTokensV2 removes the offending offer rather than + // propagating. + // + // Net effect: the poison offer is consumed off the book instead of + // being left to poison the next taker, nothing crosses, and the + // taker's own offer is not placed because its rate is + // unrepresentable -- so tecKILLED, which charges a fee and + // advances the sequence. Env env{*this, features}; env.fund(XRP(10'000), issuer, taker); env.close(); @@ -3686,18 +3708,9 @@ public: MPTTester const token{ {.env = env, .issuer = issuer, .holders = {taker}, .maxAmt = kMaxMpTokenAmount}}; - // Give the taker exactly one MPT. If the old rounding overflow - // collapsed the required input to the minimum positive amount, the - // taker could afford the bad fill and the balance checks below - // would catch the economic gain. env(pay(issuer, taker, token(1))); env.close(); - // Covers BookStep::revImp() output reduction. The issuer's offer - // is fully funded and has no transfer fee, so offer preparation - // succeeds. The taker asks for slightly less output, forcing - // limitStepOut() to reduce the offer; that strict reduction used - // to overflow and leave the poison offer on the book. auto const funded = 1'844'674'407'370'955'162LL; auto const offerOut = funded + 1; @@ -3711,21 +3724,23 @@ public: auto const issuerXRPBefore = env.balance(issuer, XRP); auto const takerXRPBefore = env.balance(taker, XRP); auto const takerMPTBefore = env.balance(taker, token); - auto const fee = env.current()->fees().base; + auto const takerSeqBefore = env.seq(taker); - auto const takerSeq = env.seq(taker); - env(offer(taker, token(funded), XRP(1))); + auto const takerSeq = takerSeqBefore; + auto const fee = env.current()->fees().base; + env(offer(taker, token(funded), XRP(1)), Ter(tecKILLED)); env.close(); - // The former overflow point must not turn into a near-free fill: - // the unusable offer is removed, the taker's offer remains, and no - // value changes hands beyond the taker's transaction fee. + // The overflowing poison offer is removed by BookStep. Nothing + // crossed, so no asset changes hands and the taker's offer is not + // placed; the fee is burned and the sequence advances. BEAST_EXPECT(env.le(poisonKeylet) == nullptr); BEAST_EXPECT( - env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr); + env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) == nullptr); BEAST_EXPECT(env.balance(issuer, XRP) == issuerXRPBefore); BEAST_EXPECT(env.balance(taker, XRP) == takerXRPBefore - fee); BEAST_EXPECT(env.balance(taker, token) == takerMPTBefore); + BEAST_EXPECT(env.seq(taker) == takerSeqBefore + 1); } { @@ -5494,6 +5509,215 @@ public: } } + void + testMPTOfferZeroRate(FeatureBitset features) + { + // An MPT offer whose quality is not representable must not REST -- on + // both the buy and sell sides, with or without a TickSize on the IOU + // issuer. Here nothing crosses it, so the whole offer is the remainder + // and the result is tecKILLED with nothing placed. + // + // getRate(TakerGets, TakerPays) returns 0 when the rate overflows: a + // large MPT TakerPays (XLS-0082 allows up to 2^63-1) over a small IOU + // TakerGets. Such an offer would otherwise (a) rest in the quality-0 + // book directory, whose index equals getBookBase(), which BookTip's + // strict successor scan never returns -- so it can never be crossed yet + // still consumes the owner's reserve; and (b) on a TickSize market, + // drive the tick-rounding path in applyGuts to divide by a zero rate, + // throwing and surfacing as tefEXCEPTION. A normally-priced offer in the + // same market is unaffected. + // + // See testMPTOfferZeroRateCrossable for the other half of the + // behavior: an unrepresentable quality that CROSSES is not rejected. + testcase("MPT Offer Zero Rate"); + + using namespace jtx; + + // Mantissa well above the ~1.84e17 overflow threshold (with an IOU + // denominator mantissa of 1e15); still within the XLS-0082 range. + auto const kBigMpt = 5'000'000'000'000'000'000LL; + + auto runScenario = [&](bool withTickSize) { + Env env{*this, features}; + auto const gw = Account{"gateway"}; + auto const alice = Account{"alice"}; + env.fund(XRP(10'000), gw, alice); + env.close(); + + auto const usd = gw["USD"]; + env(trust(alice, usd(1'000))); + env(pay(gw, alice, usd(100))); + env.close(); + + if (withTickSize) + { + auto txn = noop(gw); + txn[sfTickSize.fieldName] = 5; + env(txn); + env.close(); + BEAST_EXPECT((*env.le(gw))[sfTickSize] == 5); + } + + // gw issues a DEX-tradable MPT (CanTrade | CanTransfer by default) + // and authorizes alice to hold it. + MPT const mpt = MPTTester( + {.env = env, .issuer = gw, .holders = {alice}, .maxAmt = kMaxMpTokenAmount}); + + // Buy side: TakerPays = large MPT, TakerGets = small IOU. + // getRate() overflows to 0 and nothing crosses -> killed, no + // offer placed and no reserve consumed. + BEAST_EXPECT(getRate(usd(1), mpt(kBigMpt)) == 0); + env(offer(alice, mpt(kBigMpt), usd(1)), Ter(tecKILLED)); + env.close(); + BEAST_EXPECT(offersOnAccount(env, alice).empty()); + + // Sell side (tfSell): killed regardless of the flag, since the + // rate is computed from the raw amounts either way. + BEAST_EXPECT(getRate(usd(1), mpt(kBigMpt)) == 0); + env(offer(alice, mpt(kBigMpt), usd(1), tfSell), Ter(tecKILLED)); + env.close(); + BEAST_EXPECT(offersOnAccount(env, alice).empty()); + + // Control: a normally-priced offer in the same market still + // places (and the tick-size rounding path still works when + // withTickSize is set). + env(offer(alice, mpt(10'000'000), usd(30)), Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT(offersOnAccount(env, alice).size() == 1); + }; + + // Without a TickSize: previously placed as a dead, never-crossable + // quality-0 entry that still consumed reserve. + runScenario(/*withTickSize=*/false); + // With a TickSize: previously threw and surfaced as tefEXCEPTION. The + // rounding is now skipped when the rate is unrepresentable. + runScenario(/*withTickSize=*/true); + } + + void + testZeroRateXrpIouOffer(FeatureBitset features) + { + // A rate-0 offer is reachable without MPT: for XRP/IOU the "too + // good" underflow path makes getRate() return 0 when a tiny IOU + // TakerPays is divided by an XRP TakerGets. + // + // Without featureMPTokensV2 the offer is accepted and placed, but + // rests in the quality-0 book directory (whose index == getBookBase), + // which BookTip's strict successor scan never returns -- so it can + // never be crossed, even by a willing, better-priced counterparty. + // With featureMPTokensV2 the same offer crosses nothing and is not + // placed, so it is killed. + testcase("Zero Rate XRP/IOU Offer"); + + using namespace jtx; + + auto const gw = Account{"gateway"}; + auto const alice = Account{"alice"}; + auto const bob = Account{"bob"}; + auto const usd = gw["USD"]; + + // Smallest-magnitude IOU: mantissa kMinValue, exponent kMinOffset + // (= 1e-81). divide(tinyUsd, XRP(1000)) underflows below kMinOffset + // and canonicalizes to 0, so getRate() returns 0. + auto const tinyUsd = STAmount{usd, UINT64_C(1'000'000'000'000'000), -96}; + + auto setup = [&](Env& env) { + env.fund(XRP(100'000), gw, alice, bob); + env.close(); + env(trust(alice, usd(1'000))); + env(trust(bob, usd(1'000))); + env(pay(gw, bob, usd(100))); + env.close(); + }; + + // featureMPTokensV2 disabled: legacy behavior -- placed but inert. + { + Env env{*this, features - featureMPTokensV2}; + setup(env); + + // TakerPays = tiny IOU, TakerGets = XRP -> rate 0. + BEAST_EXPECT(getRate(XRP(1'000), tinyUsd) == 0); + env(offer(alice, tinyUsd, XRP(1'000)), Ter(tesSUCCESS)); + env.close(); + + auto const aliceOffers = offersOnAccount(env, alice); + BEAST_EXPECT(aliceOffers.size() == 1); + // Placed in the quality-0 book directory. + BEAST_EXPECT(getQuality((*aliceOffers.front())[sfBookDirectory]) == 0); + + // A complementary offer that would cross a usable offer at this + // (astronomically good) price does NOT cross it, because the + // quality-0 directory is never visited: both offers rest. + env(offer(bob, XRP(1'000), usd(10)), Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT(offersOnAccount(env, alice).size() == 1); + BEAST_EXPECT(offersOnAccount(env, bob).size() == 1); + } + + // featureMPTokensV2 disabled, with a TickSize on the IOU issuer: the + // tick-rounding path divides by the zero rate and throws, surfacing as + // tefEXCEPTION. Legacy behavior, and it must stay that way -- the + // guard that skips the rounding is gated on the amendment, since + // changing this without a gate would fork a pre-amendment ledger. + { + Env env{*this, features - featureMPTokensV2}; + setup(env); + + auto txn = noop(gw); + txn[sfTickSize.fieldName] = 5; + env(txn); + env.close(); + BEAST_EXPECT((*env.le(gw))[sfTickSize] == 5); + + BEAST_EXPECT(getRate(XRP(1'000), tinyUsd) == 0); + env(offer(alice, tinyUsd, XRP(1'000)), Ter(tefEXCEPTION)); + env.close(); + BEAST_EXPECT(offersOnAccount(env, alice).empty()); + } + + // featureMPTokensV2 enabled: nothing crosses, so the remainder is the + // whole offer and it is killed rather than placed. + { + Env env{*this, features}; + setup(env); + + BEAST_EXPECT(getRate(XRP(1000), tinyUsd) == 0); + env(offer(alice, tinyUsd, XRP(1000)), Ter(tecKILLED)); + env.close(); + BEAST_EXPECT(offersOnAccount(env, alice).empty()); + } + + // featureMPTokensV2 enabled, with a counterparty already on the book: + // the same unrepresentable quality now CROSSES. This is the reviewer's + // objection with no MPT anywhere in it -- the old preflight check + // rejected this outright even though it fills completely and rests + // nothing. + { + Env env{*this, features}; + setup(env); + + // Bob rests first: he gives usd(10) to receive XRP(1'000). + auto const bobSeq = env.seq(bob); + env(offer(bob, XRP(1'000), usd(10)), Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr); + + // Alice offers up to XRP(1'000) for a dust amount of USD -- rate + // 0, at a price bob's offer improves on enormously. + BEAST_EXPECT(getRate(XRP(1'000), tinyUsd) == 0); + env(offer(alice, tinyUsd, XRP(1'000)), Ter(tesSUCCESS)); + env.close(); + + // Alice asked for dust and got exactly that, so her offer is + // fully satisfied and never reaches the book. Bob's offer is + // barely touched and stays. The old preflight check rejected this + // transaction outright, with no MPT involved anywhere. + BEAST_EXPECT(env.balance(alice, usd).value() == tinyUsd); + BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr); + BEAST_EXPECT(offersOnAccount(env, alice).empty()); + } + } + void testAutoCreateReserve(FeatureBitset features) { @@ -5589,6 +5813,642 @@ public: } } + void + testBookOffersMPTFunding(FeatureBitset features) + { + testcase("book_offers uses MPT issuer capacity, transfer fees, and locks"); + + using namespace jtx; + + Account const issuer{"issuer"}; + Account const maker{"maker"}; + Account const buyer{"buyer"}; + + // Issuer-owned MPT offers are funded only by remaining issuance + // capacity. Once ordinary issuance consumes the cap, book_offers must + // report the stale issuer offer as zero-funded. + { + Env env{*this, features}; + + env.fund(XRP(10'000), issuer, maker, buyer); + env.close(); + + MPTTester musd( + {.env = env, .issuer = issuer, .holders = {maker, buyer}, .maxAmt = 100}); + MPT const usd = musd; + + auto const issuerOfferSeq = env.seq(issuer); + env(offer(issuer, XRP(100), usd(100))); + + musd.pay(issuer, maker, 100); + + auto const issuance = env.le(keylet::mptokenIssuance(usd.mpt())); + if (!BEAST_EXPECT(issuance)) + return; + BEAST_EXPECT(issuance->getFieldU64(sfOutstandingAmount) == 100); + BEAST_EXPECT(issuance->getFieldU64(sfMaximumAmount) == 100); + + env(offer(maker, XRP(200), usd(100))); + + json::Value const jrr = getBookOffers(env, XRP, usd); + json::Value const& bookOffers = jrr[jss::offers]; + BEAST_EXPECT(bookOffers.isArray()); + if (!BEAST_EXPECT(bookOffers.size() >= 2)) + return; + + json::Value const& issuerOffer = bookOffers[0u]; + BEAST_EXPECT(issuerOffer[sfAccount.jsonName] == issuer.human()); + BEAST_EXPECT(issuerOffer[sfSequence.jsonName] == issuerOfferSeq); + BEAST_EXPECT(issuerOffer[jss::owner_funds] == "0"); + BEAST_EXPECT(issuerOffer.isMember(jss::taker_gets_funded)); + BEAST_EXPECT(issuerOffer[jss::taker_gets_funded][jss::value] == "0"); + BEAST_EXPECT(issuerOffer.isMember(jss::taker_pays_funded)); + BEAST_EXPECT(issuerOffer[jss::taker_pays_funded] == "0"); + } + + // Multiple issuer-owned MPT offers share the same bounded self-issue + // capacity. The second offer exercises the cached running balance path + // after the first offer has consumed part of the issuer's capacity. + { + Env env{*this, features}; + + env.fund(XRP(10'000), issuer, buyer); + env.close(); + + MPTTester const musd({.env = env, .issuer = issuer, .holders = {buyer}, .maxAmt = 150}); + MPT const usd = musd; + + auto const firstIssuerOfferSeq = env.seq(issuer); + env(offer(issuer, XRP(100), usd(100))); + auto const secondIssuerOfferSeq = env.seq(issuer); + env(offer(issuer, XRP(100), usd(100))); + + json::Value const jrr = getBookOffers(env, XRP, usd); + json::Value const& bookOffers = jrr[jss::offers]; + BEAST_EXPECT(bookOffers.isArray()); + if (!BEAST_EXPECT(bookOffers.size() >= 2)) + return; + + json::Value const& firstOffer = bookOffers[0u]; + BEAST_EXPECT(firstOffer[sfAccount.jsonName] == issuer.human()); + BEAST_EXPECT(firstOffer[sfSequence.jsonName] == firstIssuerOfferSeq); + BEAST_EXPECT(firstOffer[jss::owner_funds] == "150"); + BEAST_EXPECT(!firstOffer.isMember(jss::taker_gets_funded)); + BEAST_EXPECT(!firstOffer.isMember(jss::taker_pays_funded)); + + json::Value const& secondOffer = bookOffers[1u]; + BEAST_EXPECT(secondOffer[sfAccount.jsonName] == issuer.human()); + BEAST_EXPECT(secondOffer[sfSequence.jsonName] == secondIssuerOfferSeq); + BEAST_EXPECT(!secondOffer.isMember(jss::owner_funds)); + BEAST_EXPECT(secondOffer.isMember(jss::taker_gets_funded)); + BEAST_EXPECT(secondOffer[jss::taker_gets_funded][jss::value] == "50"); + BEAST_EXPECT(secondOffer.isMember(jss::taker_pays_funded)); + BEAST_EXPECT(secondOffer[jss::taker_pays_funded] == "50000000"); + } + + auto checkTransferFeeBookOffers = [&](std::uint16_t transferFee, auto&& checkOffers) { + Env env{*this, features}; + + env.fund(XRP(10'000), issuer, maker, buyer); + env.close(); + + MPTTester const musd( + {.env = env, + .issuer = issuer, + .holders = {maker, buyer}, + .transferFee = transferFee, + .pay = 3'000}); + MPT const usd = musd; + if (transferFee != 0) + BEAST_EXPECT(musd.checkTransferFee(transferFee)); + + auto const firstOfferSeq = env.seq(maker); + env(offer(maker, XRP(1'500), usd(1'500))); + auto const secondOfferSeq = env.seq(maker); + env(offer(maker, XRP(1'500), usd(1'500))); + + json::Value const jrr = getBookOffers(env, XRP, usd); + json::Value const& bookOffers = jrr[jss::offers]; + BEAST_EXPECT(bookOffers.isArray()); + if (!BEAST_EXPECT(bookOffers.size() == 2)) + return; + + checkOffers(bookOffers, firstOfferSeq, secondOfferSeq); + }; + + // With no MPT transfer fee, two identical maker offers backed by 3000 + // owner funds are both fully funded for 1500 MPT. + checkTransferFeeBookOffers( + 0, + [&](json::Value const& bookOffers, + std::uint32_t firstOfferSeq, + std::uint32_t secondOfferSeq) { + for (auto const i : {0u, 1u}) + { + json::Value const& offer = bookOffers[i]; + BEAST_EXPECT(offer[sfAccount.jsonName] == maker.human()); + BEAST_EXPECT( + offer[sfSequence.jsonName] == (i == 0u ? firstOfferSeq : secondOfferSeq)); + BEAST_EXPECT(!offer.isMember(jss::taker_gets_funded)); + BEAST_EXPECT(!offer.isMember(jss::taker_pays_funded)); + } + BEAST_EXPECT(bookOffers[0u][jss::owner_funds] == "3000"); + }); + + // With a 50% MPT transfer fee, the first identical maker offer consumes + // 2250 owner funds, so the second offer can deliver only 500 MPT. + checkTransferFeeBookOffers( + 50'000, + [&](json::Value const& bookOffers, + std::uint32_t firstOfferSeq, + std::uint32_t secondOfferSeq) { + json::Value const& firstOffer = bookOffers[0u]; + BEAST_EXPECT(firstOffer[sfAccount.jsonName] == maker.human()); + BEAST_EXPECT(firstOffer[sfSequence.jsonName] == firstOfferSeq); + BEAST_EXPECT(firstOffer[jss::owner_funds] == "3000"); + BEAST_EXPECT(!firstOffer.isMember(jss::taker_gets_funded)); + BEAST_EXPECT(!firstOffer.isMember(jss::taker_pays_funded)); + + json::Value const& secondOffer = bookOffers[1u]; + BEAST_EXPECT(secondOffer[sfAccount.jsonName] == maker.human()); + BEAST_EXPECT(secondOffer[sfSequence.jsonName] == secondOfferSeq); + // A 50% MPT transfer fee leaves only 750 owner funds after + // the first offer. That can fund 500 MPT delivered to the + // taker on the same second offer that was fully funded without + // the transfer fee. + BEAST_EXPECT(secondOffer.isMember(jss::taker_gets_funded)); + BEAST_EXPECT(secondOffer[jss::taker_gets_funded][jss::value] == "500"); + BEAST_EXPECT(secondOffer.isMember(jss::taker_pays_funded)); + BEAST_EXPECT(secondOffer[jss::taker_pays_funded] == "500000000"); + }); + + // A large MPT balance used to overflow the fee adjustment. divide() + // assumes an IOU mantissa, always normalized into [1e15, 1e16), and + // scales the numerator by 1e17. An MPT mantissa is the raw int64 + // balance, so past ~1.8e17 the scaled quotient leaves uint64 range and + // throws -- failing the whole RPC with "internal", so one offer owner + // blanked the entire book for every caller. + // + // The quotient itself always fits, because the branch only runs when + // the rate exceeds parity. The cases below pin that at the edges of + // the domain rather than leaving it to inspection. + auto checkLargeOwnerFunds = + [&](std::uint16_t transferFee, std::int64_t funds, char const* expectedFunded) { + Env env{*this, features}; + env.fund(XRP(10'000), issuer, maker, buyer); + env.close(); + + MPT const usd = MPTTester( + {.env = env, + .issuer = issuer, + .holders = {maker, buyer}, + .transferFee = transferFee, + .maxAmt = kMaxMpTokenAmount}); + env(pay(issuer, maker, usd(funds))); + env.close(); + + auto const offerSeq = env.seq(maker); + env(offer(maker, XRP(100), usd(funds))); + env.close(); + + json::Value const jrr = getBookOffers(env, XRP, usd); + BEAST_EXPECT(!jrr.isMember(jss::error)); + json::Value const& bookOffers = jrr[jss::offers]; + BEAST_EXPECT(bookOffers.isArray()); + if (!BEAST_EXPECT(bookOffers.size() == 1)) + return; + + json::Value const& offer = bookOffers[0u]; + BEAST_EXPECT(offer[sfAccount.jsonName] == maker.human()); + BEAST_EXPECT(offer[sfSequence.jsonName] == offerSeq); + BEAST_EXPECT(offer[jss::owner_funds] == std::to_string(funds)); + BEAST_EXPECT(offer[jss::taker_gets_funded][jss::value] == expectedFunded); + }; + + // Above the ~2.77e17 boundary at the maximum transfer rate of 1.5: + // 3e17 of owner funds covers 2e17 delivered. + checkLargeOwnerFunds(kMaxTransferFee, 300'000'000'000'000'000LL, "200000000000000000"); + // Large balance at the maximum rate. Kept at 6e18 so that 6e18 * 1.5 + // stays representable: offer crossing's rate-preservation path + // overflows above that, which is a separate defect from this one. + checkLargeOwnerFunds(kMaxTransferFee, 6'000'000'000'000'000'000LL, "4000000000000000000"); + // Near-maximum balance at the smallest rate above parity. This is the + // largest quotient the branch can produce, and the case the old code + // failed earliest on -- its overflow boundary is lowest, ~1.8e17, when + // the rate is closest to parity. + checkLargeOwnerFunds(1, 9'000'000'000'000'000'000LL, "8999910000899991000"); + + // An MPT global lock makes book_offers report the locked MPT book + // liquidity as zero-funded instead of funded. + { + Env env{*this, features}; + + env.fund(XRP(10'000), issuer, maker, buyer); + env.close(); + + MPTTester musd( + {.env = env, + .issuer = issuer, + .holders = {maker, buyer}, + .pay = 100, + .flags = kMptDexFlags | tfMPTCanLock}); + MPT const usd = musd; + + auto const offerSeq = env.seq(maker); + env(offer(maker, XRP(100), usd(100))); + env.close(); + + { + json::Value const jrr = getBookOffers(env, XRP, usd); + json::Value const& bookOffers = jrr[jss::offers]; + BEAST_EXPECT(bookOffers.isArray()); + if (!BEAST_EXPECT(bookOffers.size() == 1)) + return; + + json::Value const& offer = bookOffers[0u]; + BEAST_EXPECT(offer[sfAccount.jsonName] == maker.human()); + BEAST_EXPECT(offer[sfSequence.jsonName] == offerSeq); + BEAST_EXPECT(offer[jss::owner_funds] == "100"); + BEAST_EXPECT(!offer.isMember(jss::taker_gets_funded)); + BEAST_EXPECT(!offer.isMember(jss::taker_pays_funded)); + } + + musd.set({.flags = tfMPTLock}); + + { + // The lock does not remove the offer from the ledger; + // book_offers must report it as zero-funded liquidity. + auto const bookOffers = getBookOffers(env, XRP, usd)[jss::offers]; + BEAST_EXPECT(bookOffers.isArray() && bookOffers.size() == 1); + + json::Value const& offer = bookOffers[0u]; + BEAST_EXPECT(offer[sfAccount] == maker.human()); + BEAST_EXPECT(offer[sfSequence] == offerSeq); + BEAST_EXPECT(offer[jss::owner_funds] == "0"); + BEAST_EXPECT(offer.isMember(jss::taker_gets_funded)); + BEAST_EXPECT(offer[jss::taker_gets_funded][jss::value] == "0"); + BEAST_EXPECT(offer.isMember(jss::taker_pays_funded)); + BEAST_EXPECT(offer[jss::taker_pays_funded] == "0"); + } + } + } + + // getBookBase hashes raw concatenations of fixed-width fields, so the + // (Issue,MPT) preimage `currency(20)||mptID(24)||account(20)` and the + // (MPT,Issue) preimage `mptID(24)||currency(20)||account(20)` are both + // 64 bytes and collide when the bytes align. An attacker picks the IOU + // currency, reuses an IOU issuer, and grinds an MPT issuer / sequence; + // the per-branch discriminator in getBookBase blocks this. + void + testBookBaseMixedAssetCollision(FeatureBitset /*features*/) + { + testcase("getBookBase: (Issue,MPT) vs (MPT,Issue) preimage collision"); + + // Construction recipe: + // issuerB last 4 bytes == seq_A; mptID_B = BE(5) || issuerB + // currencyA == mptID_B[0..19] = BE(5) || issuerB[0..15] + // issuerA == currencyB (both 20-byte all-0xBB) + // sharedIOUIssuer == acct_A == acct_B + AccountID issuerB; + AccountID issuerA; + Currency currencyB; + Currency currencyA; + AccountID sharedIOUIssuer; + BEAST_EXPECT(issuerB.parseHex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA00000007")); + BEAST_EXPECT(issuerA.parseHex("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")); + BEAST_EXPECT(currencyB.parseHex("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")); + BEAST_EXPECT(currencyA.parseHex("00000005AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + BEAST_EXPECT(sharedIOUIssuer.parseHex("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC")); + + Book const bookA{ + Asset{Issue{currencyA, sharedIOUIssuer}}, + Asset{MPTIssue{0x00000007u, issuerA}}, + std::nullopt}; + Book const bookB{ + Asset{MPTIssue{0x00000005u, issuerB}}, + Asset{Issue{currencyB, sharedIOUIssuer}}, + std::nullopt}; + + BEAST_EXPECT(bookA != bookB); + BEAST_EXPECT(getBookBase(bookA) != getBookBase(bookB)); + } + + // (MPT,MPT) bodies are 48 bytes and can't length-match the 64-byte + // mixed branches, but tag them too for symmetry/future-proofing; this + // test also pins the directional asymmetry of an (MPT,MPT) book. + void + testBookBaseMptMptDistinct(FeatureBitset /*features*/) + { + testcase("getBookBase: (MPT,MPT) distinguishes from mixed branches"); + + AccountID issuerX; + AccountID issuerY; + Currency currency; + AccountID iouIssuer; + BEAST_EXPECT(issuerX.parseHex("1111111111111111111111111111111111111111")); + BEAST_EXPECT(issuerY.parseHex("2222222222222222222222222222222222222222")); + BEAST_EXPECT(currency.parseHex("3333333333333333333333333333333333333333")); + BEAST_EXPECT(iouIssuer.parseHex("4444444444444444444444444444444444444444")); + + Asset const mptX{MPTIssue{1u, issuerX}}; + Asset const mptY{MPTIssue{2u, issuerY}}; + Book const mptBook{mptX, mptY, std::nullopt}; + Book const mixedBook{mptX, Asset{Issue{currency, iouIssuer}}, std::nullopt}; + Book const reversedMptBook{mptY, mptX, std::nullopt}; + + BEAST_EXPECT(getBookBase(mptBook) != getBookBase(mixedBook)); + BEAST_EXPECT(getBookBase(mptBook) != getBookBase(reversedMptBook)); + } + + void + testBookBaseDomainMptDistinct(FeatureBitset /*features*/) + { + testcase("getBookBase: domain does not reopen MPT preimage collisions"); + + // The type tag is a front prefix and the domain is a 32-byte suffix, so a + // domain'd book must (a) stay distinct from its public counterpart and + // (b) preserve the mixed-branch tag distinction that the public case has. + AccountID issuerX, issuerY, iouIssuer; + Currency currency; + BEAST_EXPECT(issuerX.parseHex("1111111111111111111111111111111111111111")); + BEAST_EXPECT(issuerY.parseHex("2222222222222222222222222222222222222222")); + BEAST_EXPECT(currency.parseHex("3333333333333333333333333333333333333333")); + BEAST_EXPECT(iouIssuer.parseHex("4444444444444444444444444444444444444444")); + + uint256 const domainA = uint256::fromVoid( + "\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD" + "\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD"); + + Asset const mptX{MPTIssue{1u, issuerX}}; + Asset const iou{Issue{currency, iouIssuer}}; + + // (a) same pair, public vs domain'd -> distinct directories. + Book const publicBook{mptX, iou, std::nullopt}; + Book const domainBook{mptX, iou, domainA}; + BEAST_EXPECT(getBookBase(publicBook) != getBookBase(domainBook)); + + // (b) mixed-branch tag distinction still holds *with* a domain set: + // (MPT,Issue) vs (Issue,MPT), both domain'd, must not collide. + Book const mi{mptX, iou, domainA}; + Book const im{iou, mptX, domainA}; + BEAST_EXPECT(mi != im); + BEAST_EXPECT(getBookBase(mi) != getBookBase(im)); + } + + void + testMPTOfferZeroRateCrossable(FeatureBitset features) + { + // An unrepresentable quality does not imply an offer that cannot + // function. "Can never be crossed" describes an offer that RESTS: + // crossing happens in applyGuts, before any residual is placed in the + // book, so an offer whose rate is unrepresentable can still consume a + // resting offer in full and never reach the quality-0 directory. + // + // The two sides of one trade do not have the same rate + // representability: getRate(TakerGets, TakerPays) overflows to 0 for + // the side paying a large MPT, but not for the side paying XRP. So a + // preflight rejection keyed on getRate() == 0 admits the resting half + // of a trade and rejects the crossing half. + testcase("MPT Offer Zero Rate - crossable quality"); + + using namespace jtx; + + // Above the rate-overflow threshold: divide() scales the XRP + // denominator up to a 1e15 mantissa and then evaluates + // muldiv(mptMantissa, 1e17, denMantissa), which exceeds 2^64 -- so + // getRate() takes its catch-all and returns 0. + auto const kBigMpt = 200'000'000'000'000'000LL; + + // Both scenarios are the same trade against the same resting offer, + // and both execute identically (at bob's price). They differ only in + // the price alice quotes, and therefore only in whether the rate on + // HER side of the book is representable. + auto runScenario = [&](STAmount const& aliceQuote, bool rateRepresentable) { + Env env{*this, features}; + auto const gw = Account{"gateway"}; + auto const alice = Account{"alice"}; + auto const bob = Account{"bob"}; + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPT const mpt = MPTTester( + {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount}); + + env(pay(gw, bob, mpt(kBigMpt))); + env.close(); + + // Bob rests the sell side: TakerPays = XRP(1), TakerGets = + // kBigMpt. getRate(TakerGets, TakerPays) is representable in this + // direction, so preflight admits it and it rests at a normal + // quality. + BEAST_EXPECT(getRate(mpt(kBigMpt), XRP(1)) != 0); + auto const bobSeq = env.seq(bob); + env(offer(bob, XRP(1), mpt(kBigMpt)), Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr); + + // Alice takes it from the other side: TakerPays = kBigMpt, + // TakerGets = her quote. + BEAST_EXPECT((getRate(aliceQuote, mpt(kBigMpt)) != 0) == rateRepresentable); + + auto const bobXrpBefore = env.balance(bob).value().xrp(); + env(offer(alice, mpt(kBigMpt), aliceQuote), Ter(tesSUCCESS)); + env.close(); + + // Alice's offer crosses bob's in full, so it never rests: nothing + // ends up in the quality-0 directory, no reserve is stranded, and + // the tick-rounding divide is never reached with a zero rate. + BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt)); + BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) == nullptr); + BEAST_EXPECT(offersOnAccount(env, alice).empty()); + // And it executes at bob's 1 XRP, whatever alice quoted. + BEAST_EXPECT(env.balance(bob).value().xrp() == bobXrpBefore + XRP(1).value().xrp()); + }; + + // Alice quotes bob's exact price. getRate() overflows to 0 on her + // side, yet the offer crosses in full and never rests. + runScenario(XRP(1), /*rateRepresentable=*/false); + // Alice quotes a price worse for herself, which halves the rate into + // representable range. Same execution as above: she pays 1 XRP for + // kBigMpt. The two cases are therefore numerically, not economically, + // different. + runScenario(XRP(2), /*rateRepresentable=*/true); + } + + void + testMPTOfferZeroRatePartialCross(FeatureBitset features) + { + // The case between the two extremes: an unrepresentable quality that + // crosses PARTIALLY. The crossed portion must execute -- it never + // touches the book -- while the residual must not be placed, since it + // would rest in the quality-0 directory holding a reserve it could + // never earn back by being crossed. + testcase("MPT Offer Zero Rate - partial cross"); + + using namespace jtx; + + auto const kBigMpt = 200'000'000'000'000'000LL; + + Env env{*this, features}; + auto const gw = Account{"gateway"}; + auto const alice = Account{"alice"}; + auto const bob = Account{"bob"}; + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPT const mpt = MPTTester( + {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount}); + + env(pay(gw, bob, mpt(kBigMpt))); + env.close(); + + // Bob rests a sell of kBigMpt for XRP(1) -- representable on his side. + auto const bobSeq = env.seq(bob); + env(offer(bob, XRP(1), mpt(kBigMpt)), Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr); + + // Alice asks for twice what bob has, at the same price. Her rate is + // unrepresentable: mantissa ratio 4e17 / 2e15 = 200 > ~184.47. + BEAST_EXPECT(getRate(XRP(2), mpt(2 * kBigMpt)) == 0); + + auto const aliceXrpBefore = env.balance(alice).value().xrp(); + auto const fee = env.current()->fees().base; + + env(offer(alice, mpt(2 * kBigMpt), XRP(2)), Ter(tesSUCCESS)); + env.close(); + + // The half that crossed executed at bob's price... + BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt)); + BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) == nullptr); + BEAST_EXPECT( + env.balance(alice).value().xrp() == aliceXrpBefore - XRP(1).value().xrp() - fee); + // ...and the half that did not is dropped rather than placed, so no + // offer rests and no reserve is consumed. + BEAST_EXPECT(offersOnAccount(env, alice).empty()); + BEAST_EXPECT((*env.le(alice))[sfOwnerCount] == 1); // the MPToken only + } + + void + testMPTOfferZeroRateTickSizeCross(FeatureBitset features) + { + // TickSize plus an unrepresentable quality plus a counterparty on the + // book. The tick-rounding path is skipped for a zero rate, since it + // would divide by that rate and throw, so the offer crosses at its raw + // price. Every other TickSize case here faces an empty book, making + // this the only coverage that the skip leaves crossing intact -- + // without it this transaction is tefEXCEPTION. + testcase("MPT Offer Zero Rate - tick size with crossing"); + + using namespace jtx; + + auto const kBigMpt = 5'000'000'000'000'000'000LL; + + Env env{*this, features}; + auto const gw = Account{"gateway"}; + auto const alice = Account{"alice"}; + auto const bob = Account{"bob"}; + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + auto const usd = gw["USD"]; + env(trust(alice, usd(1'000))); + env(pay(gw, alice, usd(100))); + env.close(); + + auto txn = noop(gw); + txn[sfTickSize.fieldName] = 5; + env(txn); + env.close(); + BEAST_EXPECT((*env.le(gw))[sfTickSize] == 5); + + MPT const mpt = MPTTester( + {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount}); + env(pay(gw, bob, mpt(kBigMpt))); + env.close(); + + // Bob rests the sell side; representable in that direction. + BEAST_EXPECT(getRate(mpt(kBigMpt), usd(1)) != 0); + auto const bobSeq = env.seq(bob); + env(offer(bob, usd(1), mpt(kBigMpt)), Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr); + + // Alice takes it from the unrepresentable side, with the tick size in + // force on her TakerGets. + BEAST_EXPECT(getRate(usd(1), mpt(kBigMpt)) == 0); + env(offer(alice, mpt(kBigMpt), usd(1)), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt)); + BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) == nullptr); + BEAST_EXPECT(offersOnAccount(env, alice).empty()); + } + + void + testMPTOfferZeroRateFlags(FeatureBitset features) + { + // A zero rate must not change what tfFillOrKill and tfImmediateOrCancel + // do. Both are handled above the unrepresentable-quality guard, but the + // ordering is not observable and no test can pin it: the guard returns + // the same pair either flag would. Immediate-or-cancel matches it by + // construction, and fill-or-kill disables partial payment + // (OfferCreate.cpp: flowCross is passed !tfFillOrKill), so a + // not-fully-fillable offer leaves crossed == false and both paths give + // {tecKILLED, false}. What this does cover is flags combined with an + // unrepresentable quality, which nothing else exercises. + testcase("MPT Offer Zero Rate - IOC and FoK"); + + using namespace jtx; + + auto const kBigMpt = 200'000'000'000'000'000LL; + + auto const runScenario = [&](std::uint32_t flags, TER expected) { + Env env{*this, features}; + auto const gw = Account{"gateway"}; + auto const alice = Account{"alice"}; + auto const bob = Account{"bob"}; + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPT const mpt = MPTTester( + {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount}); + env(pay(gw, bob, mpt(kBigMpt))); + env.close(); + + auto const bobSeq = env.seq(bob); + env(offer(bob, XRP(1), mpt(kBigMpt)), Ter(tesSUCCESS)); + env.close(); + + // Asking for twice what bob has forces a partial cross, so the + // flag handling -- not the fully-crossed early return -- decides. + BEAST_EXPECT(getRate(XRP(2), mpt(2 * kBigMpt)) == 0); + env(offer(alice, mpt(2 * kBigMpt), XRP(2), flags), Ter(expected)); + env.close(); + + auto const bobOfferLive = + env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr; + if (isTesSuccess(expected)) + { + // Immediate-or-cancel: the crossed part is kept, the rest is + // cancelled -- the same shape the guard would produce. + BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt)); + BEAST_EXPECT(!bobOfferLive); + } + else + { + // Fill-or-kill: the offer is not fully fillable, so nothing + // crosses at all and bob's offer survives untouched. + BEAST_EXPECT(env.balance(alice, mpt) == mpt(0)); + BEAST_EXPECT(bobOfferLive); + } + BEAST_EXPECT(offersOnAccount(env, alice).empty()); + }; + + runScenario(tfImmediateOrCancel, tesSUCCESS); + runScenario(tfFillOrKill, tecKILLED); + } + void testAll(FeatureBitset features) { @@ -5649,7 +6509,17 @@ public: testPartiallyFundedMPTInputOfferZeroInput(features); testFillOrKill(features); testTickSize(features); + testMPTOfferZeroRate(features); + testMPTOfferZeroRateCrossable(features); + testMPTOfferZeroRatePartialCross(features); + testMPTOfferZeroRateTickSizeCross(features); + testMPTOfferZeroRateFlags(features); + testZeroRateXrpIouOffer(features); + testBookOffersMPTFunding(features); testAutoCreateReserve(features); + testBookBaseMixedAssetCollision(features); + testBookBaseMptMptDistinct(features); + testBookBaseDomainMptDistinct(features); } void diff --git a/src/test/app/PathMPT_test.cpp b/src/test/app/PathMPT_test.cpp index ff4a024cb8..87da13087f 100644 --- a/src/test/app/PathMPT_test.cpp +++ b/src/test/app/PathMPT_test.cpp @@ -14,6 +14,8 @@ #include #include #include +#include +#include #include #include @@ -25,6 +27,8 @@ #include #include #include +#include +#include #include #include #include @@ -33,6 +37,7 @@ #include #include +#include #include #include #include @@ -231,6 +236,102 @@ public: env.require(Balance("bob", usd(24))); } + void + sourceCurrencyWithSendMax() + { + testcase("source currency with send_max"); + using namespace jtx; + + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gateway"); + env.fund(XRP(10'000), alice, bob, gw); + + MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob}}); + env(pay(gw, alice, usd(25))); + env.close(); + + // MPT source_currencies entries do not carry an issuer. A matching + // send_max identifies the same issuance, so the request should not run + // the IOU issuer reconciliation path. + auto const result = findPathsRequest( + env, + alice, + bob, + usd(-1), + std::optional(usd(10).value()), + std::optional(usd.mpt())); + BEAST_EXPECTS(!result.isMember(jss::error), result.toStyledString()); + + auto const& alternatives = result[jss::alternatives]; + if (BEAST_EXPECT(alternatives.size() == 1)) + { + auto const sa = amountFromJson(sfGeneric, alternatives[0u][jss::source_amount]); + auto const da = amountFromJson(sfGeneric, alternatives[0u][jss::destination_amount]); + BEAST_EXPECTS(equal(sa, usd(10)), sa.getFullText()); + BEAST_EXPECTS(equal(da, usd(10)), da.getFullText()); + } + } + + void + maxedOutMPTPathfinding() + { + testcase("maxed-out MPT pathfinding"); + using namespace jtx; + + auto hasMPT = [](auto const& assets, MPT const& mpt) { + return std::ranges::any_of(assets, [&](auto const& asset) { + return asset.template holds() && asset.template get() == mpt.mpt(); + }); + }; + + Env env = pathTestEnv(); + auto const gw = Account("gateway"); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const carol = Account("carol"); + + env.fund(XRP(10'000), gw, alice, bob, carol); + env.close(); + + MPT const usd = + MPTTester({.env = env, .issuer = gw, .holders = {alice, bob, carol}, .maxAmt = 100}); + env(pay(gw, alice, usd(90))); + env(pay(gw, bob, usd(10))); + env.close(); + + auto const cache = + std::make_shared(env.current(), env.app().getJournal("AssetCache")); + + BEAST_EXPECT(hasMPT(accountSourceAssets(alice.id(), cache, false), usd)); + BEAST_EXPECT(hasMPT(accountDestAssets(bob.id(), cache, false), usd)); + BEAST_EXPECT(hasMPT(accountDestAssets(carol.id(), cache, false), usd)); + + // A fully minted issuance should not be advertised as issuer-side + // mintable source liquidity. + BEAST_EXPECT(!hasMPT(accountSourceAssets(gw.id(), cache, false), usd)); + + auto [st, sa, da] = findPaths(env, alice, bob, usd(5)); + BEAST_EXPECT(st.empty()); + BEAST_EXPECT(equal(sa, usd(5))); + BEAST_EXPECT(equal(da, usd(5))); + + env(offer(carol, usd(5), XRP(5))); + env.close(); + + std::tie(st, sa, da) = findPaths(env, alice, bob, drops(-1), usd(100).value()); + BEAST_EXPECT(sa == usd(5)); + BEAST_EXPECT(equal(da, XRP(5))); + if (BEAST_EXPECT(st.size() == 1 && st[0].size() == 1)) + { + auto const& pathElem = st[0][0]; + BEAST_EXPECT( + pathElem.isOffer() && pathElem.getIssuerID() == xrpAccount() && + pathElem.getCurrency() == xrpCurrency()); + } + } + void pathFind(bool const domainEnabled) { @@ -441,6 +542,124 @@ public: } } + // Regression test: the Pathfinder constructor must honor the + // caller-supplied srcAmount (= the user's send_max from PathRequest) + // when ranking candidate paths in convert_all mode. + // + // Background. The MPT-DEX refactor of `Pathfinder::Pathfinder` + // (src/xrpld/rpc/detail/Pathfinder.cpp) replaced the original + // `mSrcAmount(srcAmount.value_or(...))` initializer with an + // unconditional `amountFromPathAsset(...)` call. The latter always + // returns the negative "no limit" STAmount sentinel, so the + // `srcAmount` constructor parameter became dead code: + // `getPathLiquidity` and `computePathRanks` ran `rippleCalculate` + // with `saMaxAmountReq` = sentinel and recorded each path's + // saturated capacity instead of the capacity reachable inside + // send_max. + // + // In convert_all_ mode (the only mode that allows send_max), + // `Pathfinder::rankPaths` ignores quality and orders purely by + // liquidity, then `Pathfinder::getBestPaths` only fills the last + // (kMaxPaths-th = 4th) slot when `pathRank.liquidity >= remaining`. + // For convert_all_ `remaining = largestAmount(dstAmount_)`, so the + // last slot effectively never fills and the cut keeps the top 3 + // ranked paths. With the wrong (unbounded-budget) ranking, a + // low-capacity / high-rate path that would actually deliver the + // most under the user's send_max can be excluded entirely. + // + // Topology. Four candidate paths from alice's XRP to bob's USD-MPT, + // each via a distinct IOU intermediary issued by a different market + // maker: + // + // charlie: XRP(1000) -> AUD(1000) -> USD(500) cap 1000 XRP, rate 0.5 + // dave: XRP(1000) -> EUR(1000) -> USD(500) cap 1000 XRP, rate 0.5 + // eve: XRP(1000) -> GBP(1000) -> USD(500) cap 1000 XRP, rate 0.5 + // frank: XRP(50) -> JPY(50) -> USD(75) cap 50 XRP, rate 1.5 + // + // Alice queries findPaths with destination = USD-MPT(-1) (convert_all) + // and send_max = XRP(100). + // + // Bug-free ranking (post-fix), with srcAmount = XRP(100): + // charlie/dave/eve liquidity = min(100, 1000) * 0.5 = 50 USD each + // frank liquidity = min(100, 50) * 1.5 = 75 USD + // -> frank ranks first; the flow uses frank's 50 XRP at 1.5 (=75 USD) + // plus 50 XRP via a 0.5-rate path (=25 USD), delivering USD(100). + // + // Pre-fix ranking, with srcAmount silently replaced by the sentinel: + // charlie/dave/eve liquidity = 1000 * 0.5 = 500 USD each + // frank liquidity = 50 * 1.5 = 75 USD + // -> frank ranks 4th; the last-slot rule excludes it from the + // surviving path set, the cut keeps the three 0.5-rate paths, + // and the flow delivers only 100 * 0.5 = USD(50). + // + // This test asserts the post-fix outcome (USD(100)). On the pre-fix + // tree the assertion fails with USD(50). + void + convertAllSendMaxRanking() + { + testcase("convert_all + send_max: srcAmount governs path ranking"); + using namespace jtx; + + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gateway"); + auto const charlie = Account("charlie"); + auto const dave = Account("dave"); + auto const eve = Account("eve"); + auto const frank = Account("frank"); + + env.fund(XRP(10'000), alice, bob, gw, charlie, dave, eve, frank); + env.close(); + + // USD MPT issued by gw; the four market makers and bob are holders. + // alice is not a holder because she only pays XRP; USD only ever + // flows from gw / market-maker offers to bob. + MPT const usd = + MPTTester({.env = env, .issuer = gw, .holders = {charlie, dave, eve, frank, bob}}); + + // Capitalize each market maker with the USD-MPT they will sell. + env(pay(gw, charlie, usd(500))); + env(pay(gw, dave, usd(500))); + env(pay(gw, eve, usd(500))); + env(pay(gw, frank, usd(75))); + env.close(); + + // Each market maker issues their own intermediate IOU. + auto const aud = charlie["AUD"]; + auto const eur = dave["EUR"]; + auto const gbp = eve["GBP"]; + auto const jpy = frank["JPY"]; + + // Three high-capacity, low-rate paths (1 XRP -> 0.5 USD-MPT, + // capacity 1000 XRP each). + env(offer(charlie, XRP(1'000), aud(1'000))); + env(offer(charlie, aud(1'000), usd(500))); + env(offer(dave, XRP(1'000), eur(1'000))); + env(offer(dave, eur(1'000), usd(500))); + env(offer(eve, XRP(1'000), gbp(1'000))); + env(offer(eve, gbp(1'000), usd(500))); + + // One low-capacity, high-rate path (1 XRP -> 1.5 USD-MPT, + // capacity 50 XRP). + env(offer(frank, XRP(50), jpy(50))); + env(offer(frank, jpy(50), usd(75))); + env.close(); + + // ripple_path_find with convert_all (USD(-1)) and send_max XRP(100). + STPathSet st; + STAmount sa; + STAmount da; + std::tie(st, sa, da) = + findPaths(env, alice, bob, usd(-1), std::optional(XRP(100).value())); + + // Post-fix: frank's high-rate path is included in the surviving + // path set, so the flow uses 50 XRP at 1.5 plus 50 XRP at 0.5, + // delivering exactly USD(100) on alice's 100-XRP budget. + BEAST_EXPECT(sa == XRP(100)); + BEAST_EXPECT(equal(da, usd(100))); + } + void run() override { @@ -448,6 +667,9 @@ public: noDirectPathNoIntermediaryNoAlternatives(); directPathNoIntermediary(); paymentAutoPathFind(); + sourceCurrencyWithSendMax(); + maxedOutMPTPathfinding(); + convertAllSendMaxRanking(); for (auto const domainEnabled : {false, true}) { pathFind(domainEnabled); diff --git a/src/test/app/Path_test.cpp b/src/test/app/Path_test.cpp index 29b4a5b048..cd61668b03 100644 --- a/src/test/app/Path_test.cpp +++ b/src/test/app/Path_test.cpp @@ -147,7 +147,8 @@ public: STAmount const& saDstAmount, std::optional const& saSendMax = std::nullopt, std::optional const& saSrcCurrency = std::nullopt, - std::optional const& domain = std::nullopt) + std::optional const& domain = std::nullopt, + std::optional const& saSrcIssuer = std::nullopt) { using namespace jtx; @@ -181,6 +182,10 @@ public: auto& sc = params[jss::source_currencies] = json::ValueType::Array; json::Value j = json::ValueType::Object; j[jss::currency] = to_string(saSrcCurrency.value()); + // Optional issuer for tests that need to exercise + // source_currencies entries more precisely than currency alone. + if (saSrcIssuer) + j[jss::issuer] = toBase58(*saSrcIssuer); sc.append(j); } if (domain) @@ -209,10 +214,11 @@ public: STAmount const& saDstAmount, std::optional const& saSendMax = std::nullopt, std::optional const& saSrcCurrency = std::nullopt, - std::optional const& domain = std::nullopt) + std::optional const& domain = std::nullopt, + std::optional const& saSrcIssuer = std::nullopt) { - json::Value result = - findPathsRequest(env, src, dst, saDstAmount, saSendMax, saSrcCurrency, domain); + json::Value result = findPathsRequest( + env, src, dst, saDstAmount, saSendMax, saSrcCurrency, domain, saSrcIssuer); BEAST_EXPECT(!result.isMember(jss::error)); STAmount da; @@ -325,6 +331,53 @@ public: BEAST_EXPECT(result.isMember(jss::error)); } + void + sourceCurrencyIssuerSelection() + { + testcase("source currency issuer selection"); + using namespace jtx; + + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gateway = Account("gateway"); + + env.fund(XRP(10000), alice, bob, gateway); + env.close(); + + auto const usd = gateway["USD"]; + env.trust(usd(600), alice); + env.trust(usd(700), bob); + env.trust(alice["USD"](700), bob); + env(pay(gateway, alice, usd(70))); + env(pay(gateway, bob, usd(50))); + env.close(); + + // Ask for USD from an explicit source issuer while send_max is + // Alice-issued USD. The parser should choose gateway-issued USD + // because gateway is the issuer in source_currencies. + // + // The Alice/Bob trust line is intentional: if Alice-issued USD is also + // considered as a source asset, pathfinding can produce an additional + // alternative. The single expected alternative below verifies that only + // the explicit issuer is selected. + auto const result = findPathsRequest( + env, + alice, + bob, + bob["USD"](-1), + alice["USD"](100).value(), + usd.currency, + std::nullopt, + gateway.id()); + auto const& alternatives = result[jss::alternatives]; + BEAST_EXPECT(alternatives.size() == 1); + auto const sa = amountFromJson(sfGeneric, alternatives[0u][jss::source_amount]); + auto const da = amountFromJson(sfGeneric, alternatives[0u][jss::destination_amount]); + BEAST_EXPECTS(equal(sa, usd(100)), sa.getFullText()); + BEAST_EXPECTS(equal(da, bob["USD"](100)), da.getFullText()); + } + void noDirectPathNoIntermediaryNoAlternatives() { @@ -1968,6 +2021,7 @@ public: run() override { sourceCurrenciesLimit(); + sourceCurrencyIssuerSelection(); noDirectPathNoIntermediaryNoAlternatives(); directPathNoIntermediary(); paymentAutoPathFind(); diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index c6cd49fa26..2743084beb 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -17,7 +17,7 @@ #include #include #include -#include +#include #include #include #include diff --git a/src/test/rpc/BookChanges_test.cpp b/src/test/rpc/BookChanges_test.cpp index 98a9372982..f0b4a4e187 100644 --- a/src/test/rpc/BookChanges_test.cpp +++ b/src/test/rpc/BookChanges_test.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -8,13 +9,33 @@ #include #include +#include + #include +#include +#include #include #include #include +#include #include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include + namespace xrpl::test { class BookChanges_test : public beast::unit_test::Suite @@ -115,6 +136,195 @@ public: BEAST_EXPECT(jrr[jss::changes][0u][jss::domain].asString() == to_string(domainID)); } + void + testSkipsOverflowingRate() + { + testcase("book_changes skips overflowing rate"); + using namespace jtx; + + Env env(*this); + Account const gw{"gw"}; + Account const iouGw{"iouGw"}; + + auto const big = MPT{gw.id(), 1}; + auto const usd = iouGw["USD"]; + + // This metadata represents a partial MPT/IOU offer fill whose deltas + // make divide(deltaGets, deltaPays) overflow before MPTokensV2 skips + // the unrepresentable book-change rate. + STObject finalFields = STObject::makeInnerObject(sfFinalFields); + finalFields.setFieldU32(sfSequence, 1); + finalFields.setFieldAmount(sfTakerGets, big(1'800'000'000'000'000'000ull)); + finalFields.setFieldAmount(sfTakerPays, usd(9)); + + STObject previousFields = STObject::makeInnerObject(sfPreviousFields); + previousFields.setFieldU32(sfSequence, 1); + previousFields.setFieldAmount(sfTakerGets, big(3'600'000'000'000'000'000ull)); + previousFields.setFieldAmount(sfTakerPays, usd(18)); + + STObject modifiedOffer{sfModifiedNode}; + modifiedOffer.setFieldU16(sfLedgerEntryType, ltOFFER); + modifiedOffer.setFieldObject(sfFinalFields, finalFields); + modifiedOffer.setFieldObject(sfPreviousFields, previousFields); + + STArray affectedNodes{sfAffectedNodes}; + affectedNodes.pushBack(std::move(modifiedOffer)); + + auto metadata = std::make_shared(sfTransactionMetaData); + metadata->setFieldArray(sfAffectedNodes, affectedNodes); + + auto tx = std::make_shared(ttOFFER_CREATE, [](STObject&) {}); + + auto const test = [&](std::unordered_set> const& features) { + auto ledger = std::make_shared( + 2, + NetClock::time_point{}, + Rules{features}, + env.current()->fees(), + env.app().getNodeFamily()); + + auto txSerializer = std::make_shared(); + tx->add(*txSerializer); + + auto metaSerializer = std::make_shared(); + metadata->add(*metaSerializer); + + ledger->rawTxInsert(uint256{1}, txSerializer, metaSerializer); + ledger->setImmutable(); + ledger->setValidated(); + + try + { + auto const result = + xrpl::rpc::computeBookChanges(std::static_pointer_cast(ledger)); + BEAST_EXPECT(result[jss::type] == "bookChanges"); + BEAST_EXPECT(result[jss::changes].size() == 0); + } + catch (std::overflow_error const&) + { + fail("Overflowing book-change rate shouldn't throw"); + } + }; + + test(std::unordered_set>{}); + test(std::unordered_set>{featureMPTokensV2}); + } + + // Build a ledger whose transactions are OfferCreates carrying the supplied + // consumed-offer deltas, then run computeBookChanges over it. Each pair is + // (TakerGets, TakerPays) fully consumed off a resting offer. + static json::Value + bookChangesFor(jtx::Env& env, std::vector> const& crossings) + { + auto ledger = std::make_shared( + 2, + NetClock::time_point{}, + Rules{std::unordered_set>{featureMPTokensV2}}, + env.current()->fees(), + env.app().getNodeFamily()); + + std::uint32_t seq = 0; + for (auto const& [gets, pays] : crossings) + { + ++seq; + + STObject finalFields = STObject::makeInnerObject(sfFinalFields); + finalFields.setFieldU32(sfSequence, seq); + finalFields.setFieldAmount(sfTakerGets, STAmount{gets.asset()}); + finalFields.setFieldAmount(sfTakerPays, STAmount{pays.asset()}); + + STObject previousFields = STObject::makeInnerObject(sfPreviousFields); + previousFields.setFieldU32(sfSequence, seq); + previousFields.setFieldAmount(sfTakerGets, gets); + previousFields.setFieldAmount(sfTakerPays, pays); + + STObject modifiedOffer{sfModifiedNode}; + modifiedOffer.setFieldU16(sfLedgerEntryType, ltOFFER); + modifiedOffer.setFieldObject(sfFinalFields, finalFields); + modifiedOffer.setFieldObject(sfPreviousFields, previousFields); + + STArray affectedNodes{sfAffectedNodes}; + affectedNodes.pushBack(std::move(modifiedOffer)); + + auto metadata = std::make_shared(sfTransactionMetaData); + metadata->setFieldArray(sfAffectedNodes, affectedNodes); + + STTx const tx{ttOFFER_CREATE, [](STObject&) {}}; + + auto txSerializer = std::make_shared(); + tx.add(*txSerializer); + + auto metaSerializer = std::make_shared(); + metadata->add(*metaSerializer); + + ledger->rawTxInsert(uint256{seq}, txSerializer, metaSerializer); + } + + ledger->setImmutable(); + ledger->setValidated(); + + return xrpl::rpc::computeBookChanges(std::static_pointer_cast(ledger)); + } + + void + testSkipsOverflowingVolume() + { + testcase("book_changes skips overflowing volume"); + using namespace jtx; + + Env env(*this); + + // Two crossings in one book, accumulated by the `+=` in the tally's + // else branch. The rate is 1 either way, so the divide() guard is not + // what is under test here. + // + // MPT: kMaxMpTokenAmount is INT64_MAX, so two halves sum past it. The + // add is a raw int64 add, which wraps to a negative amount rather than + // throwing, and canonicalize() only bounds the magnitude -- so before + // the fix this reported a negative volume. + { + auto const mptA = MPT{Account{"gw"}.id(), 1}; + auto const mptB = MPT{Account{"gw"}.id(), 2}; + auto const half = 5'000'000'000'000'000'000ull; // 2 * half > INT64_MAX + + auto const result = + bookChangesFor(env, {{mptA(half), mptB(half)}, {mptA(half), mptB(half)}}); + + BEAST_EXPECT(result[jss::type] == "bookChanges"); + if (BEAST_EXPECT(result[jss::changes].size() == 1)) + { + auto const& change = result[jss::changes][0u]; + // The second crossing is dropped, so the first one's volume + // stands. Above all it must not be negative. + BEAST_EXPECT(change[jss::volume_a].asString() == std::to_string(half)); + BEAST_EXPECT(change[jss::volume_b].asString() == std::to_string(half)); + } + } + + // IOU: the addition throws std::overflow_error once the summed + // exponent passes IOUAmount::kMaxExponent. Before the fix that + // escaped computeBookChanges entirely. + { + Account const gwA{"gwA"}; + Account const gwB{"gwB"}; + // Mantissa in range, exponent at the maximum: two of these sum to + // one exponent past it. + STAmount const bigA{gwA["USD"].issue(), UINT64_C(9'000'000'000'000'000), 80}; + STAmount const bigB{gwB["EUR"].issue(), UINT64_C(9'000'000'000'000'000), 80}; + + try + { + auto const result = bookChangesFor(env, {{bigA, bigB}, {bigA, bigB}}); + BEAST_EXPECT(result[jss::type] == "bookChanges"); + BEAST_EXPECT(result[jss::changes].size() == 1); + } + catch (std::overflow_error const&) + { + fail("Overflowing book-change volume shouldn't throw"); + } + } + } + void run() override { @@ -122,6 +332,8 @@ public: testLedgerInputDefaultBehavior(); testDomainOffer(); + testSkipsOverflowingRate(); + testSkipsOverflowingVolume(); // Note: Other aspects of the book_changes rpc are fertile grounds // for unit-testing purposes. It can be included in future work } diff --git a/src/test/rpc/LedgerRPC_test.cpp b/src/test/rpc/LedgerRPC_test.cpp index af93108ff2..e7c5dd4a80 100644 --- a/src/test/rpc/LedgerRPC_test.cpp +++ b/src/test/rpc/LedgerRPC_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,7 @@ #include #include #include +#include #include #include @@ -258,6 +260,102 @@ class LedgerRPC_test : public beast::unit_test::Suite BEAST_EXPECT(jrr[jss::ledger][jss::accountState].size() == 3u); } + void + testLedgerOwnerFundsMPTOffer() + { + testcase("Ledger owner_funds with MPT offer"); + using namespace test::jtx; + + Env env{*this}; + Account const gw{"gateway"}; + Account const alice{"alice"}; + auto const usd = gw["USD"]; + + env.fund(XRP(10'000), gw, alice); + env.close(); + env.trust(usd(1'000), alice); + env(pay(gw, alice, usd(100))); + MPTTester mpt( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 100, + .flags = tfMPTRequireAuth | kMptDexFlags, + .authHolder = true, + .close = false}); + MPT const mptAsset = mpt; + env.close(); + + env(noop(alice)); + // These offers differ only by TakerGets asset type. Omitting + // owner_funds serializes the tx JSON without computing offer balances; + // owner_funds=true asks LedgerToJson to compute accountFunds(TakerGets) + // for both offers, which is where IOU and MPT used to diverge. + env(offer(alice, XRP(10), usd(10))); + env(offer(alice, XRP(10), mptAsset(10))); + // The MPT offer was created while authorized. Unauthorizing in the + // same ledger makes owner_funds depend on AuthHandling::IgnoreAuth. + mpt.authorize({.account = gw, .holder = alice, .flags = tfMPTUnauthorize}); + env(noop(alice)); + env.close(); + + auto const ledgerHash = to_string(env.closed()->header().hash); + + auto const getTransactions = [&](bool includeOwnerFunds) { + json::Value params; + params[jss::ledger_hash] = ledgerHash; + params[jss::transactions] = true; + params[jss::expand] = true; + // The baseline omits owner_funds, which the RPC treats as false. + // Setting it true requests the same ledger, but asks the ledger + // serializer to add owner_funds to offer transactions in that + // ledger's transaction array. + if (includeOwnerFunds) + params[jss::owner_funds] = true; + + auto const result = env.rpc("json", "ledger", to_string(params))[jss::result]; + BEAST_EXPECT(!result.isMember(jss::error)); + BEAST_EXPECT(result[jss::ledger][jss::transactions].isArray()); + return result[jss::ledger][jss::transactions]; + }; + + auto const findOffer = [](json::Value const& txs, bool mpt) -> json::Value const* { + for (auto i = 0u; i < txs.size(); ++i) + { + auto const& tx = txs[i].isMember(jss::tx_json) ? txs[i][jss::tx_json] : txs[i]; + if (tx[jss::TransactionType] == jss::OfferCreate && + tx[jss::TakerGets].isMember(jss::mpt_issuance_id) == mpt) + { + return &txs[i]; + } + } + return nullptr; + }; + + // Baseline: same ledger request without owner_funds fields. + auto const baseline = getTransactions(false); + BEAST_EXPECT(baseline.size() == 5u); + BEAST_EXPECT(findOffer(baseline, false) != nullptr); + BEAST_EXPECT(findOffer(baseline, true) != nullptr); + + // Same ledger request with owner_funds added to eligible offer txs. + auto const withOwnerFunds = getTransactions(true); + // Requesting owner_funds must not change which ledger transactions are + // returned, even when one offer's TakerGets is MPT. + BEAST_EXPECT(withOwnerFunds.size() == baseline.size()); + + // The IOU offer is the control case for expected owner_funds output. + auto const* iouOfferTx = findOffer(withOwnerFunds, false); + if (BEAST_EXPECT(iouOfferTx != nullptr)) + BEAST_EXPECT((*iouOfferTx)[jss::owner_funds] == "100"); + + // MPT owner_funds should match the IOU behavior, even though Alice is + // unauthorized by the ledger snapshot used for serialization. + auto const* mptOfferTx = findOffer(withOwnerFunds, true); + if (BEAST_EXPECT(mptOfferTx != nullptr)) + BEAST_EXPECT((*mptOfferTx)[jss::owner_funds] == "100"); + } + /** * @brief ledger RPC requests as a way to drive * input options to lookupLedger. The point of this test is @@ -719,6 +817,7 @@ public: testLedgerFull(); testLedgerFullNonAdmin(); testLedgerAccounts(); + testLedgerOwnerFundsMPTOffer(); testLookupLedger(); testNoQueue(); testQueue(); diff --git a/src/xrpld/app/ledger/detail/LedgerToJson.cpp b/src/xrpld/app/ledger/detail/LedgerToJson.cpp index 9d3820e9f7..921c640f06 100644 --- a/src/xrpld/app/ledger/detail/LedgerToJson.cpp +++ b/src/xrpld/app/ledger/detail/LedgerToJson.cpp @@ -208,6 +208,7 @@ fillJsonTx( account, amount, FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, beast::Journal{beast::Journal::getNullSink()}); txJson[jss::owner_funds] = ownerFunds.getText(); } diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 771330367c..0f323995ae 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -74,10 +74,11 @@ #include #include #include -#include #include +#include #include #include +#include #include #include #include @@ -85,8 +86,11 @@ #include #include #include +#include #include #include +#include +#include #include #include #include @@ -4788,8 +4792,7 @@ NetworkOPsImp::getBookPage( ReadView const& view = *lpLedger; - bool const bGlobalFreeze = - isGlobalFrozen(view, book.out.getIssuer()) || isGlobalFrozen(view, book.in.getIssuer()); + bool const bGlobalFreeze = isGlobalFrozen(view, book.out) || isGlobalFrozen(view, book.in); bool bDone = false; bool bDirectAdvance = true; @@ -4799,7 +4802,7 @@ NetworkOPsImp::getBookPage( unsigned int uBookEntry = 0; STAmount saDirRate; - auto const rate = transferRate(view, book.out.getIssuer()); + auto const rate = transferRate(view, book.out); auto viewJ = registry_.get().getJournal("View"); while (!bDone && iLimit-- > 0) @@ -4848,12 +4851,37 @@ NetworkOPsImp::getBookPage( auto const& saTakerPays = sleOffer->getFieldAmount(sfTakerPays); STAmount saOwnerFunds; bool firstOwnerOffer(true); + auto foundBalance = [&]() { + auto umBalanceEntry = umBalance.find(uOfferOwnerID); + if (umBalanceEntry == umBalance.end()) + return false; + + // Found in running balance table. + saOwnerFunds = umBalanceEntry->second; + firstOwnerOffer = false; + return true; + }; if (book.out.getIssuer() == uOfferOwnerID) { - // If an offer is selling issuer's own IOUs, it is fully - // funded. - saOwnerFunds = saTakerGets; + book.out.visit( + [&](Issue const&) { + // If an offer is selling issuer's own IOUs, it is + // fully funded. + saOwnerFunds = saTakerGets; + }, + [&](MPTIssue const& issue) { + // MPT issuers have bounded self-issuance. Use the + // running balance table so multiple issuer-owned + // offers share the same remaining issuance + // headroom. + if (!foundBalance()) + { + // Did not find balance in table. + + saOwnerFunds = issuerFundsToSelfIssue(view, issue); + } + }); } else if (bGlobalFreeze) { @@ -4863,15 +4891,7 @@ NetworkOPsImp::getBookPage( } else { - auto umBalanceEntry = umBalance.find(uOfferOwnerID); - if (umBalanceEntry != umBalance.end()) - { - // Found in running balance table. - - saOwnerFunds = umBalanceEntry->second; - firstOwnerOffer = false; - } - else + if (!foundBalance()) { // Did not find balance in table. @@ -4907,7 +4927,28 @@ NetworkOPsImp::getBookPage( { // Need to charge a transfer fee to offer owner. offerRate = rate; - saOwnerFundsLimit = divide(saOwnerFunds, offerRate); + // Why MPT does not use divide(): divide() is built for an + // IOU mantissa, which is always normalized into + // [1e15, 1e16]. An MPT mantissa is the raw int64 balance, + // and divide() scales the numerator by 1e17, so a balance + // over ~1.8e17 leaves uint64 range and throws -- failing + // the whole RPC rather than this one offer. + // + // Why mulRatio is safe: it evaluates in 128 bits, and here + // it cannot overflow either. offerRate is + // 1e9 + 10'000 * TransferFee, so this branch runs only with + // offerRate > kParityRate, making the quotient smaller than + // saOwnerFunds. Rounded down, so reported liquidity is + // never overstated. + saOwnerFundsLimit = saOwnerFunds.holds() + ? toSTAmount( + mulRatio( + saOwnerFunds.mpt(), + kParityRate.value, + offerRate.value, + /*roundUp*/ false), + saOwnerFunds.asset()) + : divide(saOwnerFunds, offerRate); } if (saOwnerFundsLimit >= saTakerGets) @@ -4964,6 +5005,8 @@ NetworkOPsImp::getBookPage( // This is the new code that uses the book iterators // It has temporarily been disabled +// If this path is re-enabled, add MPT support mirroring the functional +// getBookPage() implementation above. void NetworkOPsImp::getBookPage( diff --git a/src/xrpld/rpc/BookChanges.h b/src/xrpld/rpc/BookChanges.h index 16f7ea8e43..1912b0512e 100644 --- a/src/xrpld/rpc/BookChanges.h +++ b/src/xrpld/rpc/BookChanges.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include #include #include +#include #include #include @@ -50,6 +52,36 @@ computeBookChanges(std::shared_ptr const& lpAccepted) std::optional>> // optional: domain id tally; + // Accumulating volume can exceed what the asset can represent, and the two + // types fail differently: STAmount's IOU addition throws, while its MPT + // addition is a raw int64 add that wraps past kMaxMpTokenAmount to a + // negative amount. Reject both so that one extreme crossing cannot poison + // this ledger's report, which is otherwise permanent -- the ledger is + // immutable and the computation deterministic. + auto const checkedAdd = [](STAmount& acc, STAmount const& delta) { + return acc.asset().visit( + [&](Issue const&) { + try + { + acc += delta; + } + catch (std::overflow_error const&) + { + return false; + } + return true; + }, + [&](MPTIssue const&) { + // Both volumes are non-negative by the time they reach the + // tally, so this cannot underflow. + auto const room = static_cast(kMaxMpTokenAmount) - acc.mpt().value(); + if (delta.mpt().value() > room) + return false; + acc += delta; + return true; + }); + }; + for (auto& tx : lpAccepted->txs) { if (!tx.first || !tx.second || !tx.first->isFieldPresent(sfTransactionType)) @@ -123,7 +155,16 @@ computeBookChanges(std::shared_ptr const& lpAccepted) if (second == beast::kZero) continue; - STAmount const rate = divide(first, second, noIssue()); + std::optional maybeRate; + try + { + maybeRate = divide(first, second, noIssue()); + } + catch (std::overflow_error const&) + { + continue; + } + STAmount const rate = *maybeRate; if (first < beast::kZero) first = -first; @@ -161,8 +202,15 @@ computeBookChanges(std::shared_ptr const& lpAccepted) // increment volume auto& entry = tally[key]; - std::get<0>(entry) += first; // side A vol - std::get<1>(entry) += second; // side B vol + // Commit both sides or neither, so an overflow on the second + // cannot leave the entry half-updated. Skipping the crossing + // matches how an unrepresentable rate is handled above. + STAmount volA = std::get<0>(entry); + STAmount volB = std::get<1>(entry); + if (!checkedAdd(volA, first) || !checkedAdd(volB, second)) + continue; + std::get<0>(entry) = volA; // side A vol + std::get<1>(entry) = volB; // side B vol if (std::get<2>(entry) < rate) // high std::get<2>(entry) = rate; diff --git a/src/xrpld/rpc/detail/AccountAssets.cpp b/src/xrpld/rpc/detail/AccountAssets.cpp index 67b9174fe3..0e71836b74 100644 --- a/src/xrpld/rpc/detail/AccountAssets.cpp +++ b/src/xrpld/rpc/detail/AccountAssets.cpp @@ -49,7 +49,7 @@ accountSourceAssets( { for (auto const& rspEntry : *mpts) { - if (!rspEntry.isZeroBalance() && !rspEntry.isMaxedOut()) + if (rspEntry.canSend(account)) assets.insert(rspEntry.getMptID()); } } @@ -86,8 +86,10 @@ accountDestAssets( { for (auto const& rspEntry : *mpts) { - if (rspEntry.isZeroBalance() && !rspEntry.isMaxedOut()) - assets.insert(rspEntry.getMptID()); + // Any cached MPT entry means this account already has an issuance + // or MPToken object. A maxed-out issuance does not prevent + // receiving existing MPT from another holder. + assets.insert(rspEntry.getMptID()); } } diff --git a/src/xrpld/rpc/detail/MPT.h b/src/xrpld/rpc/detail/MPT.h index 93c8517539..68054b2d0b 100644 --- a/src/xrpld/rpc/detail/MPT.h +++ b/src/xrpld/rpc/detail/MPT.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include namespace xrpl { @@ -31,14 +33,11 @@ public: return mptID_; } [[nodiscard]] bool - isZeroBalance() const + canSend(AccountID const& account) const { - return zeroBalance_; - } - [[nodiscard]] bool - isMaxedOut() const - { - return maxedOut_; + // A maxed-out issuance only prevents the issuer from creating more + // MPT. Holders can still send existing balances. + return account == getMPTIssuer(mptID_) ? !maxedOut_ : !zeroBalance_; } }; diff --git a/src/xrpld/rpc/detail/PathRequest.cpp b/src/xrpld/rpc/detail/PathRequest.cpp index fb132199bc..0a01031ae2 100644 --- a/src/xrpld/rpc/detail/PathRequest.cpp +++ b/src/xrpld/rpc/detail/PathRequest.cpp @@ -416,20 +416,22 @@ PathRequest::parseJson(json::Value const& jvParams) // If the assets don't match, ignore the source asset. if (srcPathAsset == saSendMax_->asset()) { - // If neither is the source and they are not equal, then the - // source issuer is illegal. - if (srcIssuerID != *raSrcAccount_ && - saSendMax_->getIssuer() != *raSrcAccount_ && - srcIssuerID != saSendMax_->getIssuer()) - { - jvStatus_ = rpcError(RpcSrcIsrMalformed); - return PFR_PJ_INVALID; - } - - // If both are the source, use the source. - // Otherwise, use the one that's not the source. - srcPathAsset.visit( + auto const status = srcPathAsset.visit( [&](Currency const& currency) { + // If neither is the source and they are not equal, + // then the source issuer is illegal. srcIssuerID + // comes from the optional IOU source_currencies + // issuer field, so this reconciliation is IOU-only. + if (srcIssuerID != *raSrcAccount_ && + saSendMax_->getIssuer() != *raSrcAccount_ && + srcIssuerID != saSendMax_->getIssuer()) + { + jvStatus_ = rpcError(RpcSrcIsrMalformed); + return PFR_PJ_INVALID; + } + + // If both are the source, use the source. + // Otherwise, use the one that's not the source. if (srcIssuerID != *raSrcAccount_) { sciSourceAssets_.insert(Issue{currency, srcIssuerID}); @@ -438,11 +440,18 @@ PathRequest::parseJson(json::Value const& jvParams) { sciSourceAssets_.insert(Issue{currency, saSendMax_->getIssuer()}); } + else { sciSourceAssets_.insert(Issue{currency, *raSrcAccount_}); } + return PFR_PJ_NOCHANGE; }, - [&](MPTID const& mpt) { sciSourceAssets_.insert(mpt); }); + [&](MPTID const& mpt) { + sciSourceAssets_.insert(mpt); + return PFR_PJ_NOCHANGE; + }); + if (status == PFR_PJ_INVALID) + return status; } } else diff --git a/src/xrpld/rpc/detail/Pathfinder.cpp b/src/xrpld/rpc/detail/Pathfinder.cpp index 642b5c4253..1f530a1165 100644 --- a/src/xrpld/rpc/detail/Pathfinder.cpp +++ b/src/xrpld/rpc/detail/Pathfinder.cpp @@ -224,7 +224,7 @@ Pathfinder::Pathfinder( , dstAmount_(saDstAmount) , srcPathAsset_(uSrcPathAsset) , srcIssuer_(uSrcIssuer) - , srcAmount_(amountFromPathAsset(uSrcPathAsset, uSrcIssuer, uSrcAccount)) + , srcAmount_(srcAmount.value_or(amountFromPathAsset(uSrcPathAsset, uSrcIssuer, uSrcAccount))) , convertAll_(convertAllCheck(dstAmount_)) , domain_(domain) , ledger_(cache->getLedger()) @@ -815,8 +815,8 @@ Pathfinder::getPathsOut( { for (auto const& mpt : *mpts) { - if (pathAsset.get() != mpt.getMptID() || mpt.isZeroBalance() || - mpt.isMaxedOut() || bAuthRequired) + if (pathAsset.get() != mpt.getMptID() || !mpt.canSend(account) || + bAuthRequired) continue; if (isDstAsset && dstAccount == getMPTIssuer(mpt)) { @@ -1079,7 +1079,10 @@ Pathfinder::addLink( } if constexpr (kIsMpt) { - return asset.isZeroBalance() || asset.isMaxedOut() || + // `asset` came from uEndAccount's cached MPTs. + // `acct` is the next issuer hop, not the + // account whose balance is being tested. + return !asset.canSend(uEndAccount) || requireAuth(*ledger_, MPTIssue{asset}, acct); } }; From b6a899583b2da4dd03aa3fd95599d2f5f16b80ac Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Wed, 26 Aug 2026 19:44:31 +0000 Subject: [PATCH 08/40] build: Make packaging reusable (#8126) --- .github/actions/release-info/action.yml | 56 ++------------------ .github/workflows/build-packaging-images.yml | 10 ++-- .github/workflows/reusable-package.yml | 4 +- {package => bin}/install-packaging-tools.sh | 0 package/Dockerfile | 7 --- package/README.md | 10 +++- package/docker/Dockerfile | 10 ++++ package/{ => docker}/publish_pkg.py | 5 +- 8 files changed, 35 insertions(+), 67 deletions(-) rename {package => bin}/install-packaging-tools.sh (100%) delete mode 100644 package/Dockerfile create mode 100644 package/docker/Dockerfile rename package/{ => docker}/publish_pkg.py (96%) diff --git a/.github/actions/release-info/action.yml b/.github/actions/release-info/action.yml index ab69a35f68..d32d937ab1 100644 --- a/.github/actions/release-info/action.yml +++ b/.github/actions/release-info/action.yml @@ -7,10 +7,10 @@ outputs: value: ${{ steps.version.outputs.version }} channel: description: "The release channel this build belongs to." - value: ${{ steps.channel.outputs.channel }} + value: ${{ steps.release_info.outputs.channel }} pkg_release: description: "The package release number: 1 for a tag, the run number otherwise." - value: ${{ steps.pkg_release.outputs.pkg_release }} + value: ${{ steps.release_info.outputs.pkg_release }} runs: using: composite @@ -39,52 +39,6 @@ runs: echo "version=${version}" | tee -a "${GITHUB_OUTPUT}" - # Only a tag says how mature a build is: a push is a develop build whatever - # its version, and a non-public codebase keeps its packages to itself. - - name: Determine release channel - id: channel - shell: bash - env: - IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} - REF_NAME: ${{ github.ref_name }} - VISIBILITY: ${{ github.event.repository.visibility }} - run: | - pre_release="" - if [[ "${REF_NAME}" == *-* ]]; then - pre_release="${REF_NAME#*-}" - fi - - if [[ "${VISIBILITY}" != "public" ]]; then - channel=private - elif [[ "${IS_TAG}" != "true" ]]; then - channel=develop - elif [[ -z "${pre_release}" ]]; then - channel=stable - elif [[ "${pre_release}" =~ ^rc[0-9]+(\+.*)?$ ]]; then - channel=rc - elif [[ "${pre_release}" =~ ^b(0|[1-9][0-9]*)(\+.*)?$ ]]; then - channel=beta - else - echo "Unsupported pre-release in tag '${REF_NAME}'. Use bN or rcN." >&2 - exit 1 - fi - - echo "channel=${channel}" | tee -a "${GITHUB_OUTPUT}" - - # A tag is packaged once, so its release number is fixed at 1. Develop builds - # repeat the same version, so the run number is what makes each push an - # upgrade rather than a reinstall. - - name: Determine package release - id: pkg_release - shell: bash - env: - IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} - RUN_NUMBER: ${{ github.run_number }} - run: | - if [[ "${IS_TAG}" == "true" ]]; then - pkg_release=1 - else - pkg_release="${RUN_NUMBER}" - fi - - echo "pkg_release=${pkg_release}" | tee -a "${GITHUB_OUTPUT}" + - name: Determine release channel and package release + id: release_info + uses: XRPLF/actions/release-info@7f956517847fb9e0b56070f72e1280f4e7404a09 diff --git a/.github/workflows/build-packaging-images.yml b/.github/workflows/build-packaging-images.yml index fd04eae995..e099decc12 100644 --- a/.github/workflows/build-packaging-images.yml +++ b/.github/workflows/build-packaging-images.yml @@ -6,13 +6,13 @@ on: - develop paths: - ".github/workflows/build-packaging-images.yml" - - "package/Dockerfile" - - "package/install-packaging-tools.sh" + - "bin/install-packaging-tools.sh" + - "package/docker/**" pull_request: paths: - ".github/workflows/build-packaging-images.yml" - - "package/Dockerfile" - - "package/install-packaging-tools.sh" + - "bin/install-packaging-tools.sh" + - "package/docker/**" workflow_dispatch: concurrency: @@ -44,6 +44,6 @@ jobs: uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/packaging-${{ matrix.distro.name }} - dockerfile: package/Dockerfile + dockerfile: package/docker/Dockerfile base_image: ${{ matrix.distro.base_image }} push: ${{ github.event_name == 'push' }} diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index aa9183be37..951b9b18dd 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -3,7 +3,7 @@ # - one job per config that carries a "package" map in linux.json # - that map names the container image and the format it builds there # - with 'publish: true' a job also uploads what it built -# (see package/publish_pkg.py) +# (see package/docker/publish_pkg.py) # # Only linux/amd64 is supported; the runner is hardcoded in the job below. name: Package @@ -133,7 +133,7 @@ jobs: NEXUS_USERNAME: ${{ secrets.remote_username }} NEXUS_PASSWORD: ${{ secrets.remote_password }} run: | - ./package/publish_pkg.py \ + ./package/docker/publish_pkg.py \ --channel "${CHANNEL}" \ --package-dir "${BUILD_DIR}" \ --nexus-url "${NEXUS_URL}" diff --git a/package/install-packaging-tools.sh b/bin/install-packaging-tools.sh similarity index 100% rename from package/install-packaging-tools.sh rename to bin/install-packaging-tools.sh diff --git a/package/Dockerfile b/package/Dockerfile deleted file mode 100644 index 978b569bd8..0000000000 --- a/package/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -ARG BASE_IMAGE=debian:bookworm - -FROM ${BASE_IMAGE} - -COPY package/install-packaging-tools.sh /tmp/install-packaging-tools.sh - -RUN /tmp/install-packaging-tools.sh diff --git a/package/README.md b/package/README.md index 8295a8a38e..645725c976 100644 --- a/package/README.md +++ b/package/README.md @@ -10,7 +10,9 @@ a build configured with `-Dvalidator_keys=ON`. package/ build_pkg.py Staging and build script (called by the CMake `package` target and CI) sign_rpm.py Signs the built RPMs (called by CI when publishing) - publish_pkg.py Uploads built packages to the XRPLF Nexus repositories (called by CI) + docker/ + Dockerfile Packaging image, built by `build-packaging-images.yml`; installs its tooling with `bin/install-packaging-tools.sh` + publish_pkg.py Uploads built packages to the XRPLF Nexus repositories (called by CI, and shipped in that image) rpm/ xrpld.spec RPM spec debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format) @@ -176,6 +178,12 @@ Nexus owns the repository metadata; nothing here indexes anything. Worth knowing - The `develop` repositories gain a package per push, so they need a cleanup policy to stay bounded; tagged channels publish each version once. +### Publishing from other repositories + +`publish_pkg.py` knows nothing about `xrpld`, so the packaging image +installs it at `/usr/local/bin/publish_pkg.py` for other XRPLF repositories that +build their packages elsewhere. + ## How `build_pkg.py` works `build_pkg.py` derives the `xrpld` software version from diff --git a/package/docker/Dockerfile b/package/docker/Dockerfile new file mode 100644 index 0000000000..b55c37b02a --- /dev/null +++ b/package/docker/Dockerfile @@ -0,0 +1,10 @@ +ARG BASE_IMAGE=debian:trixie + +FROM ${BASE_IMAGE} + +COPY bin/install-packaging-tools.sh /tmp/install-packaging-tools.sh + +RUN /tmp/install-packaging-tools.sh + +# See ../README.md, "Publishing from other repositories". +COPY package/docker/publish_pkg.py /usr/local/bin/publish_pkg.py diff --git a/package/publish_pkg.py b/package/docker/publish_pkg.py similarity index 96% rename from package/publish_pkg.py rename to package/docker/publish_pkg.py index 0bd39d0845..c9a6d3db1e 100755 --- a/package/publish_pkg.py +++ b/package/docker/publish_pkg.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 -"""Publish the packages built by build_pkg.py to the XRPLF repositories on Nexus. +"""Publish built DEB and RPM packages to the XRPLF repositories on Nexus. + +Takes packages and a channel, and nothing else, so it publishes whatever built +them; see package/README.md, "Publishing from other repositories". RPMs are uploaded to the hosted repository, but yum clients install from the 'rpm-' group repository in front of it, which serves signed metadata. From e0151229b64708cb38f3c6a5453143243a509540 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:15:38 +0000 Subject: [PATCH 09/40] fix: Waive unrealized-loss discount on sole-holder VaultClawback (#8119) --- .../tx/transactors/vault/VaultClawback.cpp | 48 ++-- src/test/app/vault/VaultBugs_test.cpp | 215 ++++++++++++++++++ 2 files changed, 250 insertions(+), 13 deletions(-) diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp index aabd833c75..059da7cc0f 100644 --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp @@ -237,6 +237,7 @@ VaultClawback::assetsToClawback( AccountID const& holder, STAmount const& clawbackAmount) { + bool const fix340Enabled = ctx_.view().rules().enabled(fixCleanup3_4_0); if (clawbackAmount.asset() != vault->at(sfAsset)) { // preclaim should have blocked this , now it's an internal error @@ -271,14 +272,32 @@ VaultClawback::assetsToClawback( // Number arithmetic can throw overflow_error when Scale and totals are large. Caught below. try { + // Do not discount a sole holder's shares: clawing back AssetsAvailable + // at the discounted rate can burn every share while loan assets remain. + auto const waiveUnrealizedLoss = + fix340Enabled && isSoleShareholder(view(), holder, sleShareIssuance) + ? WaiveUnrealizedLoss::Yes + : WaiveUnrealizedLoss::No; + if (clawbackAmount == beast::kZero) { // Zero amount means clawback all shares the holder has; derive the corresponding asset // amount from the share balance. - sharesDestroyed = accountHolds( - view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_); - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed); + // isSoleShareholder already established that the holder owns the + // entire outstanding share supply whenever the waiver applies, so + // sfOutstandingAmount gives sharesDestroyed directly, avoiding a + // redundant MPToken read via accountHolds. + sharesDestroyed = waiveUnrealizedLoss == WaiveUnrealizedLoss::Yes + ? STAmount{share, sleShareIssuance->at(sfOutstandingAmount)} + : accountHolds( + view(), + holder, + share, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + j_); + auto const maybeAssets = sharesToAssetsWithdraw( + vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss); if (!maybeAssets) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE @@ -291,16 +310,15 @@ VaultClawback::assetsToClawback( // Post-amendment: truncate shares so assetsRecovered <= // clawbackAmount by construction (matches the clamp branch // below). - auto const truncate = ctx_.view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes - : TruncateShares::No; - auto const maybeShares = - assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount, truncate); + auto const truncate = fix340Enabled ? TruncateShares::Yes : TruncateShares::No; + auto const maybeShares = assetsToSharesWithdraw( + vault, sleShareIssuance, clawbackAmount, truncate, waiveUnrealizedLoss); if (!maybeShares) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE sharesDestroyed = *maybeShares; - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed); + auto const maybeAssets = sharesToAssetsWithdraw( + vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss); if (!maybeAssets) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE assetsRecovered = *maybeAssets; @@ -312,14 +330,18 @@ VaultClawback::assetsToClawback( assetsRecovered = *assetsAvailable; { auto const maybeShares = assetsToSharesWithdraw( - vault, sleShareIssuance, assetsRecovered, TruncateShares::Yes); + vault, + sleShareIssuance, + assetsRecovered, + TruncateShares::Yes, + waiveUnrealizedLoss); if (!maybeShares) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE sharesDestroyed = *maybeShares; } - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed); + auto const maybeAssets = sharesToAssetsWithdraw( + vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss); if (!maybeAssets) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE assetsRecovered = *maybeAssets; diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index 0ecf6f0e2a..04f31c9526 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -14,9 +14,11 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -30,6 +32,7 @@ #include #include #include +#include #include #include @@ -1460,6 +1463,217 @@ private: BEAST_EXPECT(ownerCount(env, attacker) == 0); } + struct ImpairedLoanVault + { + test::jtx::Account issuer; + test::jtx::Account holder; + PrettyAsset usd; + test::jtx::Vault vault; + Keylet vaultKeylet; + MPTID shareId; + }; + + // Impairing a 1,000 loan in a 10,000 vault leaves AssetsAvailable=9,000 + // and AssetsTotal=10,000. otherDeposit > 0 splits the shares, 0 leaves + // holder as the sole shareholder. + std::optional + makeImpairedLoanVault(test::jtx::Env& env, int otherDeposit) + { + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const holder{"holder"}; + Account const other{"other"}; + Account const borrower{"borrower"}; + + env.fund(XRP(100'000), issuer, owner, holder, other, borrower); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const usd = issuer["USD"]; + env.trust(usd(100'000), owner); + env.trust(usd(100'000), holder); + env.trust(usd(100'000), other); + env.trust(usd(100'000), borrower); + env.close(); + + int const holderDeposit = 10'000 - otherDeposit; + env(pay(issuer, holder, usd(holderDeposit))); + if (otherDeposit != 0) + { + env(pay(issuer, other, usd(otherDeposit))); + } + env.close(); + + Vault const vault{env}; + auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded( + {.owner = owner, .asset = usd, .subscriptionOffset = std::chrono::seconds{60}}); + env(createTx); + env.close(); + + auto const vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return std::nullopt; + MPTID const shareId = vaultSle->at(sfShareMPTID); + + env(vault.deposit( + {.depositor = holder, .id = vaultKeylet.key, .amount = usd(holderDeposit)})); + if (otherDeposit != 0) + { + env(vault.deposit( + {.depositor = other, .id = vaultKeylet.key, .amount = usd(otherDeposit)})); + } + env.close(); + + vault.closePastSubscription(subscriptionDate); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + auto const sleBroker = env.le(brokerKeylet); + if (!BEAST_EXPECT(sleBroker)) + return std::nullopt; + auto const loanKeylet = + keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence))); + + env(set(borrower, brokerKeylet.key, usd(1'000).value()), + loan::kInterestRate(percentageToTenthBips(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // Under fixCleanup3_4_0, LoanManage rejects tfLoanImpair with + // tecTOO_SOON unless the payment is already late; advance the ledger + // past sfNextPaymentDueDate so impairment succeeds. No-op otherwise. + if (env.current()->rules().enabled(fixCleanup3_4_0)) + { + auto const loanBefore = env.le(loanKeylet); + if (!BEAST_EXPECT(loanBefore)) + return std::nullopt; + std::uint32_t const dueDate = loanBefore->at(sfNextPaymentDueDate); + env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1}); + } + + env(manage(owner, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultAfter)) + return std::nullopt; + BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == usd(9'000).value()); + BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == usd(1'000).value()); + + return ImpairedLoanVault{ + .issuer = issuer, + .holder = holder, + .usd = usd, + .vault = vault, + .vaultKeylet = vaultKeylet, + .shareId = shareId}; + } + + // Legacy clawback pricing burns every share; fixCleanup3_4_0 leaves 10% + // outstanding, backed by the impaired receivable. + void + testBugClawbackAfterLoanImpair() + { + using namespace test::jtx; + + auto clawbackHolder = [](ImpairedLoanVault const& setup, STAmount const& amount) { + return setup.vault.clawback( + {.issuer = setup.issuer, + .id = setup.vaultKeylet.key, + .holder = setup.holder, + .amount = amount}); + }; + + auto runSole = [this, &clawbackHolder](FeatureBitset features, TER expected) { + testcase( + features[fixCleanup3_4_0] + ? "VaultClawback after impaired loan (post-fixCleanup3_4_0)" + : "VaultClawback after impaired loan (pre-fixCleanup3_4_0)"); + + Env env(*this, features); + auto const maybeSetup = makeImpairedLoanVault(env, 0); + if (!maybeSetup) + { + BEAST_EXPECT(false); + return; + } + ImpairedLoanVault const& setup = *maybeSetup; + + auto const tokenBefore = env.le(keylet::mptoken(setup.shareId, setup.holder.id())); + auto const vaultBefore = env.le(setup.vaultKeylet); + auto const issuanceBefore = env.le(keylet::mptokenIssuance(setup.shareId)); + if (!BEAST_EXPECT(tokenBefore) || !BEAST_EXPECT(vaultBefore) || + !BEAST_EXPECT(issuanceBefore)) + return; + std::uint64_t const sharesBefore = tokenBefore->getFieldU64(sfMPTAmount); + + // The clawback of 19,000 exceeds AssetsAvailable (9,000), so + // VaultClawback clamps sharesDestroyed to whatever redeems + // exactly AssetsAvailable; compute that expected value using the + // same conversion helper VaultClawback itself uses, rather than + // assuming an exact 90/10 split holds under truncation. + auto const maybeSharesDestroyed = assetsToSharesWithdraw( + vaultBefore, + issuanceBefore, + setup.usd(9'000).value(), + TruncateShares::Yes, + WaiveUnrealizedLoss::Yes); + if (!BEAST_EXPECT(maybeSharesDestroyed)) + return; + std::uint64_t const expectedSharesAfter = + sharesBefore - maybeSharesDestroyed->mpt().value(); + + env(clawbackHolder(setup, setup.usd(19'000).value()), Ter(expected)); + env.close(); + if (expected != tesSUCCESS) + return; + + auto const vaultAfter = env.le(setup.vaultKeylet); + if (!BEAST_EXPECT(vaultAfter)) + return; + BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == setup.usd(0).value()); + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == setup.usd(1'000).value()); + BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == setup.usd(1'000).value()); + auto const tokenAfter = env.le(keylet::mptoken(setup.shareId, setup.holder.id())); + if (!BEAST_EXPECT(tokenAfter)) + return; + BEAST_EXPECT(tokenAfter->getFieldU64(sfMPTAmount) == expectedSharesAfter); + }; + + runSole(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED); + runSole(all_, tesSUCCESS); + + testcase("VaultClawback after impaired loan, non-sole holder"); + { + Env env(*this, all_); + auto const maybeSetup = makeImpairedLoanVault(env, 1'000); + if (!maybeSetup) + { + BEAST_EXPECT(false); + return; + } + ImpairedLoanVault const& setup = *maybeSetup; + // The waiver does not apply, so the holder's 9,000 shares are + // still priced at the discounted rate and cannot cover 9,000. + env(clawbackHolder(setup, setup.usd(9'000).value()), Ter(tecINSUFFICIENT_FUNDS)); + } + } + public: void run() override @@ -1480,6 +1694,7 @@ public: testBug6LimitBypassWithShares(); testBugClawbackRoundTripOvershoot(); testBugWithdrawRoundTripOvershoot(); + testBugClawbackAfterLoanImpair(); } }; From 3967ed6d54b4ea8e9c633c7ea61d0564d76b4188 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 27 Aug 2026 13:34:38 +0000 Subject: [PATCH 10/40] build: Update release-info to get better pkg_release (#8131) --- .github/actions/release-info/action.yml | 4 ++-- .github/scripts/strategy-matrix/linux.json | 4 ++-- .github/workflows/reusable-package.yml | 20 +++++++++++++------- .github/workflows/reusable-upload-recipe.yml | 5 +++++ docs/install.md | 2 +- package/README.md | 18 ++++++++++++------ 6 files changed, 35 insertions(+), 18 deletions(-) diff --git a/.github/actions/release-info/action.yml b/.github/actions/release-info/action.yml index d32d937ab1..e03170b2c8 100644 --- a/.github/actions/release-info/action.yml +++ b/.github/actions/release-info/action.yml @@ -9,7 +9,7 @@ outputs: description: "The release channel this build belongs to." value: ${{ steps.release_info.outputs.channel }} pkg_release: - description: "The package release number: 1 for a tag, the run number otherwise." + description: "The package release number: 1 for a tag, .git otherwise." value: ${{ steps.release_info.outputs.pkg_release }} runs: @@ -41,4 +41,4 @@ runs: - name: Determine release channel and package release id: release_info - uses: XRPLF/actions/release-info@7f956517847fb9e0b56070f72e1280f4e7404a09 + uses: XRPLF/actions/release-info@7cc0e4a8d9d0b838f92c48d312856b190341bbba diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 731536a748..d8cdbdfa52 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -74,7 +74,7 @@ "extra_cmake_args": "-Dvalidator_keys=ON", "package": { "type": "deb", - "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-45e4b88" + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-b6a8995" } } ], @@ -88,7 +88,7 @@ "extra_cmake_args": "-Dvalidator_keys=ON", "package": { "type": "rpm", - "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-45e4b88" + "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-b6a8995" } } ] diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index 951b9b18dd..2a5e6a8c04 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -2,8 +2,8 @@ # # - one job per config that carries a "package" map in linux.json # - that map names the container image and the format it builds there -# - with 'publish: true' a job also uploads what it built -# (see package/docker/publish_pkg.py) +# - every job ends with the image's publish_pkg.py, uploading what it built +# with 'publish: true' and doing a --dry-run otherwise # # Only linux/amd64 is supported; the runner is hardcoded in the job below. name: Package @@ -76,6 +76,11 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Prepare runner + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + with: + enable_ccache: false + - name: Download pre-built xrpld binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -126,14 +131,15 @@ jobs: if-no-files-found: error - name: Publish package - if: ${{ inputs.publish }} env: CHANNEL: ${{ steps.release_info.outputs.channel }} + DRY_RUN_OPTION: ${{ !inputs.publish && '--dry-run' || '' }} NEXUS_URL: ${{ inputs.nexus_url }} - NEXUS_USERNAME: ${{ secrets.remote_username }} - NEXUS_PASSWORD: ${{ secrets.remote_password }} + NEXUS_USERNAME: ${{ inputs.publish && secrets.remote_username || '' }} + NEXUS_PASSWORD: ${{ inputs.publish && secrets.remote_password || '' }} run: | - ./package/docker/publish_pkg.py \ + publish_pkg.py \ --channel "${CHANNEL}" \ --package-dir "${BUILD_DIR}" \ - --nexus-url "${NEXUS_URL}" + --nexus-url "${NEXUS_URL}" \ + ${DRY_RUN_OPTION} diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index 608a5ea988..6fa289665a 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -49,6 +49,11 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Prepare runner + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + with: + enable_ccache: false + - name: Determine release info id: release_info uses: ./.github/actions/release-info diff --git a/docs/install.md b/docs/install.md index 01cfc144a1..4c52b587b6 100644 --- a/docs/install.md +++ b/docs/install.md @@ -13,7 +13,7 @@ To build from source instead, see [BUILD.md](../BUILD.md). Packages are published to four channels: -- `stable` - the latest production release +- `stable` - production releases - `rc` - release candidates - `beta` - beta builds - `develop` - every push to the [`develop` branch](https://github.com/XRPLF/rippled/tree/develop) diff --git a/package/README.md b/package/README.md index 645725c976..027a374898 100644 --- a/package/README.md +++ b/package/README.md @@ -149,15 +149,21 @@ Versions sort in row order, so moving to a more mature channel never downgrades. The action decides the package release number on the same split: a tag's version is unique, so its packages are release 1, while develop repeats the same version -and takes `github.run_number` so each push supersedes the last. Both reach the -packaging scripts as arguments, so neither script derives anything itself. +and takes `.git`, e.g. +`857.20260826gitb6a8995` — the leading run number keeps each push superseding +the last, and the date and hash say which commit a package on +`packages.xrplf.org` came from. Both reach the packaging scripts as arguments, +so neither script derives anything itself. Publishing is the last step of each packaging job, uploading from the container -that built the packages. It runs when the caller passes `publish: true`: -`on-trigger.yml` for develop pushes in `XRPLF/rippled`, `on-tag.yml` for tags in -any `XRPLF` repository, `on-pr.yml` never. Both authenticate with the +that built the packages with the `publish_pkg.py` shipped in the image — the +same copy other repositories run. Without `publish: true` the step is a +`--dry-run`, listing the uploads it would make without needing credentials, so +any run that builds packages also exercises the upload routing. `on-trigger.yml` +passes `publish: true` for develop pushes in `XRPLF/rippled` and `on-tag.yml` +for tags in any `XRPLF` repository, both authenticating with the `NEXUS_REMOTE_USERNAME` / `NEXUS_REMOTE_PASSWORD` secrets already used for the -Conan remote. +Conan remote; `on-pr.yml` never publishes. Nexus owns the repository metadata; nothing here indexes anything. Worth knowing: From 71f5555873143b94938afbe5d77f9883e8703846 Mon Sep 17 00:00:00 2001 From: Jingchen Date: Thu, 27 Aug 2026 13:52:23 +0000 Subject: [PATCH 11/40] feat: Remove pseudo account field filter (#8042) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../xrpl/ledger/helpers/AccountRootHelpers.h | 16 ++++++---------- .../ledger/helpers/AccountRootHelpers.cpp | 17 ++++++----------- src/libxrpl/ledger/helpers/MPTokenHelpers.cpp | 3 +-- src/libxrpl/tx/invariants/MPTInvariant.cpp | 2 +- .../tx/transactors/token/MPTokenAuthorize.cpp | 2 +- 5 files changed, 15 insertions(+), 25 deletions(-) diff --git a/include/xrpl/ledger/helpers/AccountRootHelpers.h b/include/xrpl/ledger/helpers/AccountRootHelpers.h index 350fc6ca85..452d402d14 100644 --- a/include/xrpl/ledger/helpers/AccountRootHelpers.h +++ b/include/xrpl/ledger/helpers/AccountRootHelpers.h @@ -15,7 +15,6 @@ #include #include #include -#include #include namespace xrpl { @@ -353,14 +352,14 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey); * * The list is constructed during initialization and is const after that. * Pseudo-account designator fields MUST be maintained by including the - * SField::sMD_PseudoAccount flag in the SField definition. + * SField::kSmdPseudoAccount flag in the SField definition. */ [[nodiscard]] std::vector const& getPseudoAccountFields(); /** - * Returns true if and only if sleAcct is a pseudo-account or specific - * pseudo-accounts in pseudoFieldFilter. + * Returns true if and only if sleAcct is a pseudo-account of any kind + * (i.e. carries at least one field flagged with SField::kSmdPseudoAccount). * * Returns false if sleAcct is: * - NOT a pseudo-account OR @@ -368,18 +367,15 @@ getPseudoAccountFields(); * - null pointer */ [[nodiscard]] bool -isPseudoAccount(SLE::const_pointer sleAcct, std::set const& pseudoFieldFilter = {}); +isPseudoAccount(SLE::const_pointer sleAcct); /** * Convenience overload that reads the account from the view. */ [[nodiscard]] inline bool -isPseudoAccount( - ReadView const& view, - AccountID const& accountId, - std::set const& pseudoFieldFilter = {}) +isPseudoAccount(ReadView const& view, AccountID const& accountId) { - return isPseudoAccount(view.read(keylet::account(accountId)), pseudoFieldFilter); + return isPseudoAccount(view.read(keylet::account(accountId))); } /** diff --git a/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp index faca4ebfb6..819ebb04d1 100644 --- a/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp +++ b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp @@ -28,7 +28,6 @@ #include #include #include -#include #include #include @@ -515,8 +514,8 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey) } // Pseudo-account designator fields MUST be maintained by including the -// SField::sMD_PseudoAccount flag in the SField definition. (Don't forget to -// "| SField::sMD_Default"!) The fields do NOT need to be amendment-gated, +// SField::kSmdPseudoAccount flag in the SField definition. (Don't forget to +// "| SField::kSmdDefault"!) The fields do NOT need to be amendment-gated, // since a non-active amendment will not set any field, by definition. // Specific properties of a pseudo-account are NOT checked here, that's what // InvariantCheck is for. @@ -547,18 +546,14 @@ getPseudoAccountFields() } [[nodiscard]] bool -isPseudoAccount(SLE::const_pointer sleAcct, std::set const& pseudoFieldFilter) +isPseudoAccount(SLE::const_pointer sleAcct) { - auto const& fields = getPseudoAccountFields(); - // Intentionally use defensive coding here because it's cheap and makes the // semantics of true return value clean. return sleAcct && sleAcct->getType() == ltACCOUNT_ROOT && - std::count_if( - fields.begin(), fields.end(), [&sleAcct, &pseudoFieldFilter](SField const* sf) -> bool { - return sleAcct->isFieldPresent(*sf) && - (pseudoFieldFilter.empty() || pseudoFieldFilter.contains(sf)); - }) > 0; + std::ranges::any_of(getPseudoAccountFields(), [&sleAcct](SField const* sf) { + return sleAcct->isFieldPresent(*sf); + }); } std::expected diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp index 73d5fdb1d5..1b9bb19ad4 100644 --- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp @@ -384,8 +384,7 @@ requireAuth( // They are implicitly authorized for any MPT they hold, including vault shares whose // underlying asset would otherwise require auth. auto const isPseudoAccountExempt = [&] { - return (featureSAVEnabled || featureMPTV2Enabled) && - isPseudoAccount(view, account, {&sfVaultID, &sfLoanBrokerID, &sfAMMID}); + return (featureSAVEnabled || featureMPTV2Enabled) && isPseudoAccount(view, account); }; auto const mptID = keylet::mptokenIssuance(mptIssue.getMptID()); diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 2cfd069420..66b9028ed2 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -847,7 +847,7 @@ ValidMPTTransfer::isAuthorized( // auth. Exempt them here rather than relying on requireAuth: the recursive // share -> underlying descent in requireAuth fails for a pseudo-account // that holds the share but not the underlying. - if (isPseudoAccount(view, holder, {&sfVaultID, &sfLoanBrokerID, &sfAMMID})) + if (isPseudoAccount(view, holder)) return true; auto const key = keylet::mptoken(mptid, holder); diff --git a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp index c19b8f64d7..60b5c6d3af 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp @@ -150,7 +150,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) // always authorized. No need to amendment gate since Vault and LoanBroker // can only be created if the Vault amendment is enabled; AMM with MPToken asset // can only be created if MPTokensV2 is enabled. - if (isPseudoAccount(ctx.view, *holderID, {&sfVaultID, &sfLoanBrokerID, &sfAMMID})) + if (isPseudoAccount(ctx.view, *holderID)) return tecNO_PERMISSION; return tesSUCCESS; From 7281e0606ab06a2e483abb7ffa8c1ee807b581bd Mon Sep 17 00:00:00 2001 From: Jingchen Date: Thu, 27 Aug 2026 17:15:17 +0000 Subject: [PATCH 12/40] feat: Add vault invariants (#7732) Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .cspell.config.yaml | 1 + .../xrpl/tx/invariants/LoanBrokerInvariant.h | 14 + include/xrpl/tx/invariants/LoanInvariant.h | 29 +- include/xrpl/tx/invariants/VaultInvariant.h | 5 +- src/libxrpl/tx/invariants/InvariantCheck.cpp | 51 +- .../tx/invariants/LoanBrokerInvariant.cpp | 82 +- src/libxrpl/tx/invariants/LoanInvariant.cpp | 148 ++- src/libxrpl/tx/invariants/VaultInvariant.cpp | 3 +- src/test/app/invariants/InvariantsBase.cpp | 32 + src/test/app/invariants/InvariantsBase.h | 12 + .../app/invariants/InvariantsMisc_test.cpp | 254 ++++- .../InvariantsPseudoAccount_test.cpp | 283 ++++++ .../app/invariants/InvariantsVault_test.cpp | 886 +++++++++++++++++- src/test/app/lending/LoanInvariants_test.cpp | 246 +++++ src/test/jtx/impl/vault.cpp | 2 + src/test/jtx/vault.h | 3 + 16 files changed, 2032 insertions(+), 19 deletions(-) diff --git a/.cspell.config.yaml b/.cspell.config.yaml index 7b4a280c65..8929973e8a 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -387,3 +387,4 @@ words: - xxhasher - zstdio - CGNAT + - ungated diff --git a/include/xrpl/tx/invariants/LoanBrokerInvariant.h b/include/xrpl/tx/invariants/LoanBrokerInvariant.h index 979f57de35..2b8713fb90 100644 --- a/include/xrpl/tx/invariants/LoanBrokerInvariant.h +++ b/include/xrpl/tx/invariants/LoanBrokerInvariant.h @@ -19,6 +19,11 @@ namespace xrpl { * 1. If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most one * node (the root), which will only hold entries for `RippleState` or * `MPToken` objects. + * 2. Under featureLendingProtocolV1_1, an `ltLOAN_BROKER` may only be deleted + * by a `ttLOAN_BROKER_DELETE` transaction, and only when its pre-state + * `OwnerCount` is zero and its pre-state `DebtTotal` rounds to zero at the + * vault's `AssetsTotal` scale, as `LoanBrokerDelete::preclaim` requires. + * 3. At most one `ltLOAN_BROKER` may be deleted in a single transaction. * */ class ValidLoanBroker @@ -36,6 +41,15 @@ class ValidLoanBroker // pseudo-accounts. Key is the brokerID / index. It will be used to find the // LoanBroker object if brokerBefore and brokerAfter are nullptr std::map brokers_; + // The broker whose ledger entry was deleted by this transaction, if any. + // Only ttLOAN_BROKER_DELETE removes a broker, and it removes exactly one. + // This is the pre-transaction state, which is what LoanBrokerDelete::preclaim + // reads when it decides whether the broker may be deleted, so the deletion invariants inspect + // the same DebtTotal and OwnerCount that the transactor did. + SLE::const_pointer deletedBroker_ = nullptr; + // Set if visitEntry observes more than one ltLOAN_BROKER deletion in the + // same transaction. Enforced as its own invariant in finalize. + bool multipleBrokerDeletions_ = false; // Collect all the modified trust lines. Their high and low accounts will be // loaded to look for LoanBroker pseudo-accounts. std::vector lines_; diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index fc72b8d420..34ce1a4dc2 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -15,9 +15,33 @@ namespace xrpl { /** * @brief Invariants: Loans are internally consistent * - * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` + * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0`. * 2. A newly-created Loan against a closed-ended vault must satisfy * `StartDate + PaymentInterval * PaymentRemaining < Vault.RedemptionDate`. + * 3. An `ltLOAN` may only be created by a `ttLOAN_SET` transaction. + * 4. Prior to `featureLendingProtocolV1_1`, the `lsfLoanOverpayment` flag on a + * Loan must not change. From `featureLendingProtocolV1_1` onward the same + * rule is enforced by `NoModifiedUnmodifiableFields`. + * 5. Under `featureLendingProtocolV1_1`: + * a. An `ltLOAN` may only be deleted by a `ttLOAN_DELETE` transaction. + * b. If `Loan.PaymentRemaining = 0` then `Loan.NextPaymentDueDate = 0`. + * c. The `lsfLoanImpaired` flag may only change through a `ttLOAN_MANAGE` + * or `ttLOAN_PAY` transaction. + * d. The `lsfLoanDefault` flag may only change through a `ttLOAN_MANAGE` + * transaction. Combined with `NoModifiedUnmodifiableFields`, which + * rejects any clearing of `lsfLoanDefault`, this makes the flag + * write-once: `ttLOAN_MANAGE` may set it, and no transaction may + * clear it. + * e. Interest due, computed as `TotalValueOutstanding - + * PrincipalOutstanding - ManagementFeeOutstanding`, must not be + * negative. + * f. A Loan must reference a live `ltLOAN_BROKER`, and that broker must + * reference a live `ltVAULT`. + * g. Post-conditions for the Loan paid down by a successful `ttLOAN_PAY`: + * `PaymentRemaining > 0` after: `PrincipalOutstanding` and + * `PaymentRemaining` strictly decrease; `NextPaymentDueDate` + * advances by N * `PaymentInterval`, N > 0. + * `PaymentRemaining == 0` after: pinned by checks 1 and 5b. * */ class ValidLoan @@ -25,6 +49,9 @@ class ValidLoan // Pair is . After is used for most of the checks, except // those that check changed values. std::vector> loans_; + // Loans removed from the ledger, in the same form as loans_. + // Note that `after` holds the erased entry, so it is not null. + std::vector> deletedLoans_; public: void diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 2ba42f0ab4..efeec7fda6 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -48,7 +48,10 @@ namespace xrpl { * vault phase is Investment * * Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced - * by NoModifiedUnmodifiableFields (see InvariantCheck.cpp). + * by NoModifiedUnmodifiableFields (see InvariantCheck.cpp). From + * featureLendingProtocolV1_1 onwards, immutability of the vault's Asset, + * pseudo-account and ShareMPTID is likewise enforced by + * NoModifiedUnmodifiableFields; prior to that amendment it is checked here. */ class ValidVault { diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index aa4df8db42..96820d00bb 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1123,10 +1123,17 @@ NoModifiedUnmodifiableFields::finalize( ReadView const& view, beast::Journal const& j) { - static auto const kFieldChanged = [](auto const& before, auto const& after, auto const& field) { + auto const kFieldChanged = [&j, &tx](auto const& before, auto const& after, auto const& field) { bool const beforeField = before->isFieldPresent(field); bool const afterField = after->isFieldPresent(field); - return beforeField != afterField || (afterField && before->at(field) != after->at(field)); + bool const changed = + beforeField != afterField || (afterField && before->at(field) != after->at(field)); + if (changed) + { + JLOG(j.fatal()) << "Invariant failed: " << field.getName() + << " changed on immutable ledger entry in " << tx.getTransactionID(); + } + return changed; }; for (auto const& slePair : changedEntries_) { @@ -1172,13 +1179,40 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfPaymentInterval) || kFieldChanged(before, after, sfGracePeriod) || kFieldChanged(before, after, sfLoanScale); + + // lsfLoanOverpayment must never toggle. lsfLoanDefault may only + // transition from unset to set, which combined with ValidLoan's rule that + // only LoanManage may change it makes the flag write-once. + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + std::uint32_t const beforeFlags = before->getFlags(); + std::uint32_t const afterFlags = after->getFlags(); + bool const overpaymentChanged = + (beforeFlags & lsfLoanOverpayment) != (afterFlags & lsfLoanOverpayment); + if (overpaymentChanged) + { + JLOG(j.fatal()) << "Invariant failed: lsfLoanOverpayment flag " + "toggled on immutable ledger entry in " + << tx.getTransactionID(); + } + bad = bad || overpaymentChanged; + bool const defaultCleared = + (beforeFlags & lsfLoanDefault) != 0 && (afterFlags & lsfLoanDefault) == 0; + if (defaultCleared) + { + JLOG(j.fatal()) << "Invariant failed: lsfLoanDefault flag " + "cleared on immutable ledger entry in " + << tx.getTransactionID(); + } + bad = bad || defaultCleared; + } break; case ltVAULT: /* - * sfAccount, sfAsset and sfShareMPTID are already - * captured by VaultInvariant. The additional fields - * below are introduced by featureLendingProtocolV1_1 - * and only exist on V1_1 vaults. + * All the fields below are only immutable from + * featureLendingProtocolV1_1 onwards; some of them only exist on + * V1_1 vaults. Before that amendment, sfAsset, sfAccount and + * sfShareMPTID are checked by VaultInvariant instead. */ if (view.rules().enabled(featureLendingProtocolV1_1)) { @@ -1190,7 +1224,10 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfOwner) || kFieldChanged(before, after, sfWithdrawalPolicy) || kFieldChanged(before, after, sfScale) || - kFieldChanged(before, after, sfLEVersion); + kFieldChanged(before, after, sfLEVersion) || + kFieldChanged(before, after, sfAsset) || + kFieldChanged(before, after, sfAccount) || + kFieldChanged(before, after, sfShareMPTID); } break; default: diff --git a/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp index b70c02947f..e15921b7b2 100644 --- a/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp @@ -1,13 +1,18 @@ #include #include +#include #include +#include #include +#include #include +#include #include #include #include #include +#include #include #include // IWYU pragma: keep #include @@ -22,6 +27,24 @@ namespace xrpl { void ValidLoanBroker::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) { + // Track LoanBroker deletions so finalize() can enforce: + // (a) only ttLOAN_BROKER_DELETE removes a broker + // (b) at most one broker is removed per transaction + // (c) DebtTotal and OwnerCount were zero before deletion + // `before` is the pre-transaction state, which is what + // LoanBrokerDelete::preclaim reads. Erased trust lines and MPTokens need no + // special handling here: the `if (after)` branch below already records them. + if (isDelete && before && before->getType() == ltLOAN_BROKER) + { + if (deletedBroker_) + { + multipleBrokerDeletions_ = true; + } + else + { + deletedBroker_ = before; + } + } if (after) { if (after->getType() == ltLOAN_BROKER) @@ -99,6 +122,64 @@ ValidLoanBroker::finalize( // Loan Brokers will not exist on ledger if the Lending Protocol amendment // is not enabled, so there's no need to check it. + // Deletion invariants (featureLendingProtocolV1_1). At most one + // LoanBroker may be removed per transaction, and only by + // ttLOAN_BROKER_DELETE, and only when its pre-state OwnerCount is zero and + // its pre-state DebtTotal is zero to the precision of the vault asset. The + // DebtTotal check complements ValidLoan's + // LoanBrokerDelete-must-not-touch-any-loan rule: even a broker that has + // finished paying off every loan may still hold non-zero exposure until + // its LoanBrokerCoverWithdraw settles, and neither state is safe to + // delete. + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + if (multipleBrokerDeletions_) + { + JLOG(j.fatal()) + << "Invariant failed: more than one Loan Broker deleted in a single transaction"; + return false; + } + if (deletedBroker_) + { + if (tx.getTxnType() != ttLOAN_BROKER_DELETE) + { + JLOG(j.fatal()) << "Invariant failed: " << // + "Loan Broker deleted by a transaction other than LoanBrokerDelete"; + return false; + } + // Mirror LoanBrokerDelete::preclaim, which accepts a DebtTotal + // that rounds to zero at the vault's AssetsTotal scale rather than + // requiring an exact zero. Requiring more here would turn a + // transaction the transactor deliberately permits into an + // invariant failure. + if (auto const debtTotal = deletedBroker_->at(sfDebtTotal); debtTotal != beast::kZero) + { + // The erased broker is also collected in brokers_, and that + // loop reports a missing vault, so no separate diagnostic is + // needed here. Without a vault there is no scale to round at, + // so the residue cannot be excused as dust. + auto const vault = view.read(keylet::vault(deletedBroker_->at(sfVaultID))); + if (!vault || + roundToAsset( + Asset{vault->at(sfAsset)}, + debtTotal, + getAssetsTotalScale(vault), + Number::RoundingMode::TowardsZero) != beast::kZero) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker deleted with non-zero debt total"; + return false; + } + } + if (deletedBroker_->at(sfOwnerCount) != 0) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker deleted with non-zero owner count"; + return false; + } + } + } + for (auto const& line : lines_) { for (auto const& field : {&sfLowLimit, &sfHighLimit}) @@ -142,7 +223,6 @@ ValidLoanBroker::finalize( auto const& before = broker.brokerBefore; - // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3123-invariants // If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most // one node (the root), which will only hold entries for `RippleState` // or `MPToken` objects. diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index 7b96790570..2db627c272 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -1,10 +1,13 @@ #include #include +#include #include #include #include #include +#include +#include #include #include #include @@ -13,6 +16,7 @@ #include // IWYU pragma: keep #include #include +#include #include #include @@ -22,7 +26,14 @@ namespace xrpl { void ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) { - if (after && after->getType() == ltLOAN) + // Classify here, but leave the decision about which checks apply to + // finalize(), which is the only place that can see the Rules. + if (isDelete) + { + if (before && before->getType() == ltLOAN) + deletedLoans_.emplace_back(before, after); + } + else if (after && after->getType() == ltLOAN) { loans_.emplace_back(before, after); } @@ -39,6 +50,16 @@ ValidLoan::finalize( // Loans will not exist on ledger if the Lending Protocol amendment // is not enabled, so there's no need to check it. + auto const txType = tx.getTxnType(); + bool const lpV11Enabled = view.rules().enabled(featureLendingProtocolV1_1); + + // Without featureLendingProtocolV1_1 an erased Loan is subject to the same + // per-entry checks as any modified Loan. From V1_1 onward it is only subject + // to the ttLOAN_DELETE check below. + if (!lpV11Enabled) + loans_.insert(loans_.end(), deletedLoans_.begin(), deletedLoans_.end()); + + // Ledger entry validation checks. for (auto const& [before, after] : loans_) { // A closed-ended vault must not accept a loan whose final scheduled payment falls on or @@ -91,7 +112,11 @@ ValidLoan::finalize( JLOG(j.fatal()) << "Invariant failed: Fully paid off Loan still has payments remaining"; return false; } - if (before && (before->isFlag(lsfLoanOverpayment) != after->isFlag(lsfLoanOverpayment))) + + // From featureLendingProtocolV1_1 onwards this flag is immutable by way of + // NoModifiedUnmodifiableFields. + if (!lpV11Enabled && before && + (before->isFlag(lsfLoanOverpayment) != after->isFlag(lsfLoanOverpayment))) { JLOG(j.fatal()) << "Invariant failed: Loan Overpayment flag changed"; return false; @@ -123,6 +148,125 @@ ValidLoan::finalize( return false; } } + if (lpV11Enabled) + { + // Only LoanSet may create a loan. + if (!before && txType != ttLOAN_SET) + { + JLOG(j.fatal()) << "Invariant failed: Loan created by a transaction " + "other than LoanSet"; + return false; + } + + if (after->at(sfPaymentRemaining) == 0 && + after->at(~sfNextPaymentDueDate).value_or(0) != 0) + { + JLOG(j.fatal()) << "Invariant failed: Loan with zero payments must have zero next " + "payment due date"; + return false; + } + + if (before) + { + bool const wasImpaired = before->isFlag(lsfLoanImpaired); + bool const isImpaired = after->isFlag(lsfLoanImpaired); + bool const wasDefaulted = before->isFlag(lsfLoanDefault); + bool const isDefaulted = after->isFlag(lsfLoanDefault); + + if (wasImpaired != isImpaired && txType != ttLOAN_MANAGE && txType != ttLOAN_PAY) + { + JLOG(j.fatal()) << "Invariant failed: lsfLoanImpaired changed " + "outside LoanManage or LoanPay"; + return false; + } + if (wasDefaulted != isDefaulted && txType != ttLOAN_MANAGE) + { + JLOG(j.fatal()) << "Invariant failed: lsfLoanDefault changed " + "outside LoanManage"; + return false; + } + } + + // A loan must reference a live loan broker, and that broker must + // reference a live vault; otherwise the loan is orphaned and its + // balances have no counterparty on the ledger. + auto const brokerSle = view.read(keylet::loanBroker(after->at(sfLoanBrokerID))); + if (!brokerSle) + { + JLOG(j.fatal()) << "Invariant failed: Loan broker does not exist"; + return false; + } + auto const vaultSle = view.read(keylet::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + { + JLOG(j.fatal()) << "Invariant failed: Loan broker vault does not exist"; + return false; + } + + // Interest due (the total value owed less principal and management fee) + // must never be negative. TotalValueOutstanding, PrincipalOutstanding and + // ManagementFeeOutstanding are each independently rounded to sfLoanScale + // by the accounting code, so their difference can carry one unit of + // quantization noise even when the underlying flow is correct. Absorb + // one unit at that scale, matching the pattern used in ValidVault. + auto const interestDue = after->at(sfTotalValueOutstanding) - + after->at(sfPrincipalOutstanding) - after->at(sfManagementFeeOutstanding); + + // Only IOU amounts can accumulate STAmount quantization noise. For integral-domain + // assets (XRP/MPT) enforce the boundary strictly. + bool const integral = Asset{vaultSle->at(sfAsset)}.integral(); + + Number const tolerance = integral ? Number{} : Number{-1, after->at(sfLoanScale)}; + if (interestDue < tolerance) + { + JLOG(j.fatal()) << "Invariant failed: Loan interest due is negative"; + return false; + } + + // Transaction success post-conditions. A successful loan pay makes at least + // one scheduled payment, so a loan left with payments still outstanding + // must show that payment in its balance and schedule. A payment that clears + // the loan outright instead drives PaymentRemaining to zero, which the + // fully-paid-off and zero due-date checks above pin. + if (isTesSuccess(result) && txType == ttLOAN_PAY) + { + if (before && after->at(sfPaymentRemaining) != 0) + { + if (!(after->at(sfPrincipalOutstanding) < before->at(sfPrincipalOutstanding))) + { + JLOG(j.fatal()) << "Invariant failed: loan pay must strictly decrease " + "PrincipalOutstanding on a non-full-repayment"; + return false; + } + if (!(after->at(sfPaymentRemaining) < before->at(sfPaymentRemaining))) + { + JLOG(j.fatal()) << "Invariant failed: loan pay must decrease " + "PaymentRemaining on a non-full-repayment"; + return false; + } + + std::uint32_t const beforeDue = before->at(~sfNextPaymentDueDate).value_or(0); + std::uint32_t const afterDue = after->at(~sfNextPaymentDueDate).value_or(0); + std::uint32_t const interval = after->at(sfPaymentInterval); + if (afterDue <= beforeDue || interval == 0 || + (afterDue - beforeDue) % interval != 0) + { + JLOG(j.fatal()) << "Invariant failed: loan pay must advance " + "NextPaymentDueDate by a positive multiple of " + "PaymentInterval on a non-full-repayment"; + return false; + } + } + } + } + } + + // Only LoanDelete may delete a loan. + if (lpV11Enabled && txType != ttLOAN_DELETE && !deletedLoans_.empty()) + { + JLOG(j.fatal()) << "Invariant failed: Loan deleted by a transaction " + "other than LoanDelete"; + return false; } return true; } diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 1bfb9d3d43..a8ef0d3157 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -515,7 +515,8 @@ ValidVault::finalize( bool result = true; // Universal transaction checks - if (!beforeVault_.empty()) + // From LendingProtocolV1_1 onwards, vault immutability check is moved to InvariantCheck.cpp + if (!beforeVault_.empty() && !view.rules().enabled(featureLendingProtocolV1_1)) { auto const& beforeVault = beforeVault_[0]; if (afterVault.asset != beforeVault.asset || afterVault.pseudoId != beforeVault.pseudoId || diff --git a/src/test/app/invariants/InvariantsBase.cpp b/src/test/app/invariants/InvariantsBase.cpp index a573cc45ea..650a21cb07 100644 --- a/src/test/app/invariants/InvariantsBase.cpp +++ b/src/test/app/invariants/InvariantsBase.cpp @@ -10,11 +10,13 @@ #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -31,6 +33,7 @@ #include #include +#include #include #include #include @@ -206,4 +209,33 @@ InvariantsBase::createLoanBroker( return loanBrokerKeylet; } +SLE::pointer +InvariantsBase::makeLoanSle( + uint256 const& loanBrokerID, + std::uint32_t loanSeq, + AccountID const& borrower) +{ + auto sleLoan = + std::make_shared(keylet::loan(loanBrokerID, SeqProxy::rawSequence(loanSeq))); + // SoeRequired fields. + sleLoan->at(sfLoanBrokerID) = loanBrokerID; + sleLoan->at(sfLoanSequence) = loanSeq; + sleLoan->at(sfBorrower) = borrower; + sleLoan->at(sfStartDate) = 0u; + sleLoan->at(sfPaymentInterval) = 1u; + sleLoan->at(sfPeriodicPayment) = Number(1); + // SoeDefault fields, materialized so that an invariant reading them through + // at() does not throw on this hand-built entry. + sleLoan->at(sfLoanServiceFee) = Number(0); + sleLoan->at(sfLatePaymentFee) = Number(0); + sleLoan->at(sfClosePaymentFee) = Number(0); + sleLoan->at(sfPrincipalOutstanding) = Number(0); + sleLoan->at(sfTotalValueOutstanding) = Number(0); + sleLoan->at(sfManagementFeeOutstanding) = Number(0); + sleLoan->setFieldU32(sfPaymentRemaining, 0); + sleLoan->makeFieldPresent(sfOwnerNode); + sleLoan->makeFieldPresent(sfLoanBrokerNode); + return sleLoan; +} + } // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsBase.h b/src/test/app/invariants/InvariantsBase.h index 73319d0ef8..6b4327eb78 100644 --- a/src/test/app/invariants/InvariantsBase.h +++ b/src/test/app/invariants/InvariantsBase.h @@ -4,7 +4,11 @@ #include #include +#include + +#include #include +#include #include #include #include @@ -14,6 +18,7 @@ #include #include +#include #include #include #include @@ -117,6 +122,13 @@ protected: Keylet createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset); + + // Build an ltLOAN SLE with every SoeRequired field explicitly set and + // every SoeDefault field the invariants read via `at()` materialized, so + // rawInsert-based tests don't accidentally trip an unrelated invariant + // or throw from a missing SoeDefault field. + static SLE::pointer + makeLoanSle(uint256 const& loanBrokerID, std::uint32_t loanSeq, AccountID const& borrower); }; } // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsMisc_test.cpp b/src/test/app/invariants/InvariantsMisc_test.cpp index b0b6c02f5c..a0084ac530 100644 --- a/src/test/app/invariants/InvariantsMisc_test.cpp +++ b/src/test/app/invariants/InvariantsMisc_test.cpp @@ -4,10 +4,13 @@ #include #include #include +#include #include +#include #include #include +#include #include #include #include @@ -43,6 +46,7 @@ #include #include +#include #include #include #include @@ -757,7 +761,255 @@ class InvariantsMisc_test : public InvariantsBase } } - // TODO: Loan Object + // Loan flag immutability lives in NoModifiedUnmodifiableFields's + // ltLOAN case: lsfLoanOverpayment must never toggle in either + // direction, and lsfLoanDefault (gated on featureLendingProtocolV1_1) + // may only transition from unset to set. Each case needs a loan that + // already exists in the base ledger, so that the apply-view modification + // is seen as a before/after change rather than an insertion. + { + struct Case + { + std::uint32_t before; + std::uint32_t after; + std::string expected; + }; + auto const cases = std::to_array({ + {.before = lsfLoanOverpayment, + .after = 0, + .expected = "lsfLoanOverpayment flag toggled on immutable ledger entry"}, + {.before = 0, + .after = lsfLoanOverpayment, + .expected = "lsfLoanOverpayment flag toggled on immutable ledger entry"}, + {.before = lsfLoanDefault, + .after = 0, + .expected = "lsfLoanDefault flag cleared on immutable ledger entry"}, + }); + + for (auto const& c : cases) + { + Env env{*this, all_}; + Account const a1{"A1"}; + env.fund(XRP(1000), a1); + env.close(); + + OpenView ov{*env.current()}; + + auto const brokerKeylet = + keylet::loanBroker(a1.id(), SeqProxy::rawSequence(ov.seq())); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + { + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id()); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(150); + sleLoan->setFieldU32(sfPaymentRemaining, 1); + sleLoan->setFieldU32(sfFlags, c.before); + ov.rawInsert(sleLoan); + } + + STTx const tx{ttACCOUNT_SET, [](STObject&) {}}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + continue; + sleLoan->setFieldU32(sfFlags, c.after); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + continue; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains(c.expected)); + } + } + + // Pre-featureLendingProtocolV1_1 sibling of the lsfLoanOverpayment + // cases above: the same set-once immutability was originally enforced + // by ValidLoan::finalize, so with V1_1 disabled toggling the flag + // must trip that legacy check instead. lsfLoanDefault immutability + // did not exist pre-V1_1 and is not tested here. + { + auto const cases = std::to_array>({ + {lsfLoanOverpayment, 0}, + {0, lsfLoanOverpayment}, + }); + + for (auto const& [before, after] : cases) + { + Env env{*this, all_ - featureLendingProtocolV1_1}; + Account const a1{"A1"}; + env.fund(XRP(1000), a1); + env.close(); + + OpenView ov{*env.current()}; + + auto const brokerKeylet = + keylet::loanBroker(a1.id(), SeqProxy::rawSequence(ov.seq())); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + { + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id()); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(150); + sleLoan->setFieldU32(sfPaymentRemaining, 1); + sleLoan->setFieldU32(sfFlags, before); + ov.rawInsert(sleLoan); + } + + STTx const tx{ttACCOUNT_SET, [](STObject&) {}}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + continue; + sleLoan->setFieldU32(sfFlags, after); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + continue; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains("Loan Overpayment flag changed")); + } + } + + // Under featureLendingProtocolV1_1, ValidLoan::finalize requires + // interest due (total value minus principal and management fee) to be + // non-negative after each value is rounded to sfLoanScale. Test zero, + // each way to produce a one-unit deficit, and a two-unit deficit. At + // scale 0, an XRP-backed broker rejects any deficit, while an + // IOU-backed one permits one unit of rounding tolerance. + { + struct Case + { + Number totalValue; + Number principal; + Number managementFee; + bool expectFireIntegral; + bool expectFireTolerant; + }; + // The first case sits exactly at the boundary, the middle three + // perturb one component so that interest due is -1, which is within + // the tolerance, and the last overshoots it at -2. + auto const cases = std::to_array({ + {.totalValue = Number(100), + .principal = Number(100), + .managementFee = Number(0), + .expectFireIntegral = false, + .expectFireTolerant = false}, + {.totalValue = Number(99), + .principal = Number(100), + .managementFee = Number(0), + .expectFireIntegral = true, + .expectFireTolerant = false}, + {.totalValue = Number(100), + .principal = Number(101), + .managementFee = Number(0), + .expectFireIntegral = true, + .expectFireTolerant = false}, + {.totalValue = Number(100), + .principal = Number(100), + .managementFee = Number(1), + .expectFireIntegral = true, + .expectFireTolerant = false}, + {.totalValue = Number(98), + .principal = Number(100), + .managementFee = Number(0), + .expectFireIntegral = true, + .expectFireTolerant = true}, + }); + + for (bool const integralAsset : {true, false}) + { + for (auto const& c : cases) + { + Env env{*this, all_}; + Account const a1{"A1"}; + Account const issuer{"issuer"}; + env.fund(XRP(1000), a1, issuer); + env.close(); + + // The check reads the broker's vault asset to decide + // whether the rounding tolerance applies, so both + // branches need a real broker over the relevant asset. + auto const asset = [&]() -> PrettyAsset { + if (integralAsset) + return PrettyAsset{xrpIssue(), 1'000'000}; + PrettyAsset const iouAsset = issuer["IOU"]; + env(trust(a1, iouAsset(1000))); + env(pay(issuer, a1, iouAsset(1000))); + env.close(); + return iouAsset; + }(); + + auto const brokerKeylet = this->createLoanBroker(a1, env, asset); + if (!BEAST_EXPECT(env.le(brokerKeylet))) + continue; + env.close(); + + OpenView ov{*env.current()}; + + auto const loanKeylet = + keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + // Seed a loan whose interest due sits at the boundary. The + // apply-view update below moves it. + { + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id()); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(100); + sleLoan->at(sfManagementFeeOutstanding) = Number(0); + sleLoan->at(sfLoanScale) = 0; + sleLoan->setFieldU32(sfPaymentRemaining, 1); + ov.rawInsert(sleLoan); + } + + STTx const tx{ttACCOUNT_SET, [](STObject&) {}}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + continue; + sleLoan->at(sfTotalValueOutstanding) = c.totalValue; + sleLoan->at(sfPrincipalOutstanding) = c.principal; + sleLoan->at(sfManagementFeeOutstanding) = c.managementFee; + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + continue; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + auto const messages = sink.messages().str(); + if (integralAsset ? c.expectFireIntegral : c.expectFireTolerant) + { + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(messages.contains("Loan interest due is negative")); + } + else + { + // Other invariants may still fire on this raw-inserted + // loan, so only assert the specific message is absent. + BEAST_EXPECT(!messages.contains("Loan interest due is negative")); + } + } + } + } // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation. // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged. diff --git a/src/test/app/invariants/InvariantsPseudoAccount_test.cpp b/src/test/app/invariants/InvariantsPseudoAccount_test.cpp index c43e73aca8..6c8a710bef 100644 --- a/src/test/app/invariants/InvariantsPseudoAccount_test.cpp +++ b/src/test/app/invariants/InvariantsPseudoAccount_test.cpp @@ -7,18 +7,23 @@ #include #include #include +#include #include #include #include +#include #include +#include #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -28,6 +33,7 @@ #include #include #include +#include #include #include @@ -43,6 +49,8 @@ namespace xrpl::test { class InvariantsPseudoAccount_test : public InvariantsBase { + FeatureBitset const all_{test::jtx::testableAmendments()}; + void testValidPseudoAccounts() { @@ -445,6 +453,281 @@ class InvariantsPseudoAccount_test : public InvariantsBase STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, createLoanBroker); + + // Deleting the IOU holding while leaving the broker unchanged must + // still expose CoverAvailable exceeding the now-zero balance: the + // broker is discovered through the deleted trust line. XRP has no + // holding SLE, while deleting an MPToken triggers other invariants, + // so IOU isolates this check. Verify that fixCleanup3_1_3 gates it + // by expecting failure only when the amendment is enabled. + if (assetType == Asset::IOU) + { + Keylet brokerKeylet = keylet::amendments(); + Preclose const createBrokerWithCover = + [&, this](Account const& alice, Account const& issuer, Env& env) { + auto const asset = setupAsset(alice, issuer, env); + brokerKeylet = this->createLoanBroker(alice, env, asset); + if (!BEAST_EXPECT(env.le(brokerKeylet))) + return false; + env(loan_broker::coverDeposit(alice, brokerKeylet.key, asset(10))); + env.close(); + return BEAST_EXPECT(env.le(brokerKeylet)); + }; + + Precheck const deleteHolding = + [&](Account const&, Account const&, ApplyContext& ac) { + if (brokerKeylet.type != ltLOAN_BROKER) + return false; + // Read (don't touch) the broker so it is only found via + // the deleted holding, not as a modified entry. + auto const sleBroker = ac.view().read(brokerKeylet); + if (!BEAST_EXPECT(sleBroker)) + return false; + auto const pseudoAccountID = sleBroker->at(sfAccount); + + // Erase every holding in the pseudo-account directory + // and the directory root itself, mirroring a bug that + // removed the cover holding without zeroing + // CoverAvailable. Removing the root also keeps the + // zero-OwnerCount directory check from firing first. + auto sleDir = ac.view().peek(keylet::ownerDir(pseudoAccountID)); + if (!BEAST_EXPECT(sleDir)) + return false; + for (auto const& index : sleDir->getFieldV256(sfIndexes)) + { + if (auto holding = ac.view().peek(keylet::unchecked(index))) + { + ac.view().erase(holding); + } + } + ac.view().erase(sleDir); + return true; + }; + + // With fixCleanup3_1_3: the invariant fires. + doInvariantCheck( + makeEnv(all_), + {{"Loan Broker cover available is greater than pseudo-account asset balance"}}, + deleteHolding, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createBrokerWithCover); + + // Without fixCleanup3_1_3: the same state is silently accepted. + doInvariantCheck( + makeEnv(all_ - fixCleanup3_1_3), + {}, + deleteHolding, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tesSUCCESS, tesSUCCESS}, + createBrokerWithCover); + } + + // A LoanBroker may only be removed by ttLOAN_BROKER_DELETE. Erase + // the broker in the apply view under a non-delete tx type and + // expect the deletion-tx invariant to fire. + doInvariantCheck( + {{"Loan Broker deleted by a transaction other than LoanBrokerDelete"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + if (loanBrokerKeylet.type != ltLOAN_BROKER) + return false; + auto sleBroker = ac.view().peek(loanBrokerKeylet); + if (!BEAST_EXPECT(sleBroker)) + return false; + ac.view().erase(sleBroker); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + } + + // A LoanBrokerDelete must not remove a broker whose pre-transaction + // DebtTotal is non-zero. visitEntry captures `before` from the parent + // view, so the DebtTotal must be seeded in the OpenView before the + // ApplyContext is constructed; a Precheck modification would only + // land in the applyView (visible as `after`) and would leave `before` + // at the createLoanBroker-produced zero. + { + Env env{*this}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset); + if (!BEAST_EXPECT(env.le(brokerKeylet))) + return; + env.close(); + + OpenView ov{*env.current()}; + + // Seed a non-zero DebtTotal in the base view so `before` at + // visitEntry time reports it. + { + auto const sleBrokerRead = ov.read(brokerKeylet); + if (!BEAST_EXPECT(sleBrokerRead)) + return; + auto sleBroker = std::make_shared(*sleBrokerRead); + sleBroker->at(sfDebtTotal) = Number(1); + ov.rawReplace(sleBroker); + } + + STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sleBroker = ac.view().peek(brokerKeylet); + if (!BEAST_EXPECT(sleBroker)) + return; + ac.view().erase(sleBroker); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT( + sink.messages().str().contains("Loan Broker deleted with non-zero debt total")); + } + + // Residual DebtTotal dust that rounds to zero at the vault asset's + // scale must not trip the invariant: LoanBrokerDelete::preclaim + // deliberately permits it, so the invariant must not be stricter. + // Other invariants may still object to a hand-erased broker, so only + // the absence of the DebtTotal complaint is asserted. + { + Env env{*this}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset); + if (!BEAST_EXPECT(env.le(brokerKeylet))) + return; + env.close(); + + OpenView ov{*env.current()}; + + // A thousandth of a drop: non-zero, but zero once quantized to XRP. + { + auto const sleBrokerRead = ov.read(brokerKeylet); + if (!BEAST_EXPECT(sleBrokerRead)) + return; + auto sleBroker = std::make_shared(*sleBrokerRead); + sleBroker->at(sfDebtTotal) = Number(1, -3); + ov.rawReplace(sleBroker); + } + + STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sleBroker = ac.view().peek(brokerKeylet); + if (!BEAST_EXPECT(sleBroker)) + return; + ac.view().erase(sleBroker); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + [[maybe_unused]] TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT( + !sink.messages().str().contains("Loan Broker deleted with non-zero debt total")); + } + + // A LoanBrokerDelete must not remove a broker whose pre-transaction + // OwnerCount is non-zero. DebtTotal is left at zero so the earlier + // check passes and the OwnerCount check is what fires. + { + Env env{*this}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset); + if (!BEAST_EXPECT(env.le(brokerKeylet))) + return; + env.close(); + + OpenView ov{*env.current()}; + + { + auto const sleBrokerRead = ov.read(brokerKeylet); + if (!BEAST_EXPECT(sleBrokerRead)) + return; + auto sleBroker = std::make_shared(*sleBrokerRead); + sleBroker->at(sfOwnerCount) = 1; + ov.rawReplace(sleBroker); + } + + STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sleBroker = ac.view().peek(brokerKeylet); + if (!BEAST_EXPECT(sleBroker)) + return; + ac.view().erase(sleBroker); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT( + sink.messages().str().contains("Loan Broker deleted with non-zero owner count")); + } + + // Only one LoanBroker may be deleted per transaction. Create two + // brokers under different owners, then erase both in the apply view + // and expect the multi-deletion invariant to fire. + { + Keylet loanBrokerKeylet1 = keylet::amendments(); + Keylet loanBrokerKeylet2 = keylet::amendments(); + Preclose const createTwoBrokers = [&, this]( + Account const& a1, Account const& a2, Env& env) { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + loanBrokerKeylet1 = this->createLoanBroker(a1, env, xrpAsset); + loanBrokerKeylet2 = this->createLoanBroker(a2, env, xrpAsset); + return BEAST_EXPECT(env.le(loanBrokerKeylet1) && env.le(loanBrokerKeylet2)); + }; + + doInvariantCheck( + {{"more than one Loan Broker deleted in a single transaction"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sle1 = ac.view().peek(loanBrokerKeylet1); + auto sle2 = ac.view().peek(loanBrokerKeylet2); + if (!BEAST_EXPECT(sle1 && sle2)) + return false; + ac.view().erase(sle1); + ac.view().erase(sle2); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createTwoBrokers); } } diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp index 4b6002580b..d816c36c15 100644 --- a/src/test/app/invariants/InvariantsVault_test.cpp +++ b/src/test/app/invariants/InvariantsVault_test.cpp @@ -8,12 +8,16 @@ #include #include #include +#include #include #include #include #include +#include +#include #include +#include #include #include #include @@ -38,10 +42,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -68,6 +74,19 @@ class InvariantsVault_test : public InvariantsBase AccountID account; int amount; }; + // Parameters for a synthetic loan object created alongside a vault + // adjustment. The interest due booked to the vault is + // totalValueOutstanding - principalOutstanding - managementFeeOutstanding. + struct LoanParams + { + int principalOutstanding = 0; + int totalValueOutstanding = 0; + int managementFeeOutstanding = 0; + AccountID borrower = beast::kZero; + // Broker the created loan references. Left unset when the test does + // not depend on the broker resolving to a real ledger entry. + uint256 brokerKey = beast::kZero; + }; struct Adjustments { // NOLINTBEGIN(readability-redundant-member-init) @@ -79,6 +98,10 @@ class InvariantsVault_test : public InvariantsBase std::optional vaultAssets = std::nullopt; std::optional accountAssets = std::nullopt; std::optional accountShares = std::nullopt; + std::optional createLoan = std::nullopt; + // Number of loan objects to create (only used when createLoan is + // set); a valid loan set creates exactly one. + int loanCount = 1; // NOLINTEND(readability-redundant-member-init) }; constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) { @@ -186,6 +209,26 @@ class InvariantsVault_test : public InvariantsBase (*sleMPToken)[sfMPTAmount] = addSigned(*(*sleMPToken)[sfMPTAmount], pair.amount); ac.update(sleMPToken); } + + if (args.createLoan) + { + auto const& lp = *args.createLoan; + bool const anyOutstanding = lp.principalOutstanding != 0 || + lp.totalValueOutstanding != 0 || lp.managementFeeOutstanding != 0; + // The vault key stands in for an unset broker: it keeps the loan + // keylet distinct per vault while resolving to no broker. + uint256 const brokerKey = lp.brokerKey != beast::kZero ? lp.brokerKey : keylet.key; + for (std::uint32_t seq = 1; seq <= static_cast(args.loanCount); + ++seq) + { + auto sleLoan = makeLoanSle(brokerKey, seq, lp.borrower); + sleLoan->at(sfPrincipalOutstanding) = Number(lp.principalOutstanding); + sleLoan->at(sfTotalValueOutstanding) = Number(lp.totalValueOutstanding); + sleLoan->at(sfManagementFeeOutstanding) = Number(lp.managementFeeOutstanding); + sleLoan->setFieldU32(sfPaymentRemaining, anyOutstanding ? 1 : 0); + ac.insert(sleLoan); + } + } return true; }; @@ -206,7 +249,10 @@ class InvariantsVault_test : public InvariantsBase Account const a3{"A3"}; Account const a4{"A4"}; - auto const precloseXrp = [&](Account const& a1, Account const& a2, Env& env) -> bool { + auto const precloseXrp = [&](Account const& a1, + Account const& a2, + Env& env, + VaultVersion version = VaultVersion::CashBasis) -> bool { env.fund(XRP(1000), a3, a4); Vault const vault{env}; auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); @@ -217,6 +263,18 @@ class InvariantsVault_test : public InvariantsBase return true; }; + auto const createClosedXrpBroker = + [&](Account const& owner, Env& env) -> std::optional> { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + auto const brokerKeylet = createLoanBroker(owner, env, xrpAsset); + auto const sleBroker = env.le(brokerKeylet); + if (!BEAST_EXPECT(sleBroker)) + return std::nullopt; + auto const vaultKeylet = keylet::vault(sleBroker->at(sfVaultID)); + env.close(std::chrono::seconds{61}); + return std::pair{vaultKeylet, brokerKeylet}; + }; + testcase << "Vault general checks"; doInvariantCheck( {"vault deletion succeeded without deleting a vault"}, @@ -598,7 +656,81 @@ class InvariantsVault_test : public InvariantsBase precloseXrp, TxAccount::A2); + // Under featureLendingProtocolV1_1 the immutability of sfAsset, sfAccount, + // sfShareMPTID and sfLEVersion is enforced by NoModifiedUnmodifiableFields. doInvariantCheck( + {"changed an unchangeable field"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, MPTIssue(MPTID(42))}); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"changed an unchangeable field"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + sleVault->setAccountID(sfAccount, a2.id()); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"changed an unchangeable field"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + (*sleVault)[sfShareMPTID] = MPTID(42); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"changed an unchangeable field"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + (*sleVault)[sfLEVersion] = std::to_underlying(VaultVersion::Legacy); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&precloseXrp](Account const& a1, Account const& a2, Env& env) { + return precloseXrp(a1, a2, env, VaultVersion::CashBasis); + }); + + // Pre-featureLendingProtocolV1_1 sfAsset, sfAccount and sfShareMPTID are + // guarded by ValidVault instead, so both paths need coverage. ValidVault + // returns early once the result is already tec, hence no escalation to + // tef on the second pass. + auto const preLendingV11Amendments = all_ - featureLendingProtocolV1_1; + doInvariantCheck( + makeEnv(preLendingV11Amendments), {"violation of vault immutable data"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); @@ -615,6 +747,7 @@ class InvariantsVault_test : public InvariantsBase precloseXrp); doInvariantCheck( + makeEnv(preLendingV11Amendments), {"violation of vault immutable data"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); @@ -631,6 +764,7 @@ class InvariantsVault_test : public InvariantsBase precloseXrp); doInvariantCheck( + makeEnv(preLendingV11Amendments), {"violation of vault immutable data"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); @@ -698,9 +832,12 @@ class InvariantsVault_test : public InvariantsBase TxAccount::A2); // Without fixCleanup3_4_0 the same state must NOT trip the invariant, - // preserving pre-amendment behavior (no fork risk). + // preserving pre-amendment behavior (no fork risk). Also remove + // featureLendingProtocolV1_1 so finalizeLoanManage's stricter checks + // (exactly one loan touched) do not fire from a bare vault mutation + // that does not touch a loan. doInvariantCheck( - makeEnv(all_ - fixCleanup3_4_0), + makeEnv(all_ - fixCleanup3_4_0 - featureLendingProtocolV1_1), {}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); @@ -808,6 +945,746 @@ class InvariantsVault_test : public InvariantsBase precloseXrp, TxAccount::A2); + // ttLOAN_SET pre-featureLendingProtocolV1_1: finalizeLoanSet short- + // circuits and returns success without inspecting the loan or the + // vault. The same state that trips the principal-outstanding check + // under V1_1 must be silently accepted here. + doInvariantCheck( + makeEnv(all_ - featureLendingProtocolV1_1), + {}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .assetsAvailable = -200, + .vaultAssets = -200, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 200}, + .createLoan = LoanParams{ + .principalOutstanding = 300, + .totalValueOutstanding = 300, + .borrower = a1.id(), + }}); + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }}, + {tesSUCCESS, tesSUCCESS}, + precloseXrp); + + // ttLOAN_MANAGE: a loan is created rather than modified. This object- + // existence rule applies on both invariant passes. + doInvariantCheck( + {"Loan created by a transaction other than LoanSet"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .createLoan = LoanParams{ + .principalOutstanding = 100, + .totalValueOutstanding = 100, + .borrower = a1.id(), + }}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanImpair); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_MANAGE: loss unrealized driven negative + doInvariantCheck( + {"loss unrealized must not be negative"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, Adjustments{.lossUnrealized = -1}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // Loan flags may only change under the transaction types that own + // those transitions. + { + struct Case + { + std::uint32_t before; + std::uint32_t after; + std::string expected; + }; + auto const cases = std::to_array({ + {.before = 0, + .after = lsfLoanImpaired, + .expected = "lsfLoanImpaired changed outside LoanManage or LoanPay"}, + {.before = lsfLoanImpaired, + .after = 0, + .expected = "lsfLoanImpaired changed outside LoanManage or LoanPay"}, + {.before = 0, + .after = lsfLoanDefault, + .expected = "lsfLoanDefault changed outside LoanManage"}, + }); + + for (auto const& c : cases) + { + Env env{*this, all_}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + auto const keys = createClosedXrpBroker(a1, env); + if (!keys) + continue; + auto const& brokerKeylet = keys->second; + + OpenView ov{*env.current()}; + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + { + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id()); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(150); + sleLoan->setFieldU32(sfPaymentRemaining, 1); + sleLoan->setFieldU32(sfFlags, c.before); + ov.rawInsert(sleLoan); + } + + STTx const tx{ttACCOUNT_SET, [](STObject&) {}}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + continue; + sleLoan->setFieldU32(sfFlags, c.after); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + continue; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains(c.expected)); + } + } + + // ttLOAN_MANAGE (default): a defaulted loan atomically enters a + // terminal state, which drops sfNextPaymentDueDate from the ledger + // entry. Seed a loan that already carries lsfLoanDefault so the + // "must newly set" check passes, then leave sfNextPaymentDueDate + // present and non-zero on the after-image; the residual due-date + // check must then fire. + { + Env env{*this, all_}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + BEAST_EXPECT(precloseXrp(a1, a2, env)); + env.close(); + + OpenView ov{*env.current()}; + + auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1)); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + // Pre-insert a loan that is not yet defaulted but has a + // NextPaymentDueDate set; the apply-view mutation below flips + // lsfLoanDefault (so the "must newly set" check passes) while + // leaving the due date behind. + { + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id()); + sleLoan->setFieldU32(sfNextPaymentDueDate, 123); + ov.rawInsert(sleLoan); + } + + STTx const tx{ + ttLOAN_MANAGE, [](STObject& t) { t.setFieldU32(sfFlags, tfLoanDefault); }}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + return; + sleLoan->setFieldU32(sfFlags, lsfLoanDefault); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains( + "Loan with zero payments must have zero next payment due date")); + } + + // ttLOAN_PAY pre-featureLendingProtocolV1_1: finalizeLoanPay short- + // circuits and returns success. The same "no vault balance change" + // state that trips the check under V1_1 must be silently accepted + // here. + doInvariantCheck( + makeEnv(all_ - featureLendingProtocolV1_1), + {}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, Adjustments{}); + }, + XRPAmount{}, + STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, + {tesSUCCESS, tesSUCCESS}, + precloseXrp); + + // ttLOAN_PAY: cash is credited to the vault and a loan is created + // rather than modified. This object-existence rule applies on both + // invariant passes. + doInvariantCheck( + {"Loan created by a transaction other than LoanSet"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .assetsTotal = 50, + .assetsAvailable = 50, + .vaultAssets = 50, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -50}, + .createLoan = LoanParams{ + .principalOutstanding = 100, + .totalValueOutstanding = 100, + .borrower = a1.id(), + }}); + }, + XRPAmount{}, + STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(50)); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_PAY: loss unrealized driven negative. The cash inflow is + // valid, but loss unrealized is set below zero. + doInvariantCheck( + {"loss unrealized must not be negative"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .assetsTotal = 100, + .assetsAvailable = 100, + .lossUnrealized = -1, + .vaultAssets = 100, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -100}}); + }, + XRPAmount{}, + STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_PAY success post-conditions. A loan left with payments still + // remaining after a successful payment must show that payment in its + // balance and schedule: PrincipalOutstanding and PaymentRemaining both + // strictly decrease, and NextPaymentDueDate advances by a positive + // multiple of PaymentInterval. Each case seeds the same loan, then applies + // an after-image that breaks exactly one of those conditions. + { + struct Case + { + Number principal; + std::uint32_t remaining; + std::uint32_t dueDate; + std::string expected; + }; + auto const cases = std::to_array({ + {.principal = Number(100), + .remaining = 1, + .dueDate = 110, + .expected = "loan pay must strictly decrease PrincipalOutstanding"}, + {.principal = Number(50), + .remaining = 2, + .dueDate = 110, + .expected = "loan pay must decrease PaymentRemaining"}, + {.principal = Number(50), + .remaining = 1, + .dueDate = 100, + .expected = "loan pay must advance NextPaymentDueDate"}, + // Advanced, but not by a whole number of payment intervals. + {.principal = Number(50), + .remaining = 1, + .dueDate = 105, + .expected = "loan pay must advance NextPaymentDueDate"}, + }); + + for (auto const& c : cases) + { + Env env{*this, all_}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + auto const keys = createClosedXrpBroker(a1, env); + if (!keys) + continue; + auto const& brokerKeylet = keys->second; + + OpenView ov{*env.current()}; + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + { + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id()); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(150); + sleLoan->at(sfPaymentInterval) = 10u; + sleLoan->setFieldU32(sfPaymentRemaining, 2); + sleLoan->setFieldU32(sfNextPaymentDueDate, 100); + ov.rawInsert(sleLoan); + } + + STTx const tx{ + ttLOAN_PAY, [](STObject& t) { t.setFieldAmount(sfAmount, XRPAmount(50)); }}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + continue; + sleLoan->at(sfPrincipalOutstanding) = c.principal; + sleLoan->setFieldU32(sfPaymentRemaining, c.remaining); + sleLoan->setFieldU32(sfNextPaymentDueDate, c.dueDate); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + continue; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains(c.expected)); + } + } + + // ttLOAN_MANAGE (default): the write-off is rounded downward at the + // pre-default AssetsTotal scale. A near-total IOU default can leave + // valid positive dust while moving the posterior AssetsTotal to a much + // finer scale. The dust must be bounded by the former scale rather than + // compared with one unit at the posterior scale. + { + Env env{*this, all_ | featureLendingProtocolV1_1}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const borrower{"borrower"}; + env.fund(XRP(1000), issuer, owner, borrower); + env.close(); + + PrettyAsset const iouAsset{issuer["IOU"]}; + auto const brokerKeylet = createLoanBroker(owner, env, iouAsset); + auto const sleBrokerBase = env.le(brokerKeylet); + if (!BEAST_EXPECT(sleBrokerBase)) + return; + auto const vaultKeylet = keylet::vault(sleBrokerBase->at(sfVaultID)); + env.close(); + + Number const assetsTotalBefore{1, 1}; + Number const loanOwed{9'999'999'999'999'999LL, -15}; + Number const assetsTotalAfter{1, -14}; + auto const beforeScale = scale(assetsTotalBefore, iouAsset); + auto const afterScale = scale(assetsTotalAfter, iouAsset); + Number const residual = (assetsTotalAfter - assetsTotalBefore) - (-loanOwed); + Number const beforeTolerance{1, beforeScale}; + Number const afterTolerance{1, afterScale}; + + BEAST_EXPECT(afterScale < beforeScale); + BEAST_EXPECT(residual > beast::kZero && residual < beforeTolerance); + BEAST_EXPECT(residual > afterTolerance); + + OpenView ov{*env.current()}; + { + auto const sleVaultRead = ov.read(vaultKeylet); + if (!BEAST_EXPECT(sleVaultRead)) + return; + auto sleVault = std::make_shared(*sleVaultRead); + sleVault->at(sfAssetsTotal) = assetsTotalBefore; + sleVault->at(sfAssetsAvailable) = Number(0); + ov.rawReplace(sleVault); + + auto const sharesKeylet = keylet::mptokenIssuance(sleVaultRead->at(sfShareMPTID)); + auto const sleSharesRead = ov.read(sharesKeylet); + if (!BEAST_EXPECT(sleSharesRead)) + return; + auto sleShares = std::make_shared(*sleSharesRead); + sleShares->at(sfOutstandingAmount) = 1; + ov.rawReplace(sleShares); + } + { + auto const sleBrokerRead = ov.read(brokerKeylet); + if (!BEAST_EXPECT(sleBrokerRead)) + return; + auto sleBroker = std::make_shared(*sleBrokerRead); + sleBroker->at(sfDebtTotal) = loanOwed; + ov.rawReplace(sleBroker); + } + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + { + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, borrower.id()); + sleLoan->at(sfPrincipalOutstanding) = loanOwed; + sleLoan->at(sfTotalValueOutstanding) = loanOwed; + sleLoan->setFieldU32(sfPaymentRemaining, 1); + ov.rawInsert(sleLoan); + } + + STTx const tx{ + ttLOAN_MANAGE, [](STObject& t) { t.setFieldU32(sfFlags, tfLoanDefault); }}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + { + auto sleVault = ac.view().peek(vaultKeylet); + if (!BEAST_EXPECT(sleVault)) + return; + sleVault->at(sfAssetsTotal) = assetsTotalAfter; + ac.view().update(sleVault); + } + { + auto sleBroker = ac.view().peek(brokerKeylet); + if (!BEAST_EXPECT(sleBroker)) + return; + sleBroker->at(sfDebtTotal) = Number(0); + ac.view().update(sleBroker); + } + { + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + return; + sleLoan->at(sfPrincipalOutstanding) = Number(0); + sleLoan->at(sfTotalValueOutstanding) = Number(0); + sleLoan->setFieldU32(sfPaymentRemaining, 0); + sleLoan->setFieldU32(sfFlags, lsfLoanDefault); + ac.view().update(sleLoan); + } + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tesSUCCESS); + } + + // A loan may only be deleted by a LoanDelete transaction, and only once + // it is fully paid off. Both branches are exercised by creating a real + // loan in the Preclose (so it exists in the base ledger with outstanding + // principal) and then erasing it in the Precheck. + { + Keylet loanKeylet = keylet::amendments(); + auto const precloseLoan = [&loanKeylet, this]( + Account const& a1, Account const& a2, Env& env) -> bool { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset); + auto const brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return false; + auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID)); + Vault const vault{env}; + env(vault.deposit( + {.depositor = a1, .id = vaultKeylet.key, .amount = xrpAsset(100)})); + env.close(std::chrono::seconds{61}); + + loanKeylet = keylet::loan( + brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))); + env(loan::set(a2, brokerKeylet.key, xrpAsset(50).value()), + loan::kCounterparty(a1), + Sig(sfCounterpartySignature, a1), + loan::kPaymentInterval(60), + loan::kPaymentTotal(1), + Fee(env.current()->fees().base * 2)); + env.close(); + return BEAST_EXPECT(env.le(loanKeylet)); + }; + + auto const eraseLoan = [&loanKeylet](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanKeylet); + if (!sle) + return false; + ac.view().erase(sle); + return true; + }; + + // Deleting the loan under any transaction type other than LoanDelete + // (here the neutral ttACCOUNT_SET) is a violation, even while the + // loan still has outstanding obligations: the transaction-type check + // fires before the not-fully-paid-off check. + doInvariantCheck( + {"Loan deleted by a transaction other than LoanDelete"}, + eraseLoan, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseLoan); + } + + STTx const loanSetTx{ + ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(0); }}; + + // Loan interest due (total value less principal and management fee) must + // never be negative. The loan below carries a total value short of its + // principal, while every individual field stays non-negative. A real + // broker over an XRP vault is created in the preclose, both so the + // earlier broker-existence checks pass and so the deficit is measured + // in an integral asset domain, where no rounding tolerance applies. + { + Keylet brokerKeylet = keylet::amendments(); + auto const precloseBroker = [&brokerKeylet, this]( + Account const& a1, Account const&, Env& env) -> bool { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + brokerKeylet = this->createLoanBroker(a1, env, xrpAsset); + env.close(); + return BEAST_EXPECT(env.le(brokerKeylet)); + }; + + doInvariantCheck( + {"Loan interest due is negative"}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id()); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(90); + sleLoan->setFieldU32(sfPaymentRemaining, 1); + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + loanSetTx, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseBroker); + } + + // Each of these loan STNumber fields must never be negative. The loan + // is created directly with a single field set negative while the + // paid-off bookkeeping is kept consistent, so that only the " + // is negative" check trips. + for (auto const field : { + &sfLoanServiceFee, + &sfLatePaymentFee, + &sfClosePaymentFee, + &sfPrincipalOutstanding, + &sfTotalValueOutstanding, + &sfManagementFeeOutstanding, + }) + { + // The outstanding-balance fields also feed the paid-off checks, so + // a loan carrying one must still have payments remaining; a loan + // with only a negative fee stays fully paid off (zero remaining). + bool const isOutstanding = *field == sfPrincipalOutstanding || + *field == sfTotalValueOutstanding || *field == sfManagementFeeOutstanding; + doInvariantCheck( + {field->getName() + " is negative"}, + [&, field](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1)); + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id()); + sleLoan->at(*field) = Number(-10); + sleLoan->setFieldU32(sfPaymentRemaining, isOutstanding ? 1 : 0); + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + loanSetTx); + } + + // Mirror of the loop above for the strictly-positive constraint: a + // loan's sfPeriodicPayment must always be > 0. Cover both boundary + // failure modes (zero and negative). + for (Number const& badValue : {Number(0), Number(-1)}) + { + doInvariantCheck( + {std::string{sfPeriodicPayment.getName()} + " is zero or negative"}, + [&, badValue](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1)); + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id()); + sleLoan->at(sfPeriodicPayment) = badValue; + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + loanSetTx); + } + + // A loan with sfPaymentRemaining == 0 must be fully paid off in every + // outstanding-balance dimension. Insert a bare loan that reports zero + // payments remaining but still carries a non-zero principal owed; the + // paid-off invariant must reject it before the later broker-existence + // check has a chance to run. + doInvariantCheck( + {"Loan with zero payments remaining has not been paid off"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1)); + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id()); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(100); + sleLoan->at(sfPeriodicPayment) = Number(1); + sleLoan->setFieldU32(sfPaymentRemaining, 0); + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + loanSetTx); + + // Converse: a loan whose outstanding balances are all zero has been + // fully paid off and must carry zero payments remaining. Insert a + // fully-zeroed loan with sfPaymentRemaining = 1 to trip the check. + doInvariantCheck( + {"Fully paid off Loan still has payments remaining"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1)); + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id()); + sleLoan->setFieldU32(sfPaymentRemaining, 1); + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + loanSetTx); + + // A loan must reference a live loan broker. A bare loan SLE is + // inserted with every other loan-level field kept consistent so the + // earlier ValidLoan checks pass; sfLoanBrokerID defaults to zero, + // which resolves to no broker, and the broker-existence check trips. + doInvariantCheck( + {"Loan broker does not exist"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleLoan = makeLoanSle(uint256{}, 1, a2.id()); + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + loanSetTx); + + // A loan's broker must in turn reference a live vault. A real broker + // is created in the preclose so its sfVaultID points at an existing + // vault; the precheck then erases that vault and inserts a loan + // referencing the broker, so the broker-existence check passes and + // the broker-vault-existence check trips. + { + Keylet brokerKeylet = keylet::amendments(); + auto const precloseBroker = [&brokerKeylet, this]( + Account const& a1, Account const&, Env& env) -> bool { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + brokerKeylet = this->createLoanBroker(a1, env, xrpAsset); + env.close(); + return BEAST_EXPECT(env.le(brokerKeylet)); + }; + + doInvariantCheck( + {"Loan broker vault does not exist"}, + [&brokerKeylet](Account const&, Account const&, ApplyContext& ac) { + auto sleBroker = ac.view().peek(brokerKeylet); + if (!sleBroker) + return false; + auto sleVault = ac.view().peek(keylet::vault(sleBroker->at(sfVaultID))); + if (!sleVault) + return false; + ac.view().erase(sleVault); + + auto const loanKeylet = + keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + auto sleLoan = std::make_shared(loanKeylet); + sleLoan->at(sfLoanBrokerID) = brokerKeylet.key; + sleLoan->at(sfPrincipalOutstanding) = Number(0); + sleLoan->at(sfTotalValueOutstanding) = Number(0); + sleLoan->at(sfManagementFeeOutstanding) = Number(0); + sleLoan->at(sfPeriodicPayment) = Number(1); + sleLoan->setFieldU32(sfPaymentRemaining, 0); + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + loanSetTx, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseBroker); + } + + // ttVAULT_SET: owner is immutable (enforced by + // NoModifiedUnmodifiableFields under featureLendingProtocolV1_1. + doInvariantCheck( + {"changed an unchangeable field"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + sleVault->setAccountID(sfOwner, a2.id()); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + // ttVAULT_SET: withdrawal policy is immutable + doInvariantCheck( + {"changed an unchangeable field"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + sleVault->setFieldU8( + sfWithdrawalPolicy, + static_cast(sleVault->getFieldU8(sfWithdrawalPolicy) + 1)); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + // ttVAULT_SET: scale is immutable + doInvariantCheck( + {"changed an unchangeable field"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + sleVault->setFieldU8( + sfScale, static_cast(sleVault->getFieldU8(sfScale) + 1)); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + // featureLendingProtocolV1_1 moves the vault immutability checks from VaultInvariant to + // InvariantCheck. + doInvariantCheck( + makeEnv(all_), + {"changed an unchangeable field"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + sleVault->setFieldU8( + sfWithdrawalPolicy, + static_cast(sleVault->getFieldU8(sfWithdrawalPolicy) + 1)); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + testcase << "Vault create"; doInvariantCheck( { @@ -1907,8 +2784,7 @@ class InvariantsVault_test : public InvariantsBase // Synthesize a Loan whose final scheduled payment lands // exactly at RedemptionDate: StartDate = red, interval = 60, // remaining = 1 => red + 60 >= red. - auto sleLoan = std::make_shared( - keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq))); + auto sleLoan = makeLoanSle(closedEndedBrokerKeylet.key, loanSeq, a1.id()); sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; sleLoan->at(sfLoanSequence) = loanSeq; sleLoan->at(sfBorrower) = a1.id(); diff --git a/src/test/app/lending/LoanInvariants_test.cpp b/src/test/app/lending/LoanInvariants_test.cpp index 264dbdcd24..b3d4803aff 100644 --- a/src/test/app/lending/LoanInvariants_test.cpp +++ b/src/test/app/lending/LoanInvariants_test.cpp @@ -25,7 +25,9 @@ #include #include #include +#include +#include #include #include @@ -383,6 +385,85 @@ private: isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale)); } + // Verify an overpayment cannot reduce principal without covering and + // advancing at least one scheduled instalment: reject an extra-only amount, + // but accept an instalment plus extra. Enable V1_1 explicitly because + // LoanTestBase::all_ excludes it. + void + testLoanPayOverpaymentScheduleInvariant(FeatureBitset features) + { + testcase("LoanPay overpayment schedule advancement"); + + using namespace jtx; + using namespace loan; + + Env env{*this, features | featureLendingProtocolV1_1}; + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1000}; + + BrokerInfo const broker = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = asset(100'000).value(), + .managementFeeRate = TenthBips16(10'000), + }); + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + // Principal 10,000 over 3 payments, overpayment enabled. One scheduled + // payment is ~3,333, so an amount well below that cannot cover one. + auto const loanKeylet = nextLoanKeylet(env, broker); + env(loan::set(borrower, broker.brokerID, asset(10'000).value(), tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + loan::kPaymentInterval(86400 * 30), + loan::kPaymentTotal(3), + loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))), + loanSetFee); + env.close(); + + auto const before = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(before.paymentRemaining == 3); + + STAmount const belowOnePayment = asset(1'000).value(); + BEAST_EXPECT((belowOnePayment < STAmount{asset, before.periodicPayment})); + + auto const payFee = Fee(env.current()->fees().base * 2); + + // The amount does not cover a scheduled payment, so makeRegularPayment makes zero scheduled + // payments and returns tecINSUFFICIENT_PAYMENT before the Extra branch runs. Were the + // payment to succeed while touching only principal, PaymentRemaining and NextPaymentDueDate + // would silently fail to advance. + env(pay(borrower, loanKeylet.key, belowOnePayment, tfLoanOverpayment), + payFee, + Ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + + auto const afterReject = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(afterReject.paymentRemaining == before.paymentRemaining); + BEAST_EXPECT(afterReject.principalOutstanding == before.principalOutstanding); + BEAST_EXPECT(afterReject.nextPaymentDate == before.nextPaymentDate); + + // PaymentRemaining drops by one, NextPaymentDueDate advances by one interval, and + // PrincipalOutstanding strictly decreases (by more than a plain payment thanks to the + // extra). + STAmount const onePaymentPlusExtra = asset(5'000).value(); + env(pay(borrower, loanKeylet.key, onePaymentPlusExtra, tfLoanOverpayment), payFee); + env.close(); + + auto const afterPay = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(afterPay.paymentRemaining == before.paymentRemaining - 1); + BEAST_EXPECT(afterPay.principalOutstanding < before.principalOutstanding); + BEAST_EXPECT(afterPay.nextPaymentDate == before.nextPaymentDate + before.paymentInterval); + } + void testAccountSendMptMinAmountInvariant(FeatureBitset features) { @@ -851,12 +932,175 @@ private: }); } + void + testLoanSetRecipientScaleInvariant() + { + using namespace jtx; + using namespace loan; + + auto const runCase = [&](bool coarseBorrower) { + testcase( + coarseBorrower ? "LoanSet borrower balance uses coarsest scale" + : "LoanSet broker owner balance uses coarsest scale"); + + Env env(*this, all_ | featureLendingProtocolV1_1); + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + Number const coarseBalance{100'000'000'000LL}; + Number const regularBalance{100'000'000}; + PrettyAsset const asset = createFundedRippleIouAsset( + env, + issuer, + lender, + borrower, + coarseBorrower ? regularBalance : coarseBalance, + coarseBorrower ? coarseBalance : regularBalance); + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + Number const principal{1'012'345, -5}; + Number const originationFee{123'456, -6}; + Account const& recipient = coarseBorrower ? borrower : lender; + Number const expected = coarseBorrower ? principal : originationFee; + auto const before = env.balance(recipient, asset); + + if (coarseBorrower) + { + env(set(borrower, broker.brokerID, principal), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + } + else + { + env(set(borrower, broker.brokerID, principal), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + kLoanOriginationFee(originationFee), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + } + env.close(); + + auto const after = env.balance(recipient, asset); + Number const received = after.number() - before.number(); + auto const recipientScale = + std::max(before.value().exponent(), after.value().exponent()); + auto const vaultScale = broker.vaultScale(env); + Number const tolerance{1, recipientScale}; + + BEAST_EXPECT(recipientScale > vaultScale); + BEAST_EXPECT(received != expected); + BEAST_EXPECT( + abs(roundToAsset(asset, received, recipientScale) - + roundToAsset(asset, expected, recipientScale)) <= tolerance); + }; + + runCase(/*coarseBorrower=*/true); + runCase(/*coarseBorrower=*/false); + } + + // Under featureLendingProtocolV1_1, ValidLoan::finalize enforces + // TotalValueOutstanding >= PrincipalOutstanding + ManagementFeeOutstanding + // ("interest due is non-negative"). This test drives the transactor + // through a multi-payment scenario with a non-zero management fee and + // messy IOU-scale rounding; if any rounding path in LoanPay were to + // inflate PrincipalOutstanding or ManagementFeeOutstanding relative to + // TotalValueOutstanding by even one ULP, the invariant would fire and + // the LoanPay would return tecINVARIANT_FAILED instead of tesSUCCESS. + void + testLoanPayInterestDueNonNegativeInvariant() + { + testcase("LoanPay interest-due non-negative invariant"); + + using namespace jtx; + using namespace loan; + + Env env(*this, all_ | featureLendingProtocolV1_1); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + // Default broker params carry managementFeeRate = 100 tenth-bips + // (1%), which is what makes managementFeeOutstanding accumulate + // non-trivially through the payment schedule. + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + auto const loanKeylet = nextLoanKeylet(env, broker); + + // Messy interest rate, non-trivial payment count. Values chosen so + // that periodicPayment and each roundedInterest/managementFee share + // are unlikely to be representable exactly at loanScale. + env(set(borrower, broker.brokerID, Number{1'000}), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32{24'346}), + kPaymentTotal(24), + kPaymentInterval(86400 * 30), + loanSetFee); + env.close(); + + auto const payFee = Fee(env.current()->fees().base * 2); + // Boundary check up front on the freshly-created loan. + { + auto const initial = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT( + initial.totalValue >= + initial.principalOutstanding + initial.managementFeeOutstanding); + } + + // Six regular scheduled payments. If the invariant fires the + // Ter(tesSUCCESS) assertion below catches it; the identity check + // then re-asserts it in the test for a clearer failure message. + std::uint32_t prevPaymentRemaining = 24; + for (int i = 0; i < 6; ++i) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return; + // Match the amount LoanPay expects for a scheduled payment: + // periodicPayment rounded at loanScale, plus the flat service + // fee (0 here by default, but included for robustness). + auto const payAmount = STAmount{ + iouAsset, + roundPeriodicPayment( + iouAsset, loanSle->at(sfPeriodicPayment), loanSle->at(sfLoanScale)) + + loanSle->at(sfLoanServiceFee)}; + env(pay(borrower, loanKeylet.key, payAmount), payFee, Ter(tesSUCCESS)); + env.close(); + + auto const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT( + state.totalValue >= state.principalOutstanding + state.managementFeeOutstanding); + BEAST_EXPECT(state.paymentRemaining == prevPaymentRemaining - 1); + prevPaymentRemaining = state.paymentRemaining; + } + } + // Tests run under each entry in amendmentCombinations(). void runAmendmentSensitive(FeatureBitset features) { testLoanPayComputePeriodicPaymentInvariants(features); testLoanPayDebtDecreaseInvariant(features); + testLoanPayOverpaymentScheduleInvariant(features); testAccountSendMptMinAmountInvariant(features); testMinimumBrokerCoverConsistency(features); } @@ -865,6 +1109,8 @@ public: void run() override { + testLoanSetRecipientScaleInvariant(); + testLoanPayInterestDueNonNegativeInvariant(); for (auto const& features : jtx::amendmentCombinations( {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp index 4688e8c4a6..5bf8ac9981 100644 --- a/src/test/jtx/impl/vault.cpp +++ b/src/test/jtx/impl/vault.cpp @@ -39,6 +39,8 @@ Vault::create(CreateArgs const& args) const jv[sfSubscriptionDate] = *args.subscriptionDate; if (args.redemptionDate) jv[sfRedemptionDate] = *args.redemptionDate; + if (args.leVersion) + jv[sfLEVersion] = std::to_underlying(*args.leVersion); return {jv, keylet}; } diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h index 6b2ffddfb3..000e8a20ea 100644 --- a/src/test/jtx/vault.h +++ b/src/test/jtx/vault.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -33,6 +34,8 @@ struct Vault std::nullopt; // NOLINT(readability-redundant-member-init) std::optional redemptionDate = std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional leVersion = + std::nullopt; // NOLINT(readability-redundant-member-init) }; /** From 9a7c5ea593fabc8a424c80d5f5c140722b2d3fe2 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 27 Aug 2026 17:26:55 +0000 Subject: [PATCH 13/40] chore: Bump version to 3.4.0-b3 (#8132) --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index f1917eccff..0bc02f98ae 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.4.0-b2" +char const* const versionString = "3.4.0-b3" // clang-format on ; From fac20a06f3341a5850ac15f14c734c238df16dfd Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:21:25 +0000 Subject: [PATCH 14/40] refactor: Add common helper function for injected metadata fields in RPCs (#5706) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mayukha Vadari Co-authored-by: Mayukha Vadari Co-authored-by: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Co-authored-by: Ayaz Salikhov --- API-CHANGELOG.md | 4 +- .../xrpl/protocol/NFTSyntheticSerializer.h | 19 -- .../protocol/NFTSyntheticSerializer.cpp | 24 --- src/test/app/AccountDelete_test.cpp | 20 +- src/test/app/NFToken_test.cpp | 193 ++++++++++++------ src/xrpld/app/ledger/detail/LedgerToJson.cpp | 42 ++-- src/xrpld/app/misc/NetworkOPs.cpp | 8 +- src/xrpld/rpc/detail/SyntheticFields.cpp | 40 ++++ src/xrpld/rpc/detail/SyntheticFields.h | 39 ++++ src/xrpld/rpc/handlers/account/AccountTx.cpp | 8 +- .../rpc/handlers/transaction/Simulate.cpp | 9 +- src/xrpld/rpc/handlers/transaction/Tx.cpp | 8 +- 12 files changed, 259 insertions(+), 155 deletions(-) delete mode 100644 include/xrpl/protocol/NFTSyntheticSerializer.h delete mode 100644 src/libxrpl/protocol/NFTSyntheticSerializer.cpp create mode 100644 src/xrpld/rpc/detail/SyntheticFields.cpp create mode 100644 src/xrpld/rpc/detail/SyntheticFields.h diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index d521f9c024..df58282f76 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -30,15 +30,14 @@ This section contains changes targeting a future version. - `account_tx`: Added an optional `delegate` request object to filter delegated transactions. The object requires `delegate_filter`, which must be either `actor` for transactions owned by the requested account but signed by another account, or `authorizer` for transactions signed by the requested account on behalf of another account. The optional `counter_party` account narrows the results to a specific signer/delegate for `actor` or a specific owner/delegator for `authorizer`. Malformed `delegate`, `delegate_filter`, and `counter_party` values return standard invalid field errors, and invalid account IDs return `actMalformed`. When paginating delegate-filtered queries, a marker from a delegate-filtered query includes a `delegate` flag and is only valid for follow-up requests that also supply `delegate` (mixing marker conventions returns `invalidParams`). Because filtering is applied after the ledger scan, a page may contain fewer results than `limit` (possibly zero) while still returning a marker, so callers must continue until no marker is present. - - `ledger_entry`, `account_objects`: The `Delegate` ledger entry now includes an optional `DestinationNode` field, which stores the index into the authorized account's owner directory. This field is present on entries created after bidirectional directory tracking was introduced and may appear in RPC responses for those entries. ([#6681](https://github.com/XRPLF/rippled/pull/6681)) - - `server_definitions`: Added the following new sections to the response ([#6321](https://github.com/XRPLF/rippled/pull/6321)): - `TRANSACTION_FORMATS`: Describes the fields and their optionality for each transaction type, including common fields shared across all transactions. - `LEDGER_ENTRY_FORMATS`: Describes the fields and their optionality for each ledger entry type, including common fields shared across all ledger entries. - `TRANSACTION_FLAGS`: Maps transaction type names to their supported flags and flag values. - `LEDGER_ENTRY_FLAGS`: Maps ledger entry type names to their flags and flag values. - `ACCOUNT_SET_FLAGS`: Maps AccountSet flag names (asf flags) to their numeric values. +- `ledger`: `nftoken_id`, `nftoken_ids`, and `offer_id` are now included in transaction metadata when transactions are expanded (`expand`, or admin-only `full`), matching the `tx`, `account_tx`, and `subscribe` (`transactions` stream) responses. ([#5706](https://github.com/XRPLF/rippled/pull/5706)) ### Bugfixes @@ -59,6 +58,7 @@ This section contains changes targeting a future version. - `vault_info`: `vault_id` and `owner` must now be strings, matching how `ledger_entry` reads the same fields. An object or an array in either field previously produced an internal error, and a number was silently converted to its decimal text; `vault_id` now returns `invalidParams` and `owner` returns `actMalformed`. [#8015](https://github.com/XRPLF/rippled/pull/8015) - `gateway_balances`: The `account` and `ident` fields now return an `invalidParams` error if the value is not a string, instead of an `internal` error. [#7655](https://github.com/XRPLF/rippled/pull/7655) - `account_lines`: The `peer` field now returns an error if the value is not a string. [#7728](https://github.com/XRPLF/rippled/pull/7728) +- `ledger`: `delivered_amount` is now included in the metadata of successful `AccountDelete` transactions when transactions are expanded (`expand`, or admin-only `full`). Previously it was only added for `Payment` and `CheckCash`, which made `ledger` inconsistent with `tx` and `account_tx`. [#5706](https://github.com/XRPLF/rippled/pull/5706) ## XRP Ledger server version 3.1.0 diff --git a/include/xrpl/protocol/NFTSyntheticSerializer.h b/include/xrpl/protocol/NFTSyntheticSerializer.h deleted file mode 100644 index df4fedb707..0000000000 --- a/include/xrpl/protocol/NFTSyntheticSerializer.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - -namespace xrpl::rpc { - -/** - * Adds common synthetic fields to transaction-related JSON responses - */ -/** @{ */ -void -insertNFTSyntheticInJson(json::Value&, std::shared_ptr const&, TxMeta const&); -/** @} */ - -} // namespace xrpl::rpc diff --git a/src/libxrpl/protocol/NFTSyntheticSerializer.cpp b/src/libxrpl/protocol/NFTSyntheticSerializer.cpp deleted file mode 100644 index fd44ae1f33..0000000000 --- a/src/libxrpl/protocol/NFTSyntheticSerializer.cpp +++ /dev/null @@ -1,24 +0,0 @@ -#include - -#include -#include -#include -#include -#include -#include - -#include - -namespace xrpl::rpc { - -void -insertNFTSyntheticInJson( - json::Value& response, - std::shared_ptr const& transaction, - TxMeta const& transactionMeta) -{ - insertNFTokenID(response[jss::meta], transaction, transactionMeta); - insertNFTokenOfferID(response[jss::meta], transaction, transactionMeta); -} - -} // namespace xrpl::rpc diff --git a/src/test/app/AccountDelete_test.cpp b/src/test/app/AccountDelete_test.cpp index 15668d4d71..aa7fe898e3 100644 --- a/src/test/app/AccountDelete_test.cpp +++ b/src/test/app/AccountDelete_test.cpp @@ -29,6 +29,8 @@ #include #include #include +#include +#include #include #include #include @@ -67,7 +69,8 @@ private: // We can't use env.meta() here, because meta() doesn't include // delivered_amount. env.close(); - json::Value const meta = env.rpc("tx", txHash)[jss::result][jss::meta]; + json::Value const txResult = env.rpc("tx", txHash)[jss::result]; + json::Value const meta = txResult[jss::meta]; // Expect there to be a DeliveredAmount field. if (!BEAST_EXPECT(meta.isMember(sfDeliveredAmount.jsonName))) @@ -78,6 +81,21 @@ private: json::Value const jsonExpect{amount.getJson(JsonOptions::Values::None)}; BEAST_EXPECT(meta[sfDeliveredAmount.jsonName] == jsonExpect); BEAST_EXPECT(meta[jss::delivered_amount] == jsonExpect); + + // The `ledger` RPC (with expanded transactions) should also report + // delivered_amount for this transaction, matching the `tx` RPC. + json::Value ledgerParams; + ledgerParams[jss::ledger_index] = txResult[jss::ledger_index].asUInt(); + ledgerParams[jss::transactions] = true; + ledgerParams[jss::expand] = true; + + auto const ledgerResult = env.rpc("json", "ledger", to_string(ledgerParams)); + auto const& ledgerTx = ledgerResult[jss::result][jss::ledger][jss::transactions][0u]; + BEAST_EXPECT(ledgerTx[jss::hash].asString() == txHash); + + json::Value const& ledgerMeta = ledgerTx[jss::metaData]; + BEAST_EXPECT(ledgerMeta[sfDeliveredAmount.jsonName] == jsonExpect); + BEAST_EXPECT(ledgerMeta[jss::delivered_amount] == jsonExpect); } // Helper function to create a payment channel. diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp index 08c12e94d1..89c14fbfc5 100644 --- a/src/test/app/NFToken_test.cpp +++ b/src/test/app/NFToken_test.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -6326,84 +6327,162 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.fund(XRP(10000), alice, bob, broker); env.close(); - // Verify `nftoken_id` value equals to the NFTokenID that was - // changed in the most recent NFTokenMint or NFTokenAcceptOffer - // transaction - auto verifyNFTokenID = [&](uint256 const& actualNftID) { + // Transaction metadata is not always reported under the same field + // name: the `ledger` RPC uses `metaData`, the others use `meta`. + auto const getMeta = [](json::Value const& tx) -> json::Value const* { + if (tx.isMember(jss::meta)) + return &tx[jss::meta]; + if (tx.isMember(jss::metaData)) + return &tx[jss::metaData]; + return nullptr; + }; + + // Neither is the transaction hash: api_version 1 nests the + // transaction under `tx`, later versions use `tx_json`, and some + // responses put the hash on the entry itself. + auto const getHash = [](json::Value const& entry) -> std::string { + if (entry.isMember(jss::tx) && entry[jss::tx].isMember(jss::hash)) + return entry[jss::tx][jss::hash].asString(); + if (entry.isMember(jss::tx_json) && entry[jss::tx_json].isMember(jss::hash)) + return entry[jss::tx_json][jss::hash].asString(); + return entry[jss::hash].asString(); + }; + + // Run `verifyMeta` against the metadata of the most recent + // transaction as reported by the `tx`, `ledger` and `account_tx` + // RPCs, so that the synthetic fields are checked in every response + // that carries them. Runs under both api_version 1 (`tx`/`meta`) + // and the latest api_version (`tx_json`/synthetic fields alongside + // it), since the two versions place fields differently. + auto verifyMetaInAllResponses = [&](auto verifyMeta) { // Get the hash for the most recent transaction. std::string const txHash{ env.tx()->getJson(JsonOptions::Values::None)[jss::hash].asString()}; env.close(); - json::Value const meta = env.rpc("tx", txHash)[jss::result][jss::meta]; - // Expect nftokens_id field - if (!BEAST_EXPECT(meta.isMember(jss::nftoken_id))) - return; + for (unsigned const apiVersion : + {unsigned{rpc::kApiMinimumSupportedVersion}, + unsigned{rpc::kApiMaximumSupportedVersion}}) + { + // Test 1: Check tx RPC response + json::Value const txResult = env.rpc(apiVersion, "tx", txHash)[jss::result]; + verifyMeta(txResult[jss::meta]); - // Check the value of NFT ID in the meta with the - // actual value - uint256 nftID; - BEAST_EXPECT(nftID.parseHex(meta[jss::nftoken_id].asString())); - BEAST_EXPECT(nftID == actualNftID); + // Test 2: Check ledger RPC response with expanded + // transactions + json::Value ledgerParams; + ledgerParams[jss::ledger_index] = txResult[jss::ledger_index].asUInt(); + ledgerParams[jss::transactions] = true; + ledgerParams[jss::expand] = true; + + auto const ledgerResult = + env.rpc(apiVersion, "json", "ledger", to_string(ledgerParams)); + auto const& ledgerTx = + ledgerResult[jss::result][jss::ledger][jss::transactions][0u]; + + // Verify transaction hash matches + BEAST_EXPECT(getHash(ledgerTx) == txHash); + + if (auto const* meta = getMeta(ledgerTx); BEAST_EXPECT(meta != nullptr)) + verifyMeta(*meta); + + // Test 3: Check account_tx RPC response + // The transaction is not necessarily alice's, so query + // account_tx for the account that actually submitted it. + json::Value accountTxParams; + accountTxParams[jss::account] = txResult.isMember(jss::tx_json) + ? txResult[jss::tx_json][jss::Account].asString() + : txResult[jss::Account].asString(); + + auto const accountTxResult = + env.rpc(apiVersion, "json", "account_tx", to_string(accountTxParams)); + + // account_tx ordering is not guaranteed, so find our + // transaction by hash rather than assuming it is the most + // recent one. + json::Value const* accountTx = nullptr; + for (auto const& entry : accountTxResult[jss::result][jss::transactions]) + { + if (getHash(entry) == txHash) + { + accountTx = &entry; + break; + } + } + + if (!BEAST_EXPECT(accountTx != nullptr)) + continue; + + if (auto const* meta = getMeta(*accountTx); BEAST_EXPECT(meta != nullptr)) + verifyMeta(*meta); + } + }; + + // Verify `nftoken_id` value equals to the NFTokenID that was + // changed in the most recent NFTokenMint or NFTokenAcceptOffer + // transaction + auto verifyNFTokenID = [&](uint256 const& actualNftID) { + verifyMetaInAllResponses([&](json::Value const& meta) { + // Expect nftoken_id field + if (!BEAST_EXPECT(meta.isMember(jss::nftoken_id))) + return; + + // Check the value of NFT ID matches + uint256 nftID; + BEAST_EXPECT(nftID.parseHex(meta[jss::nftoken_id].asString())); + BEAST_EXPECT(nftID == actualNftID); + }); }; // Verify `nftoken_ids` value equals to the NFTokenIDs that were // changed in the most recent NFTokenCancelOffer transaction auto verifyNFTokenIDsInCancelOffer = [&](std::vector actualNftIDs) { - // Get the hash for the most recent transaction. - std::string const txHash{ - env.tx()->getJson(JsonOptions::Values::None)[jss::hash].asString()}; - - env.close(); - json::Value const meta = env.rpc("tx", txHash)[jss::result][jss::meta]; - - // Expect nftokens_ids field and verify the values - if (!BEAST_EXPECT(meta.isMember(jss::nftoken_ids))) - return; - - // Convert NFT IDs from json::Value to uint256 - std::vector metaIDs; - std::transform( - meta[jss::nftoken_ids].begin(), - meta[jss::nftoken_ids].end(), - std::back_inserter(metaIDs), - [this](json::Value id) { - uint256 nftID; - BEAST_EXPECT(nftID.parseHex(id.asString())); - return nftID; - }); - - // Sort both array to prepare for comparison - std::ranges::sort(metaIDs); + // Sort to prepare for comparison std::ranges::sort(actualNftIDs); - // Make sure the expect number of NFTs is correct - BEAST_EXPECT(metaIDs.size() == actualNftIDs.size()); + verifyMetaInAllResponses([&](json::Value const& meta) { + // Expect nftoken_ids field and verify the values + if (!BEAST_EXPECT(meta.isMember(jss::nftoken_ids))) + return; - // Check the value of NFT ID in the meta with the - // actual values - for (size_t i = 0; i < metaIDs.size(); ++i) - BEAST_EXPECT(metaIDs[i] == actualNftIDs[i]); + // Convert NFT IDs from json::Value to uint256 + std::vector metaIDs; + std::transform( + meta[jss::nftoken_ids].begin(), + meta[jss::nftoken_ids].end(), + std::back_inserter(metaIDs), + [this](json::Value id) { + uint256 nftID; + BEAST_EXPECT(nftID.parseHex(id.asString())); + return nftID; + }); + + std::ranges::sort(metaIDs); + + // Make sure the expect number of NFTs is correct + if (!BEAST_EXPECT(metaIDs.size() == actualNftIDs.size())) + return; + + // Check the value of NFT ID in the meta with the + // actual values + for (size_t i = 0; i < metaIDs.size(); ++i) + BEAST_EXPECT(metaIDs[i] == actualNftIDs[i]); + }); }; // Verify `offer_id` value equals to the offerID that was // changed in the most recent NFTokenCreateOffer tx auto verifyNFTokenOfferID = [&](uint256 const& offerID) { - // Get the hash for the most recent transaction. - std::string const txHash{ - env.tx()->getJson(JsonOptions::Values::None)[jss::hash].asString()}; + verifyMetaInAllResponses([&](json::Value const& meta) { + // Expect offer_id field and verify the value + if (!BEAST_EXPECT(meta.isMember(jss::offer_id))) + return; - env.close(); - json::Value const meta = env.rpc("tx", txHash)[jss::result][jss::meta]; - - // Expect offer_id field and verify the value - if (!BEAST_EXPECT(meta.isMember(jss::offer_id))) - return; - - uint256 metaOfferID; - BEAST_EXPECT(metaOfferID.parseHex(meta[jss::offer_id].asString())); - BEAST_EXPECT(metaOfferID == offerID); + uint256 metaOfferID; + BEAST_EXPECT(metaOfferID.parseHex(meta[jss::offer_id].asString())); + BEAST_EXPECT(metaOfferID == offerID); + }); }; // Check new fields in tx meta when for all NFTtransactions diff --git a/src/xrpld/app/ledger/detail/LedgerToJson.cpp b/src/xrpld/app/ledger/detail/LedgerToJson.cpp index 921c640f06..1d789aa604 100644 --- a/src/xrpld/app/ledger/detail/LedgerToJson.cpp +++ b/src/xrpld/app/ledger/detail/LedgerToJson.cpp @@ -4,8 +4,7 @@ #include #include #include -#include -#include +#include #include #include @@ -19,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -141,19 +141,12 @@ fillJsonTx( { txJson[jss::meta] = stMeta->getJson(JsonOptions::Values::None); - // If applicable, insert delivered amount - if (txnType == ttPAYMENT || txnType == ttCHECK_CASH) - { - rpc::insertDeliveredAmount( - txJson[jss::meta], - fill.ledger, - txn, - {txn->getTransactionID(), fill.ledger.seq(), *stMeta}); - } - - // If applicable, insert mpt issuance id - rpc::insertMPTokenIssuanceID( - txJson[jss::meta], txn, {txn->getTransactionID(), fill.ledger.seq(), *stMeta}); + // Insert all synthetic fields + rpc::insertAllSyntheticInJson( + txJson[jss::meta], + fill.ledger, + txn, + {txn->getTransactionID(), fill.ledger.seq(), *stMeta}); } if (!fill.ledger.open()) @@ -177,19 +170,12 @@ fillJsonTx( { txJson[jss::metaData] = stMeta->getJson(JsonOptions::Values::None); - // If applicable, insert delivered amount - if (txnType == ttPAYMENT || txnType == ttCHECK_CASH) - { - rpc::insertDeliveredAmount( - txJson[jss::metaData], - fill.ledger, - txn, - {txn->getTransactionID(), fill.ledger.seq(), *stMeta}); - } - - // If applicable, insert mpt issuance id - rpc::insertMPTokenIssuanceID( - txJson[jss::metaData], txn, {txn->getTransactionID(), fill.ledger.seq(), *stMeta}); + // Insert all synthetic fields + rpc::insertAllSyntheticInJson( + txJson[jss::metaData], + fill.ledger, + txn, + {txn->getTransactionID(), fill.ledger.seq(), *stMeta}); } } diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 0f323995ae..b492906a86 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -28,9 +28,8 @@ #include #include #include -#include -#include #include +#include #include #include @@ -92,7 +91,6 @@ #include #include #include -#include #include #include #include @@ -3467,9 +3465,7 @@ NetworkOPsImp::transJson( if (meta) { jvObj[jss::meta] = meta->get().getJson(JsonOptions::Values::None); - rpc::insertDeliveredAmount(jvObj[jss::meta], *ledger, transaction, meta->get()); - rpc::insertNFTSyntheticInJson(jvObj, transaction, meta->get()); - rpc::insertMPTokenIssuanceID(jvObj[jss::meta], transaction, meta->get()); + rpc::insertAllSyntheticInJson(jvObj[jss::meta], *ledger, transaction, meta->get()); } // add CTID where the needed data for it exists diff --git a/src/xrpld/rpc/detail/SyntheticFields.cpp b/src/xrpld/rpc/detail/SyntheticFields.cpp new file mode 100644 index 0000000000..9acf3abb36 --- /dev/null +++ b/src/xrpld/rpc/detail/SyntheticFields.cpp @@ -0,0 +1,40 @@ +#include + +#include +#include + +#include +#include +#include + +#include + +namespace xrpl::rpc { + +void +insertAllSyntheticInJson( + json::Value& metadata, + ReadView const& ledger, + std::shared_ptr const& transaction, + TxMeta const& transactionMeta) +{ + insertDeliveredAmount(metadata, ledger, transaction, transactionMeta); + insertNFTokenID(metadata, transaction, transactionMeta); + insertNFTokenOfferID(metadata, transaction, transactionMeta); + insertMPTokenIssuanceID(metadata, transaction, transactionMeta); +} + +void +insertAllSyntheticInJson( + json::Value& metadata, + JsonContext const& context, + std::shared_ptr const& transaction, + TxMeta const& transactionMeta) +{ + insertDeliveredAmount(metadata, context, transaction, transactionMeta); + insertNFTokenID(metadata, transaction, transactionMeta); + insertNFTokenOfferID(metadata, transaction, transactionMeta); + insertMPTokenIssuanceID(metadata, transaction, transactionMeta); +} + +} // namespace xrpl::rpc diff --git a/src/xrpld/rpc/detail/SyntheticFields.h b/src/xrpld/rpc/detail/SyntheticFields.h new file mode 100644 index 0000000000..6ece4bbcd5 --- /dev/null +++ b/src/xrpld/rpc/detail/SyntheticFields.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +#include + +namespace xrpl { + +class ReadView; + +namespace rpc { + +struct JsonContext; + +/** + * Adds all synthetic fields to transaction metadata JSON. + * This includes delivered amount, NFT synthetic fields, and MPToken issuance + * ID. + */ +/** @{ */ +void +insertAllSyntheticInJson( + json::Value& metadata, + ReadView const&, + std::shared_ptr const&, + TxMeta const&); + +void +insertAllSyntheticInJson( + json::Value& metadata, + JsonContext const&, + std::shared_ptr const&, + TxMeta const&); +/** @} */ + +} // namespace rpc +} // namespace xrpl diff --git a/src/xrpld/rpc/handlers/account/AccountTx.cpp b/src/xrpld/rpc/handlers/account/AccountTx.cpp index 7b0c34e048..5385776b36 100644 --- a/src/xrpld/rpc/handlers/account/AccountTx.cpp +++ b/src/xrpld/rpc/handlers/account/AccountTx.cpp @@ -4,12 +4,11 @@ #include #include #include -#include -#include #include #include #include #include +#include #include #include @@ -22,7 +21,6 @@ #include #include #include -#include #include #include #include @@ -378,9 +376,7 @@ populateJsonResponse( if (txnMeta) { jvObj[jss::meta] = txnMeta->getJson(JsonOptions::Values::IncludeDate); - insertDeliveredAmount(jvObj[jss::meta], context, txn, *txnMeta); - rpc::insertNFTSyntheticInJson(jvObj, sttx, *txnMeta); - rpc::insertMPTokenIssuanceID(jvObj[jss::meta], sttx, *txnMeta); + rpc::insertAllSyntheticInJson(jvObj[jss::meta], context, sttx, *txnMeta); } else { diff --git a/src/xrpld/rpc/handlers/transaction/Simulate.cpp b/src/xrpld/rpc/handlers/transaction/Simulate.cpp index 8441add08b..61cdddafde 100644 --- a/src/xrpld/rpc/handlers/transaction/Simulate.cpp +++ b/src/xrpld/rpc/handlers/transaction/Simulate.cpp @@ -4,7 +4,7 @@ #include #include #include -#include +#include #include #include @@ -20,7 +20,6 @@ #include #include #include -#include #include #include #include @@ -290,12 +289,8 @@ simulateTxn(rpc::JsonContext& context, std::shared_ptr transaction) else { jvResult[jss::meta] = result.metadata->getJson(JsonOptions::Values::None); - rpc::insertDeliveredAmount( + rpc::insertAllSyntheticInJson( jvResult[jss::meta], view, transaction->getSTransaction(), *result.metadata); - rpc::insertNFTSyntheticInJson( - jvResult, transaction->getSTransaction(), *result.metadata); - rpc::insertMPTokenIssuanceID( - jvResult[jss::meta], transaction->getSTransaction(), *result.metadata); } } diff --git a/src/xrpld/rpc/handlers/transaction/Tx.cpp b/src/xrpld/rpc/handlers/transaction/Tx.cpp index ee7110bf6b..cebe427af8 100644 --- a/src/xrpld/rpc/handlers/transaction/Tx.cpp +++ b/src/xrpld/rpc/handlers/transaction/Tx.cpp @@ -5,8 +5,9 @@ #include #include #include -#include #include +#include +#include #include #include @@ -18,7 +19,6 @@ #include #include #include -#include #include #include #include @@ -253,9 +253,7 @@ populateJsonResponse( if (meta) { response[jss::meta] = meta->getJson(JsonOptions::Values::None); - insertDeliveredAmount(response[jss::meta], context, result.txn, *meta); - rpc::insertNFTSyntheticInJson(response, sttx, *meta); - rpc::insertMPTokenIssuanceID(response[jss::meta], sttx, *meta); + rpc::insertAllSyntheticInJson(response[jss::meta], context, sttx, *meta); } } response[jss::validated] = result.validated; From de6e5d3a94ef79b25b2036d4f02e229c5eddc822 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:51:39 +0000 Subject: [PATCH 15/40] fix: Keep VaultDeposit share count after the assetsTotal clamp (#8140) --- .../tx/transactors/vault/VaultDeposit.cpp | 29 ++----------------- .../vault/VaultTransactorPrecision_test.cpp | 9 ++++-- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index adb8b3f8f2..fc72159444 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -347,37 +347,14 @@ VaultDeposit::doApply() if (fix340Enabled) { // Round down at the posterior sfAssetsTotal scale so the vault is credited by no more - // than the depositor paid. + // than the depositor paid. Keep the share count from the first round trip: the clamp + // only drops a last digit of the new total. Converting the clamped amount back to + // shares would mint fewer shares while still charging the N-share debit. auto const maybeClamped = clampToAssetsTotalScale(vault, assetsDeposited); if (!maybeClamped) return maybeClamped.error(); assetsDeposited = *maybeClamped; - // The pre-clamp share count would over-issue by the trimmed ULP and give the depositor - // more value than they credited. - auto const maybeReShares = assetsToSharesDeposit(vault, sleIssuance, assetsDeposited); - if (!maybeReShares) - return tecINTERNAL; // LCOV_EXCL_LINE - - sharesCreated = *maybeReShares; - - if (sharesCreated == beast::kZero) - return tecPRECISION_LOSS; - - // The re-derived share count would over-issue if it round-trips back to more assets - // than the clamped amount actually paid. Unreachable unless a conversion helper is - // broken. - // LCOV_EXCL_START - auto const maybeReAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated); - if (!maybeReAssets) - return tecINTERNAL; - if (*maybeReAssets > assetsDeposited) - { - JLOG(j_.error()) << "VaultDeposit: would take more than offered."; - return tecINTERNAL; - } - // LCOV_EXCL_STOP - // The actual deposit amount is truncated to whole shares, converted back to assets, // and clamped to the sfAssetsTotal scale (post-fixCleanup3_4_0). Check the depositor's // balance here—after clamping—before making any state changes. diff --git a/src/test/app/vault/VaultTransactorPrecision_test.cpp b/src/test/app/vault/VaultTransactorPrecision_test.cpp index e06c87ee68..8e8ee2d629 100644 --- a/src/test/app/vault/VaultTransactorPrecision_test.cpp +++ b/src/test/app/vault/VaultTransactorPrecision_test.cpp @@ -92,9 +92,14 @@ class VaultTransactorPrecision_test : public VaultPrecisionFixture if (before.sharesTotal == Number{0}) continue; Number const shareValue = (before.assetsTotal * sharesMinted) / before.sharesTotal; + // The depositor is never charged more than the shares they received are worth. BEAST_EXPECTS( - shareValue <= tDelta, - "amount=" + std::to_string(amount) + " shareValue > assetsTaken"); + tDelta <= shareValue, + "amount=" + std::to_string(amount) + " assetsTaken > shareValue"); + // Discount is strictly less than one ULP of the new AssetsTotal + BEAST_EXPECTS( + shareValue - tDelta < oneUnit(asset.raw(), after.assetsTotal), + "amount=" + std::to_string(amount) + " discount is not below one unit"); } { From b2453b626e2227ff86733dce3f26c232fbfa341e Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:06:17 +0000 Subject: [PATCH 16/40] fix: Unblock VaultSet and cash-basis LoanSet at AssetsMaximum (#8143) --- src/libxrpl/tx/invariants/VaultInvariant.cpp | 31 ++- .../tx/transactors/lending/LoanSet.cpp | 13 +- .../app/invariants/InvariantsVault_test.cpp | 36 +++ src/test/app/lending/LoanCashBasis_test.cpp | 260 +++++++++++++++++- src/test/app/lending/LoanTestBase.h | 5 + 5 files changed, 327 insertions(+), 18 deletions(-) diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index a8ef0d3157..c0f98a8128 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -380,7 +380,7 @@ ValidVault::finalize( beast::Journal const& j) { bool const enforce = view.rules().enabled(featureSingleAssetVault); - bool const fixEnabled = view.rules().enabled(fixCleanup3_4_0); + bool const fix340Enabled = view.rules().enabled(fixCleanup3_4_0); if (!isTesSuccess(ret)) return true; // Do not perform checks @@ -572,7 +572,7 @@ ValidVault::finalize( else { bool const gapExceeded = [&] { - if (!fixEnabled) + if (!fix340Enabled) { return afterVault.lossUnrealized > afterVault.assetsTotal - afterVault.assetsAvailable; @@ -594,7 +594,7 @@ ValidVault::finalize( } } - if (fixEnabled && afterVault.lossUnrealized < kZero) + if (fix340Enabled && afterVault.lossUnrealized < kZero) { JLOG(j.fatal()) << "Invariant failed: loss unrealized must not be negative"; result = false; @@ -765,8 +765,13 @@ ValidVault::finalize( result = false; } + // AssetsTotal may exceed AssetsMaximum when the excess is interest. After + // fixCleanup3_4_0, only reject a VaultSet that supplies sfAssetsMaximum or + // otherwise changes the cap to a nonzero value still below AssetsTotal. if (afterVault.assetsMaximum > kZero && - afterVault.assetsTotal > afterVault.assetsMaximum) + afterVault.assetsTotal > afterVault.assetsMaximum && + (!fix340Enabled || tx.isFieldPresent(sfAssetsMaximum) || + beforeVault.assetsMaximum != afterVault.assetsMaximum)) { JLOG(j.fatal()) << // "Invariant failed: set assets outstanding must not " @@ -880,7 +885,7 @@ ValidVault::finalize( result = false; } - bool const acctVaultAddsUp = fixEnabled + bool const acctVaultAddsUp = fix340Enabled ? agreesWithinOneUnit( localVaultDeltaAssets * -1, accountDeltaAssets, @@ -935,7 +940,7 @@ ValidVault::finalize( auto const assetTotalDelta = roundToAsset( vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); - bool const totalAddsUp = fixEnabled + bool const totalAddsUp = fix340Enabled ? agreesWithinOneUnit(assetTotalDelta, vaultDeltaAssets, vaultAsset, minScale) : assetTotalDelta == vaultDeltaAssets; if (!totalAddsUp) @@ -947,7 +952,7 @@ ValidVault::finalize( auto const assetAvailableDelta = roundToAsset( vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); - bool const availableAddsUp = fixEnabled + bool const availableAddsUp = fix340Enabled ? agreesWithinOneUnit( assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale) : assetAvailableDelta == vaultDeltaAssets; @@ -993,7 +998,7 @@ ValidVault::finalize( // value merely rounds down to zero, so a missing delta while // the pool still held positive effective value indicates a // real accounting bug, not this exception. - bool const zeroDeltaIsLegitimate = fixEnabled && !maybeVaultDeltaAssets && + bool const zeroDeltaIsLegitimate = fix340Enabled && !maybeVaultDeltaAssets && beforeVault.assetsTotal == beforeVault.lossUnrealized; if (!maybeVaultDeltaAssets && !zeroDeltaIsLegitimate) @@ -1100,7 +1105,7 @@ ValidVault::finalize( vaultDeltaAssets.delta * -1 - destinationDelta.delta, destinationScale, Number::RoundingMode::Downward) == kZero; - bool const withdrawAddsUp = fixEnabled + bool const withdrawAddsUp = fix340Enabled ? agreesWithinOneUnit( localPseudoDeltaAssets * -1, roundedDestinationDelta, @@ -1150,7 +1155,7 @@ ValidVault::finalize( auto const assetTotalDelta = roundToAsset( vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); // Note, vaultBalance is negative (see check above) - bool const totalAddsUp = fixEnabled + bool const totalAddsUp = fix340Enabled ? agreesWithinOneUnit( assetTotalDelta, vaultPseudoDeltaAssets, vaultAsset, minScale) : assetTotalDelta == vaultPseudoDeltaAssets; @@ -1164,7 +1169,7 @@ ValidVault::finalize( auto const assetAvailableDelta = roundToAsset( vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); - bool const availableAddsUp = fixEnabled + bool const availableAddsUp = fix340Enabled ? agreesWithinOneUnit( assetAvailableDelta, vaultPseudoDeltaAssets, vaultAsset, minScale) : assetAvailableDelta == vaultPseudoDeltaAssets; @@ -1213,7 +1218,7 @@ ValidVault::finalize( auto const assetsTotalDelta = roundToAsset( vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); - bool const totalAddsUp = fixEnabled + bool const totalAddsUp = fix340Enabled ? agreesWithinOneUnit( assetsTotalDelta, vaultDeltaAssets, vaultAsset, minScale) : assetsTotalDelta == vaultDeltaAssets; @@ -1228,7 +1233,7 @@ ValidVault::finalize( vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); - bool const availableAddsUp = fixEnabled + bool const availableAddsUp = fix340Enabled ? agreesWithinOneUnit( assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale) : assetAvailableDelta == vaultDeltaAssets; diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 2def3d2eb2..d84f5b09b3 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -336,7 +336,12 @@ LoanSet::preclaim(PreclaimContext const& ctx) } } - if (vault->at(sfAssetsMaximum) != 0 && vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum)) + // Accrual origination credits interestDue into AssetsTotal, so a vault + // already at AssetsMaximum cannot take another loan. Cash-basis origination + // does not change AssetsTotal (see cash_basis::loanOriginationDeltas), so + // this leftover accrual gate must not apply there. + if (getVaultVersion(vault) != VaultVersion::CashBasis && vault->at(sfAssetsMaximum) != 0 && + vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum)) { JLOG(ctx.j.warn()) << "Vault at maximum assets limit. Can't add another loan."; return tecLIMIT_EXCEEDED; @@ -467,9 +472,11 @@ LoanSet::doApply() properties.loanState.managementFeeDue); XRPL_ASSERT_PARTS( - *vaultSle->at(sfAssetsMaximum) == 0 || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy, + *vaultSle->at(sfAssetsMaximum) == 0 || + getVaultVersion(vaultSle) == VaultVersion::CashBasis || + *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy, "xrpl::LoanSet::doApply", - "Vault is below maximum limit"); + "accrual vault is below maximum limit"); if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue)) { diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp index d816c36c15..caf4e9cfb6 100644 --- a/src/test/app/invariants/InvariantsVault_test.cpp +++ b/src/test/app/invariants/InvariantsVault_test.cpp @@ -865,6 +865,42 @@ class InvariantsVault_test : public InvariantsBase precloseXrp, TxAccount::A2); + // The cap check has two post-fixCleanup3_4_0 triggers: the transaction + // supplied sfAssetsMaximum, or the cap changed. The case above covers + // the cap-changed one (its ttVAULT_SET carries no fields). This covers + // the other: the cap is left alone at 30 XRP and the transaction + // carries sfAssetsMaximum, so only the isFieldPresent disjunct can + // fire. AssetsTotal is pushed past the cap here rather than in + // preclose because VaultSet::doApply refuses to set a cap below + // AssetsTotal, so the over-cap state is only reachable by fabrication. + // Raising AssetsTotal also trips the "must not change assets + // outstanding" check, hence two expected messages. + Number const vaultCap = XRP(30).number(); + doInvariantCheck( + {"set must not change assets outstanding", + "set assets outstanding must not exceed assets maximum"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.assetsTotal = XRP(1).value().xrp().drops(); + })); + }, + XRPAmount{}, + STTx{ttVAULT_SET, [&](STObject& tx) { tx[sfAssetsMaximum] = vaultCap; }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) -> bool { + env.fund(XRP(1000), a3, a4); + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + tx[sfAssetsMaximum] = vaultCap; + env(tx); + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)})); + return true; + }, + TxAccount::A2); + doInvariantCheck( {"assets maximum must not be negative"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { diff --git a/src/test/app/lending/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp index a3ca28437d..e238838306 100644 --- a/src/test/app/lending/LoanCashBasis_test.cpp +++ b/src/test/app/lending/LoanCashBasis_test.cpp @@ -4,10 +4,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -25,6 +27,7 @@ #include #include #include +#include #include namespace xrpl::test { @@ -42,8 +45,9 @@ class LoanCashBasis_test : public LoanTestBase { private: // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas, - // and the AssetsMaximum/DebtMaximum guards (which always check against - // principal + interestDue, regardless of the amendment). + // and the AssetsMaximum/DebtMaximum guards. Accrual AssetsMaximum still + // requires headroom for interestDue; cash-basis AssetsMaximum does not, + // because origination does not credit interest into AssetsTotal. void testCashBasisLoanSetOrigination() { @@ -226,6 +230,10 @@ private: // Even far less headroom than interestDue still succeeds, since // cash-basis origination never adds interest to AssetsTotal. runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); + // Fully subscribed: AssetsTotal == AssetsMaximum. Accrual preclaim + // used to refuse this; origination must still succeed because it + // does not change AssetsTotal. + runVaultGuard(all_ | featureLendingProtocolV1_1, Number{0}, tesSUCCESS); } // DebtMaximum guard: cash-basis projects principal-only DebtTotal; @@ -489,6 +497,252 @@ private: } } + // VaultSet must still succeed when cash-basis LoanPay has already pushed + // AssetsTotal above a nonzero AssetsMaximum. Before fixCleanup3_4_0, + // ValidVault rejects that with tecINVARIANT_FAILED even though + // VaultSet::doApply and the product rule allow the over-cap state when + // the excess is interest. + void + testVaultSetWhileAssetsTotalExceedsMaximum() + { + using namespace jtx; + using namespace loan; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + auto run = + [&](FeatureBitset features, TER expectedOverCapSet, bool native, bool vaultPrivate) { + bool const fix340Enabled = features[fixCleanup3_4_0]; + testcase( + std::string("cash-basis: VaultSet while AssetsTotal exceeds AssetsMaximum") + + (native ? " XRP" : " IOU") + (vaultPrivate ? " private" : "") + + (fix340Enabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + Env env(*this, features); + + BrokerParameters params = brokerParams; + if (vaultPrivate) + params.vaultFlags = tfVaultPrivate; + + PrettyAsset vaultAsset = xrpAsset; + if (native) + { + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + } + else + { + vaultAsset = createFundedIouAsset(env, issuer, lender, borrower); + } + + BrokerInfo const broker{createVaultAndBroker(env, vaultAsset, lender, params)}; + auto const vaultBefore = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBefore); + // One unit at the vault's asset scale so the stored cap is + // strictly above AssetsTotal (a smaller ULP rounds away). + // Cash-basis origination does not credit interest, so LoanSet + // still succeeds. + Number const slack{1, -static_cast(vaultBefore->at(sfScale))}; + Number const assetsMaximum = Number(vaultBefore->at(sfAssetsTotal)) + slack; + + Vault const vault{env}; + { + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = assetsMaximum; + env(tx); + env.close(); + } + + { + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfData] = "AA"; + env(tx, Ter(tesSUCCESS)); + env.close(); + } + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanKeylet = keylet::loan( + broker.brokerID, SeqProxy::rawSequence(brokerBeforeLoan->at(sfLoanSequence))); + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = 12'000, + .interest = TenthBips32{percentageToTenthBips(12)}, + .payTotal = 4, + .payInterval = 600, + .gracePd = 300, + }; + env(loanParams(env, broker)); + env.close(); + + auto const vaultAfterLoan = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterLoan); + BEAST_EXPECT(vaultAfterLoan->at(sfAssetsTotal) <= assetsMaximum); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + STAmount const payment{ + vaultAsset, + roundPeriodicPayment(vaultAsset, state.periodicPayment, state.loanScale) * + Number{3, -1} * 5}; + env(pay(borrower, loanKeylet.key, payment), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAboveMaximum = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAboveMaximum); + BEAST_EXPECT(vaultAboveMaximum->at(sfAssetsTotal) > assetsMaximum); + BEAST_EXPECT(vaultAboveMaximum->at(sfAssetsMaximum) == assetsMaximum); + + { + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfData] = "BB"; + env(tx, Ter(expectedOverCapSet)); + env.close(); + } + + if (vaultPrivate) + { + pdomain::Credentials const credentials{ + {.issuer = lender, .credType = "credential"}}; + env(pdomain::setTx(lender, credentials)); + auto const domainId = pdomain::getNewDomain(env.meta()); + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfDomainID] = to_string(domainId); + env(tx, Ter(expectedOverCapSet)); + env.close(); + } + + if (!fix340Enabled) + return; + + { + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = assetsMaximum; + env(tx, Ter(tecLIMIT_EXCEEDED)); + env.close(); + } + + { + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = Number{0}; + env(tx, Ter(tesSUCCESS)); + env.close(); + } + }; + + FeatureBitset const withFix = all_ | featureLendingProtocolV1_1; + FeatureBitset const withoutFix = withFix - fixCleanup3_4_0; + + run(withFix, tesSUCCESS, true, true); + run(withoutFix, tecINVARIANT_FAILED, true, true); + run(withFix, tesSUCCESS, false, false); + run(withoutFix, tecINVARIANT_FAILED, false, false); + } + + void + testCashBasisLoanSetAfterInterestExceedsCap() + { + testcase("cash-basis: LoanSet after interest pushes AssetsTotal past AssetsMaximum"); + + using namespace jtx; + using namespace loan; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + Env env(*this, all_ | featureLendingProtocolV1_1); + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + auto const vaultBefore = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBefore); + Number const assetsMaximum = Number(vaultBefore->at(sfAssetsTotal)); + + Vault const vault{env}; + { + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = assetsMaximum; + env(tx); + env.close(); + } + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const firstLoanKeylet = keylet::loan( + broker.brokerID, SeqProxy::rawSequence(brokerBeforeLoan->at(sfLoanSequence))); + + Number const firstPrincipal = xrpAsset(12'000).value(); + env(set(borrower, broker.brokerID, firstPrincipal), + kCounterparty(lender), + kInterestRate(TenthBips32{percentageToTenthBips(12)}), + kPaymentTotal(4), + kPaymentInterval(600), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterFirst = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterFirst); + BEAST_EXPECT(vaultAfterFirst->at(sfAssetsTotal) == assetsMaximum); + BEAST_EXPECT(vaultAfterFirst->at(sfAssetsAvailable) == assetsMaximum - firstPrincipal); + + LoanState const state = getCurrentState(env, broker, firstLoanKeylet); + STAmount const payment{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * Number{3, -1} * + 5}; + env(pay(borrower, firstLoanKeylet.key, payment), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterPay = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterPay); + BEAST_EXPECT(vaultAfterPay->at(sfAssetsTotal) > assetsMaximum); + BEAST_EXPECT(vaultAfterPay->at(sfAssetsAvailable) > beast::kZero); + + auto const brokerAfterPay = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerAfterPay); + auto const secondLoanKeylet = keylet::loan( + broker.brokerID, SeqProxy::rawSequence(brokerAfterPay->at(sfLoanSequence))); + + Number const secondPrincipal = xrpAsset(1'000).value(); + env(set(borrower, broker.brokerID, secondPrincipal), + kCounterparty(lender), + kInterestRate(TenthBips32{percentageToTenthBips(12)}), + kPaymentTotal(4), + kPaymentInterval(600), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterSecond = env.le(broker.vaultKeylet()); + auto const secondLoan = env.le(secondLoanKeylet); + BEAST_EXPECT(vaultAfterSecond && secondLoan); + BEAST_EXPECT(vaultAfterSecond->at(sfAssetsTotal) == vaultAfterPay->at(sfAssetsTotal)); + BEAST_EXPECT(secondLoan->at(sfPrincipalOutstanding) == secondPrincipal); + } + // 3. LoanManage: impair, unimpair, and default. void testCashBasisLoanManage() @@ -1013,6 +1267,8 @@ public: { testCashBasisLoanSetOrigination(); testCashBasisLoanPay(); + testVaultSetWhileAssetsTotalExceedsMaximum(); + testCashBasisLoanSetAfterInterestExceedsCap(); testCashBasisLoanManage(); testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); testCashBasisEndToEndTrajectory(); diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index c9d4a3185b..a1241d804e 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -101,6 +101,10 @@ protected: TenthBips32 coverRateLiquidation = percentageToTenthBips(25); std::string data = {}; // NOLINT(readability-redundant-member-init) std::uint32_t flags = 0; + // VaultCreate flags (e.g. tfVaultPrivate). Distinct from `flags`, + // which are passed to LoanBrokerSet. + std::optional vaultFlags = + std::nullopt; // NOLINT(readability-redundant-member-init) // If set, the vault is created with this sfScale value. Useful for // tests that need finer loanScale to exercise rounding edge cases. std::optional vaultScale = @@ -526,6 +530,7 @@ protected: auto [tx, vaultKeylet] = vault.create( {.owner = lender, .asset = asset, + .flags = params.vaultFlags, .vaultKind = effectiveVaultKind == VaultKind::OpenEnded ? std::optional{} : std::optional{std::to_underlying(effectiveVaultKind)}, From ccd5dc5e06b3fcf36b45ee370707355290ee8d9d Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:40:53 +0000 Subject: [PATCH 17/40] fix: Keep LoanBrokerDelete valid for auth-required MPT cover (#8144) --- include/xrpl/tx/invariants/MPTInvariant.h | 7 ++ src/libxrpl/tx/invariants/MPTInvariant.cpp | 21 ++++- src/test/app/lending/LoanBroker_test.cpp | 101 ++++++++++++++++++++- 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/include/xrpl/tx/invariants/MPTInvariant.h b/include/xrpl/tx/invariants/MPTInvariant.h index 5740cd5be2..ddda348d2e 100644 --- a/include/xrpl/tx/invariants/MPTInvariant.h +++ b/include/xrpl/tx/invariants/MPTInvariant.h @@ -215,6 +215,13 @@ class ValidMPTTransfer // Deleted MPToken // MPToken key: true if MPTAuthorized is set hash_map deletedAuthorized_; + // Every touched AccountRoot (not only pseudos): + // AccountID -> whether it was a pseudo-account BEFORE this transaction + // applied. Needed because a transaction may erase a pseudo-account and + // move MPT out of it in the same transaction; by finalize() time the + // view no longer shows it as a pseudo-account (or as existing at all). + // False entries freeze the pre-tx classification for touched non-pseudos. + hash_map pseudoAccountsBefore_; public: /** diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 66b9028ed2..09b4308165 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -832,6 +832,14 @@ ValidMPTTransfer::visitEntry( if (after) update(*after, false); + + // Record whether every touched AccountRoot was a pseudo-account BEFORE + // the transaction applied (true and false). A transaction that erases a + // pseudo-account (and moves MPT out of it) in the same transaction leaves + // no trace of its pseudo-account status in the post-transaction view + // isAuthorized() sees at finalize() time. + if (before && before->getType() == ltACCOUNT_ROOT) + pseudoAccountsBefore_[before->at(sfAccount)] = isPseudoAccount(before); } bool @@ -844,10 +852,19 @@ ValidMPTTransfer::isAuthorized( // Pseudo-accounts (Vault, LoanBroker, AMM) hold assets on behalf of their // participants and are implicitly authorized for any MPT they hold, // including vault shares whose underlying asset would otherwise require - // auth. Exempt them here rather than relying on requireAuth: the recursive + // auth. Exempt them here rather than relying on requireAuth: the recursive // share -> underlying descent in requireAuth fails for a pseudo-account // that holds the share but not the underlying. - if (isPseudoAccount(view, holder)) + // + // Use the pre-transaction classification for any account this + // transaction touched (pseudoAccountsBefore_): the post-transaction view + // is wrong for an account this same transaction erased. Untouched + // accounts aren't in the map, so fall back to the current view, which is + // still accurate for them since nothing changed. + auto const pseudoIt = pseudoAccountsBefore_.find(holder); + bool const isPseudo = + pseudoIt != pseudoAccountsBefore_.end() ? pseudoIt->second : isPseudoAccount(view, holder); + if (isPseudo) return true; auto const key = keylet::mptoken(mptid, holder); diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp index d75b359868..5b3ea854f8 100644 --- a/src/test/app/lending/LoanBroker_test.cpp +++ b/src/test/app/lending/LoanBroker_test.cpp @@ -1849,6 +1849,96 @@ class LoanBroker_test : public beast::unit_test::Suite BEAST_EXPECT(aliceBalanceAfter == aliceBalanceBefore); } + void + testLoanBrokerDeleteRequireAuthMPT(FeatureBitset features) + { + testcase << "LoanBrokerDelete - auth-required broker pseudo-account MPT " + << (features[fixCleanup3_4_0] ? "post-fix" : "pre-fix"); + using namespace jtx; + using namespace loan_broker; + + Account const issuer("issuer"); + Account const alice("alice"); + + Env env(*this, features); + env.fund(XRP(100'000), issuer, alice); + env.close(); + + // Create an auth-required MPT and authorize alice as a holder. The + // broker pseudo-account's cover MPToken is auto-created later + // (addEmptyHolding -> authorizeMPToken) with lsfMPTAuthorized clear; + // the pseudo-account is implicitly authorized to hold any MPT + // regardless of that flag. + auto tester = MPTTester( + {.env = env, + .issuer = issuer, + .holders = {alice}, + .pay = 20'000, + .flags = tfMPTRequireAuth | tfMPTCanTransfer, + .authHolder = true}); + + PrettyAsset const mpt{tester.issuanceID()}; + + // Create vault + Vault const vault{env}; + auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = mpt}); + env(tx); + env.close(); + + // Deposit into vault + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = mpt(10'000)})); + env.close(); + + // Create loan broker + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); + env(set(alice, vaultKeylet.key)); + env.close(); + + // Deposit cover + env(coverDeposit(alice, brokerKeylet.key, mpt(5'000).value())); + env.close(); + + // Verify cover is deposited + auto const broker = env.le(brokerKeylet); + if (!BEAST_EXPECT(broker)) + return; + BEAST_EXPECT(broker->at(sfCoverAvailable) > 0); + + // Get the broker pseudo-account + auto const brokerPseudoID = broker->at(sfAccount); + + // Verify the broker pseudo-account has an MPToken, and that it was + // never explicitly authorized (issuer cannot authorize a + // pseudo-account holder; see MPTokenAuthorize::preclaim). + auto const pseudoMptKey = keylet::mptoken(tester.issuanceID(), brokerPseudoID); + auto const pseudoMpt = env.le(pseudoMptKey); + if (!BEAST_EXPECT(pseudoMpt)) + return; + BEAST_EXPECT(!pseudoMpt->isFlag(lsfMPTAuthorized)); + + // Record alice's balance before deletion + auto const aliceBalanceBefore = env.balance(alice, mpt); + + // LoanBrokerDelete sends the remaining cover out of the broker pseudo-account, deletes its + // now-empty MPToken, and erases the pseudo AccountRoot. Before the fix, + // ValidMPTTransfer::isAuthorized evaluates isPseudoAccount() on the post-transaction view + // (where the pseudo-account is already gone) and falls back to the MPToken's + // lsfMPTAuthorized flag, which was never set, so the invariant treats the broker as an + // unauthorized sender and the whole transaction fails once fixCleanup3_4_0 makes the check + // enforcing. + env(del(alice, brokerKeylet.key), Ter(tesSUCCESS)); + env.close(); + + // Broker and its pseudo-account MPToken are gone + BEAST_EXPECT(env.le(brokerKeylet) == nullptr); + BEAST_EXPECT(env.le(pseudoMptKey) == nullptr); + + // Alice received the cover + auto const aliceBalanceAfter = env.balance(alice, mpt); + BEAST_EXPECT(aliceBalanceAfter > aliceBalanceBefore); + } + void testCoverDepositFreezes() { @@ -2550,7 +2640,7 @@ class LoanBroker_test : public beast::unit_test::Suite using namespace jtx; using namespace std::chrono_literals; - bool const fixEnabled = features[fixCleanup3_4_0]; + bool const fix340Enabled = features[fixCleanup3_4_0]; Env env(*this, features); @@ -2611,7 +2701,7 @@ class LoanBroker_test : public beast::unit_test::Suite env(coverWithdrawToDest(), loan_broker::kDestination(dest), Ter{tecNO_PERMISSION}); env.close(); - if (!fixEnabled) + if (!fix340Enabled) { // Pre-fix: sfCredentialIDs in LoanBrokerCoverWithdraw is disabled env(coverWithdrawToDest(), @@ -3035,6 +3125,13 @@ public: testLoanBrokerDeleteFrozenIOU(all_); testLoanBrokerDeleteFrozenIOU(all_ - fixCleanup3_2_0); + + // featureMPTokensV2 independently makes ValidMPTTransfer enforcing, + // but it's Supported::No (never enabled on real networks); exclude + // it here so fixCleanup3_4_0 alone is the deciding amendment, as it + // would be on mainnet. + testLoanBrokerDeleteRequireAuthMPT(all_ - featureMPTokensV2); + testLoanBrokerDeleteRequireAuthMPT(all_ - featureMPTokensV2 - fixCleanup3_4_0); // TODO: Write clawback failure tests with an issuer / MPT that doesn't // have the right flags set. } From b3b38e4416c59b57060af701e76212b939ea7527 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:43:11 +0000 Subject: [PATCH 18/40] fix: Correct fee-payer XRP delta in ValidVault for sponsored VaultWithdraw (#8141) --- include/xrpl/tx/Transactor.h | 10 +- include/xrpl/tx/invariants/VaultInvariant.h | 55 ++- src/libxrpl/tx/invariants/VaultInvariant.cpp | 103 ++++-- src/test/app/Sponsor_test.cpp | 6 +- src/test/app/vault/VaultBugs_test.cpp | 339 +++++++++++++++++++ 5 files changed, 474 insertions(+), 39 deletions(-) diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index 96ad7e00bc..aabde69ff9 100644 --- a/include/xrpl/tx/Transactor.h +++ b/include/xrpl/tx/Transactor.h @@ -259,6 +259,13 @@ public: static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx, std::uint32_t extraBaseFeeMultiplier); + // Exposed for invariant checks (e.g. ValidVault) that need to know which + // ledger entry actually pays a transaction's fee, distinguishing an + // ordinary sender, a delegate, and pre-funded vs. co-signed fee + // sponsorship. + static FeePayer + getFeePayer(ReadView const& view, STTx const& tx); + /* Do NOT define an invokePreflight function in a derived class. Instead, define: @@ -525,9 +532,6 @@ private: std::pair reset(XRPAmount fee); - static FeePayer - getFeePayer(ReadView const& view, STTx const& tx); - TER consumeSeqProxy(SLE::pointer const& sleAccount); TER diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index efeec7fda6..8297b941d1 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -131,20 +131,57 @@ private: deltaAssets(AccountID const& id) const; /** - * @brief Return the vault-asset delta for the transaction's sending - * account, adjusted for the fee. + * @brief Return the AccountRoot whose XRP balance actually absorbed a + * transaction's fee, if any. * - * Calls @c deltaAssets for @c tx[sfAccount] and, for non-delegated XRP - * transactions, adds the consumed fee back so the invariant sees the net - * asset movement rather than the fee-reduced balance change. + * Mirrors @c Transactor::getFeePayer, but resolves to @c std::nullopt for + * a pre-funded sponsorship: that fee is drawn from the @c ltSponsorship + * object's @c sfFeeAmount, never from the sponsor's own AccountRoot, so + * there is no balance to add back there. * - * @param tx The transaction being applied. - * @param fee Fee charged by this transaction. + * @param view Read-only view of the ledger after the transaction. + * @param tx The transaction being applied. + * @return The fee-paying AccountRoot's id, or @c std::nullopt when the + * fee was not drawn from any AccountRoot balance. + */ + [[nodiscard]] static std::optional + feePayerAccountRoot(ReadView const& view, STTx const& tx); + + /** + * @brief Return the vault-asset delta for a party inspected as a + * withdrawal/deposit counterparty, adjusted for the fee. + * + * Calls @c deltaAssets for @p id and, for XRP transactions, adds the + * consumed fee back only when @p id is the AccountRoot that actually + * paid it (per @c feePayerAccountRoot) -- so the invariant sees the net + * asset movement rather than a fee-reduced balance change, regardless of + * whether @p id is the sender, a distinct destination, a delegate, or a + * co-signed fee sponsor. Post-@c fixCleanup3_4_0, any resulting + * economically-zero delta is always normalized to absence. + * + * Pre-@c fixCleanup3_4_0 this replicates the legacy behaviour exactly: + * only @c tx[sfAccount] could ever receive a fee correction (and only + * when it was itself, per @c STTx::getFeePayerID, the fee payer). After + * that sender-only correction a zero delta is collapsed to absence; if + * the correction does not apply, a present-zero delta is kept as-is. + * + * @param view Read-only view of the ledger after the transaction. + * @param id Account being inspected as sender or destination. + * @param tx The transaction being applied. + * @param fee Fee charged by this transaction. + * @param fix340Enabled Whether @c fixCleanup3_4_0 is enabled, as already + * determined once by @c finalize. * @return The fee-adjusted delta, or @c std::nullopt if the net delta is - * zero or the account entry was not touched. + * zero (always post-amendment; pre-amendment only after the + * sender-only fee correction) or the entry was not touched. */ [[nodiscard]] std::optional - deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const; + deltaAssetsForParty( + ReadView const& view, + AccountID const& id, + STTx const& tx, + XRPAmount fee, + bool fix340Enabled) const; /** * @brief Return the vault-share balance-change delta for an account. diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index c0f98a8128..ff3a20c8ff 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -235,21 +236,57 @@ ValidVault::deltaAssets(AccountID const& id) const vaultAsset.value()); } +std::optional +ValidVault::feePayerAccountRoot(ReadView const& view, STTx const& tx) +{ + auto const feePayer = Transactor::getFeePayer(view, tx); + if (feePayer.type == FeePayerType::SponsorPreFunded) + return std::nullopt; + return feePayer.id; +} + std::optional -ValidVault::deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const +ValidVault::deltaAssetsForParty( + ReadView const& view, + AccountID const& id, + STTx const& tx, + XRPAmount fee, + bool fix340Enabled) const { auto const& vaultAsset = afterVault_[0].asset; - auto ret = deltaAssets(tx[sfAccount]); + auto ret = deltaAssets(id); if (!ret.has_value() || !vaultAsset.native()) return ret; - // Only add the fee back if tx[sfAccount] actually paid it. When the fee is - // paid by someone else (a delegate or a fee sponsor), the - // account's XRP balance moved only by the vault amount. - if (tx.getFeePayerID() != tx[sfAccount]) - return ret; + if (!fix340Enabled) + { + // Legacy behaviour: only tx[sfAccount] was ever considered for a fee + // correction, and only when STTx::getFeePayerID identified it as the + // fee payer (which is never true for a sponsor, since + // self-sponsorship is disallowed). After that sender-only correction + // a zero delta is collapsed to absence; if the correction does not + // apply, a present-zero is returned as-is. + if (id != tx[sfAccount] || tx.getFeePayerID() != id) + return ret; - ret->delta += fee.drops(); + ret->delta += fee.drops(); + if (ret->delta == kZero) + return std::nullopt; + + return ret; + } + + // Add the fee back only onto the AccountRoot that actually paid it: an + // ordinary sender, a delegate, or a co-signed fee sponsor -- but never a + // pre-funded sponsorship, whose fee is drawn from the ltSponsorship + // object rather than the sponsor's own XRP balance. + if (auto const payer = feePayerAccountRoot(view, tx); payer && *payer == id) + ret->delta += fee.drops(); + + // Normalize an economically zero delta to absence regardless of who (if + // anyone) paid the fee, so a touched-but-unchanged AccountRoot (e.g. the + // sender in a third-party withdrawal, touched only for sequence/ticket + // processing) is never misread as a second payout recipient. if (ret->delta == kZero) return std::nullopt; @@ -860,7 +897,8 @@ ValidVault::finalize( if (!issuerDeposit) { - auto const maybeAccDeltaAssets = deltaAssetsTxAccount(tx, fee); + auto const maybeAccDeltaAssets = + deltaAssetsForParty(view, tx[sfAccount], tx, fee, fix340Enabled); if (!maybeAccDeltaAssets) { JLOG(j.fatal()) @@ -1033,21 +1071,39 @@ ValidVault::finalize( if (!issuerWithdrawal) { - auto const maybeAccDelta = deltaAssetsTxAccount(tx, fee); - auto const maybeOtherAccDelta = [&]() -> std::optional { - if (auto const destination = tx[~sfDestination]; - destination && *destination != tx[sfAccount]) - return deltaAssets(*destination); - return std::nullopt; - }(); + // Identify the intended recipient explicitly from + // sfDestination (falling back to sfAccount for a + // self-withdrawal), rather than inferring it from which + // side happens to show a delta. When a distinct + // destination is named, the sending account must not + // also show a real economic delta -- that would mean two + // accounts were paid, which is always a bug, regardless + // of what (if anything) the named destination received. + auto const destinationField = tx[~sfDestination]; + AccountID const recipient = destinationField.value_or(tx[sfAccount]); + bool const distinctDestination = + destinationField.has_value() && *destinationField != tx[sfAccount]; - if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value()) + // Intentionally ungated: `fix340Enabled &&` here would let the + // pre-amendment sponsored case succeed and change consensus. + if (distinctDestination && + deltaAssetsForParty(view, tx[sfAccount], tx, fee, fix340Enabled) + .has_value()) { - // Both changed is always a bug. Neither changed is - // consistent only with a legitimate zero-value - // withdrawal, which moves nothing on either side — - // there is nothing left to cross-check. - if (!zeroDeltaIsLegitimate || maybeAccDelta.has_value()) + JLOG(j.fatal()) << // + "Invariant failed: withdrawal must change one destination balance"; + return false; + } + + auto const maybeRecipientDelta = + deltaAssetsForParty(view, recipient, tx, fee, fix340Enabled); + + if (!maybeRecipientDelta.has_value()) + { + // A legitimate zero-value withdrawal moves nothing to + // the recipient either; there is nothing left to + // cross-check. + if (!zeroDeltaIsLegitimate) { JLOG(j.fatal()) << // "Invariant failed: withdrawal must change one destination balance"; @@ -1059,8 +1115,7 @@ ValidVault::finalize( // A one-sided change is cross-checked even for a // legitimate zero vault delta: the destination must // then have moved by (rounded) zero as well. - auto const destinationDelta = - *maybeAccDelta.or_else([&] { return maybeOtherAccDelta; }); + auto const destinationDelta = *maybeRecipientDelta; // the scale of destinationDelta can be coarser than // minScale, so we take that into account when rounding diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index e58d8c9f8f..593edfe0a4 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -5585,9 +5585,9 @@ public: Ter(tesSUCCESS)); env.close(); - // The same helper (deltaAssetsTxAccount) drives the withdraw path, so a - // fee-sponsored withdrawal back to the depositor's own account also - // passes on the destination side. + // The same fee-correction logic (ValidVault::deltaAssetsForParty) + // drives the withdraw path, so a fee-sponsored withdrawal back to + // the depositor's own account also passes on the destination side. env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(50)}), Fee(XRP(1)), sponsor::As(sponsor, spfSponsorFee), diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index 04f31c9526..dc75371f3e 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -1674,6 +1675,340 @@ private: } } + // Bug 1: a sponsored XRP VaultWithdraw to a distinct destination is + // rejected because the vault invariant treats the holder's touched + // but economically unchanged AccountRoot as a second payout + // recipient. Sequence/ticket processing still touches the holder + // while the sponsor pays the fee, so the holder's XRP delta is + // present-zero and is not normalized away. If this happens on the + // last Subscription ledger of a closed-ended vault, the holder + // cannot retry until Redemption (tecTOO_SOON during Investment). + // + // Fixed by ValidVault::deltaAssetsForParty always collapsing an + // economically-zero XRP delta to absence, regardless of who paid the + // fee. + void + testBugSponsoredWithdrawZeroDeltaMisclassifiedAsSecondRecipient() + { + using namespace test::jtx; + + auto runScenario = [this](FeatureBitset features, TER expected) { + Env env{*this, features}; + Account const owner{"owner"}; + Account const holder{"holder"}; + Account const destination{"destination"}; + Account const sponsor{"sponsor"}; + env.fund(XRP(10'000), owner, holder, destination, sponsor); + env.close(); + + constexpr std::uint32_t investmentPeriod = 14u * 24u * 60u * 60u; + auto const [vault, vaultKeylet, subscriptionDate, redemptionDate] = + makeClosedEndedVault(env, owner, xrpIssue(), 120u, investmentPeriod); + BEAST_EXPECT(redemptionDate - subscriptionDate == investmentPeriod); + + env(vault.deposit( + {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()})); + env.close(); + + // Inclusive SubscriptionDate boundary: still Subscription, so an + // ordinary withdrawal is allowed. + closeToTime(env, tp{d{subscriptionDate}}); + + auto const vaultBefore = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultBefore)) + return; + auto const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + auto const holderBalanceBefore = env.balance(holder); + auto const destinationBalanceBefore = env.balance(destination); + auto const sponsorBalanceBefore = env.balance(sponsor); + auto const fee = env.current()->fees().base; + + auto withdraw = vault.withdraw( + {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()}); + withdraw[sfDestination] = destination.human(); + env(withdraw, + Fee(fee), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Ter(expected)); + env.close(); + + auto const vaultAfter = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultAfter)) + return; + BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore - fee); + + if (expected == tesSUCCESS) + { + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore - XRP(100).value()); + BEAST_EXPECT(env.balance(holder) == holderBalanceBefore); + BEAST_EXPECT(env.balance(destination) == destinationBalanceBefore + XRP(100)); + return; + } + + // Invariant rollback: the payout and share burn are undone, but + // sequence processing and the sponsored fee charge remain. + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore); + BEAST_EXPECT(env.balance(holder) == holderBalanceBefore); + BEAST_EXPECT(env.balance(destination) == destinationBalanceBefore); + + // Once the ledger advances into Investment, the same holder + // cannot retry until Redemption. + auto retry = vault.withdraw( + {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()}); + retry[sfDestination] = destination.human(); + env(retry, Ter(tecTOO_SOON)); + }; + + testcase( + "bug: sponsored XRP withdrawal to a distinct destination misreads a " + "touched-but-zero sender delta as a second recipient " + "(pre-fixCleanup3_4_0)"); + runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED); + + testcase( + "bug: sponsored XRP withdrawal to a distinct destination succeeds " + "(post-fixCleanup3_4_0)"); + runScenario(all_, tesSUCCESS); + } + + // Bug 2: a co-signed fee sponsor named as the withdrawal's own + // destination pays its fee from the same AccountRoot it is paid into, + // so its net XRP delta is (payout - fee). The invariant never fee- + // corrected the destination side at all, so this always failed the + // equal-amount check against the vault's outflow (payout). + // + // Fixed by ValidVault::deltaAssetsForParty adding the fee back onto + // whichever inspected party's AccountRoot actually paid it -- the + // sender, or a distinct destination -- not just the sender. + void + testBugSponsorAsDestinationFeeMisappliedToPayout() + { + using namespace test::jtx; + + auto runScenario = [this](FeatureBitset features, TER expected) { + Env env{*this, features}; + Account const owner{"owner"}; + Account const holder{"holder"}; + Account const sponsor{"sponsor"}; + env.fund(XRP(10'000), owner, holder, sponsor); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = xrpIssue()}); + env(vaultTx); + env.close(); + + env(vault.deposit( + {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()})); + env.close(); + + auto const vaultBefore = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultBefore)) + return; + auto const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + auto const sponsorBalanceBefore = env.balance(sponsor); + auto const fee = env.current()->fees().base; + + // The sponsor both receives the withdrawal (as sfDestination) + // and pays its own fee (co-signed) from the same AccountRoot. + auto withdraw = vault.withdraw( + {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()}); + withdraw[sfDestination] = sponsor.human(); + env(withdraw, + Fee(fee), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Ter(expected)); + env.close(); + + auto const vaultAfter = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultAfter)) + return; + + if (expected == tesSUCCESS) + { + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore - XRP(100).value()); + // Paid the withdrawal, then separately debited for the fee + // it chose to cover; net effect is payout minus fee. + BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore + XRP(100) - fee); + return; + } + + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore); + BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore - fee); + }; + + testcase( + "bug: co-signed sponsor named as withdrawal destination has its " + "own fee debit misread as breaking the payout equality " + "(pre-fixCleanup3_4_0)"); + runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED); + + testcase( + "bug: co-signed sponsor named as withdrawal destination succeeds " + "(post-fixCleanup3_4_0)"); + runScenario(all_, tesSUCCESS); + } + + // Pre-funded fee sponsorship draws the fee from ltSponsorship.sfFeeAmount, + // so feePayerAccountRoot must return nullopt rather than the sponsor's + // AccountRoot. A bystander sponsor leaves that branch unexercised: the + // result is only consulted by deltaAssetsForParty via `payer && *payer == + // id`. Naming the sponsor as sfDestination makes the early return + // load-bearing -- returning the sponsor's id instead of nullopt would add + // the fee back onto a balance that never paid it, and the equal-amount + // check against the vault outflow would fail. + // + // Contrast testBugSponsorAsDestinationFeeMisappliedToPayout, where the + // sponsor co-signs and so really does pay from its own AccountRoot. + void + testPrefundedFeeWithdraw() + { + using namespace test::jtx; + + auto runScenario = [this]( + FeatureBitset features, + TER expected, + bool const sponsorIsDestination) { + Env env{*this, features}; + Account const owner{"owner"}; + Account const holder{"holder"}; + Account const destination{"destination"}; + Account const sponsor{"sponsor"}; + env.fund(XRP(10'000), owner, holder, destination, sponsor); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = xrpIssue()}); + env(vaultTx); + env.close(); + + env(vault.deposit( + {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()})); + env.close(); + + auto const fee = env.current()->fees().base; + env(sponsor::set_fee(sponsor, 0, fee), sponsor::SponseeAcc(holder)); + env.close(); + + auto const vaultBefore = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultBefore)) + return; + auto const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + auto const holderBalanceBefore = env.balance(holder); + auto const destinationBalanceBefore = env.balance(destination); + auto const sponsorBalanceBefore = env.balance(sponsor); + + Account const& recipient = sponsorIsDestination ? sponsor : destination; + auto withdraw = vault.withdraw( + {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()}); + withdraw[sfDestination] = recipient.human(); + env(withdraw, Fee(fee), sponsor::As(sponsor, spfSponsorFee), Ter(expected)); + env.close(); + + auto const vaultAfter = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultAfter)) + return; + // Holder is economically unchanged (sequence only); the fee is + // taken from the sponsorship object, not any AccountRoot. + BEAST_EXPECT(env.balance(holder) == holderBalanceBefore); + + if (expected == tesSUCCESS) + { + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore - XRP(100).value()); + if (sponsorIsDestination) + { + // The sponsor receives the payout and is not debited for + // the fee. The sponsor has to BE the destination for + // FeePayerType::SponsorPreFunded to matter. + BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore + XRP(100)); + } + else + { + BEAST_EXPECT(env.balance(destination) == destinationBalanceBefore + XRP(100)); + BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore); + } + auto const sponsorship = env.le(keylet::sponsorship(sponsor, holder)); + if (!BEAST_EXPECT(sponsorship)) + return; + BEAST_EXPECT(!sponsorship->isFieldPresent(sfFeeAmount)); + return; + } + + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore); + BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore); + if (!sponsorIsDestination) + BEAST_EXPECT(env.balance(destination) == destinationBalanceBefore); + }; + + testcase( + "pre-funded fee XRP withdrawal to a distinct destination succeeds " + "(post-fixCleanup3_4_0)"); + runScenario(all_, tesSUCCESS, false); + + testcase( + "bug: pre-funded sponsor named as withdrawal destination misreads " + "the sender's touched-but-zero delta as a second recipient " + "(pre-fixCleanup3_4_0)"); + runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED, true); + + testcase( + "bug: pre-funded sponsor named as withdrawal destination receives " + "the full payout (post-fixCleanup3_4_0)"); + runScenario(all_, tesSUCCESS, true); + } + + // Unsponsored third-party XRP withdrawal: the sender's AccountRoot moves + // by exactly -fee. Pre-amendment, the sender-only fee correction then + // collapses that to absence so the dual-recipient guard does not fire. + void + testUnsponsoredWithdrawToDistinctDestinationPreAmendment() + { + using namespace test::jtx; + + testcase( + "unsponsored XRP withdrawal to a distinct destination succeeds " + "(pre-fixCleanup3_4_0)"); + + Env env{*this, all_ - fixCleanup3_4_0}; + Account const owner{"owner"}; + Account const holder{"holder"}; + Account const destination{"destination"}; + env.fund(XRP(10'000), owner, holder, destination); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = xrpIssue()}); + env(vaultTx); + env.close(); + + env(vault.deposit( + {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()})); + env.close(); + + auto const vaultBefore = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultBefore)) + return; + auto const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + auto const holderBalanceBefore = env.balance(holder); + auto const destinationBalanceBefore = env.balance(destination); + auto const fee = env.current()->fees().base; + + auto withdraw = vault.withdraw( + {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()}); + withdraw[sfDestination] = destination.human(); + env(withdraw, Fee(fee), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultAfter)) + return; + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore - XRP(100).value()); + BEAST_EXPECT(env.balance(holder) == holderBalanceBefore - fee); + BEAST_EXPECT(env.balance(destination) == destinationBalanceBefore + XRP(100)); + } + public: void run() override @@ -1695,6 +2030,10 @@ public: testBugClawbackRoundTripOvershoot(); testBugWithdrawRoundTripOvershoot(); testBugClawbackAfterLoanImpair(); + testBugSponsoredWithdrawZeroDeltaMisclassifiedAsSecondRecipient(); + testBugSponsorAsDestinationFeeMisappliedToPayout(); + testPrefundedFeeWithdraw(); + testUnsponsoredWithdrawToDistinctDestinationPreAmendment(); } }; From 7863ac8cf6a3d30a236d213e6c6bd3c0795b7fd4 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:27:05 +0000 Subject: [PATCH 19/40] fix: Add 60s buffer before closed-ended vault redemption (#8151) --- include/xrpl/protocol/Protocol.h | 15 ++++- include/xrpl/tx/invariants/VaultInvariant.h | 4 +- src/libxrpl/tx/invariants/LoanInvariant.cpp | 15 +++-- .../tx/transactors/lending/LoanSet.cpp | 13 +++- .../app/invariants/InvariantsVault_test.cpp | 19 +++--- src/test/app/lending/LoanSet_test.cpp | 66 ++++++++++++++++--- src/test/app/lending/LoanTestBase.h | 4 +- src/test/app/vault/VaultClosedEnded_test.cpp | 13 ++-- 8 files changed, 110 insertions(+), 39 deletions(-) diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index e6768efd76..8edd4bf4fd 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -348,13 +348,24 @@ enum class VaultPhase : std::uint8_t { Redemption, }; +/** + * Minimum gap between a closed-ended loan's final scheduled payment and the + * vault's RedemptionDate. LoanSet rejects a schedule whose final payment is + * fewer than this many seconds before RedemptionDate. + */ +constexpr std::uint32_t kLoanRedemptionBuffer = std::chrono::seconds{60}.count(); + /** * Bounds on the length of a closed-ended vault's Investment phase * (RedemptionDate - SubscriptionDate). At vault creation the gap must satisfy * kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod. + * + * 180s is enough to originate a loan that uses the minimum payment interval + * and kLoanRedemptionBuffer after StartDate, which is strictly after + * SubscriptionDate. The interval and buffer need not be equal; only their + * sum plus one second must fit in this floor. */ -constexpr std::uint32_t kMinInvestmentPeriod = - std::chrono::seconds{std::chrono::minutes{1}}.count(); +constexpr std::uint32_t kMinInvestmentPeriod = std::chrono::seconds{180}.count(); // This is 946708560 seconds which 30 x 365.2425 days (the average length of a Gregorian year). constexpr std::uint32_t kMaxInvestmentPeriod = std::chrono::seconds{std::chrono::years{30}}.count(); diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 8297b941d1..ee52f4edb3 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -211,8 +211,8 @@ private: * * For a closed-ended vault, a loan may only be originated while the vault is in the Investment * phase (strictly past @c SubscriptionDate and before @c RedemptionDate). Open-ended vaults (@c - * NoPhase) are unaffected. The complementary maturity bound (final payment strictly precedes @c - * RedemptionDate) is enforced by @c ValidLoan. + * NoPhase) are unaffected. The complementary maturity bound (final payment precedes @c + * RedemptionDate by at least @c kLoanRedemptionBuffer) is enforced by @c ValidLoan. */ [[nodiscard]] bool finalizeLoanSet(ReadView const& view, beast::Journal const& j) const; diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index 2db627c272..b34d7088be 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -62,10 +62,11 @@ ValidLoan::finalize( // Ledger entry validation checks. for (auto const& [before, after] : loans_) { - // A closed-ended vault must not accept a loan whose final scheduled payment falls on or - // after the vault's RedemptionDate. This mirrors the LoanSet::preclaim gate and only fires - // on loan creation; once the loan exists, its StartDate / PaymentInterval are immutable and - // PaymentRemaining only decreases, so the bound is preserved. + // A closed-ended vault must not accept a loan whose final scheduled payment falls fewer + // than kLoanRedemptionBuffer seconds before the vault's RedemptionDate. This mirrors the + // LoanSet::preclaim gate and only fires on loan creation; once the loan exists, its + // StartDate / PaymentInterval are immutable and PaymentRemaining only decreases, so the + // bound is preserved. if (!before && isTesSuccess(result)) { auto const broker = view.read(keylet::loanBroker(after->at(sfLoanBrokerID))); @@ -80,11 +81,13 @@ ValidLoan::finalize( std::uint32_t const interval = after->at(sfPaymentInterval); std::uint32_t const remaining = after->at(sfPaymentRemaining); std::uint32_t const redemption = vault->at(sfRedemptionDate); - if (std::uint64_t{startDate} + (std::uint64_t{interval} * remaining) >= + if (std::uint64_t{startDate} + (std::uint64_t{interval} * remaining) + + kLoanRedemptionBuffer > redemption) { JLOG(j.fatal()) << "Invariant failed: closed-ended loan final payment " - "must precede RedemptionDate"; + "must precede RedemptionDate by at least " + "kLoanRedemptionBuffer"; return false; } } diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index d84f5b09b3..b67c244bac 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -40,6 +40,12 @@ namespace xrpl { +// StartDate is strictly after SubscriptionDate. A min-gap vault must still +// fit a minimum-interval loan plus kLoanRedemptionBuffer. The interval and +// buffer constants are independent; only their sum (plus the +1 for a +// strictly-later StartDate) is required to fit in kMinInvestmentPeriod. +static_assert(kMinInvestmentPeriod >= LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer + 1); + bool LoanSet::checkExtraFeatures(PreflightContext const& ctx) { @@ -327,10 +333,11 @@ LoanSet::preclaim(PreclaimContext const& ctx) { auto const finalPayment = std::uint64_t{getStartDate(ctx.view)} + (std::uint64_t{interval} * total); - if (finalPayment >= vault->at(sfRedemptionDate)) + if (finalPayment + kLoanRedemptionBuffer > vault->at(sfRedemptionDate)) { - JLOG(ctx.j.warn()) << "Final loan payment date is on or after " - "the vault's redemption date."; + JLOG(ctx.j.warn()) + << "Final loan payment date is fewer than " << kLoanRedemptionBuffer + << " seconds before the vault's redemption date."; return tecNO_PERMISSION; } } diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp index caf4e9cfb6..dcf783a1b5 100644 --- a/src/test/app/invariants/InvariantsVault_test.cpp +++ b/src/test/app/invariants/InvariantsVault_test.cpp @@ -2794,13 +2794,15 @@ class InvariantsVault_test : public InvariantsBase "RedemptionDate"; // A newly-created loan against a closed-ended vault must satisfy StartDate + - // PaymentInterval * PaymentRemaining < RedemptionDate. LoanSet::preclaim enforces the same - // bound; this test synthesises an invalid loan directly in the ApplyView so the invariant - // catches it even when preclaim is bypassed. + // PaymentInterval * PaymentRemaining + kLoanRedemptionBuffer <= RedemptionDate. + // LoanSet::preclaim enforces the same bound; this test synthesises a loan whose + // final payment is still before RedemptionDate (so the old unbuffered check would + // pass) but inside the buffer zone. Keylet closedEndedBrokerKeylet = keylet::amendments(); std::uint32_t closedEndedRed = 0; doInvariantCheck( - {"closed-ended loan final payment must precede RedemptionDate"}, + {"closed-ended loan final payment must precede RedemptionDate by at least " + "kLoanRedemptionBuffer"}, [&](Account const& a1, Account const&, ApplyContext& ac) { // Touch the vault so ValidVault::finalizeLoanSet sees an // entry in afterVault_; the vault is in Investment, so @@ -2817,15 +2819,14 @@ class InvariantsVault_test : public InvariantsBase return false; std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence); - // Synthesize a Loan whose final scheduled payment lands - // exactly at RedemptionDate: StartDate = red, interval = 60, - // remaining = 1 => red + 60 >= red. + // Final payment at RedemptionDate - (kLoanRedemptionBuffer - 1): still + // strictly before RedemptionDate, but inside the buffer. auto sleLoan = makeLoanSle(closedEndedBrokerKeylet.key, loanSeq, a1.id()); sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; sleLoan->at(sfLoanSequence) = loanSeq; sleLoan->at(sfBorrower) = a1.id(); - sleLoan->at(sfStartDate) = closedEndedRed; - sleLoan->at(sfPaymentInterval) = 60; + sleLoan->at(sfStartDate) = closedEndedRed - kLoanRedemptionBuffer; + sleLoan->at(sfPaymentInterval) = 1; sleLoan->at(sfPaymentRemaining) = 1; sleLoan->at(sfTotalValueOutstanding) = Number(100); sleLoan->at(sfPeriodicPayment) = Number(1); diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 3571853b47..9829f23138 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -602,6 +603,8 @@ private: testcase("LoanSet closed-ended: phase and maturity bound"); using namespace jtx; using namespace loan; + using d = NetClock::duration; + using tp = NetClock::time_point; Account const issuer{"issuer"}; Account const lender{"lender"}; @@ -663,9 +666,9 @@ private: setLoan(env, broker, tesSUCCESS); }); - // 4. Rejected during Investment when the loan's final payment would land on or after - // RedemptionDate. Use a tight redemptionOffset and a schedule whose final payment is well - // past that boundary. + // 4. Rejected during Investment when the loan's final payment would land fewer than + // kLoanRedemptionBuffer seconds before RedemptionDate. Use a tight redemptionOffset and a + // schedule whose final payment is well past that boundary. withEnv([&](Env& env, PrettyAsset const& asset) { constexpr std::uint32_t kRedemptionOffset = 3u * 24u * 3600u; auto const broker = createVaultAndBroker( @@ -684,16 +687,16 @@ private: env.close(); }); - // 5. Boundary: schedule whose finalPayment lands exactly (RedemptionDate - 1) is accepted, - // and one second later (== RedemptionDate) is rejected. Uses payTotal = 1 so the arithmetic - // is simple: finalPayment = startDate + interval. + // 5. Boundary: a finalPayment exactly kLoanRedemptionBuffer seconds before + // RedemptionDate is accepted; one second later is rejected. Uses payTotal = 1 so + // finalPayment = startDate + interval. withEnv([&](Env& env, PrettyAsset const& asset) { auto const broker = createVaultAndBroker( env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); BEAST_EXPECT(broker.redemptionDate.has_value()); auto const startDate = env.now().time_since_epoch().count(); - auto const acceptInterval = *broker.redemptionDate - 1 - startDate; + auto const acceptInterval = *broker.redemptionDate - kLoanRedemptionBuffer - startDate; env(set(lender, broker.brokerID, broker.asset(100).value()), kCounterparty(borrower), Sig(sfCounterpartySignature, borrower), @@ -703,8 +706,8 @@ private: Ter(tesSUCCESS)); env.close(); - auto const rejectInterval = - *broker.redemptionDate - env.now().time_since_epoch().count(); + auto const rejectInterval = *broker.redemptionDate - (kLoanRedemptionBuffer - 1) - + env.now().time_since_epoch().count(); env(set(lender, broker.brokerID, broker.asset(100).value()), kCounterparty(borrower), Sig(sfCounterpartySignature, borrower), @@ -714,6 +717,51 @@ private: Ter(tecNO_PERMISSION)); env.close(); }); + + // 6. A vault whose Investment window is exactly kMinInvestmentPeriod can originate a + // minimum-interval, single-payment loan at the start of Investment, and rejects the same + // schedule once StartDate no longer leaves kLoanRedemptionBuffer before RedemptionDate. + // Do not pin an unrounded wall-clock instant: Env::close rounds to the close-time + // resolution. Read env.now() (the same clock LoanSet::preclaim uses) and assert the + // buffer relationship before each LoanSet. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, + asset, + lender, + BrokerParameters{ + .vaultKind = VaultKind::ClosedEnded, + .subscriptionOffset = 300u, + .redemptionOffset = kMinInvestmentPeriod, + .skipPhaseAdvance = true}); + BEAST_EXPECT(broker.subscriptionDate.has_value()); + BEAST_EXPECT(broker.redemptionDate.has_value()); + + auto const red = *broker.redemptionDate; + auto const startDate = [&]() { return env.now().time_since_epoch().count(); }; + auto const minLoan = [&](TER expected) { + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(1u), + kPaymentInterval(LoanSet::kMinPaymentInterval), + Ter(expected)); + env.close(); + }; + + // First Investment ledger: the minimum schedule still clears the buffer. + env.close(tp{d{*broker.subscriptionDate + 1}}); + BEAST_EXPECT(startDate() > *broker.subscriptionDate); + BEAST_EXPECT(startDate() + LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer <= red); + minLoan(tesSUCCESS); + + // Still Investment, but the minimum schedule no longer clears the buffer. + while (startDate() + LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer <= red) + env.close(); + BEAST_EXPECT(startDate() < red); + minLoan(tecNO_PERMISSION); + }); } public: diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index a1241d804e..13dffb6b9e 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -119,8 +119,8 @@ protected: std::uint32_t subscriptionOffset = 60; // Seconds between SubscriptionDate and RedemptionDate. Must be >= kMinInvestmentPeriod, < // kMaxInvestmentPeriod, and generous enough to fit any loan schedule the test runs - // (finalPayment must be strictly before RedemptionDate). Default sized to comfortably - // exceed any schedule realistic tests are likely to configure. + // (finalPayment must precede RedemptionDate by at least kLoanRedemptionBuffer). Default + // sized to comfortably exceed any schedule realistic tests are likely to configure. std::uint32_t redemptionOffset = 10u * 365u * 24u * 60u * 60u; // When true, createVaultAndBroker skips its automatic clock advance past SubscriptionDate. // Useful for tests that need to observe the vault while it is still in the Subscription diff --git a/src/test/app/vault/VaultClosedEnded_test.cpp b/src/test/app/vault/VaultClosedEnded_test.cpp index 252a7f4990..5ed242f8a4 100644 --- a/src/test/app/vault/VaultClosedEnded_test.cpp +++ b/src/test/app/vault/VaultClosedEnded_test.cpp @@ -81,7 +81,7 @@ private: /* * Valid closed-ended creation with a comfortably interior gap (well above - * MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD). + * kMinInvestmentPeriod and well below kMaxInvestmentPeriod). */ withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { auto const sub = env.now().time_since_epoch().count() + 60; @@ -145,7 +145,7 @@ private: }); /* - * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >= + * Gap smaller than kMinInvestmentPeriod => temMALFORMED. Includes the SubscriptionDate >= * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red < * sub case, the latter yielding a negative signed int64 gap that is caught by the * sub-minimum branch of the gap check. @@ -206,8 +206,9 @@ private: env(tx, Ter{temMALFORMED}); }); - // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is - // inclusive). + // Happy path: gap exactly equal to kMinInvestmentPeriod is accepted (lower bound is + // inclusive). A min-gap vault can originate a minimum-interval loan; see + // LoanSet_test::testLoanSetClosedEnded. withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { auto const sub = env.now().time_since_epoch().count() + 60; auto const red = sub + minPeriod; @@ -547,7 +548,7 @@ private: Asset const asset = xrpIssue(); // Widen the Investment window so a single-payment loan (min payment - // interval kMinPaymentInterval = 60s) fits before RedemptionDate. + // interval 60s plus kLoanRedemptionBuffer) fits before RedemptionDate. auto const [vault, keylet, sub, red] = makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u); @@ -627,7 +628,7 @@ private: auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); Asset const asset = xrpIssue(); // Widen the Investment window so a single-payment loan (min payment interval - // kMinPaymentInterval = 60s) fits before RedemptionDate with headroom. + // 60s plus kLoanRedemptionBuffer) fits before RedemptionDate with headroom. auto const [vault, keylet, sub, red] = makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); From deaf5964942a052458b9b976adfb3102f3ada173 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Tue, 1 Sep 2026 21:57:34 +0000 Subject: [PATCH 20/40] docs: Add AGENTS.md/CLAUDE.md for AI coding agent guidance (#8067) --- .gitignore | 9 +++++++-- AGENTS.md | 42 ++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + CONTRIBUTING.md | 13 ++++++++++++- src/libxrpl/tx/AGENTS.md | 5 +++++ src/libxrpl/tx/CLAUDE.md | 1 + src/xrpld/rpc/AGENTS.md | 5 +++++ src/xrpld/rpc/CLAUDE.md | 1 + 8 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 src/libxrpl/tx/AGENTS.md create mode 120000 src/libxrpl/tx/CLAUDE.md create mode 100644 src/xrpld/rpc/AGENTS.md create mode 120000 src/xrpld/rpc/CLAUDE.md diff --git a/.gitignore b/.gitignore index c5af8eb7b4..5b21076af3 100644 --- a/.gitignore +++ b/.gitignore @@ -72,11 +72,16 @@ DerivedData /.zed/ # AI tools. +# Shared/committable AI agent config (AGENTS.md, CLAUDE.md, GEMINI.md, .claude/settings.json, +# tool-specific rules files, etc.) should be checked in — see CONTRIBUTING.md. Only the +# personal/local variants below are ignored. /.agent /.agents /.augment -/.claude -/CLAUDE.md +/.claude/settings.local.json +AGENTS.override.md +CLAUDE.local.md +GEMINI.local.md # Python __pycache__ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..85bf9befb2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# AGENTS.md + +This file provides guidance to AI coding agents (Claude Code, and other AGENTS.md-compatible tools) when working with code in this repository. + +## Build + +Required on Linux/macOS: use the Nix devshell, which sets up the compiler, Conan, ccache, and (optionally) Rust automatically. + +```bash +nix develop +``` + +For alternate devshell variants (specific compiler, no-compiler, coverage), see [docs/build/nix.md](./docs/build/nix.md). For the manual build steps, CMake options, and protocol codegen commands, see [BUILD.md](./BUILD.md) (`## Steps`, `## Options`, `## Code generation`). + +Rust crate tests (independent of the CMake build): `cargo test --manifest-path crates/Cargo.toml --workspace` (CI uses `cargo nextest`). + +## Testing + +Unit tests are a custom framework built into the `xrpld` binary itself (not Boost.Test/GTest/Catch); see [CONTRIBUTING.md](./CONTRIBUTING.md#unit-tests) for the basic invocation. Notes not covered there: + +- A suite's `--unittest` name is built from the arguments to its `BEAST_DEFINE_TESTSUITE`/`BEAST_DEFINE_TESTSUITE_PRIO` macro (usually at the bottom of the test file), in reverse order and joined with `.`: `BEAST_DEFINE_TESTSUITE(Credentials, app, xrpl)` → `xrpl.app.Credentials`. +- `--unittest-arg` does nothing — don't use it. +- Tests that run offline in under a minute should be automatic `--unittest` suites; anything else is a manual/integration test. +- New tests should be written using `gtest` under `src/tests/` unless that isn't possible, in which case fall back to the legacy Beast framework under `src/test/`. `tests/` (top-level) holds integration tests exercised against `libxrpl`/`xrpld`. + +## Lint/Format + +See [CONTRIBUTING.md](./CONTRIBUTING.md#pre-commit-hooks) for `pre-commit` setup and [CONTRIBUTING.md](./CONTRIBUTING.md#clang-tidy) for `clang-tidy` (opt-in, needs local `clang-tidy` and generated headers). + +## Code Style + +New file placement and header levelization: see [CONTRIBUTING.md](./CONTRIBUTING.md#before-making-a-pull-request). Braces, whitespace, member order, and other conventions: see [docs/CodingStyle.md](./docs/CodingStyle.md). `XRPL_ASSERT`/`UNREACHABLE` contracts: see [CONTRIBUTING.md](./CONTRIBUTING.md#contracts-and-instrumentation). Commit messages: see [CONTRIBUTING.md](./CONTRIBUTING.md#good-commit-messages). + +## Architecture + +Paths below reflect the current layout; update this section if modularization moves a subsystem to a different directory. + +- `include/xrpl/` + `src/libxrpl/` — the core protocol library: ledger, shamap, consensus, crypto, json, resource, nodestore, rdb, peerfinder, and `tx/` (transaction application: `Transactor.cpp`, `applySteps.cpp`, invariants, payment paths). `tx/transactors/` has one file per transaction type, grouped by subsystem: `escrow/`, `vault/`, `lending/`, `sponsor/`, `nft/`, `token/` (MPT), `payment_channel/`, `permissioned_domain/`, `dex/`, `oracle/`, `did/`, `credentials/`, `bridge/`, `check/`, `delegate/`, `account/`, `system/`. Any change to transaction-processing behavior must be gated behind an Amendment. +- `src/xrpld/` — the server application built on top of `libxrpl`: `app`, `core`, `overlay` (P2P networking), `peerfinder`, `perflog`, `rpc`, `shamap`. `main` builds an `ApplicationImp` implementing `Application`; most components hold a reference to it (`app_`), giving broad cross-component access — expect to trace call chains through `Application&`. +- `src/test/` — unit tests mirroring the subsystems above, plus `jtx/` (the transaction-building test DSL — e.g. `jtx/escrow.h`, `jtx/vault.h`, `jtx/sponsor.h`, `jtx/permissioned_dex.h`) and `unit_test/` (the custom test framework itself, derived from Beast). +- `src/tests/` — unit tests for `libxrpl` written in `gtest`, gradually replacing the `src/test` equivalents. +- `crates/` — a Rust workspace (only built with `-Dxrpld -Drust=ON`) bridged into C++ via `cxxbridge`/the `cxx` crate; currently just a `hello_world` interop scaffold. Requires the Rust toolchain pinned in `rust-toolchain.toml` (the Nix devshell provides it automatically). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35309a9824..1035124f31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,17 @@ to an existing XLS. Neither change will be released (in an amendment's case, marked as `Supported::yes`) until the corresponding XLS's status is `Final`. +## AI coding agents + +[`AGENTS.md`](./AGENTS.md) (and its `CLAUDE.md` symlink, for Claude Code) holds shared, checked-in guidance for AI coding agents working in this repository — build/test/lint commands and architecture notes. Additional `AGENTS.md` files may exist in subdirectories to give agents context specific to that part of the codebase; whenever you add one, also add a `CLAUDE.md` symlink pointing to it (`ln -s AGENTS.md CLAUDE.md`) so Claude Code picks it up too. + +If you want to give an agent personal instructions that shouldn't be shared with other contributors (e.g. your own workflow preferences), those are gitignored, not checked in: + +- `CLAUDE.local.md` — read by Claude Code alongside `CLAUDE.md`. +- `AGENTS.override.md` — read by AGENTS.md-compatible tools that support a personal override file layered on top of `AGENTS.md`. + +Likewise, `.claude/settings.local.json` is for personal, untracked Claude Code settings, while `.claude/settings.json` is shared. + ## Before making a pull request (Or marking a draft pull request as ready.) @@ -82,7 +93,7 @@ If you create new source files, they must be organized as follows: under `include/xrpl`, and source (`.cpp`) files must go under `src/libxrpl`. - All other non-test files must go under `src/xrpld`. -- All test source files must go under `src/test`. +- New test source files should use `gtest` and go under `src/tests`, unless that isn't possible, in which case they should use our legacy test framework and go under `src/test`. - All benchmark source files must go under `src/benchmarks`. The source must be formatted according to the style guide below. The easiest diff --git a/src/libxrpl/tx/AGENTS.md b/src/libxrpl/tx/AGENTS.md new file mode 100644 index 0000000000..e2261fc1ad --- /dev/null +++ b/src/libxrpl/tx/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md — tx + +See the repo-level [AGENTS.md](../../../AGENTS.md) for general build/test/style guidance. + +Any change to transaction-processing behavior must be gated behind an amendment. New amendments (and fixes, i.e. `fix*` amendments) are added to [`include/xrpl/protocol/detail/features.macro`](../../../include/xrpl/protocol/detail/features.macro), as an `XRPL_FEATURE(...)` or `XRPL_FIX(...)` entry added to the top of the list (the list is kept in reverse chronological order). Once the pre-amendment code path for a retired amendment is removed, move its entry to `XRPL_RETIRE_FEATURE(...)`/`XRPL_RETIRE_FIX(...)` instead of deleting it. diff --git a/src/libxrpl/tx/CLAUDE.md b/src/libxrpl/tx/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/src/libxrpl/tx/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/xrpld/rpc/AGENTS.md b/src/xrpld/rpc/AGENTS.md new file mode 100644 index 0000000000..14fdd7a03e --- /dev/null +++ b/src/xrpld/rpc/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md — rpc + +See the repo-level [AGENTS.md](../../../AGENTS.md) for general build/test/style guidance. + +Any change to a public RPC method's behavior (new/changed/removed fields, parameters, or error conditions) needs a corresponding entry in [`API-CHANGELOG.md`](../../../API-CHANGELOG.md), under the `## Unreleased` section (`### Additions`, `### Deprecations`, etc. as appropriate). diff --git a/src/xrpld/rpc/CLAUDE.md b/src/xrpld/rpc/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/src/xrpld/rpc/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 8809bdf3f0683219dcbf67a7d0531817bd9a619d Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:15:29 +0000 Subject: [PATCH 21/40] fix: Treat an existing IOU line as a no-op in addEmptyHolding (#8154) --- .../xrpl/ledger/helpers/RippleStateHelpers.h | 9 +- include/xrpl/ledger/helpers/TokenHelpers.h | 6 + src/libxrpl/ledger/helpers/MPTokenHelpers.cpp | 2 + .../ledger/helpers/RippleStateHelpers.cpp | 21 +- src/libxrpl/ledger/helpers/TokenHelpers.cpp | 26 ++ .../lending/LoanBrokerCoverWithdraw.cpp | 7 + .../tx/transactors/lending/LoanSet.cpp | 20 +- .../tx/transactors/vault/VaultWithdraw.cpp | 9 + src/test/app/lending/LoanPay_test.cpp | 65 ++++ src/test/app/lending/LoanSet_test.cpp | 65 ++++ src/test/app/vault/VaultBugs_test.cpp | 354 ++++++++++++++++++ 11 files changed, 575 insertions(+), 9 deletions(-) diff --git a/include/xrpl/ledger/helpers/RippleStateHelpers.h b/include/xrpl/ledger/helpers/RippleStateHelpers.h index a0508d074f..1a0f92a94b 100644 --- a/include/xrpl/ledger/helpers/RippleStateHelpers.h +++ b/include/xrpl/ledger/helpers/RippleStateHelpers.h @@ -239,8 +239,13 @@ canTransfer(ReadView const& view, Issue const& issue, AccountID const& from, Acc //------------------------------------------------------------------------------ /** - * Any transactors that call addEmptyHolding() in doApply must call - * canAddHolding() in preflight with the same View and Asset + * XRP and the issuer itself are always tesSUCCESS. Otherwise, after + * fixCleanup3_4_0, an existing trust line returns tecDUPLICATE without + * consulting issuer freeze or DefaultRipple; both still apply on the create + * path (DefaultRipple off is terNO_RIPPLE). canAddHolding() ignores existing + * holdings, so transactors that may create a holding in doApply should gate + * their preclaim call on it: after the amendment only when no holding + * exists, before it always. */ [[nodiscard]] TER addEmptyHolding( diff --git a/include/xrpl/ledger/helpers/TokenHelpers.h b/include/xrpl/ledger/helpers/TokenHelpers.h index 5153b43cb2..2a2f1b568e 100644 --- a/include/xrpl/ledger/helpers/TokenHelpers.h +++ b/include/xrpl/ledger/helpers/TokenHelpers.h @@ -319,6 +319,12 @@ transferRate(ReadView const& view, STAmount const& amount); [[nodiscard]] TER canAddHolding(ReadView const& view, Asset const& asset); +/** + * True if the account already holds this asset (or is the issuer / XRP). + */ +[[nodiscard]] bool +holdingExists(ReadView const& view, AccountID const& account, Asset const& asset); + [[nodiscard]] TER addEmptyHolding( ApplyViewContext ctx, diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp index 1b9bb19ad4..27dcd84675 100644 --- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp @@ -184,6 +184,8 @@ addEmptyHolding( auto const mpt = ctx.view.peek(keylet::mptokenIssuance(mptID)); if (!mpt) return tefINTERNAL; // LCOV_EXCL_LINE + // Unlike IOU addEmptyHolding (post-fixCleanup3_4_0), a locked issuance is + // still rejected before the "MPToken already exists" short circuit. if (mpt->isFlag(lsfMPTLocked)) return tefINTERNAL; // LCOV_EXCL_LINE if (ctx.view.peek(keylet::mptoken(mptID, accountID))) diff --git a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp index 706564db6f..cc02b56305 100644 --- a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp +++ b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp @@ -652,21 +652,32 @@ addEmptyHolding( auto const& issuerId = issue.getIssuer(); auto const& currency = issue.currency; - if (isGlobalFrozen(ctx.view, issuerId)) - return tecFROZEN; // LCOV_EXCL_LINE - auto const& srcId = issuerId; auto const& dstId = accountID; auto const high = srcId > dstId; auto const index = keylet::trustLine(srcId, dstId, currency); + // Post-fixCleanup3_4_0: an existing line is a no-op. Issuer freeze and + // DefaultRipple only matter when this function has to create a line. + bool const fix340Enabled = ctx.view.rules().enabled(fixCleanup3_4_0); + if (fix340Enabled && ctx.view.exists(index)) + return tecDUPLICATE; + + if (isGlobalFrozen(ctx.view, issuerId)) + return tecFROZEN; // LCOV_EXCL_LINE + auto const sleSrc = ctx.view.peek(keylet::account(srcId)); auto const sleDst = ctx.view.peek(keylet::account(dstId)); if (!sleDst || !sleSrc) return tefINTERNAL; // LCOV_EXCL_LINE + // Create path: DefaultRipple is still required. terNO_RIPPLE is + // intentional so VaultWithdraw / CoverWithdraw fail in preclaim via + // canAddHolding (retryable, no fee) rather than claiming a tec* fee + // in doApply. Transactor::operator() will not apply and will not + // convert it to tefINTERNAL. if (!sleSrc->isFlag(lsfDefaultRipple)) - return tecINTERNAL; // LCOV_EXCL_LINE + return fix340Enabled ? TER{terNO_RIPPLE} : tecINTERNAL; // If the line already exists, don't create it again. - if (ctx.view.read(index)) + if (!fix340Enabled && ctx.view.exists(index)) return tecDUPLICATE; // A reserve sponsor only covers tx.Account's own objects. diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp index aaf99a3c0a..2c2c943a9f 100644 --- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp @@ -583,6 +583,32 @@ canAddHolding(ReadView const& view, Asset const& asset) asset.value()); } +[[nodiscard]] bool +holdingExists(ReadView const& view, AccountID const& account, Issue const& issue) +{ + if (issue.native() || account == issue.getIssuer()) + return true; + return view.exists(keylet::trustLine(account, issue)); +} + +[[nodiscard]] bool +holdingExists(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue) +{ + if (account == mptIssue.getIssuer()) + return true; + return view.exists(keylet::mptoken(mptIssue.getMptID(), account)); +} + +[[nodiscard]] bool +holdingExists(ReadView const& view, AccountID const& account, Asset const& asset) +{ + return std::visit( + [&](TIss const& issue) -> bool { + return holdingExists(view, account, issue); + }, + asset.value()); +} + TER addEmptyHolding( ApplyViewContext ctx, diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp index e914596599..88b6f8c38b 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp @@ -65,6 +65,7 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx) { auto const fix320Enabled = ctx.view.rules().enabled(fixCleanup3_2_0); auto const fix330Enabled = ctx.view.rules().enabled(fixCleanup3_3_0); + auto const fix340Enabled = ctx.view.rules().enabled(fixCleanup3_4_0); auto const& tx = ctx.tx; auto const account = tx[sfAccount]; @@ -140,6 +141,12 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx) if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType)) return ter; + if (fix340Enabled && account == dstAcct && !holdingExists(ctx.view, dstAcct, vaultAsset)) + { + if (auto const ter = canAddHolding(ctx.view, vaultAsset); !isTesSuccess(ter)) + return ter; + } + if (fix330Enabled) { if (auto const ret = diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index b67c244bac..f7b97dfedf 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -372,8 +372,24 @@ LoanSet::preclaim(PreclaimContext const& ctx) } } - if (auto const ter = canAddHolding(ctx.view, asset)) - return ter; + // canAddHolding is an issuer-level check (DefaultRipple for IOU, + // lsfMPTCanTransfer for MPT); neither overload looks at the + // destination, so the holdingExists() clauses only decide whether a + // create path is reachable at all. It always runs before + // fixCleanup3_4_0: IOU addEmptyHolding checks DefaultRipple ahead of + // the existing-line case, so only preclaim can turn an existing line + // under a cleared DefaultRipple into terNO_RIPPLE rather than + // tecINTERNAL. After the amendment an existing line short-circuits to + // tecDUPLICATE, which doApply ignores, so run the check only when the + // borrower lacks a holding, or the origination fee is nonzero and the + // broker owner lacks one. + auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); + if (!ctx.view.rules().enabled(fixCleanup3_4_0) || !holdingExists(ctx.view, borrower, asset) || + (originationFee != beast::kZero && !holdingExists(ctx.view, brokerOwner, asset))) + { + if (auto const ter = canAddHolding(ctx.view, asset)) + return ter; + } // vaultPseudo is going to send funds, so it can't be frozen. if (auto const ret = checkFrozen(ctx.view, vaultPseudo, asset)) diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 697612af3e..4dc5b95c89 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -205,6 +205,15 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType); !isTesSuccess(ter)) return ter; + // Fail early when self-destination would have to create a holding. + // Skip when a holding already exists: canAddHolding does not look at that, + // and would block a no-op create (the DefaultRipple-cleared self-withdraw). + if (fix340Enabled && account == dstAcct && !holdingExists(ctx.view, dstAcct, vaultAsset)) + { + if (auto const ter = canAddHolding(ctx.view, vaultAsset); !isTesSuccess(ter)) + return ter; + } + // The checks above only establish that an account may hold the asset. A // private vault additionally restricts who may take part in it, so paying // its asset out to a third party requires both ends of that payout to be diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp index 038ef4067b..7cd31b7988 100644 --- a/src/test/app/lending/LoanPay_test.cpp +++ b/src/test/app/lending/LoanPay_test.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include namespace xrpl::test { @@ -1419,6 +1420,69 @@ private: BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate); } + // LoanPay does not call canAddHolding. addEmptyHolding recreates the + // broker-owner holding when the borrower is also the broker owner. After + // fixCleanup3_4_0 an existing line is a no-op even if DefaultRipple is + // off; pre-fix that path dies with tecINTERNAL. + void + testLoanPaySelfBrokerExistingLineDefaultRipple() + { + using namespace jtx; + using namespace loan; + + auto run = [this](FeatureBitset features, TER expected) { + testcase( + std::string( + "LoanPay broker-owner borrower existing line after " + "issuer clears asfDefaultRipple (") + + (features[fixCleanup3_4_0] ? "post" : "pre") + "-fixCleanup3_4_0)"); + + Env env(*this, features); + Account const issuer{"issuer"}; + Account const alice{"alice"}; + + env.fund(XRP(10'000), issuer, alice); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const usd{issuer["USD"]}; + env(trust(alice, usd(10'000'000))); + env.close(); + env(pay(issuer, alice, usd(2'000'000))); + env.close(); + + auto const broker = createVaultAndBroker(env, usd, alice); + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const loanKeylet = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))); + + Number const serviceFee = usd(2).value(); + env(set(alice, broker.brokerID, usd(1'000).value()), + Sig(sfCounterpartySignature, alice), + kLoanServiceFee(serviceFee), + Fee(env.current()->fees().base * 2)); + env.close(); + + env(fclear(issuer, asfDefaultRipple)); + env.close(); + BEAST_EXPECT(env.le(keylet::trustLine(alice.id(), usd.raw().get()))); + + auto const state = getCurrentState(env, broker, loanKeylet); + STAmount const payment{ + usd, + roundPeriodicPayment(usd, state.periodicPayment + serviceFee, state.loanScale)}; + + env(pay(alice, loanKeylet.key, payment), Ter(expected)); + env.close(); + }; + + run(all_ - fixCleanup3_4_0, tecINTERNAL); + run(all_, tesSUCCESS); + } + void runAmendmentIndependent() { @@ -1429,6 +1493,7 @@ private: testLoanPayCatchUpFeeAtExactDueDatePostAmendment(); testLoanPayCatchUpFeeAtExactDueDatePreAmendment(); testRepayIntoUnauthorizedVault(); + testLoanPaySelfBrokerExistingLineDefaultRipple(); } // Tests run under each entry in amendmentCombinations(). diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 9829f23138..5eea6f83fe 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include @@ -764,6 +765,69 @@ private: }); } + // LoanSet used to call canAddHolding unconditionally, so an existing + // borrower line still failed with terNO_RIPPLE after the issuer cleared + // DefaultRipple. After fixCleanup3_4_0, skip that gate when the holding + // already exists. + void + testLoanSetExistingLineAfterIssuerClearsDefaultRipple() + { + using namespace jtx; + using namespace loan; + + auto run = [this](FeatureBitset features, TER expected) { + testcase( + std::string( + "LoanSet existing borrower line after issuer " + "clears asfDefaultRipple (") + + (features[fixCleanup3_4_0] ? "post" : "pre") + "-fixCleanup3_4_0)"); + + Env env(*this, features); + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(10'000), issuer, lender, borrower); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const usd{issuer["USD"]}; + env(trust(lender, usd(10'000'000))); + env(trust(borrower, usd(10'000'000))); + env.close(); + env(pay(issuer, lender, usd(2'000'000))); + env(pay(issuer, borrower, usd(1'000))); + env.close(); + BEAST_EXPECT(env.le(keylet::trustLine(borrower.id(), usd.raw().get()))); + + auto const broker = createVaultAndBroker(env, usd, lender); + + env(fclear(issuer, asfDefaultRipple)); + env.close(); + + Number const destBefore = env.balance(borrower, usd.raw()).number(); + env(set(borrower, broker.brokerID, usd(100).value()), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(expected)); + env.close(); + + Number const destAfter = env.balance(borrower, usd.raw()).number(); + if (isTesSuccess(expected)) + { + BEAST_EXPECT(destAfter == destBefore + Number{100}); + } + else + { + BEAST_EXPECT(destAfter == destBefore); + } + }; + + run(all_ - fixCleanup3_4_0, terNO_RIPPLE); + run(all_, tesSUCCESS); + } + public: void run() override @@ -773,6 +837,7 @@ public: testLoanSet(features); testLoanSetClosedEnded(); + testLoanSetExistingLineAfterIssuerClearsDefaultRipple(); } }; diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index dc75371f3e..5b6e756e59 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include +#include #include #include #include @@ -1675,6 +1677,357 @@ private: } } + // addEmptyHolding() used to check isGlobalFrozen(issuer) and + // !lsfDefaultRipple before the "line already exists" tecDUPLICATE + // short circuit. doWithdraw() calls addEmptyHolding() for a + // self-destination payout and only tolerates tecDUPLICATE, so + // tecINTERNAL from a missing DefaultRipple flag aborted the + // withdrawal. fixCleanup3_4_0 checks existence first and maps the + // create-path DefaultRipple miss to terNO_RIPPLE. Global freeze on an + // existing line is still rejected later by checkWithdrawFreeze. + void + testBugSelfWithdrawAfterIssuerClearsDefaultRipple() + { + using namespace test::jtx; + + auto runExistingLine = [this]( + FeatureBitset features, + TER selfExpected, + bool issuerGlobalFreeze = false) { + Env env(*this, features); + Account const issuer{"issuer"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + + env.fund(XRP(10'000), issuer, alice, bob); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const usd{issuer["USD"]}; + Issue const usdIssue = usd.raw().get(); + env(trust(alice, usd(10'000))); + env(trust(bob, usd(10'000))); + env.close(); + env(pay(issuer, alice, usd(1'000))); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd}); + env(vaultTx); + env.close(); + + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(500)})); + env.close(); + + env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(50)})); + env.close(); + + env(fclear(issuer, asfDefaultRipple)); + env.close(); + if (issuerGlobalFreeze) + { + env(fset(issuer, asfGlobalFreeze)); + env.close(); + } + + BEAST_EXPECT(env.le(keylet::trustLine(alice.id(), usdIssue))); + + // Alice's USD line is unchanged; a later deposit still succeeds + // unless the issuer is globally frozen. + if (!issuerGlobalFreeze) + { + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(10)})); + env.close(); + } + + Number const destBefore = env.balance(alice, usd.raw()).number(); + Number const vaultBefore = env.le(vaultKeylet)->at(sfAssetsTotal); + Number const withdrawAmt{50}; + + env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(50)}), + Ter(selfExpected)); + env.close(); + + Number const destAfter = env.balance(alice, usd.raw()).number(); + Number const vaultAfter = env.le(vaultKeylet)->at(sfAssetsTotal); + if (isTesSuccess(selfExpected)) + { + BEAST_EXPECT(destAfter == destBefore + withdrawAmt); + BEAST_EXPECT(vaultAfter == vaultBefore - withdrawAmt); + } + else + { + BEAST_EXPECT(destAfter == destBefore); + BEAST_EXPECT(vaultAfter == vaultBefore); + } + + if (!issuerGlobalFreeze) + { + auto destTx = + vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(50)}); + destTx[sfDestination] = bob.human(); + env(destTx); + env.close(); + } + }; + + auto runDeletedLine = [this](FeatureBitset features, TER selfExpected) { + Env env(*this, features); + Account const issuer{"issuer"}; + Account const alice{"alice"}; + + env.fund(XRP(10'000), issuer, alice); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const usd{issuer["USD"]}; + Issue const usdIssue = usd.raw().get(); + env(trust(alice, usd(10'000))); + env.close(); + env(pay(issuer, alice, usd(500))); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd}); + env(vaultTx); + env.close(); + + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(500)})); + env.close(); + + env(trust(alice, usd(0))); + env.close(); + BEAST_EXPECT(!env.le(keylet::trustLine(alice.id(), usdIssue))); + env(fclear(issuer, asfDefaultRipple)); + env.close(); + + env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(50)}), + Ter(selfExpected)); + env.close(); + }; + + auto runCoverWithdraw = [this](FeatureBitset features, TER selfExpected) { + using namespace loan_broker; + + Env env(*this, features); + Account const issuer{"issuer"}; + Account const alice{"alice"}; + + env.fund(XRP(10'000), issuer, alice); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const usd{issuer["USD"]}; + Issue const usdIssue = usd.raw().get(); + env(trust(alice, usd(10'000))); + env.close(); + env(pay(issuer, alice, usd(1'000))); + env.close(); + + Vault const vault{env}; + auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded( + {.owner = alice, .asset = usd, .subscriptionOffset = std::chrono::seconds{60}}); + (void)subscriptionDate; + env(createTx); + env.close(); + + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(500)})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); + env(set(alice, vaultKeylet.key)); + env.close(); + env(coverDeposit(alice, brokerKeylet.key, usd(100).value())); + env.close(); + + env(fclear(issuer, asfDefaultRipple)); + env.close(); + BEAST_EXPECT(env.le(keylet::trustLine(alice.id(), usdIssue))); + + Number const destBefore = env.balance(alice, usd.raw()).number(); + Number const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable); + Number const withdrawAmt{50}; + + env(coverWithdraw(alice, brokerKeylet.key, usd(50).value()), Ter(selfExpected)); + env.close(); + + Number const destAfter = env.balance(alice, usd.raw()).number(); + Number const coverAfter = env.le(brokerKeylet)->at(sfCoverAvailable); + if (isTesSuccess(selfExpected)) + { + BEAST_EXPECT(destAfter == destBefore + withdrawAmt); + BEAST_EXPECT(coverAfter == coverBefore - withdrawAmt); + } + else + { + BEAST_EXPECT(destAfter == destBefore); + BEAST_EXPECT(coverAfter == coverBefore); + } + }; + + auto runDeletedCoverWithdraw = [this](FeatureBitset features, TER selfExpected) { + using namespace loan_broker; + + Env env(*this, features); + Account const issuer{"issuer"}; + Account const alice{"alice"}; + + env.fund(XRP(10'000), issuer, alice); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const usd{issuer["USD"]}; + Issue const usdIssue = usd.raw().get(); + env(trust(alice, usd(10'000))); + env.close(); + env(pay(issuer, alice, usd(600))); + env.close(); + + Vault const vault{env}; + auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded( + {.owner = alice, .asset = usd, .subscriptionOffset = std::chrono::seconds{60}}); + (void)subscriptionDate; + env(createTx); + env.close(); + + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(500)})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); + env(set(alice, vaultKeylet.key)); + env.close(); + env(coverDeposit(alice, brokerKeylet.key, usd(100).value())); + env.close(); + + env(trust(alice, usd(0))); + env.close(); + BEAST_EXPECT(!env.le(keylet::trustLine(alice.id(), usdIssue))); + env(fclear(issuer, asfDefaultRipple)); + env.close(); + + env(coverWithdraw(alice, brokerKeylet.key, usd(50).value()), Ter(selfExpected)); + env.close(); + }; + + auto runPrivateVault = [this](FeatureBitset features, TER selfExpected) { + Env env(*this, features); + Account const issuer{"issuer"}; + Account const alice{"alice"}; + Account const pdOwner{"pdOwner"}; + Account const credIssuer{"credIssuer"}; + std::string const credType = "credential"; + + env.fund(XRP(10'000), issuer, alice, pdOwner, credIssuer); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const usd{issuer["USD"]}; + env(trust(alice, usd(10'000))); + env.close(); + env(pay(issuer, alice, usd(1'000))); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = + vault.create({.owner = alice, .asset = usd, .flags = tfVaultPrivate}); + env(vaultTx); + env.close(); + + pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}}; + env(pdomain::setTx(pdOwner, credentials)); + auto const domainId = pdomain::getNewDomain(env.meta()); + { + auto domainTx = vault.set({.owner = alice, .id = vaultKeylet.key}); + domainTx[sfDomainID] = to_string(domainId); + env(domainTx); + env.close(); + } + + env(credentials::create(alice, credIssuer, credType)); + env(credentials::accept(alice, credIssuer, credType)); + env.close(); + + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(500)})); + env.close(); + + env(fclear(issuer, asfDefaultRipple)); + env.close(); + + env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(50)}), + Ter(selfExpected)); + env.close(); + }; + + testcase( + "bug: VaultWithdraw to self fails with tecINTERNAL after issuer " + "clears asfDefaultRipple even though the trust line exists " + "(pre-fixCleanup3_4_0)"); + runExistingLine(all_ - fixCleanup3_4_0, tecINTERNAL); + + testcase( + "bug: VaultWithdraw to self succeeds after issuer clears " + "asfDefaultRipple when the trust line exists (post-fixCleanup3_4_0)"); + runExistingLine(all_, tesSUCCESS); + + testcase( + "bug: VaultWithdraw to self with an existing line still gets " + "tecFROZEN under asfGlobalFreeze (post-fixCleanup3_4_0)"); + runExistingLine(all_, tecFROZEN, true); + + testcase( + "bug: VaultWithdraw to self fails with tecINTERNAL after issuer " + "clears asfDefaultRipple and the trust line was deleted " + "(pre-fixCleanup3_4_0)"); + runDeletedLine(all_ - fixCleanup3_4_0, tecINTERNAL); + + testcase( + "bug: VaultWithdraw to self fails with terNO_RIPPLE after issuer " + "clears asfDefaultRipple and the trust line was deleted " + "(post-fixCleanup3_4_0)"); + runDeletedLine(all_, terNO_RIPPLE); + + testcase( + "bug: LoanBrokerCoverWithdraw to self fails with tecINTERNAL after " + "issuer clears asfDefaultRipple even though the trust line exists " + "(pre-fixCleanup3_4_0)"); + runCoverWithdraw(all_ - fixCleanup3_4_0, tecINTERNAL); + + testcase( + "bug: LoanBrokerCoverWithdraw to self succeeds after issuer clears " + "asfDefaultRipple when the trust line exists (post-fixCleanup3_4_0)"); + runCoverWithdraw(all_, tesSUCCESS); + + testcase( + "bug: LoanBrokerCoverWithdraw to self fails with tecINTERNAL after " + "issuer clears asfDefaultRipple and the trust line was deleted " + "(pre-fixCleanup3_4_0)"); + runDeletedCoverWithdraw(all_ - fixCleanup3_4_0, tecINTERNAL); + + testcase( + "bug: LoanBrokerCoverWithdraw to self fails with terNO_RIPPLE after " + "issuer clears asfDefaultRipple and the trust line was deleted " + "(post-fixCleanup3_4_0)"); + runDeletedCoverWithdraw(all_, terNO_RIPPLE); + + testcase( + "bug: private VaultWithdraw to self fails with tecINTERNAL after " + "issuer clears asfDefaultRipple even though the trust line exists " + "(pre-fixCleanup3_4_0)"); + runPrivateVault(all_ - fixCleanup3_4_0, tecINTERNAL); + + testcase( + "bug: private VaultWithdraw to self succeeds after issuer clears " + "asfDefaultRipple when the trust line exists (post-fixCleanup3_4_0)"); + runPrivateVault(all_, tesSUCCESS); + } + // Bug 1: a sponsored XRP VaultWithdraw to a distinct destination is // rejected because the vault invariant treats the holder's touched // but economically unchanged AccountRoot as a second payout @@ -2030,6 +2383,7 @@ public: testBugClawbackRoundTripOvershoot(); testBugWithdrawRoundTripOvershoot(); testBugClawbackAfterLoanImpair(); + testBugSelfWithdrawAfterIssuerClearsDefaultRipple(); testBugSponsoredWithdrawZeroDeltaMisclassifiedAsSecondRecipient(); testBugSponsorAsDestinationFeeMisappliedToPayout(); testPrefundedFeeWithdraw(); From 346ea40f69f9bc316c0aae4ba8b3f20cb3f9f7cc Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:45:52 +0000 Subject: [PATCH 22/40] fix: Allow zero-value MPT vault withdraw when the asset holding is missing (#8153) --- src/libxrpl/ledger/View.cpp | 15 +- src/libxrpl/tx/invariants/VaultInvariant.cpp | 4 +- src/test/app/vault/VaultBugs_test.cpp | 493 +++++++++++++++++++ 3 files changed, 505 insertions(+), 7 deletions(-) diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 0cd082ff47..75a49187b4 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -543,12 +543,19 @@ doWithdraw( { auto const dstSle = ctx.view.read(keylet::account(dstAcct)); - // Create trust line or MPToken for the receiving account + // Create a trust line or MPToken for a self-destination only when there + // is a payout to credit. Post-fixCleanup3_4_0, a zero-value withdraw + // (e.g. share redemption from a fully impaired vault) must not insert + // an empty holding: that records a one-sided zero delta and can also + // create+delete MPTokens in the same transaction. if (dstAcct == senderAcct) { - if (auto const ter = addEmptyHolding(ctx, senderAcct, priorBalance, amount.asset(), j); - !isTesSuccess(ter) && ter != tecDUPLICATE) - return ter; + if (amount > beast::kZero || !ctx.view.rules().enabled(fixCleanup3_4_0)) + { + if (auto const ter = addEmptyHolding(ctx, senderAcct, priorBalance, amount.asset(), j); + !isTesSuccess(ter) && ter != tecDUPLICATE) + return ter; + } } else { diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index ff3a20c8ff..69c3ce92e0 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -1129,9 +1129,7 @@ ValidVault::finalize( // only. If the receiver's trust line sits at a coarser scale, the inflow // may safely round down to zero. // - // XRP and MPT remain strict. Because they are integer-exact, a zero - // destination delta indicates a true accounting bug, not a rounding - // artifact. + // XRP and MPT remain strict for rounding artifacts. bool const tolerateZeroDelta = view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral(); auto const invalidBalanceChange = tolerateZeroDelta diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index 5b6e756e59..02949b8619 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -1677,6 +1678,495 @@ private: } } + // Bug: a fully impaired vault may pay zero assets for a share burn. + // Sending zero MPT is a no-op, so the vault pseudo-account's asset + // MPToken is never written and ValidVault, which only records deltas for + // created, modified or deleted entries, sees no vault delta at all. + // + // Pre-fixCleanup3_4_0 that alone makes the withdrawal impossible: + // zeroDeltaIsLegitimate is gated on the amendment, so the absent vault + // delta fails "withdrawal must change vault balance". Every pre-amendment + // arm below dies there, before any destination-side check runs. + // + // The destination side differs per arm, and only the vault-delta return + // hides that pre-amendment. With Alice's asset MPToken already present + // nothing touches it, so she has no delta either. With it missing, + // doWithdraw still called addEmptyHolding for a self-destination on a + // zero payout and created her MPToken at amount 0; a created MPToken is + // recorded even at zero, so she arrives with a present-and-zero delta, + // which for an integral MPT asset the destination check would reject if + // it were reached. + // + // ValidMPTIssuance is a separate checker and still runs. It only trips on + // the one arm that both creates and deletes an MPToken: Alice's last + // share with the asset MPToken missing, where addEmptyHolding creates the + // asset token while her share token is deleted (created + deleted > 1). + // Leftover shares with the token missing is create-only, and a last share + // with the token present is delete-only; neither exceeds one. Bob still + // owns shares throughout, so this is never the vault's final outstanding + // share. + // + // Post-fixCleanup3_4_0, doWithdraw skips addEmptyHolding on a zero + // payout and zeroDeltaIsLegitimate lets the vault-delta and + // missing-recipient-delta checks accept the transfer. A present + // destination delta of zero is still rejected. + void + testBugMptZeroWithdrawMissingHolding() + { + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + using namespace std::chrono_literals; + + auto runScenario = [this]( + FeatureBitset features, + bool removeAssetToken, + bool withdrawAllAliceShares, + TER expected) { + testcase( + std::string{"bug: MPT vault zero-value withdraw "} + + (removeAssetToken ? "without asset MPToken" : "with asset MPToken") + + (withdrawAllAliceShares ? ", Alice's last share" : ", Alice has leftover shares") + + (features[fixCleanup3_4_0] ? " (post-fixCleanup3_4_0)" : " (pre-fixCleanup3_4_0)")); + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const borrower{"borrower"}; + + env.fund(XRP(100'000), issuer, owner, alice, bob, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanTransfer}); + PrettyAsset const asset = mptt.issuanceID(); + mptt.authorize({.account = owner}); + mptt.authorize({.account = alice}); + mptt.authorize({.account = bob}); + mptt.authorize({.account = borrower}); + env.close(); + + env(pay(issuer, alice, asset(2))); + env(pay(issuer, bob, asset(8))); + env.close(); + + Vault const vault{env}; + auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded( + {.owner = owner, .asset = asset, .subscriptionOffset = 60s}); + env(createTx); + env.close(); + + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(2)})); + env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = asset(8)})); + env.close(); + + vault.closePastSubscription(subscriptionDate); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + auto const sleBroker = env.le(brokerKeylet); + if (!BEAST_EXPECT(sleBroker)) + return; + auto const loanKeylet = keylet::loan( + brokerKeylet.key, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence))); + + env(set(borrower, brokerKeylet.key, asset(10).value()), + kInterestRate(percentageToTenthBips(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const loanBefore = env.le(loanKeylet); + if (!BEAST_EXPECT(loanBefore)) + return; + std::uint32_t const dueDate = loanBefore->at(sfNextPaymentDueDate); + env.close(NetClock::time_point{NetClock::duration{dueDate}} + 1s); + + env(manage(owner, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultImpaired = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultImpaired)) + return; + BEAST_EXPECT(vaultImpaired->at(sfAssetsAvailable) == asset(0).value()); + BEAST_EXPECT(vaultImpaired->at(sfAssetsTotal) == vaultImpaired->at(sfLossUnrealized)); + Number const totalBefore = vaultImpaired->at(sfAssetsTotal); + Number const lossBefore = vaultImpaired->at(sfLossUnrealized); + + MPTID const shareId = vaultImpaired->at(sfShareMPTID); + auto const issuanceBefore = env.le(keylet::mptokenIssuance(shareId)); + if (!BEAST_EXPECT(issuanceBefore)) + return; + std::uint64_t const outstandingBefore = + issuanceBefore->getFieldU64(sfOutstandingAmount); + + auto const tokenAlice = env.le(keylet::mptoken(shareId, alice.id())); + if (!BEAST_EXPECT(tokenAlice)) + return; + std::uint64_t const sharesBefore = tokenAlice->getFieldU64(sfMPTAmount); + BEAST_EXPECT(sharesBefore == 2); + std::uint64_t const sharesToRedeem = withdrawAllAliceShares ? sharesBefore : 1; + STAmount const redeemShares{MPTIssue{shareId}, Number(sharesToRedeem)}; + + auto const assetTokenKeylet = keylet::mptoken(mptt.issuanceID(), alice.id()); + if (removeAssetToken) + { + mptt.authorize({.account = alice, .flags = tfMPTUnauthorize}); + env.close(); + BEAST_EXPECT(!env.le(assetTokenKeylet)); + } + else + { + auto const existing = env.le(assetTokenKeylet); + if (!BEAST_EXPECT(existing)) + return; + BEAST_EXPECT(existing->getFieldU64(sfMPTAmount) == 0); + } + + std::uint32_t const redemptionDate = vaultImpaired->at(sfRedemptionDate); + env.close(NetClock::time_point{NetClock::duration{redemptionDate}} + 1s); + + env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = redeemShares}), + Ter(expected)); + env.close(); + if (expected != tesSUCCESS) + return; + + if (removeAssetToken) + { + BEAST_EXPECT(!env.le(assetTokenKeylet)); + } + else + { + auto const assetAfter = env.le(assetTokenKeylet); + if (!BEAST_EXPECT(assetAfter)) + return; + BEAST_EXPECT(assetAfter->getFieldU64(sfMPTAmount) == 0); + } + + auto const shareAfter = env.le(keylet::mptoken(shareId, alice.id())); + if (withdrawAllAliceShares) + { + BEAST_EXPECT(!shareAfter); + } + else if (BEAST_EXPECT(shareAfter)) + { + BEAST_EXPECT(shareAfter->getFieldU64(sfMPTAmount) == sharesBefore - sharesToRedeem); + } + + auto const vaultAfter = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultAfter)) + return; + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore); + BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore); + BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == asset(0).value()); + + auto const issuanceAfter = env.le(keylet::mptokenIssuance(shareId)); + if (!BEAST_EXPECT(issuanceAfter)) + return; + BEAST_EXPECT( + issuanceAfter->getFieldU64(sfOutstandingAmount) == + outstandingBefore - sharesToRedeem); + }; + + runScenario( + all_, false /* removeAssetToken */, false /* withdrawAllAliceShares */, tesSUCCESS); + runScenario( + all_, false /* removeAssetToken */, true /* withdrawAllAliceShares */, tesSUCCESS); + runScenario( + all_, true /* removeAssetToken */, false /* withdrawAllAliceShares */, tesSUCCESS); + runScenario( + all_, true /* removeAssetToken */, true /* withdrawAllAliceShares */, tesSUCCESS); + runScenario( + all_ - fixCleanup3_4_0, + false /* removeAssetToken */, + false /* withdrawAllAliceShares */, + tecINVARIANT_FAILED); + runScenario( + all_ - fixCleanup3_4_0, + false /* removeAssetToken */, + true /* withdrawAllAliceShares */, + tecINVARIANT_FAILED); + runScenario( + all_ - fixCleanup3_4_0, + true /* removeAssetToken */, + false /* withdrawAllAliceShares */, + tecINVARIANT_FAILED); + runScenario( + all_ - fixCleanup3_4_0, + true /* removeAssetToken */, + true /* withdrawAllAliceShares */, + tecINVARIANT_FAILED); + } + + // IOU analogue of the missing-MPToken case above. Alice removes her + // zero-balance trust line after depositing, then burns one unit from her + // scaled share balance after the vault is fully impaired. Bob's share + // balance keeps this out of the sole-shareholder loss-waiver and + // final-outstanding-share paths. A zero payout must not recreate Alice's + // unsolicited trust line. + void + testBugIouZeroWithdrawMissingTrustLine() + { + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + using namespace std::chrono_literals; + + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const borrower{"borrower"}; + + env.fund(XRP(100'000), issuer, owner, alice, bob, borrower); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const asset = issuer["USD"]; + env.trust(asset(100), owner); + env.trust(asset(100), alice); + env.trust(asset(100), bob); + env.trust(asset(100), borrower); + env.close(); + + env(pay(issuer, alice, asset(2))); + env(pay(issuer, bob, asset(8))); + env.close(); + + Vault const vault{env}; + auto const [createTx, vaultKeylet, subscriptionDate] = + vault.createClosedEnded({.owner = owner, .asset = asset, .subscriptionOffset = 60s}); + env(createTx); + env.close(); + + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(2)})); + env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = asset(8)})); + env.close(); + + auto const assetLine = keylet::trustLine(alice, asset.raw().get()); + if (!BEAST_EXPECT(env.le(assetLine))) + return; + env.trust(asset(0), alice); + env.close(); + BEAST_EXPECT(!env.le(assetLine)); + + vault.closePastSubscription(subscriptionDate); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + auto const sleBroker = env.le(brokerKeylet); + if (!BEAST_EXPECT(sleBroker)) + return; + auto const loanKeylet = + keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence))); + + env(set(borrower, brokerKeylet.key, asset(10).value()), + kInterestRate(percentageToTenthBips(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const loanBefore = env.le(loanKeylet); + if (!BEAST_EXPECT(loanBefore)) + return; + std::uint32_t const dueDate = loanBefore->at(sfNextPaymentDueDate); + env.close(NetClock::time_point{NetClock::duration{dueDate}} + 1s); + + env(manage(owner, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultImpaired = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultImpaired)) + return; + BEAST_EXPECT(vaultImpaired->at(sfAssetsAvailable) == asset(0).value()); + BEAST_EXPECT(vaultImpaired->at(sfAssetsTotal) == vaultImpaired->at(sfLossUnrealized)); + Number const totalBefore = vaultImpaired->at(sfAssetsTotal); + Number const lossBefore = vaultImpaired->at(sfLossUnrealized); + + MPTID const shareId = vaultImpaired->at(sfShareMPTID); + auto const tokenAlice = env.le(keylet::mptoken(shareId, alice.id())); + if (!BEAST_EXPECT(tokenAlice)) + return; + std::uint64_t const sharesBefore = tokenAlice->getFieldU64(sfMPTAmount); + // Default IOU vault scale is 6, so 2 USD mints 2e6 shares. Redeem one + // leftover share; do not require 1:1 like the MPT case. + BEAST_EXPECT(sharesBefore > 1); + STAmount const redeemShares{MPTIssue{shareId}, Number(1)}; + + std::uint32_t const redemptionDate = vaultImpaired->at(sfRedemptionDate); + env.close(NetClock::time_point{NetClock::duration{redemptionDate}} + 1s); + + env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = redeemShares}), + Ter(tesSUCCESS)); + env.close(); + + // A regression in the View guard would recreate this line even though + // no asset value was paid. + BEAST_EXPECT(!env.le(assetLine)); + + auto const shareAfter = env.le(keylet::mptoken(shareId, alice.id())); + if (!BEAST_EXPECT(shareAfter)) + return; + BEAST_EXPECT(shareAfter->getFieldU64(sfMPTAmount) == sharesBefore - 1); + + auto const vaultAfter = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultAfter)) + return; + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore); + BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore); + BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == asset(0).value()); + } + + // Same zero-payout withdrawal as testBugMptZeroWithdrawMissingHolding, but + // the vault asset is XRP. addEmptyHolding is a no-op for native assets. + // Sequence processing still touches the sender AccountRoot; a sponsored + // fee leaves that XRP balance economically unchanged. After the + // sponsored-withdraw fee-payer fix, deltaAssetsForParty collapses that + // economically-zero XRP delta to absence, so tesSUCCESS takes the + // missing-recipient-delta arm gated by zeroDeltaIsLegitimate. This test + // covers that live SUCCESS path. Pre-fixCleanup3_4_0 still fails the + // invariant. + void + testBugXrpZeroWithdrawSponsoredFee() + { + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + using namespace std::chrono_literals; + + auto runScenario = [this](FeatureBitset features, TER expected) { + testcase( + std::string{"bug: XRP vault zero-value withdraw with sponsored fee"} + + (features[fixCleanup3_4_0] ? " (post-fixCleanup3_4_0)" : " (pre-fixCleanup3_4_0)")); + + Env env(*this, features); + + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const borrower{"borrower"}; + Account const sponsor{"sponsor"}; + + env.fund(XRP(100'000), owner, alice, bob, borrower, sponsor); + env.close(); + + PrettyAsset const asset{xrpIssue()}; + Vault const vault{env}; + auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded( + {.owner = owner, .asset = asset, .subscriptionOffset = 60s}); + env(createTx); + env.close(); + + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(2)})); + env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = asset(8)})); + env.close(); + + vault.closePastSubscription(subscriptionDate); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + auto const sleBroker = env.le(brokerKeylet); + if (!BEAST_EXPECT(sleBroker)) + return; + auto const loanKeylet = keylet::loan( + brokerKeylet.key, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence))); + + env(set(borrower, brokerKeylet.key, asset(10).value()), + kInterestRate(percentageToTenthBips(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const loanBefore = env.le(loanKeylet); + if (!BEAST_EXPECT(loanBefore)) + return; + std::uint32_t const dueDate = loanBefore->at(sfNextPaymentDueDate); + env.close(NetClock::time_point{NetClock::duration{dueDate}} + 1s); + + env(manage(owner, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultImpaired = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultImpaired)) + return; + BEAST_EXPECT(vaultImpaired->at(sfAssetsAvailable) == asset(0).value()); + BEAST_EXPECT(vaultImpaired->at(sfAssetsTotal) == vaultImpaired->at(sfLossUnrealized)); + Number const totalBefore = vaultImpaired->at(sfAssetsTotal); + Number const lossBefore = vaultImpaired->at(sfLossUnrealized); + + MPTID const shareId = vaultImpaired->at(sfShareMPTID); + auto const tokenAlice = env.le(keylet::mptoken(shareId, alice.id())); + if (!BEAST_EXPECT(tokenAlice)) + return; + std::uint64_t const sharesBefore = tokenAlice->getFieldU64(sfMPTAmount); + BEAST_EXPECT(sharesBefore == 2); + STAmount const redeemShares{MPTIssue{shareId}, Number(1)}; + + std::uint32_t const redemptionDate = vaultImpaired->at(sfRedemptionDate); + env.close(NetClock::time_point{NetClock::duration{redemptionDate}} + 1s); + + auto const aliceBalanceBefore = env.balance(alice); + auto const sponsorBalanceBefore = env.balance(sponsor); + auto const fee = env.current()->fees().base; + + env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = redeemShares}), + Fee(fee), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Ter(expected)); + env.close(); + + BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore - fee); + BEAST_EXPECT(env.balance(alice) == aliceBalanceBefore); + + if (expected != tesSUCCESS) + return; + + auto const shareAfter = env.le(keylet::mptoken(shareId, alice.id())); + if (!BEAST_EXPECT(shareAfter)) + return; + BEAST_EXPECT(shareAfter->getFieldU64(sfMPTAmount) == sharesBefore - 1); + + auto const vaultAfter = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultAfter)) + return; + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore); + BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore); + BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == asset(0).value()); + }; + + runScenario(all_, tesSUCCESS); + runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED); + } + // addEmptyHolding() used to check isGlobalFrozen(issuer) and // !lsfDefaultRipple before the "line already exists" tecDUPLICATE // short circuit. doWithdraw() calls addEmptyHolding() for a @@ -2383,6 +2873,9 @@ public: testBugClawbackRoundTripOvershoot(); testBugWithdrawRoundTripOvershoot(); testBugClawbackAfterLoanImpair(); + testBugMptZeroWithdrawMissingHolding(); + testBugIouZeroWithdrawMissingTrustLine(); + testBugXrpZeroWithdrawSponsoredFee(); testBugSelfWithdrawAfterIssuerClearsDefaultRipple(); testBugSponsoredWithdrawZeroDeltaMisclassifiedAsSecondRecipient(); testBugSponsorAsDestinationFeeMisappliedToPayout(); From 5d8fd9824e98202ca8504aea9e45f2bafe0419cd Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:00:50 +0000 Subject: [PATCH 23/40] fix: Revert credential cleanup for pseudo-accounts (#8161) --- .cspell.config.yaml | 1 - .../xrpl/ledger/helpers/CredentialHelpers.h | 27 ---- include/xrpl/protocol/Protocol.h | 10 -- src/libxrpl/ledger/helpers/AMMHelpers.cpp | 14 -- .../ledger/helpers/CredentialHelpers.cpp | 32 ----- src/libxrpl/tx/Transactor.cpp | 14 +- src/libxrpl/tx/invariants/MPTInvariant.cpp | 8 -- .../transactors/lending/LoanBrokerDelete.cpp | 15 --- .../tx/transactors/vault/VaultDelete.cpp | 14 -- src/test/app/AMM_test.cpp | 47 ------- src/test/app/lending/LoanBroker_test.cpp | 122 ------------------ src/test/app/vault/VaultBugs_test.cpp | 113 ---------------- 12 files changed, 4 insertions(+), 413 deletions(-) diff --git a/.cspell.config.yaml b/.cspell.config.yaml index 8929973e8a..c1af739255 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -366,7 +366,6 @@ words: - venv - vfalco - vinnie - - vkeylet - wasmi - wextra - wptr diff --git a/include/xrpl/ledger/helpers/CredentialHelpers.h b/include/xrpl/ledger/helpers/CredentialHelpers.h index 6d235b4316..8b1c819bf4 100644 --- a/include/xrpl/ledger/helpers/CredentialHelpers.h +++ b/include/xrpl/ledger/helpers/CredentialHelpers.h @@ -14,7 +14,6 @@ #include #include -#include #include #include #include @@ -34,32 +33,6 @@ checkExpired(SLE const& sleCredential, NetClock::time_point const& closed); [[nodiscard]] TER deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j); -/** - * @brief Remove credentials pinned to a pseudo-account's owner directory. - * - * Cleans up credentials that were linked to a pseudo-account (Vault, LoanBroker, - * AMM), which such an account can neither accept nor delete. Only credentials - * are removed; every other object is left in place. The walk visits at most - * @p maxNodesToDelete directory entries and charges the ones it leaves alone - * against that budget too, so a directory holding other objects yields fewer - * than @p maxNodesToDelete deletions. On reaching the bound the result is - * `tecINCOMPLETE` and the caller must propagate it so a later transaction - * resumes. - * - * @param view Mutable ledger view. - * @param pseudoAcct The pseudo-account whose directory is cleaned. - * @param maxNodesToDelete Upper bound on directory entries processed in one call. - * @param j Journal for diagnostics. - * @return tesSUCCESS once no credentials remain, tecINCOMPLETE if the bound was - * reached, or a deletion error. - */ -[[nodiscard]] TER -deletePseudoAccountCredentials( - ApplyView& view, - AccountID const& pseudoAcct, - std::uint16_t maxNodesToDelete, - beast::Journal j); - // Amendment and parameters checks for sfCredentialIDs field NotTEC checkFields(STTx const& tx, Rules const& rules, beast::Journal j); diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 8edd4bf4fd..ec9b9ba70a 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -407,16 +407,6 @@ using TxID = uint256; */ constexpr std::uint16_t kMaxDeletableAmmTrustLines = 512; -/** - * The maximum number of owner-directory entries to walk when clearing - * credentials pinned to a pseudo-account, in a single transaction. - * - * The walk stops after this many entries whether or not each one turns out to - * be a credential, so a directory that also holds other objects yields fewer - * deletions per transaction. - */ -constexpr std::uint16_t kMaxDeletablePseudoAccountCredentials = 512; - /** * The maximum length of a URI inside an Oracle */ diff --git a/src/libxrpl/ledger/helpers/AMMHelpers.cpp b/src/libxrpl/ledger/helpers/AMMHelpers.cpp index 20a793e4cb..fcad22d2d5 100644 --- a/src/libxrpl/ledger/helpers/AMMHelpers.cpp +++ b/src/libxrpl/ledger/helpers/AMMHelpers.cpp @@ -11,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -691,12 +690,6 @@ deleteAMMTrustLines( return {deleteAMMTrustLine(sb, sleItem, ammAccountID, j), SkipEntry::No}; } - // A credential naming the pseudo-account as subject can't be - // accepted or deleted by it and would otherwise permanently pin the - // AMM. Clean it up here, inside the same bounded walk, so the - // pinned AMM can still be deleted. - if (sb.rules().enabled(fixCleanup3_4_0) && nodeType == ltCREDENTIAL) - return {credentials::deleteSLE(sb, sleItem, j), SkipEntry::No}; // LCOV_EXCL_START JLOG(j.error()) << "deleteAMMObjects: deleting non-trustline or non-MPT " << nodeType; return {tecINTERNAL, SkipEntry::No}; @@ -774,8 +767,6 @@ deleteAMMAccount(Sandbox& sb, Asset const& asset, Asset const& asset2, beast::Jo // LCOV_EXCL_STOP } - // deleteAMMTrustLines also removes any credentials pinned to the AMM - // pseudo-account, within its bounded walk. if (auto const ter = deleteAMMTrustLines(sb, ammAccountID, kMaxDeletableAmmTrustLines, j); !isTesSuccess(ter)) return ter; @@ -917,11 +908,6 @@ isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID c ++nMPT; continue; } - // A credential naming the pseudo-account as subject can be pinned - // to its owner directory. Ignore it here; deleteAMMTrustLines - // removes it when the AMM is deleted. - if (view.rules().enabled(fixCleanup3_4_0) && entryType == ltCREDENTIAL) - continue; if (entryType != ltRIPPLE_STATE) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE auto const lowLimit = sle->getFieldAmount(sfLowLimit); diff --git a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp index 9c3ca4ec78..5ba832957d 100644 --- a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp +++ b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp @@ -5,10 +5,8 @@ #include #include #include -#include #include #include -#include #include #include #include @@ -129,36 +127,6 @@ deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j) return tesSUCCESS; } -TER -deletePseudoAccountCredentials( - ApplyView& view, - AccountID const& pseudoAcct, - std::uint16_t maxNodesToDelete, - beast::Journal j) -{ - XRPL_ASSERT( - isPseudoAccount(view.read(keylet::account(pseudoAcct))), - "xrpl::credentials::deletePseudoAccountCredentials : is a pseudo-account"); - - // Delete the credentials linked into the pseudo-account's owner directory, - // visiting at most maxNodesToDelete entries. Any other object is left in - // place; the caller's own checks decide whether the remaining directory - // blocks deletion. If the bound is reached, cleanupOnAccountDelete returns - // tecINCOMPLETE and the caller propagates it so a later transaction resumes. - return cleanupOnAccountDelete( - view, - keylet::ownerDir(pseudoAcct), - [&view, &j](LedgerEntryType nodeType, uint256 const&, SLE::pointer& sleItem) - -> std::pair { - if (nodeType == ltCREDENTIAL) - return {deleteSLE(view, sleItem, j), SkipEntry::No}; - - return {tesSUCCESS, SkipEntry::Yes}; - }, - j, - maxNodesToDelete); -} - NotTEC checkFields(STTx const& tx, Rules const& rules, beast::Journal j) { diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp index 63092cc128..6bf99e567d 100644 --- a/src/libxrpl/tx/Transactor.cpp +++ b/src/libxrpl/tx/Transactor.cpp @@ -1246,7 +1246,7 @@ removeExpiredNFTokenOffers( } static void -removeDeletedCredentials(ApplyView& view, std::vector const& creds, beast::Journal viewJ) +removeExpiredCredentials(ApplyView& view, std::vector const& creds, beast::Journal viewJ) { for (auto const& index : creds) { @@ -1255,7 +1255,7 @@ removeDeletedCredentials(ApplyView& view, std::vector const& creds, bea if (auto const ter = credentials::deleteSLE(view, sle, viewJ); !isTesSuccess(ter)) { JLOG(viewJ.error()) - << "removeDeletedCredentials: failed to delete credential. Err: " + << "removeExpiredCredentials: failed to delete expired credential. Err: " << transToken(ter); } } @@ -1437,8 +1437,7 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee) // should be used, making it possible to do more useful work // when transactions fail with a `tec` code. - auto typesForResult = [credentialCleanup = - view().rules().enabled(fixCleanup3_4_0)](TER const ter) { + auto typesForResult = [](TER const ter) { std::unordered_set types; if ((ter == tecOVERSIZE) || (ter == tecKILLED)) { @@ -1447,11 +1446,6 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee) else if (ter == tecINCOMPLETE) { types.insert(ltRIPPLE_STATE); - // A bounded pseudo-account credential cleanup (VaultDelete / - // LoanBrokerDelete) persists its partial credential deletions so a - // later transaction can resume. - if (credentialCleanup) - types.insert(ltCREDENTIAL); } else if (ter == tecEXPIRED) { @@ -1529,7 +1523,7 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee) removeDeletedTrustLines(view(), ids, viewJ); break; case ltCREDENTIAL: - removeDeletedCredentials(view(), ids, viewJ); + removeExpiredCredentials(view(), ids, viewJ); break; // LCOV_EXCL_START default: diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 09b4308165..e38e8f2b93 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -234,14 +234,6 @@ ValidMPTIssuance::finalize( if (hasPrivilege(tx, Privilege::DestroyMptIssuance)) { - // A VaultDelete that is still cleaning up credentials pinned to its - // pseudo-account returns tecINCOMPLETE and has not yet reached the - // share issuance. Don't require the issuance to be removed until - // the deletion completes (a later transaction). - if (rules.enabled(fixCleanup3_4_0) && txnType == ttVAULT_DELETE && - result == tecINCOMPLETE) - return mptIssuancesDeleted_ == 0 && mptIssuancesCreated_ == 0; - if (mptIssuancesDeleted_ == 0) { JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion " diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp index 06907ce366..433d77806a 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp @@ -4,13 +4,11 @@ #include #include #include -#include #include #include #include #include #include -#include #include #include #include @@ -142,19 +140,6 @@ LoanBrokerDelete::doApply() auto const brokerPseudoID = broker->at(sfAccount); - // Remove any credentials pinned to the broker pseudo-account before anything - // else. They would otherwise keep its owner directory alive and block - // deletion with tecHAS_OBLIGATIONS. Doing it first means a bounded, - // tecINCOMPLETE cleanup can be resumed by a later transaction without having - // already torn down the broker. - if (view().rules().enabled(fixCleanup3_4_0)) - { - if (auto const ter = credentials::deletePseudoAccountCredentials( - view(), brokerPseudoID, kMaxDeletablePseudoAccountCredentials, j_); - !isTesSuccess(ter)) - return ter; - } - if (!view().dirRemove( keylet::ownerDir(accountID_), broker->at(sfOwnerNode), broker->key(), false)) { diff --git a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp index 35bf80c29f..9c6c41654b 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include #include @@ -102,19 +101,6 @@ VaultDelete::doApply() if (!vault) return tefINTERNAL; // LCOV_EXCL_LINE - // Remove any credentials pinned to the vault pseudo-account before anything - // else. They would otherwise keep its owner directory alive and block - // deletion with tecHAS_OBLIGATIONS. Doing it first means a bounded, - // tecINCOMPLETE cleanup can be resumed by a later transaction without having - // already torn down the vault. - if (view().rules().enabled(fixCleanup3_4_0)) - { - if (auto const ter = credentials::deletePseudoAccountCredentials( - view(), vault->at(sfAccount), kMaxDeletablePseudoAccountCredentials, j_); - !isTesSuccess(ter)) - return ter; - } - // Destroy the asset holding. auto asset = vault->at(sfAsset); diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index a1d5260606..0212035c6e 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include #include @@ -5193,51 +5192,6 @@ private: {features}); } - void - testCredentialPinsPseudoAccount() - { - testcase("Credential pins AMM pseudo-account"); - - using namespace jtx; - FeatureBitset const all{testableAmendments()}; - - // A credential issued to an AMM pseudo-account can't be accepted or - // deleted by it. A pin created before the cure activates stays pinned - // in the pseudo-account's owner directory and makes AMM deletion fail - // with tecINTERNAL (deleteAMMTrustLines rejects the unexpected - // directory entry). - Account const attacker{"attacker"}; - char const credType[] = "FN36"; - - Env env(*this, all - fixCleanup3_3_0 - fixCleanup3_4_0); - fund(env, gw_, {alice_}, XRP(20'000), {USD(10'000)}); - env.fund(XRP(1'000), attacker); - env.close(); - - AMM amm(env, alice_, XRP(10'000), USD(10'000)); - Account const ammAcct{"amm pseudo-account", amm.ammAccount()}; - env.memoize(ammAcct); - - env(credentials::create(ammAcct, attacker, credType)); - env.close(); - auto const credKey = credentials::keylet(ammAcct, attacker, credType); - BEAST_EXPECT(env.le(credKey)); - - // Emptying the AMM would auto-delete it, but the pinned credential makes - // deleteAMMAccount fail; the withdraw is rolled back and the AMM stays. - amm.withdrawAll(alice_, std::nullopt, Ter(tecINTERNAL)); - BEAST_EXPECT(amm.ammExists()); - - env.enableFeature(fixCleanup3_4_0); - env.close(); - - // The pre-existing pin is cleaned up and the AMM deletes. - amm.withdrawAll(alice_); - BEAST_EXPECT(!amm.ammExists()); - BEAST_EXPECT(!env.le(credKey)); - BEAST_EXPECT(!env.le(keylet::ownerDir(amm.ammAccount()))); - } - void testAutoDelete() { @@ -7505,7 +7459,6 @@ private: FeatureBitset const all{testableAmendments()}; testInvalidInstance(); testInstanceCreate(); - testCredentialPinsPseudoAccount(); for (auto const& f : amendmentCombinations({fixCleanup3_3_0, featureAMMClawback})) testInvalidDeposit(f); testDeposit(); diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp index 5b3ea854f8..3bcda42c7e 100644 --- a/src/test/app/lending/LoanBroker_test.cpp +++ b/src/test/app/lending/LoanBroker_test.cpp @@ -2968,126 +2968,6 @@ class LoanBroker_test : public beast::unit_test::Suite runTestCases(all_ - fixCleanup3_2_0); } - void - testCredentialPinsPseudoAccount() - { - using namespace test::jtx; - using namespace loan_broker; - - // A credential issued to a LoanBroker pseudo-account can't be accepted - // or deleted by it, so it stays pinned in the pseudo-account's owner - // directory and blocks LoanBrokerDelete with tecHAS_OBLIGATIONS. A pin - // created before the cure activates is removed by LoanBrokerDelete once - // it does. - Account const alice{"alice"}; // vault & broker owner - Account const attacker{"attacker"}; - char const credType[] = "FN36"; - - Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0}; - env.fund(XRP(1'000'000), alice, attacker); - env.close(); - - Vault const vault{env}; - auto [vtx, vkeylet] = vault.create({.owner = alice, .asset = xrpIssue()}); - env(vtx); - env.close(); - BEAST_EXPECT(env.le(vkeylet)); - - auto const brokerKeylet = - keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); - env(set(alice.id(), vkeylet.key)); - env.close(); - - auto const broker = env.le(brokerKeylet); - BEAST_EXPECT(broker); - Account const pseudo{"broker pseudo-account", broker->at(sfAccount)}; - env.memoize(pseudo); - - testcase("Credential pins broker pseudo-account"); - env(credentials::create(pseudo, attacker, credType)); - env.close(); - - auto const credKey = credentials::keylet(pseudo, attacker, credType); - BEAST_EXPECT(env.le(credKey)); - BEAST_EXPECT(ownerCount(env, attacker) == 1); - - env(del(alice.id(), brokerKeylet.key), Ter(tecHAS_OBLIGATIONS)); - env.close(); - - env.enableFeature(fixCleanup3_4_0); - env.close(); - - // The pre-existing pin no longer blocks deletion; the credential is - // cleaned up and the issuer's owner count is restored. - testcase("LoanBrokerDelete removes pinned credential"); - env(del(alice.id(), brokerKeylet.key)); - env.close(); - - BEAST_EXPECT(!env.le(credKey)); - BEAST_EXPECT(!env.le(brokerKeylet)); - BEAST_EXPECT(!env.le(keylet::account(pseudo.id()))); - BEAST_EXPECT(ownerCount(env, attacker) == 0); - } - - void - testCredentialPinOverflow() - { - using namespace test::jtx; - using namespace loan_broker; - testcase("Credential pin cleanup is bounded (tecINCOMPLETE)"); - - // A pseudo-account can be pinned with more credentials than one - // transaction is allowed to clean up. LoanBrokerDelete then removes - // them a bounded batch at a time, returning tecINCOMPLETE until the - // last batch. - Account const alice{"alice"}; - Account const attacker{"attacker"}; - - Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0}; - env.fund(XRP(10'000'000), alice, attacker); - env.close(); - - Vault const vault{env}; - auto [vtx, vkeylet] = vault.create({.owner = alice, .asset = xrpIssue()}); - env(vtx); - env.close(); - BEAST_EXPECT(env.le(vkeylet)); - - auto const brokerKeylet = - keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); - env(set(alice.id(), vkeylet.key)); - env.close(); - - auto const broker = env.le(brokerKeylet); - BEAST_EXPECT(broker); - Account const pseudo{"broker pseudo-account", broker->at(sfAccount)}; - env.memoize(pseudo); - - // Pin more than one cleanup batch's worth of credentials. - std::uint16_t const count = kMaxDeletablePseudoAccountCredentials + 3; - for (std::uint16_t i = 0; i < count; ++i) - env(credentials::create(pseudo, attacker, std::to_string(i))); - env.close(); - BEAST_EXPECT(ownerCount(env, attacker) == count); - - env.enableFeature(fixCleanup3_4_0); - env.close(); - - // First delete removes one bounded batch and reports it isn't finished. - env(del(alice.id(), brokerKeylet.key), Ter(tecINCOMPLETE)); - env.close(); - BEAST_EXPECT(env.le(brokerKeylet)); // broker still exists - auto const remaining = ownerCount(env, attacker); - BEAST_EXPECT(remaining > 0 && remaining < count); - - // Second delete finishes the cleanup and removes the broker. - env(del(alice.id(), brokerKeylet.key)); - env.close(); - BEAST_EXPECT(!env.le(brokerKeylet)); - BEAST_EXPECT(!env.le(keylet::account(pseudo.id()))); - BEAST_EXPECT(ownerCount(env, attacker) == 0); - } - public: void run() override @@ -3106,8 +2986,6 @@ public: testDisabled(); testLifecycle(); - testCredentialPinsPseudoAccount(); - testCredentialPinOverflow(); testInvalidLoanBrokerDelete(); testInvalidLoanBrokerSet(); testRequireAuth(); diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index 02949b8619..cc30bd6091 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -1356,117 +1356,6 @@ private: } } - void - testCredentialPinsPseudoAccount() - { - using namespace test::jtx; - - // A credential issued to a vault pseudo-account can't be accepted or - // deleted by it (pseudo-accounts can't sign), so it stays pinned in the - // pseudo-account's owner directory and blocks VaultDelete with - // tecHAS_OBLIGATIONS. A pin created before the cure activates is removed - // by VaultDelete once it does. - Account const owner{"owner"}; - Account const attacker{"attacker"}; - char const credType[] = "FN36"; - - Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0}; - env.fund(XRP(1'000'000), owner, attacker); - env.close(); - - Vault const vault{env}; - PrettyAsset const asset = xrpIssue(); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - auto const vaultSle = env.le(keylet); - BEAST_EXPECT(vaultSle); - Account const pseudo{"vault pseudo-account", vaultSle->at(sfAccount)}; - env.memoize(pseudo); - - // The pseudo-account owns the share issuance; the pin must not change - // its owner count (an unaccepted credential is owned by the issuer). - auto const pseudoOwnerCount = ownerCount(env, pseudo); - - testcase("Credential pins vault pseudo-account"); - env(credentials::create(pseudo, attacker, credType)); - env.close(); - - auto const credKey = credentials::keylet(pseudo, attacker, credType); - BEAST_EXPECT(env.le(credKey)); - BEAST_EXPECT(ownerCount(env, attacker) == 1); - BEAST_EXPECT(ownerCount(env, pseudo) == pseudoOwnerCount); - - // The pin blocks deletion of an otherwise-empty vault. - env(vault.del({.owner = owner, .id = keylet.key}), Ter(tecHAS_OBLIGATIONS)); - env.close(); - - env.enableFeature(fixCleanup3_4_0); - env.close(); - - // The pre-existing pin no longer blocks deletion; the credential is - // cleaned up and the issuer's owner count is restored. - testcase("VaultDelete removes pinned credential"); - env(vault.del({.owner = owner, .id = keylet.key})); - env.close(); - - BEAST_EXPECT(!env.le(credKey)); - BEAST_EXPECT(!env.le(keylet)); - BEAST_EXPECT(!env.le(::xrpl::keylet::account(pseudo.id()))); - BEAST_EXPECT(ownerCount(env, attacker) == 0); - } - - void - testCredentialPinOverflow() - { - using namespace test::jtx; - testcase("Credential pin cleanup is bounded (tecINCOMPLETE)"); - - // A pseudo-account can be pinned with more credentials than one - // transaction is allowed to clean up. VaultDelete then removes them a - // bounded batch at a time, returning tecINCOMPLETE until the last batch. - Account const owner{"owner"}; - Account const attacker{"attacker"}; - - Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0}; - env.fund(XRP(10'000'000), owner, attacker); - env.close(); - - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()}); - env(tx); - env.close(); - auto const vaultSle = env.le(keylet); - BEAST_EXPECT(vaultSle); - Account const pseudo{"vault pseudo-account", vaultSle->at(sfAccount)}; - env.memoize(pseudo); - - // Pin more than one cleanup batch's worth of credentials. - std::uint16_t const count = kMaxDeletablePseudoAccountCredentials + 3; - for (std::uint16_t i = 0; i < count; ++i) - env(credentials::create(pseudo, attacker, std::to_string(i))); - env.close(); - BEAST_EXPECT(ownerCount(env, attacker) == count); - - env.enableFeature(fixCleanup3_4_0); - env.close(); - - // First delete removes one bounded batch and reports it isn't finished. - env(vault.del({.owner = owner, .id = keylet.key}), Ter(tecINCOMPLETE)); - env.close(); - BEAST_EXPECT(env.le(keylet)); // vault still exists - auto const remaining = ownerCount(env, attacker); - BEAST_EXPECT(remaining > 0 && remaining < count); - - // Second delete finishes the cleanup and removes the vault. - env(vault.del({.owner = owner, .id = keylet.key})); - env.close(); - BEAST_EXPECT(!env.le(keylet)); - BEAST_EXPECT(!env.le(::xrpl::keylet::account(pseudo.id()))); - BEAST_EXPECT(ownerCount(env, attacker) == 0); - } - struct ImpairedLoanVault { test::jtx::Account issuer; @@ -2867,8 +2756,6 @@ public: testBugVaultDepositOvercreditsAcrossScaleBoundary(); testBugVaultLockedByPartialWithdraw(); testVaultDepositNegativeBalanceFromOppositeLimit(); - testCredentialPinsPseudoAccount(); - testCredentialPinOverflow(); testBug6LimitBypassWithShares(); testBugClawbackRoundTripOvershoot(); testBugWithdrawRoundTripOvershoot(); From f0fd6ad85e89cfbda9676a8ce8e9b84817aaea11 Mon Sep 17 00:00:00 2001 From: Bart Date: Wed, 2 Sep 2026 18:06:48 +0000 Subject: [PATCH 24/40] fix: Clamp the depth used to index selectBranch's key byte (#7941) Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com> --- src/libxrpl/shamap/SHAMapNodeID.cpp | 63 ++++++- src/tests/libxrpl/shamap/SHAMapNodeID.cpp | 193 ++++++++++++++++++++++ 2 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 src/tests/libxrpl/shamap/SHAMapNodeID.cpp diff --git a/src/libxrpl/shamap/SHAMapNodeID.cpp b/src/libxrpl/shamap/SHAMapNodeID.cpp index 8fd7afe8fc..42b946b921 100644 --- a/src/libxrpl/shamap/SHAMapNodeID.cpp +++ b/src/libxrpl/shamap/SHAMapNodeID.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -40,11 +41,41 @@ depthMask(unsigned int depth) return kMasks.entry[depth]; } +// The prefix of `key` at `depth`: the leading nibbles naming the subtree a node at that depth +// identifies, with the remainder of the key masked off. +static uint256 +maskedToDepth(uint256 const& key, unsigned int depth) +{ + return key & depthMask(depth); +} + +// Whether `id` at `depth` is what `key` looks like once masked down to that depth, i.e. +// whether an ID with this depth and id names a subtree that `key` falls under. +static bool +isPrefixOfAtDepth(uint256 const& id, unsigned int depth, uint256 const& key) +{ + return maskedToDepth(key, depth) == id; +} + // canonicalize the hash to a node ID for this depth SHAMapNodeID::SHAMapNodeID(unsigned int depth, uint256 const& hash) : id_(hash), depth_(depth) { - XRPL_ASSERT( - depth <= SHAMap::kLeafDepth, "xrpl::SHAMapNodeID::SHAMapNodeID : maximum depth input"); + // Every SHAMapNodeID's depth is stored here, so this is the one place that can stop an + // out-of-range one from being kept: a depth past kLeafDepth would go on to index depthMask + // out of bounds, and getRawString would narrow it to a byte, silently renaming the node. + // Clamp rather than throw, since node IDs are built from peer-supplied depths on the ledger + // data path, where no caller catches an exception before it reaches a thread boundary. + if (depth_ > SHAMap::kLeafDepth) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::SHAMapNodeID::SHAMapNodeID : depth within tree"); + depth_ = SHAMap::kLeafDepth; + id_ = maskedToDepth(id_, depth_); + // LCOV_EXCL_STOP + } + + // Reads the clamped member rather than the depth argument, so it cannot index depthMask past + // its last entry even once the clamp above has reported the bad input and carried on. XRPL_ASSERT( isPrefixOf(id_), "xrpl::SHAMapNodeID::SHAMapNodeID : hash and depth inputs do match"); } @@ -89,7 +120,7 @@ SHAMapNodeID::getChildNodeID(unsigned int branch) const bool SHAMapNodeID::isPrefixOf(uint256 const& key) const { - return (key & depthMask(depth_)) == id_; + return isPrefixOfAtDepth(id_, depth_, key); } [[nodiscard]] std::optional @@ -102,9 +133,9 @@ deserializeSHAMapNodeID(void const* data, std::size_t size) unsigned int const depth = *(static_cast(data) + 32); if (depth <= SHAMap::kLeafDepth) { - auto const id = uint256::fromVoid(data); - - if (id == (id & depthMask(depth))) + // Reject a serialized ID carrying bits below its own depth. Checked before + // constructing, since the constructor asserts that same property. + if (auto const id = uint256::fromVoid(data); isPrefixOfAtDepth(id, depth, id)) ret.emplace(depth, id); } } @@ -115,7 +146,11 @@ deserializeSHAMapNodeID(void const* data, std::size_t size) [[nodiscard]] unsigned int selectBranch(SHAMapNodeID const& id, uint256 const& hash) { - auto const depth = id.getDepth(); + XRPL_ASSERT(id.getDepth() < SHAMap::kLeafDepth, "xrpl::selectBranch : depth below leaf depth"); + + // A depth-64 ID has no nibble left to select. Callers must not ask, but clamp anyway to keep + // the read below the end of the 32-byte key. + auto const depth = std::min(id.getDepth(), SHAMap::kLeafDepth - 1u); auto branch = static_cast(*(hash.begin() + (depth / 2))); if ((depth & 1) != 0u) @@ -134,8 +169,18 @@ selectBranch(SHAMapNodeID const& id, uint256 const& hash) SHAMapNodeID SHAMapNodeID::createID(unsigned int depth, uint256 const& key) { - XRPL_ASSERT(depth <= SHAMap::kLeafDepth, "xrpl::SHAMapNodeID::createID : valid depth"); - return SHAMapNodeID(depth, key & depthMask(depth)); + // The mask is chosen here, before the constructor runs, so the clamp there cannot cover this + // call: an out-of-range depth would index depthMask's table while still evaluating this + // argument. A public factory has to hold its own bound. + if (depth > SHAMap::kLeafDepth) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::SHAMapNodeID::createID : depth within tree"); + depth = SHAMap::kLeafDepth; + // LCOV_EXCL_STOP + } + + return SHAMapNodeID(depth, maskedToDepth(key, depth)); } } // namespace xrpl diff --git a/src/tests/libxrpl/shamap/SHAMapNodeID.cpp b/src/tests/libxrpl/shamap/SHAMapNodeID.cpp new file mode 100644 index 0000000000..95b7497c9c --- /dev/null +++ b/src/tests/libxrpl/shamap/SHAMapNodeID.cpp @@ -0,0 +1,193 @@ +#include + +#include +#include +#include + +#include + +#include + +namespace xrpl::tests { + +// An arbitrary 32-byte key reused across tests below that don't care about its specific value, +// only that it is a well-formed key. +constexpr uint256 kTestKey("b92891fe4ef6cee585fdc6fda1e09eb4d386363158ec3321b8123e5a772c6ca8"); + +TEST(SHAMapNodeIDTest, root_is_prefix_of_every_key) +{ + SHAMapNodeID const root; + EXPECT_EQ(root.getDepth(), 0u); + EXPECT_TRUE(root.isPrefixOf(uint256{})); + EXPECT_TRUE(root.isPrefixOf(kTestKey)); +} + +TEST(SHAMapNodeIDTest, child_id_is_prefix_of_keys_in_that_branch) +{ + // Walking the branches spelled by the key's own nibbles must keep every + // intermediate ID a prefix of that key. + SHAMapNodeID id; + for (auto depth = 0u; depth < SHAMap::kLeafDepth; ++depth) + { + id = id.getChildNodeID(selectBranch(id, kTestKey)); + EXPECT_EQ(id.getDepth(), depth + 1); + EXPECT_TRUE(id.isPrefixOf(kTestKey)) << "depth " << id.getDepth(); + } +} + +TEST(SHAMapNodeIDTest, wrong_branch_is_not_prefix_of_key) +{ + SHAMapNodeID const root; + auto const correct = selectBranch(root, kTestKey); + ASSERT_EQ(correct, 0xbu); + + // An ID built from the wrong branch still has a valid depth and a self-consistent mask, so + // isPrefixOf(kTestKey) below is what actually distinguishes the correct branch from the rest. + for (auto branch = 0u; branch < SHAMap::kBranchFactor; ++branch) + { + auto const child = root.getChildNodeID(branch); + EXPECT_EQ(child.getDepth(), 1u); + EXPECT_EQ(child.isPrefixOf(kTestKey), branch == correct) << "branch " << branch; + } +} + +TEST(SHAMapNodeIDTest, prefix_check_is_depth_sensitive) +{ + // kTestKey and kOther agree on the first two nibbles ("b9") and then diverge. + constexpr uint256 kOther("b99891fe4ef6cee585fdc6fda1e09eb4d386363158ec3321b8123e5a772c6ca8"); + + auto id = SHAMapNodeID{}.getChildNodeID(selectBranch(SHAMapNodeID{}, kTestKey)); + EXPECT_TRUE(id.isPrefixOf(kTestKey)); + EXPECT_TRUE(id.isPrefixOf(kOther)) << "shared first nibble"; + + id = id.getChildNodeID(selectBranch(id, kTestKey)); + EXPECT_TRUE(id.isPrefixOf(kTestKey)); + EXPECT_TRUE(id.isPrefixOf(kOther)) << "shared second nibble"; + + // Third nibble differs, so the deeper ID no longer covers kOther. + id = id.getChildNodeID(selectBranch(id, kTestKey)); + EXPECT_TRUE(id.isPrefixOf(kTestKey)); + EXPECT_FALSE(id.isPrefixOf(kOther)); +} + +TEST(SHAMapNodeIDTest, leaf_id_from_key_is_prefix_of_that_key) +{ + SHAMapNodeID const leaf{SHAMap::kLeafDepth, kTestKey}; + EXPECT_TRUE(leaf.isPrefixOf(kTestKey)); + + // At full depth the prefix is the whole key, so nothing else matches. + constexpr uint256 kOther("b92891fe4ef6cee585fdc6fda1e09eb4d386363158ec3321b8123e5a772c6ca9"); + EXPECT_FALSE(leaf.isPrefixOf(kOther)); +} + +TEST(SHAMapNodeIDTest, create_id_masks_key_to_depth) +{ + for (auto depth = 0u; depth <= SHAMap::kLeafDepth; ++depth) + { + auto const id = SHAMapNodeID::createID(depth, kTestKey); + EXPECT_EQ(id.getDepth(), depth); + EXPECT_TRUE(id.isPrefixOf(kTestKey)) << "depth " << depth; + } +} + +// The guards below must hold with XRPL_ASSERT compiled out (NDEBUG), so each one +// has to be a real runtime check rather than an assert. + +TEST(SHAMapNodeIDTest, child_of_leaf_depth_id_throws) +{ + auto const leafDepthID = SHAMapNodeID::createID(SHAMap::kLeafDepth, kTestKey); + ASSERT_EQ(leafDepthID.getDepth(), SHAMap::kLeafDepth); + EXPECT_THROW((void)leafDepthID.getChildNodeID(0), std::logic_error); +} + +TEST(SHAMapNodeIDDeathTest, out_of_range_depth_is_clamped) +{ + // A depth past kLeafDepth has no mask in depthMask's 65-entry table, so both the constructor + // and createID clamp it. createID needs its own clamp: it picks the mask while evaluating the + // constructor's argument, so the constructor's clamp cannot cover that read. + // + // Both clamps are marked UNREACHABLE, which is an assert and therefore fatal wherever asserts + // are live. Only a build with them compiled out (or routed to Antithesis's non-fatal handler) + // reaches the clamp itself, so that is the only configuration that can assert on the result. +#if defined(NDEBUG) || defined(ENABLE_VOIDSTAR) + for (auto const depth : {SHAMap::kLeafDepth + 1u, 100u, 255u, 256u, 320u}) + { + auto const id = SHAMapNodeID::createID(depth, kTestKey); + + // Clamped to a real depth, not the depth asked for, and not a byte-narrowed version of it: + // 256 would otherwise become 0 and name the root, 320 would become 64. + EXPECT_EQ(id.getDepth(), SHAMap::kLeafDepth) << "depth " << depth; + + // id_ and depth_ still agree, so the object is usable rather than merely non-crashing. + EXPECT_TRUE(id.isPrefixOf(kTestKey)) << "depth " << depth; + EXPECT_EQ(id, SHAMapNodeID::createID(SHAMap::kLeafDepth, kTestKey)) << "depth " << depth; + + // The clamp holds through the wire format too, which encodes the depth in one byte. + auto const roundTripped = deserializeSHAMapNodeID(id.getRawString()); + ASSERT_TRUE(roundTripped.has_value()) << "depth " << depth; + EXPECT_EQ(roundTripped->getDepth(), SHAMap::kLeafDepth) << "depth " << depth; + } + + // The constructor clamps on its own, for the paths that do not go through createID. + SHAMapNodeID const direct{SHAMap::kLeafDepth + 1u, uint256{}}; + EXPECT_EQ(direct.getDepth(), SHAMap::kLeafDepth); +#else + EXPECT_DEATH( + (void)SHAMapNodeID::createID(SHAMap::kLeafDepth + 1u, kTestKey), "depth within tree"); +#endif +} + +TEST(SHAMapNodeIDDeathTest, select_branch_clamps_leaf_depth) +{ + // selectBranch's own precondition is depth < kLeafDepth: a depth-64 ID has no nibble left + // to select. That makes it unlike the guards above, which have a throw/return reachable + // even with XRPL_ASSERT compiled out; selectBranch has no such path, so the two build + // configurations have to be tested differently. + // + // Under ENABLE_VOIDSTAR, XRPL_ASSERT routes to Antithesis's assert_impl, which only records + // the hit and returns rather than aborting, even though NDEBUG is undefined there (voidstar + // requires a Debug build). So the assert is live in name but never fatal, the same as the + // NDEBUG case below. + auto const leafDepthID = SHAMapNodeID::createID(SHAMap::kLeafDepth, kTestKey); + +#if defined(NDEBUG) || defined(ENABLE_VOIDSTAR) + // With the assert compiled out or routed to a non-fatal handler, the clamp is what stands + // between this call and reading past the end of the 32-byte key. Clamping means it reads the + // same byte, and returns the same branch, as the deepest ID that still has one: depth 63. + auto const deepestWithBranchID = SHAMapNodeID::createID(SHAMap::kLeafDepth - 1u, kTestKey); + auto const branch = selectBranch(leafDepthID, kTestKey); + EXPECT_LT(branch, SHAMap::kBranchFactor); + EXPECT_EQ(branch, selectBranch(deepestWithBranchID, kTestKey)); +#else + // In a debug build the assert is live and must reject this call outright, in a forked + // process so a failure here cannot take down the rest of the suite. + EXPECT_DEATH((void)selectBranch(leafDepthID, kTestKey), "depth below leaf depth"); +#endif +} + +TEST(SHAMapNodeIDTest, deserialize_rejects_out_of_range_depth) +{ + // getRawString() only serializes a depth already accepted by the constructor's own + // assertion, so an out-of-range depth here is built by hand instead. + auto serializeWithRawDepth = [](unsigned int depth) { + Serializer s; + s.addBitString(uint256{}); + s.add8(static_cast(depth)); + return s.getString(); + }; + + for (auto const depth : {65u, 100u, 255u}) + { + EXPECT_FALSE(deserializeSHAMapNodeID(serializeWithRawDepth(depth)).has_value()) + << "depth " << depth; + } + + // A depth-64 ID is legal, since leaves live there, but it has no children. + auto const id = + deserializeSHAMapNodeID(SHAMapNodeID{SHAMap::kLeafDepth, uint256{}}.getRawString()); + ASSERT_TRUE(id.has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) has_value checked above + EXPECT_THROW((void)id->getChildNodeID(0), std::logic_error); +} + +} // namespace xrpl::tests From 7d7275847d1fb7bcf2b179f4cf5d755254d6fe6b Mon Sep 17 00:00:00 2001 From: Kassaking7 <96991820+Kassaking7@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:38:01 +0000 Subject: [PATCH 25/40] fix: PermissionedDEX (CreateOffer/Payment) never deletes expired credentials (#6827) --- .../tx/transactors/dex/OfferCreate.cpp | 49 +++- .../tx/transactors/payment/Payment.cpp | 63 ++++- src/test/app/PermissionedDEX_test.cpp | 215 +++++++++++++++++- 3 files changed, 317 insertions(+), 10 deletions(-) diff --git a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp index 7ab1143d12..57ba6eff0d 100644 --- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp +++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -242,8 +243,31 @@ OfferCreate::preclaim(PreclaimContext const& ctx) // is part of the domain if (ctx.tx.isFieldPresent(sfDomainID)) { - if (!permissioned_dex::accountInDomain(ctx.view, id, ctx.tx[sfDomainID])) - return tecNO_PERMISSION; + if (ctx.view.rules().enabled(fixCleanup3_4_0)) + { + auto const domainID = ctx.tx[sfDomainID]; + auto const sleDomain = ctx.view.read(keylet::permissionedDomain(domainID)); + if (!sleDomain) + return tecNO_PERMISSION; + + // Domain owner is always considered in the domain, no credential check + // needed. For all other accounts, use validDomain which detects expired + // credentials. Suppress tecEXPIRED here so doApply can run and delete + // the expired credential SLEs from the ledger. + if (sleDomain->getAccountID(sfOwner) != id) + { + // validDomain returns tecNO_AUTH when no matching credential is + // found. Map it to tecNO_PERMISSION to preserve existing behavior. + if (auto const err = credentials::validDomain(ctx.view, domainID, id); + !isTesSuccess(err) && err != tecEXPIRED) + return tecNO_PERMISSION; + } + } + else + { + if (!permissioned_dex::accountInDomain(ctx.view, id, ctx.tx[sfDomainID])) + return tecNO_PERMISSION; + } } if (auto const ter = canTrade(ctx.view, saTakerPays.asset()); !isTesSuccess(ter)) @@ -1000,6 +1024,27 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel) TER OfferCreate::doApply() { + // If a DomainID is present, verify the account is still in the domain and + // delete any expired credential SLEs. This must happen before the Sandboxes + // are created: if we return a tec error, the engine applies sbCancel (not + // sb) to rawView, so deletions made inside sb would be lost. Deletions made + // directly to ctx_.view() here are preserved regardless of which branch + // applyGuts takes. + if (ctx_.tx.isFieldPresent(sfDomainID) && ctx_.view().rules().enabled(fixCleanup3_4_0)) + { + auto const domainID = ctx_.tx[sfDomainID]; + auto const sleDomain = ctx_.view().read(keylet::permissionedDomain(domainID)); + if (!sleDomain) + return tecINTERNAL; // LCOV_EXCL_LINE + + if (sleDomain->getAccountID(sfOwner) != accountID_) + { + if (auto const err = verifyValidDomain(ctx_.view(), accountID_, domainID, j_); + !isTesSuccess(err)) + return err; + } + } + // This is the ledger view that we work against. Transactions are applied // as we go on processing transactions. Sandbox sb(&ctx_.view()); diff --git a/src/libxrpl/tx/transactors/payment/Payment.cpp b/src/libxrpl/tx/transactors/payment/Payment.cpp index c8b00f0193..c4c2f9227b 100644 --- a/src/libxrpl/tx/transactors/payment/Payment.cpp +++ b/src/libxrpl/tx/transactors/payment/Payment.cpp @@ -458,11 +458,41 @@ Payment::preclaim(PreclaimContext const& ctx) if (ctx.tx.isFieldPresent(sfDomainID)) { - if (!permissioned_dex::accountInDomain(ctx.view, ctx.tx[sfAccount], ctx.tx[sfDomainID])) - return tecNO_PERMISSION; + if (ctx.view.rules().enabled(fixCleanup3_4_0)) + { + auto const domainID = ctx.tx[sfDomainID]; + auto const sleDomain = ctx.view.read(keylet::permissionedDomain(domainID)); + if (!sleDomain) + return tecNO_PERMISSION; - if (!permissioned_dex::accountInDomain(ctx.view, ctx.tx[sfDestination], ctx.tx[sfDomainID])) - return tecNO_PERMISSION; + // Domain owner is always considered in the domain. For other accounts, + // suppress tecEXPIRED so doApply can run and delete expired credential + // SLEs from the ledger. + auto const checkAccount = [&](AccountID const& acct) -> TER { + if (sleDomain->getAccountID(sfOwner) == acct) + return tesSUCCESS; + // validDomain returns tecNO_AUTH when no matching credential is + // found. Map it to tecNO_PERMISSION to preserve existing behavior. + if (auto const err = credentials::validDomain(ctx.view, domainID, acct); + !isTesSuccess(err) && err != tecEXPIRED) + return tecNO_PERMISSION; + return tesSUCCESS; + }; + + if (auto const err = checkAccount(ctx.tx[sfAccount]); !isTesSuccess(err)) + return err; + if (auto const err = checkAccount(ctx.tx[sfDestination]); !isTesSuccess(err)) + return err; + } + else + { + if (!permissioned_dex::accountInDomain(ctx.view, ctx.tx[sfAccount], ctx.tx[sfDomainID])) + return tecNO_PERMISSION; + + if (!permissioned_dex::accountInDomain( + ctx.view, ctx.tx[sfDestination], ctx.tx[sfDomainID])) + return tecNO_PERMISSION; + } } return tesSUCCESS; @@ -471,6 +501,31 @@ Payment::preclaim(PreclaimContext const& ctx) TER Payment::doApply() { + // If a DomainID is present, verify both sender and destination are still in + // the domain and delete any expired credential SLEs from the ledger. + if (ctx_.tx.isFieldPresent(sfDomainID) && ctx_.view().rules().enabled(fixCleanup3_4_0)) + { + auto const domainID = ctx_.tx[sfDomainID]; + auto const sleDomain = ctx_.view().read(keylet::permissionedDomain(domainID)); + if (!sleDomain) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const cleanupFor = [&](AccountID const& acct) -> TER { + if (sleDomain->getAccountID(sfOwner) == acct) + return tesSUCCESS; + return verifyValidDomain(ctx_.view(), acct, domainID, j_); + }; + + auto const destination = ctx_.tx[sfDestination]; + auto const senderErr = cleanupFor(accountID_); + auto const destinationErr = accountID_ == destination ? senderErr : cleanupFor(destination); + + if (!isTesSuccess(senderErr)) + return senderErr; + if (!isTesSuccess(destinationErr)) + return destinationErr; + } + auto const deliverMin = ctx_.tx[~sfDeliverMin]; // Ripple if source or destination is non-native or if there are paths. diff --git a/src/test/app/PermissionedDEX_test.cpp b/src/test/app/PermissionedDEX_test.cpp index ddb56a1480..fbe942948d 100644 --- a/src/test/app/PermissionedDEX_test.cpp +++ b/src/test/app/PermissionedDEX_test.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -179,7 +180,10 @@ class PermissionedDEX_test : public beast::unit_test::Suite void testOfferCreate(FeatureBitset features) { - testcase("OfferCreate"); + bool const fixEnabled = features[fixCleanup3_4_0]; + + testcase << "OfferCreate" + << (fixEnabled ? " (Cleanup3_4_0 enabled)" : " (Cleanup3_4_0 disabled)"); // test preflight { @@ -273,8 +277,10 @@ class PermissionedDEX_test : public beast::unit_test::Suite // time advance env.close(std::chrono::seconds(20)); - // devin cannot create offer with expired cred - env(offer(devin, XRP(10), USD(10)), Domain(domainID), Ter(tecNO_PERMISSION)); + // Devin cannot create offer with expired cred. After fixCleanup3_4_0, + // doApply deletes the expired credential SLE and returns tecEXPIRED. + TER const expectedExpiredCredTer = fixEnabled ? tecEXPIRED : tecNO_PERMISSION; + env(offer(devin, XRP(10), USD(10)), Domain(domainID), Ter(expectedExpiredCredTer)); env.close(); } @@ -1510,7 +1516,9 @@ class PermissionedDEX_test : public beast::unit_test::Suite env.close(std::chrono::seconds(100)); // Confirm devin can no longer create domain offers. - env(offer(devin, XRP(1), USD(1)), Domain(domainID), Ter(tecNO_PERMISSION)); + // After fixCleanup3_4_0, OfferCreate deletes the expired credential and + // returns tecEXPIRED (covered in depth by testExpiredCredentialCleanup). + env(offer(devin, XRP(1), USD(1)), Domain(domainID), Ter(tecEXPIRED)); env.close(); // The hybrid offer must still exist in the open book after expiry. @@ -1635,6 +1643,202 @@ class PermissionedDEX_test : public beast::unit_test::Suite BEAST_EXPECT(!offerExists(env, bob, carolOfferSeq)); } + void + testExpiredCredentialCleanup(FeatureBitset features) + { + bool const fixEnabled = features[fixCleanup3_4_0]; + + testcase << "Expired credential cleanup" + << (fixEnabled ? " (Cleanup3_4_0 enabled)" : " (Cleanup3_4_0 disabled)"); + + TER const expectedExpiredCredTer = fixEnabled ? tecEXPIRED : tecNO_PERMISSION; + + auto const fundAccount = + [](Env& env, Account const& account, Account const& gw, IOU const& usd) { + env.fund(XRP(1000), account); + env.close(); + env.trust(usd(1000), account); + env.close(); + env(pay(gw, account, usd(100))); + env.close(); + }; + + auto const fundDevin = [&](Env& env, Account const& gw, IOU const& usd) { + Account const devin("devin"); + fundAccount(env, devin, gw, usd); + return devin; + }; + + auto const createExpiringCredential = [](Env& env, + Account const& subject, + Account const& issuer, + std::string const& credType) { + auto jv = credentials::create(subject, issuer, credType); + uint32_t const t = env.current()->header().parentCloseTime.time_since_epoch().count(); + jv[sfExpiration.jsonName] = t + 20; + env(jv); + env(credentials::accept(subject, issuer, credType)); + env.close(); + + return keylet::credential(subject.id(), issuer.id(), makeSlice(credType)); + }; + + auto const expectExpiredCredentialState = [&](Env const& env, Keylet const& credKey) { + if (fixEnabled) + { + BEAST_EXPECT(!env.le(credKey)); + } + else + { + BEAST_EXPECT(env.le(credKey)); + } + }; + + // A payment referencing a non-existent domain is rejected in preclaim. + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + uint256 const badDomain{ + "F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E3370F3649CE134" + "E5"}; + + env(offer(bob, XRP(10), USD(10)), Domain(domainID)); + env.close(); + + env(pay(alice, bob, USD(10)), + Path(~USD), + Sendmax(XRP(10)), + Domain(badDomain), + Ter(tecNO_PERMISSION)); + env.close(); + } + + // OfferCreate with an expired credential. + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + Account const devin = fundDevin(env, gw, USD); + auto const credKey = createExpiringCredential(env, devin, domainOwner, credType); + BEAST_EXPECT(env.le(credKey)); // credential exists before expiry + + env.close(std::chrono::seconds(20)); + + env(offer(devin, XRP(10), USD(10)), Domain(domainID), Ter(expectedExpiredCredTer)); + env.close(); + + expectExpiredCredentialState(env, credKey); + } + + // Payment where the sender's credential is expired. + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + Account const devin = fundDevin(env, gw, USD); + auto const credKey = createExpiringCredential(env, devin, domainOwner, credType); + + auto const bobOfferSeq{env.seq(bob)}; + auto const bobCredKey = + keylet::credential(bob.id(), domainOwner.id(), makeSlice(credType)); + env(offer(bob, XRP(10), USD(10)), Domain(domainID)); + env.close(); + + BEAST_EXPECT(env.le(credKey)); + BEAST_EXPECT(env.le(bobCredKey)); + BEAST_EXPECT(offerExists(env, bob, bobOfferSeq)); + + env.close(std::chrono::seconds(20)); + + env(pay(devin, alice, USD(10)), + Path(~USD), + Sendmax(XRP(10)), + Domain(domainID), + Ter(expectedExpiredCredTer)); + env.close(); + + expectExpiredCredentialState(env, credKey); + BEAST_EXPECT(env.le(bobCredKey)); + BEAST_EXPECT(offerExists(env, bob, bobOfferSeq)); + } + + // Payment where the destination's credential is expired. + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + Account const devin = fundDevin(env, gw, USD); + auto const credKey = createExpiringCredential(env, devin, domainOwner, credType); + + auto const bobOfferSeq{env.seq(bob)}; + auto const bobCredKey = + keylet::credential(bob.id(), domainOwner.id(), makeSlice(credType)); + env(offer(bob, XRP(10), USD(10)), Domain(domainID)); + env.close(); + + BEAST_EXPECT(env.le(credKey)); + BEAST_EXPECT(env.le(bobCredKey)); + BEAST_EXPECT(offerExists(env, bob, bobOfferSeq)); + + env.close(std::chrono::seconds(20)); + + env(pay(alice, devin, USD(10)), + Path(~USD), + Sendmax(XRP(10)), + Domain(domainID), + Ter(expectedExpiredCredTer)); + env.close(); + + expectExpiredCredentialState(env, credKey); + BEAST_EXPECT(env.le(bobCredKey)); + BEAST_EXPECT(offerExists(env, bob, bobOfferSeq)); + } + + // Payment where both sender and destination credentials are expired. + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + Account const devin = fundDevin(env, gw, USD); + Account const erin("erin"); + fundAccount(env, erin, gw, USD); + + auto const devinCredKey = createExpiringCredential(env, devin, domainOwner, credType); + auto const erinCredKey = createExpiringCredential(env, erin, domainOwner, credType); + + auto const bobOfferSeq{env.seq(bob)}; + auto const bobCredKey = + keylet::credential(bob.id(), domainOwner.id(), makeSlice(credType)); + env(offer(bob, XRP(10), USD(10)), Domain(domainID)); + env.close(); + + BEAST_EXPECT(env.le(devinCredKey)); + BEAST_EXPECT(env.le(erinCredKey)); + BEAST_EXPECT(env.le(bobCredKey)); + BEAST_EXPECT(offerExists(env, bob, bobOfferSeq)); + + env.close(std::chrono::seconds(20)); + + env(pay(devin, erin, USD(10)), + Path(~USD), + Sendmax(XRP(10)), + Domain(domainID), + Ter(expectedExpiredCredTer)); + env.close(); + + expectExpiredCredentialState(env, devinCredKey); + expectExpiredCredentialState(env, erinCredKey); + BEAST_EXPECT(env.le(bobCredKey)); + BEAST_EXPECT(offerExists(env, bob, bobOfferSeq)); + } + } + void testHybridMalformedOffer(FeatureBitset features) { @@ -2209,6 +2413,7 @@ public: // Test domain offer (w/o hybrid) testOfferCreate(all); testOfferCreate(all - fixCleanup3_2_0); + testOfferCreate(all - fixCleanup3_4_0); testPayment(all); testPayment(all - fixCleanup3_2_0); testBookStep(all); @@ -2219,6 +2424,8 @@ public: testAmmQualityNotLeaked(all); testAmmQualityNotLeaked(all - fixCleanup3_3_0); testAutoBridge(all); + testExpiredCredentialCleanup(all); + testExpiredCredentialCleanup(all - fixCleanup3_4_0); // Test hybrid offers testHybridOfferCreate(all); From 636d2d4851219f81f6475e6c18c088e3a895d544 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S <21219765+ckeshava@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:48:53 +0000 Subject: [PATCH 26/40] fix: Reinforce the priority of AMMClawback in case of insufficient reserves (#7796) Co-authored-by: Claude Opus 4.8 (1M context) --- include/xrpl/ledger/helpers/TokenHelpers.h | 6 ++ include/xrpl/tx/transactors/dex/AMMWithdraw.h | 12 +++ .../tx/transactors/dex/AMMClawback.cpp | 4 + .../tx/transactors/dex/AMMWithdraw.cpp | 14 +++ src/test/app/AMMClawbackMPT_test.cpp | 85 +++++++++++++++++++ src/test/app/AMMClawback_test.cpp | 77 +++++++++++++++++ 6 files changed, 198 insertions(+) diff --git a/include/xrpl/ledger/helpers/TokenHelpers.h b/include/xrpl/ledger/helpers/TokenHelpers.h index 2a2f1b568e..12fa8a105e 100644 --- a/include/xrpl/ledger/helpers/TokenHelpers.h +++ b/include/xrpl/ledger/helpers/TokenHelpers.h @@ -38,6 +38,12 @@ enum class FreezeHandling { IgnoreFreeze, ZeroIfFrozen }; */ enum class AuthHandling { IgnoreAuth, ZeroIfUnauthorized }; +/** + * Controls whether the recipient owner-reserve check is enforced when + * auto-creating a trustline or MPToken during AMMWithdraw or AMMClawback. + */ +enum class ReserveHandling : bool { EnforceReserve, IgnoreReserve }; + /** * Controls whether to include the account's full spendable balance */ diff --git a/include/xrpl/tx/transactors/dex/AMMWithdraw.h b/include/xrpl/tx/transactors/dex/AMMWithdraw.h index 6861fa7bc4..ea0ad3b253 100644 --- a/include/xrpl/tx/transactors/dex/AMMWithdraw.h +++ b/include/xrpl/tx/transactors/dex/AMMWithdraw.h @@ -109,6 +109,11 @@ public: * @param lpTokens current LPT balance * @param lpTokensWithdraw amount of tokens to withdraw * @param tfee trading fee in basis points + * @param freezeHandling whether a frozen balance is reported as zero + * @param authHandling whether an unauthorized MPT balance is reported as + * zero + * @param reserveHandling whether the recipient owner-reserve check is + * enforced when a trustline or MPToken has to be auto-created * @param withdrawAll if withdrawing all lptokens * @param priorBalance balance before fees * @return @@ -128,6 +133,7 @@ public: std::uint16_t tfee, FreezeHandling freezeHandling, AuthHandling authHandling, + ReserveHandling reserveHandling, WithdrawAll withdrawAll, XRPAmount const& priorBalance, beast::Journal const& journal); @@ -150,6 +156,11 @@ public: * @param lpTokensAMMBalance current AMM LPT balance * @param lpTokensWithdraw amount of lptokens to withdraw * @param tfee trading fee in basis points + * @param freezeHandling whether a frozen balance is reported as zero + * @param authHandling whether an unauthorized MPT balance is reported as + * zero + * @param reserveHandling whether the recipient owner-reserve check is + * enforced when a trustline or MPToken has to be auto-created * @param withdrawAll if withdraw all lptokens * @param priorBalance balance before fees * @return @@ -169,6 +180,7 @@ public: std::uint16_t tfee, FreezeHandling freezeHandling, AuthHandling authHandling, + ReserveHandling reserveHandling, WithdrawAll withdrawAll, XRPAmount const& priorBalance, beast::Journal const& journal); diff --git a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp index b25c90069c..f95f257ab6 100644 --- a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp @@ -237,6 +237,7 @@ AMMClawback::applyGuts(Sandbox& sb) 0, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, + ReserveHandling::IgnoreReserve, WithdrawAll::Yes, preFeeBalance_, ctx_.journal); @@ -345,6 +346,7 @@ AMMClawback::equalWithdrawMatchingOneAmount( 0, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, + ReserveHandling::IgnoreReserve, WithdrawAll::Yes, preFeeBalance_, ctx_.journal); @@ -385,6 +387,7 @@ AMMClawback::equalWithdrawMatchingOneAmount( 0, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, + ReserveHandling::IgnoreReserve, WithdrawAll::No, preFeeBalance_, ctx_.journal); @@ -406,6 +409,7 @@ AMMClawback::equalWithdrawMatchingOneAmount( 0, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, + ReserveHandling::IgnoreReserve, WithdrawAll::No, preFeeBalance_, ctx_.journal); diff --git a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp index 7744c128af..77b9071cf8 100644 --- a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp @@ -527,6 +527,7 @@ AMMWithdraw::withdraw( tfee, issuerFreezeHandling(), AuthHandling::ZeroIfUnauthorized, + ReserveHandling::EnforceReserve, isWithdrawAll(ctx_.tx), preFeeBalance_, j_); @@ -548,6 +549,7 @@ AMMWithdraw::withdraw( std::uint16_t tfee, FreezeHandling freezeHandling, AuthHandling authHandling, + ReserveHandling reserveHandling, WithdrawAll withdrawAll, XRPAmount const& priorBalance, beast::Journal const& journal) @@ -681,6 +683,14 @@ AMMWithdraw::withdraw( }); if (assetNotExists) { + // Intentionally ignore the reserve check for AMMClawback, so the + // holder can not avoid clawback by deleting the trustline/MPToken + // and keeping a low spendable balance. AMMClawback has a higher + // priority than the reserve check. + if (view.rules().enabled(fixCleanup3_4_0) && + reserveHandling == ReserveHandling::IgnoreReserve) + return tesSUCCESS; + auto sleAccount = view.peek(keylet::account(account)); if (!sleAccount) return tecINTERNAL; // LCOV_EXCL_LINE @@ -850,6 +860,7 @@ AMMWithdraw::equalWithdrawTokens( tfee, issuerFreezeHandling(), AuthHandling::ZeroIfUnauthorized, + ReserveHandling::EnforceReserve, isWithdrawAll(ctx_.tx), preFeeBalance_, ctx_.journal); @@ -903,6 +914,7 @@ AMMWithdraw::equalWithdrawTokens( std::uint16_t tfee, FreezeHandling freezeHandling, AuthHandling authHandling, + ReserveHandling reserveHandling, WithdrawAll withdrawAll, XRPAmount const& priorBalance, beast::Journal const& journal) @@ -926,6 +938,7 @@ AMMWithdraw::equalWithdrawTokens( tfee, freezeHandling, authHandling, + reserveHandling, WithdrawAll::Yes, priorBalance, journal); @@ -962,6 +975,7 @@ AMMWithdraw::equalWithdrawTokens( tfee, freezeHandling, authHandling, + reserveHandling, withdrawAll, priorBalance, journal); diff --git a/src/test/app/AMMClawbackMPT_test.cpp b/src/test/app/AMMClawbackMPT_test.cpp index 1d75c4db22..44eb61395a 100644 --- a/src/test/app/AMMClawbackMPT_test.cpp +++ b/src/test/app/AMMClawbackMPT_test.cpp @@ -2198,6 +2198,89 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite BEAST_EXPECT(amm.ammExists()); } + void + testClawbackBypassesReserve(FeatureBitset features) + { + // Same as the IOU case, but the paired asset is an MPT alice does not + // hold yet. The reserve check is skipped on the clawback path while + // createMPToken() still runs, so alice's MPToken is created even though + // neither she nor the low-XRP issuer can cover the owner reserve. + testcase("test clawback bypasses recipient reserve (MPT)"); + using namespace jtx; + + Env env(*this, features); + Account const gw{"gateway"}; // IOU issuer + claw authority, low XRP + Account const gw2{"gateway2"}; // MPT issuer of the paired asset + Account const carol{"carol"}; + Account const alice{"alice"}; + + auto const usd = gw["USD"]; + auto const baseFee = env.current()->fees().base; + + env.fund(XRP(1'000'000), gw2, carol); + // Low XRP so the legacy issuer-balance check cannot pass. + env.fund(env.current()->fees().accountReserve(0, 1) + baseFee * 10, gw); + // Reserve for the USD trustline and LP token trustline. + env.fund(env.current()->fees().accountReserve(2, 1) + baseFee * 5, alice); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + + // The paired MPT: transferable so an AMM can hold it, and no + // RequireAuth so createMPToken()'s WeakAuth check passes. + MPT const btc = MPTTester( + {.env = env, + .issuer = gw2, + .holders = {carol}, + .pay = 1'000'000, + .flags = kMptDexFlags}); + + env.trust(usd(1'000'000), carol); + env(pay(gw, carol, usd(100'000))); + env.close(); + AMM amm(env, carol, usd(1'000), btc(1'000), Ter(tesSUCCESS)); + env.close(); + + // alice holds a USD trustline and LP tokens, but no BTC MPToken. + env.trust(usd(100'000), alice); + env(pay(gw, alice, usd(1'000))); + env.close(); + amm.deposit(alice, usd(100)); + + BEAST_EXPECT(env.ownerCount(alice) == 2); + BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID, alice.id()))); + + // AMMWithdraw still enforces the reserve check. + amm.withdrawAll(alice, std::nullopt, Ter(tecINSUFFICIENT_RESERVE)); + BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID, alice.id()))); + BEAST_EXPECT(env.ownerCount(alice) == 2); + // alice cannot afford a third owner object. + BEAST_EXPECT(env.balance(alice) < STAmount(env.current()->fees().accountReserve(3, 1))); + + if (features[fixCleanup3_4_0]) + { + // Reserve check skipped; the paired BTC returns to alice on a + // newly created MPToken. + env(amm::ammClawback(gw, alice, usd, btc, usd(10)), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(env.le(keylet::mptoken(btc.issuanceID, alice.id()))); + BEAST_EXPECT(env.balance(alice, btc) > btc(0)); + BEAST_EXPECT(env.ownerCount(alice) == 3); + } + else + { + // Legacy path: the check runs against max(issuer, holder) XRP, + // neither of which covers a third owner object. + env(amm::ammClawback(gw, alice, usd, btc, usd(10)), Ter(tecINSUFFICIENT_RESERVE)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID, alice.id()))); + BEAST_EXPECT(env.ownerCount(alice) == 2); + } + } + void run() override { @@ -2225,6 +2308,8 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite featureLendingProtocol); testLastHolderLPTokenBalance(all - fixAMMClawbackRounding); testClawAssetCheck(all); + testClawbackBypassesReserve(all); + testClawbackBypassesReserve(all - fixCleanup3_4_0); } }; diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp index 230d148ff9..4f025f08eb 100644 --- a/src/test/app/AMMClawback_test.cpp +++ b/src/test/app/AMMClawback_test.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -2715,6 +2716,81 @@ class AMMClawback_test : public beast::unit_test::Suite } } + void + testClawbackBypassesReserve(FeatureBitset features) + { + // Clawback must not fail the holder-side reserve check: a holder could + // otherwise veto it by omitting the paired trustline. AMMWithdraw still + // enforces the check. Pre-fixCleanup3_4_0 the holder's reserve was + // compared against max(issuer pre-fee, holder current) XRP, so the + // clawback was blocked when neither balance covered it. + testcase("test clawback bypasses recipient reserve"); + using namespace jtx; + + Env env(*this, features); + Account const gw{"gateway"}; + Account const carol{"carol"}; + Account const alice{"alice"}; + + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + auto const baseFee = env.current()->fees().base; + + env.fund(XRP(1'000'000), carol); + // Low XRP so the legacy issuer-balance check cannot pass. + env.fund(env.current()->fees().accountReserve(0, 1) + baseFee * 10, gw); + // Reserve for the USD trustline and LP token trustline. + env.fund(env.current()->fees().accountReserve(2, 1) + baseFee * 5, alice); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + + env.trust(usd(1'000'000), carol); + env.trust(eur(1'000'000), carol); + env(pay(gw, carol, usd(100'000))); + env(pay(gw, carol, eur(100'000))); + env.close(); + AMM amm(env, carol, usd(1'000), eur(1'000), Ter(tesSUCCESS)); + env.close(); + + // alice holds a USD trustline and LP tokens, but no EUR trustline. + env.trust(usd(100'000), alice); + env(pay(gw, alice, usd(1'000))); + env.close(); + amm.deposit(alice, usd(100)); + + BEAST_EXPECT(env.ownerCount(alice) == 2); + // alice cannot afford a third owner object. + BEAST_EXPECT(env.balance(alice) < STAmount(env.current()->fees().accountReserve(3, 1))); + + // AMMWithdraw still enforces the reserve check. + amm.withdraw( + WithdrawArg{ + .account = alice, .asset1Out = eur(1), .err = Ter(tecINSUFFICIENT_RESERVE)}); + BEAST_EXPECT(env.ownerCount(alice) == 2); + + if (features[fixCleanup3_4_0]) + { + // Reserve check skipped; the paired EUR returns to alice on a + // newly created EUR trustline. + env(amm::ammClawback(gw, alice, usd, eur, usd(10)), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(env.le(keylet::trustLine(alice.id(), eur.issue()))); + BEAST_EXPECT(env.balance(alice, eur) > eur(0)); + BEAST_EXPECT(env.ownerCount(alice) == 3); + } + else + { + // Legacy path: the check runs against max(issuer, holder) XRP, + // neither of which covers a third owner object. + env(amm::ammClawback(gw, alice, usd, eur, usd(10)), Ter(tecINSUFFICIENT_RESERVE)); + env.close(); + BEAST_EXPECT(env.ownerCount(alice) == 2); + } + } + void testExactLPTokenEquality(FeatureBitset features) { @@ -2809,6 +2885,7 @@ class AMMClawback_test : public beast::unit_test::Suite testAssetFrozen(features); testSingleDepositAndClawback(features); testLastHolderLPTokenBalance(features); + testClawbackBypassesReserve(features); testExactLPTokenEquality(features); } } From 49cdc105de5c3d5773ae0ebb0c523649d7c86439 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Wed, 2 Sep 2026 21:18:13 +0000 Subject: [PATCH 27/40] fix: Correct and simplify Linux packaging (#8165) --- .github/workflows/reusable-package.yml | 173 +++++++++++++++++++++++-- bin/install-packaging-tools.sh | 6 +- package/README.md | 84 +++++++----- package/build_pkg.py | 16 ++- package/debian/control | 3 +- package/debian/copyright | 75 ++++++++++- package/debian/rules | 22 ++++ package/debian/xrpld.docs | 1 + package/debian/xrpld.links | 3 +- package/debian/xrpld.lintian-overrides | 6 + package/docker/Dockerfile | 6 +- package/docker/publish_pkg.py | 17 ++- package/rpm/xrpld.spec | 23 +++- package/shared/xrpld.service | 2 + package/sign_rpm.py | 2 + 15 files changed, 365 insertions(+), 74 deletions(-) mode change 100644 => 100755 package/debian/rules create mode 100644 package/debian/xrpld.lintian-overrides diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index 2a5e6a8c04..3986feed7f 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -1,11 +1,14 @@ -# Build Linux packages from the pre-built xrpld and validator-keys artifacts: +# Build, verify and publish Linux packages from the pre-built xrpld and +# validator-keys artifacts, in three stages: # -# - one job per config that carries a "package" map in linux.json -# - that map names the container image and the format it builds there -# - every job ends with the image's publish_pkg.py, uploading what it built -# with 'publish: true' and doing a --dry-run otherwise +# - 'package' builds and signs one format per config that carries a "package" +# map in linux.json; that map names the container image and the format +# - 'test-install' installs what was built on a range of distros and runs the +# binaries there, so a package that cannot be installed never reaches Nexus +# - 'publish' uploads with the image's publish_pkg.py, doing a --dry-run +# unless 'publish: true' # -# Only linux/amd64 is supported; the runner is hardcoded in the job below. +# Only linux/amd64 is supported; the runner is hardcoded in the jobs below. name: Package on: @@ -39,6 +42,7 @@ defaults: env: BUILD_DIR: build + PACKAGE_DIR: packages jobs: generate-matrix: @@ -70,7 +74,7 @@ jobs: contents: read runs-on: ["self-hosted", "Linux", "X64", "heavy"] container: ${{ matrix.image }} - timeout-minutes: 30 + timeout-minutes: 10 steps: - name: Checkout repository @@ -112,24 +116,167 @@ jobs: --pkg-release "${PKG_RELEASE}" \ --channel "${CHANNEL}" - # Before the upload, so the artifact and the published package are the - # same bytes. DEBs are not signed, so the key is never set on that job. + # Before the upload, so the artifact, the tested package and the published + # package are the same bytes. - name: Sign RPM if: ${{ inputs.publish && matrix.package_type == 'rpm' }} env: PKG_SIGNING_KEY: ${{ secrets.signing_key }} run: ./package/sign_rpm.py --package-dir "${BUILD_DIR}" + # Split from the debug symbols, which are an order of magnitude larger, so + # that test-install downloads only what it installs. - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.xrpld_artifact_name }}-pkg path: | - ${{ env.BUILD_DIR }}/debbuild/*.deb - ${{ env.BUILD_DIR }}/debbuild/*.ddeb - ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/*.rpm + ${{ env.BUILD_DIR }}/debbuild/xrpld_*.deb + ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/xrpld-[0-9]*.rpm if-no-files-found: error + - name: Upload debug symbol artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.xrpld_artifact_name }}-pkg-debug + path: | + ${{ env.BUILD_DIR }}/debbuild/xrpld-dbgsym_*.deb + ${{ env.BUILD_DIR }}/debbuild/xrpld-dbgsym_*.ddeb + ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/xrpld-debuginfo-*.rpm + if-no-files-found: error + + # Every distro family the packages target, oldest release first, so both ends + # of the dependency range they declare are exercised. + test-install: + needs: [package] + strategy: + fail-fast: false + matrix: + include: + - package_type: deb + image: debian:11 + - package_type: deb + image: debian:12 + - package_type: deb + image: debian:13 + - package_type: deb + image: ubuntu:20.04 + - package_type: deb + image: ubuntu:22.04 + - package_type: deb + image: ubuntu:24.04 + - package_type: deb + image: ubuntu:26.04 + + - package_type: rpm + image: almalinux:9 + - package_type: rpm + image: almalinux:10 + - package_type: rpm + image: rockylinux/rockylinux:9 + - package_type: rpm + image: rockylinux/rockylinux:10 + - package_type: rpm + image: registry.access.redhat.com/ubi9/ubi + - package_type: rpm + image: registry.access.redhat.com/ubi10/ubi + name: "install ${{ matrix.package_type }} on ${{ matrix.image }}" + permissions: + contents: read + runs-on: ubuntu-latest + container: ${{ matrix.image }} + timeout-minutes: 5 + + steps: + # Both formats land in one directory; the step below picks its own by + # extension, so this stays independent of the artifact names. + - name: Download package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: "*-pkg" + merge-multiple: true + path: ${{ env.PACKAGE_DIR }} + + - name: Find the package + id: find + env: + PACKAGE_TYPE: ${{ matrix.package_type }} + run: | + package="$(find "${PACKAGE_DIR}" -type f -name "*.${PACKAGE_TYPE}" -print -quit)" + test -n "${package}" || { + echo "no .${PACKAGE_TYPE} found in ${PACKAGE_DIR}" >&2 + exit 1 + } + echo "package=${package}" >>"${GITHUB_OUTPUT}" + + - name: Install the DEB + if: ${{ matrix.package_type == 'deb' }} + env: + DEBIAN_FRONTEND: noninteractive + PACKAGE: ${{ steps.find.outputs.package }} + run: | + # Stock Debian and Ubuntu images carry no package lists, so apt has + # nothing to resolve the systemd dependency from until it fetches them. + apt-get update -qq + apt-get install -y "./${PACKAGE}" + + - name: Install the RPM + if: ${{ matrix.package_type == 'rpm' }} + env: + PACKAGE: ${{ steps.find.outputs.package }} + run: dnf install -y "./${PACKAGE}" + + - name: Run xrpld + run: xrpld --version + + - name: Run validator-keys + run: validator-keys --version + + - name: Run rippled, the legacy compatibility symlink + run: rippled --version + + - name: Check the service account + run: id xrpld + + - name: Check the state directory + run: test -d /var/lib/xrpld + + - name: Check the log directory + run: test -d /var/log/xrpld + + publish: + needs: [generate-matrix, package, test-install] + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} + name: "publish ${{ matrix.xrpld_artifact_name }}" + permissions: + contents: read + runs-on: ["self-hosted", "Linux", "X64", "heavy"] + container: ${{ matrix.image }} + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Prepare runner + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + with: + enable_ccache: false + + # Both artifacts, so the debug symbols are published alongside the package. + - name: Download package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ${{ matrix.xrpld_artifact_name }}-pkg* + merge-multiple: true + path: ${{ env.PACKAGE_DIR }} + + - name: Determine release info + id: release_info + uses: ./.github/actions/release-info + - name: Publish package env: CHANNEL: ${{ steps.release_info.outputs.channel }} @@ -140,6 +287,6 @@ jobs: run: | publish_pkg.py \ --channel "${CHANNEL}" \ - --package-dir "${BUILD_DIR}" \ + --package-dir "${PACKAGE_DIR}" \ --nexus-url "${NEXUS_URL}" \ ${DRY_RUN_OPTION} diff --git a/bin/install-packaging-tools.sh b/bin/install-packaging-tools.sh index 36557364ae..0d1fce3055 100755 --- a/bin/install-packaging-tools.sh +++ b/bin/install-packaging-tools.sh @@ -25,7 +25,9 @@ esac # Packaging runs in a vanilla distro image, so the tooling comes from the distro's # archive rather than from nixpkgs: # -# - debhelper and dpkg-dev build the DEB +# - debhelper and dpkg-dev build the DEB, and lintian checks it +# - binutils gives debian/rules the readelf its glibc-floor check runs; it +# already arrives via dpkg-dev, but that tool is called directly # - rpm-build builds the RPM, with systemd-rpm-macros and redhat-rpm-config # supplying the systemd and find-debuginfo macros the spec uses # - rpm-sign and gnupg2 sign the built RPM @@ -37,11 +39,13 @@ function install() { debian | ubuntu) apt-get update -y apt-get install -y --no-install-recommends \ + binutils \ ca-certificates \ debhelper \ debhelper-compat \ dpkg-dev \ git \ + lintian \ python3 ;; diff --git a/package/README.md b/package/README.md index 027a374898..6e88309ecd 100644 --- a/package/README.md +++ b/package/README.md @@ -15,7 +15,7 @@ package/ publish_pkg.py Uploads built packages to the XRPLF Nexus repositories (called by CI, and shipped in that image) rpm/ xrpld.spec RPM spec - debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format) + debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, xrpld.lintian-overrides, source/format) shared/ xrpld.service systemd unit file (used by both RPM and DEB) xrpld.sysusers sysusers.d config (used by both RPM and DEB) @@ -34,10 +34,10 @@ image and both CI and local builds pick it up — and names the format that imag builds in `type`, which CI passes to `build_pkg.py` as `--package-type`; the two have to stay in step. -| Package type | Image (`configs.[].package.image` in `linux.json`) | Tools required | -| ------------ | ---------------------------------------------------------- | --------------------------------------------------- | -| RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild`, `rpmsign` | -| DEB | `ghcr.io/xrplf/xrpld/packaging-debian:sha-` | `dpkg-buildpackage`, debhelper with compat level 13 | +| Package type | Image (`configs.[].package.image` in `linux.json`) | Tools required | +| ------------ | ---------------------------------------------------------- | -------------------------------------------------------------- | +| RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild`, `rpmsign` | +| DEB | `ghcr.io/xrplf/xrpld/packaging-debian:sha-` | `dpkg-buildpackage`, debhelper with compat level 13, `lintian` | To print the full packaging matrix (artifact names and images) for the current `linux.json`: @@ -51,13 +51,19 @@ To print the full packaging matrix (artifact names and images) for the current ### Via CI Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call -`reusable-package.yml`. That workflow generates its own packaging matrix from -the configs that carry a `package` map (via `generate.py --packaging`) and fans -out one job per distro. Each job downloads the pre-built `xrpld` and -`validator-keys` binary artifacts and runs in that distro's container, building -the format `package.type` declares. The packaging script derives the package -version from the downloaded binary's `xrpld --version` output; no CMake -configure or build step is needed inside the packaging job. +`reusable-package.yml`, which runs in three stages: + +1. `package` fans out one job per config carrying a `package` map, building and + signing in that config's container, and uploading `-pkg` alongside + `-pkg-debug` for the much larger debug symbols. +2. `test-install` installs `-pkg` in the container of every distro the + packages target and runs the binaries there, so one that cannot be installed + never reaches Nexus. +3. `publish` uploads both artifacts, or lists what it would upload. + +The packaging script derives the package version from the downloaded binary's +`xrpld --version` output; no CMake configure or build step is needed inside the +packaging job. The binaries come from the `debian` and `rhel` build configs themselves — the ones carrying the `package` map — which pass `-Dvalidator_keys=ON` so that the @@ -94,8 +100,7 @@ docker run --rm \ --pkg-release "${PKG_RELEASE}" \ --channel UNRELEASED -# Output: -# build/debbuild/*.deb (DEB + dbgsym; Debian names both .deb) +# Output (the deb image writes build/debbuild/*.deb instead): # build/rpmbuild/RPMS/x86_64/*.rpm ``` @@ -155,9 +160,9 @@ the last, and the date and hash say which commit a package on `packages.xrplf.org` came from. Both reach the packaging scripts as arguments, so neither script derives anything itself. -Publishing is the last step of each packaging job, uploading from the container -that built the packages with the `publish_pkg.py` shipped in the image — the -same copy other repositories run. Without `publish: true` the step is a +Publishing is its own job, gated behind `test-install`, uploading from the same +image that built the packages with the `publish_pkg.py` shipped in it — the +same copy other repositories run. Without `publish: true` the job is a `--dry-run`, listing the uploads it would make without needing credentials, so any run that builds packages also exercises the upload routing. `on-trigger.yml` passes `publish: true` for develop pushes in `XRPLF/rippled` and `on-tag.yml` @@ -202,6 +207,9 @@ the final release. If that normalized package version still contains `-`, packaging fails because RPM forbids `-` in `Version`, and Debian uses `-` as the upstream/revision separator. +> [!NOTE] +> Debug and sanitizer builds are not packaged yet. + `pkg_version` is the normalized package metadata version derived inside `build_pkg.py` from the binary-reported `xrpld` version (`-` pre-release separator converted to `~`). It is not a separate user input. @@ -279,37 +287,45 @@ service restart. 2. Stages the binaries, configs, `README.md`, `LICENSE.md`, and `validator-keys-LICENSE`. 3. Copies `package/debian/` control files into `debbuild/source/debian/`. -4. Copies shared service/sysusers/tmpfiles into `debian/` where `dh_installsystemd`, `dh_installsysusers`, and `dh_installtmpfiles` pick them up automatically. +4. Copies shared service/sysusers/tmpfiles/logrotate into `debian/` where `dh_installsystemd`, `dh_installsysusers`, `dh_installtmpfiles` and `dh_installlogrotate` pick them up automatically. 5. Generates a minimal `debian/changelog` using `${pkg_version}-${PKG_RELEASE}`, where `pkg_version` is derived from the binary-reported `xrpld` version. 6. Runs `dpkg-buildpackage -b --no-sign -d` (`-d` skips the build-dependency check, since the binary is already built). `debian/rules` uses manual `install` commands. + + It also rewrites the `libc6` bound to `LIBC_MIN` in `debian/rules`, the glibc + the Nix toolchain builds against. `dpkg-shlibdeps` would otherwise derive it + from the build host's symbols file — on trixie that yields `libc6 (>= 2.34)` + because of `sysconf`, locking out distros the binaries run on. A check fails + the build if either binary outgrows `LIBC_MIN`. + 7. Output: `debbuild/*.deb`, the binary package and the `-dbgsym` package. Debian gives dbgsym packages a `.deb` extension; only Ubuntu uses `.ddeb`. ## Post-build verification ```bash -# DEB -dpkg-deb -c debbuild/*.deb | grep -E 'systemd|sysusers|tmpfiles' +# DEB (one invocation per package: the dbgsym package is a .deb too) +for deb in debbuild/*.deb; do dpkg-deb -c "${deb}"; done | grep -E 'systemd|sysusers|tmpfiles' +lintian -I debbuild/*.deb # RPM rpm -qlp rpmbuild/RPMS/x86_64/*.rpm - -# Optional, and not in the packaging image: apt-get install -y lintian -lintian -I debbuild/*.deb ``` +`lintian` still reports `embedded-library zlib`, `no-manual-page` and +`initial-upload-closes-no-bugs`; only the `/usr/local` tags are overridden. + ## Reproducibility -`build_pkg.py` sets `SOURCE_DATE_EPOCH` from the latest git commit time and -exports it; the RPM spec clamps file modification times to it via -`%build_mtime_policy`. The remaining variables -below further improve reproducibility but are _not_ set by the script — export -them yourself if needed: +Both formats build reproducibly as they are: the same binaries at the same +commit give byte-identical packages on a rebuild, and nothing has to be +exported by hand. -```bash -export TZ=UTC -export LC_ALL=C.UTF-8 -export GZIP=-n -export DEB_BUILD_OPTIONS="noautodbgsym reproducible=+fixfilepath" -``` +`build_pkg.py` sets `SOURCE_DATE_EPOCH` from the latest git commit time. +`dpkg-buildpackage` honours it on its own; the RPM spec sets three macros: + +- `%clamp_mtime_to_source_date_epoch` — file modification times, from + `SOURCE_DATE_EPOCH`. +- `%use_source_date_epoch_as_buildtime` — the `BUILDTIME` header, from the + same. +- `%_buildhost` — pinned, so the builder's hostname stays out of the header. diff --git a/package/build_pkg.py b/package/build_pkg.py index 2518d8c1db..1aaf53d5ff 100755 --- a/package/build_pkg.py +++ b/package/build_pkg.py @@ -19,7 +19,7 @@ from pathlib import Path # This script lives in the repository it packages. SRC_DIR = Path(__file__).resolve().parents[1] -PRE_RELEASE = re.compile(r"^(b0|b[1-9][0-9]*|rc[0-9]+)(\+.*)?$") +PRE_RELEASE = re.compile(r"^(b|rc)(0|[1-9][0-9]*)(\+.*)?$") # Files both packaging systems consume, staged under the same names. STAGED_FROM_BUILD = ("xrpld", "validator-keys", "validator-keys-LICENSE") @@ -133,6 +133,14 @@ def stage_common(build_dir: Path, dest: Path) -> None: shutil.copy2(build_dir / name, dest / name) for source, name in STAGED_FROM_SRC.items(): shutil.copy2(SRC_DIR / source, dest / name) + + +def stage_units(dest: Path) -> None: + """Copy the systemd, sysusers, tmpfiles and logrotate files into dest. + + Each format wants them somewhere else: rpmbuild reads them from SOURCES, + debhelper from debian/. + """ for name in STAGED_UNITS: shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / name) @@ -146,6 +154,7 @@ def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: spec = topdir / "SPECS" / "xrpld.spec" shutil.copy2(SRC_DIR / "package" / "rpm" / "xrpld.spec", spec) stage_common(build_dir, topdir / "SOURCES") + stage_units(topdir / "SOURCES") run( "rpmbuild", @@ -178,8 +187,7 @@ def build_deb( shutil.copytree(SRC_DIR / "package" / "debian", staging / "debian") # debhelper picks these up from debian/ automatically. - for name in STAGED_UNITS: - shutil.copy2(staging / name, staging / "debian" / name) + stage_units(staging / "debian") date = datetime.fromtimestamp(epoch, timezone.utc).strftime( "%a, %d %b %Y %H:%M:%S %z" @@ -193,8 +201,6 @@ def build_deb( """) (staging / "debian" / "changelog").write_text(changelog) - (staging / "debian" / "rules").chmod(0o755) - run("dpkg-buildpackage", "-b", "--no-sign", "-d", cwd=staging) diff --git a/package/debian/control b/package/debian/control index 62e5d79ef1..359f39f770 100644 --- a/package/debian/control +++ b/package/debian/control @@ -4,6 +4,7 @@ Priority: optional Maintainer: XRPL Foundation Rules-Requires-Root: no Build-Depends: + binutils, debhelper-compat (= 13) Standards-Version: 4.7.0 Homepage: https://github.com/XRPLF/rippled @@ -11,8 +12,6 @@ Vcs-Git: https://github.com/XRPLF/rippled.git Vcs-Browser: https://github.com/XRPLF/rippled Package: xrpld -Section: net -Priority: optional Architecture: any Depends: ${shlibs:Depends}, diff --git a/package/debian/copyright b/package/debian/copyright index 2cf673854a..baaa12e13c 100644 --- a/package/debian/copyright +++ b/package/debian/copyright @@ -1,5 +1,5 @@ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: rippled +Upstream-Name: xrpld Source: https://github.com/XRPLF/rippled Files: * @@ -15,7 +15,7 @@ Copyright: 2016, Ripple Labs Inc. 2009-2010, Satoshi Nakamoto 2011, The Bitcoin developers 2003-2005, Tom Wu -License: ISC +License: ISC and BSL-1.0 and MIT and Tom-Wu Comment: Built from https://github.com/ripple/validator-keys-tool at the commit pinned in cmake/XrplValidatorKeys.cmake. Besides ISC-licensed code it incorporates work under the Boost Software License 1.0 (ASIO), the MIT/X11 @@ -35,3 +35,74 @@ License: ISC WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +License: BSL-1.0 + Boost Software License - Version 1.0 - August 17th, 2003 + . + Permission is hereby granted, free of charge, to any person or organization + obtaining a copy of the software and accompanying documentation covered by + this license (the "Software") to use, reproduce, display, distribute, + execute, and transmit the Software, and to prepare derivative works of the + Software, and to permit third-parties to whom the Software is furnished to + do so, all subject to the following: + . + The copyright notices in the Software and this entire statement, including + the above license grant, this restriction and the following disclaimer, + must be included in all copies of the Software, in whole or in part, and + all derivative works of the Software, unless such copies or derivative + works are solely in the form of machine-executable object code generated by + a source language processor. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT + SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE + FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + +License: MIT + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + +License: Tom-Wu + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + . + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, + EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY + WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + . + IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, + INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER + RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF + THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT + OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + . + In addition, the following condition applies: + . + All redistributions must retain an intact copy of this copyright notice + and disclaimer. diff --git a/package/debian/rules b/package/debian/rules old mode 100644 new mode 100755 index 8f880b8192..4a9e4ab281 --- a/package/debian/rules +++ b/package/debian/rules @@ -2,6 +2,12 @@ export DH_VERBOSE = 1 +# The glibc the Nix toolchain builds against, and so the real floor for the +# binaries. dpkg-shlibdeps would instead derive libc6 (>= 2.34) from the build +# host's symbols file, where sysconf carries that minver, locking out distros +# the binaries actually run on. +LIBC_MIN = 2.31 + %: dh $@ @@ -11,6 +17,8 @@ override_dh_auto_configure override_dh_auto_build override_dh_auto_test: override_dh_installsystemd: dh_installsystemd --no-stop-on-upgrade xrpld.service +# The tmpfiles snippet sets ownership to the xrpld user, so the sysusers snippet +# has to be emitted first: run it early and make its own sequence slot a no-op. execute_before_dh_installtmpfiles: dh_installsysusers @@ -22,5 +30,19 @@ override_dh_install: install -D -m 0644 xrpld.cfg debian/xrpld/etc/xrpld/xrpld.cfg install -D -m 0644 validators.txt debian/xrpld/etc/xrpld/validators.txt +override_dh_shlibdeps: + dh_shlibdeps + # Guards against the toolchain moving past LIBC_MIN and the packages then + # claiming a floor they do not meet. + for binary in xrpld validator-keys; do \ + needed=$$(readelf --dyn-syms --wide $$binary \ + | grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -uV | tail -1); \ + if dpkg --compare-versions "$$needed" gt "$(LIBC_MIN)"; then \ + echo "$$binary needs glibc $$needed, above LIBC_MIN $(LIBC_MIN)" >&2; \ + exit 1; \ + fi; \ + done + sed -i 's/libc6 (>= [0-9.]*)/libc6 (>= $(LIBC_MIN))/' debian/xrpld.substvars + override_dh_dwz: @: diff --git a/package/debian/xrpld.docs b/package/debian/xrpld.docs index 77681ddc6e..97325dfcf5 100644 --- a/package/debian/xrpld.docs +++ b/package/debian/xrpld.docs @@ -1,2 +1,3 @@ README.md +LICENSE.md validator-keys-LICENSE diff --git a/package/debian/xrpld.links b/package/debian/xrpld.links index 10d34f5b8c..6dea4f28f3 100644 --- a/package/debian/xrpld.links +++ b/package/debian/xrpld.links @@ -1,2 +1,3 @@ -# Legacy compat symlinks (remove next major release) +# Legacy compatibility for pre-FHS package layouts. +# TODO: remove after rippled fully deprecated. usr/bin/xrpld usr/local/bin/rippled diff --git a/package/debian/xrpld.lintian-overrides b/package/debian/xrpld.lintian-overrides new file mode 100644 index 0000000000..a0b3f583ed --- /dev/null +++ b/package/debian/xrpld.lintian-overrides @@ -0,0 +1,6 @@ +# The /usr/local/bin/rippled symlink is deliberate compatibility for pre-FHS +# layouts, so the Policy 9.1.2 tags it raises are expected. +# TODO: remove alongside debian/xrpld.links after rippled fully deprecated. +xrpld: dir-in-usr-local [usr/local/bin/] +xrpld: file-in-usr-local [usr/local/bin/rippled] +xrpld: file-in-unusual-dir [usr/local/bin/rippled] diff --git a/package/docker/Dockerfile b/package/docker/Dockerfile index b55c37b02a..adf372b6fa 100644 --- a/package/docker/Dockerfile +++ b/package/docker/Dockerfile @@ -2,9 +2,9 @@ ARG BASE_IMAGE=debian:trixie FROM ${BASE_IMAGE} -COPY bin/install-packaging-tools.sh /tmp/install-packaging-tools.sh - -RUN /tmp/install-packaging-tools.sh +# Bind-mounted rather than copied in, so the installer never lands in a layer. +RUN --mount=type=bind,source=bin/install-packaging-tools.sh,target=/install-packaging-tools.sh \ + /install-packaging-tools.sh # See ../README.md, "Publishing from other repositories". COPY package/docker/publish_pkg.py /usr/local/bin/publish_pkg.py diff --git a/package/docker/publish_pkg.py b/package/docker/publish_pkg.py index c9a6d3db1e..84a0448e7b 100755 --- a/package/docker/publish_pkg.py +++ b/package/docker/publish_pkg.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Publish built DEB and RPM packages to the XRPLF repositories on Nexus. -Takes packages and a channel, and nothing else, so it publishes whatever built -them; see package/README.md, "Publishing from other repositories". +Knows nothing about what it uploads beyond the channel, so it publishes whatever +built the packages; see package/README.md, "Publishing from other repositories". RPMs are uploaded to the hosted repository, but yum clients install from the 'rpm-' group repository in front of it, which serves signed metadata. @@ -29,6 +29,9 @@ STALL_TIMEOUT = 300 ATTEMPTS = 4 RETRY_DELAY = 5 +# 429 is Nexus asking to slow down, not a rejection, so it retries like a 5xx. +RETRYABLE_STATUSES = (429,) + def build_opener() -> urllib.request.OpenerDirector: """An opener with no redirect handler, so a 3xx raises instead of being followed. @@ -47,9 +50,9 @@ def build_opener() -> urllib.request.OpenerDirector: def upload(url: str, method: str, headers: dict[str, str], package: Path) -> None: """Send one package, retrying only what is worth retrying. - A 4xx is a deterministic rejection, so it is reported at once rather than - re-sending the whole body three more times. Nexus explains what it rejected - in the response body, so that body is always surfaced. + A 4xx other than 429 is a deterministic rejection, so it is reported at once + rather than re-sending the whole body three more times. Nexus explains what + it rejected in the response body, so that body is always surfaced. """ opener = build_opener() @@ -67,7 +70,7 @@ def upload(url: str, method: str, headers: dict[str, str], package: Path) -> Non except urllib.error.HTTPError as error: detail = error.read().decode(errors="replace").strip() reason = f"HTTP {error.code}: {detail}" - retryable = error.code >= 500 + retryable = error.code >= 500 or error.code in RETRYABLE_STATUSES except (urllib.error.URLError, OSError) as error: reason = str(error) retryable = True @@ -121,6 +124,8 @@ def main() -> None: token = base64.b64encode(f"{username}:{password}".encode()).decode() auth = {"Authorization": f"Basic {token}"} + # Deliberately not shared with sign_rpm.py: this script ships standalone in + # the packaging image for other repositories to run. packages = sorted( path for path in package_dir.rglob("*") diff --git a/package/rpm/xrpld.spec b/package/rpm/xrpld.spec index 23974c8900..5139cd54e5 100644 --- a/package/rpm/xrpld.spec +++ b/package/rpm/xrpld.spec @@ -17,6 +17,11 @@ URL: https://github.com/XRPLF/rippled ExclusiveArch: x86_64 aarch64 BuildRequires: systemd-rpm-macros +# These have to precede %%debug_package: it opens the debuginfo subpackage, and +# any tag after it is silently dropped from the main package. +%{?systemd_requires} +%{?sysusers_requires_compat} + %undefine _debugsource_packages %debug_package # Level 3 rather than the el9 default of 19: it shrinks the multi-gigabyte @@ -25,10 +30,13 @@ BuildRequires: systemd-rpm-macros %global _binary_payload w3.zstdio %global _find_debuginfo_dwz_opts %{nil} -%build_mtime_policy clamp_to_source_date_epoch +# Reproducibility: the first two take their value from the SOURCE_DATE_EPOCH +# build_pkg.py exports. Without these the header records the wall clock and the +# build container's hostname, so two builds of the same commit differ. +%global clamp_mtime_to_source_date_epoch 1 +%global use_source_date_epoch_as_buildtime 1 +%global _buildhost xrplf.org -%{?systemd_requires} -%{?sysusers_requires_compat} %description xrpld is the reference implementation of the XRP Ledger protocol. It @@ -53,7 +61,7 @@ install -Dm0644 %{_sourcedir}/validators.txt %{buildroot}%{_sysconfdir}/%{ install -Dm0644 %{_sourcedir}/xrpld.service %{buildroot}%{_unitdir}/xrpld.service install -Dm0644 %{_sourcedir}/xrpld.sysusers %{buildroot}%{_sysusersdir}/xrpld.conf install -Dm0644 %{_sourcedir}/xrpld.tmpfiles %{buildroot}%{_tmpfilesdir}/xrpld.conf -install -Dm0644 /dev/null %{buildroot}%{_presetdir}/50-xrpld.preset +install -d %{buildroot}%{_presetdir} cat >%{buildroot}%{_presetdir}/50-xrpld.preset <<'EOF' enable xrpld.service EOF @@ -76,7 +84,7 @@ ln -s %{_bindir}/%{name} %{buildroot}/usr/local/bin/rippled %sysusers_create_package %{name} %{_sourcedir}/xrpld.sysusers %post -systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || : +%tmpfiles_create_package %{name} %{_sourcedir}/xrpld.tmpfiles %systemd_post xrpld.service %preun @@ -86,11 +94,12 @@ systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || : %systemd_postun xrpld.service %files +%attr(0755,root,root) %dir %{_docdir}/%{name} %license %{_docdir}/%{name}/LICENSE.md %license %{_docdir}/%{name}/validator-keys-LICENSE %doc %{_docdir}/%{name}/README.md -%dir %{_sysconfdir}/%{name} +%attr(0755,root,root) %dir %{_sysconfdir}/%{name} %{_bindir}/%{name} %{_bindir}/validator-keys @@ -101,7 +110,7 @@ systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || : %{_unitdir}/xrpld.service -%{_presetdir}/50-xrpld.preset +%attr(0644,root,root) %{_presetdir}/50-xrpld.preset %{_sysusersdir}/xrpld.conf %{_tmpfilesdir}/xrpld.conf %ghost %dir /var/lib/xrpld diff --git a/package/shared/xrpld.service b/package/shared/xrpld.service index 22e6359ef0..27dd6a5a3a 100644 --- a/package/shared/xrpld.service +++ b/package/shared/xrpld.service @@ -17,6 +17,8 @@ ProtectHome=true PrivateTmp=true User=xrpld Group=xrpld +# xrpld.tmpfiles creates these at install and boot; these recreate them on +# every start, so a removed directory does not stop the service. StateDirectory=xrpld StateDirectoryMode=0750 LogsDirectory=xrpld diff --git a/package/sign_rpm.py b/package/sign_rpm.py index 05c719b710..07bda9f392 100755 --- a/package/sign_rpm.py +++ b/package/sign_rpm.py @@ -107,6 +107,8 @@ def main() -> None: args = parser.parse_args() package_dir: Path = args.package_dir + # Deliberately not shared with publish_pkg.py, which ships standalone in the + # packaging image. rpms = sorted(path for path in package_dir.rglob("*.rpm") if path.is_file()) # Signing nothing would otherwise look like a successful signing. assert rpms, f"no RPMs found in {package_dir}" From 6e1eb88e6eab916b2659aa71c951cfc255dafa21 Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 3 Sep 2026 11:52:08 +0000 Subject: [PATCH 28/40] ci: Update prepare-runner SHA (#8168) Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com> --- .github/workflows/check-tools.yml | 2 +- .github/workflows/publish-docs.yml | 2 +- .github/workflows/reusable-build-test-config.yml | 2 +- .github/workflows/reusable-clang-tidy.yml | 2 +- .github/workflows/reusable-package.yml | 4 ++-- .github/workflows/reusable-upload-recipe.yml | 2 +- .github/workflows/upload-conan-deps.yml | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/check-tools.yml b/.github/workflows/check-tools.yml index 1169140481..148ee9a781 100644 --- a/.github/workflows/check-tools.yml +++ b/.github/workflows/check-tools.yml @@ -79,7 +79,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + uses: XRPLF/actions/prepare-runner@c83c0e6a4d270cb022277b48cfdaa68c906e9ded with: enable_ccache: false diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 8c5d10929c..072d861456 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -47,7 +47,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + uses: XRPLF/actions/prepare-runner@c83c0e6a4d270cb022277b48cfdaa68c906e9ded with: enable_ccache: false diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 2846c3fb85..13e20b2211 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -129,7 +129,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + uses: XRPLF/actions/prepare-runner@c83c0e6a4d270cb022277b48cfdaa68c906e9ded with: enable_ccache: ${{ inputs.ccache_enabled }} diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index 045d384181..5f45fcf732 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + uses: XRPLF/actions/prepare-runner@c83c0e6a4d270cb022277b48cfdaa68c906e9ded with: enable_ccache: false diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index 3986feed7f..c3df348faa 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -81,7 +81,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + uses: XRPLF/actions/prepare-runner@c83c0e6a4d270cb022277b48cfdaa68c906e9ded with: enable_ccache: false @@ -261,7 +261,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + uses: XRPLF/actions/prepare-runner@c83c0e6a4d270cb022277b48cfdaa68c906e9ded with: enable_ccache: false diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index 6fa289665a..beb1104ab0 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -50,7 +50,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + uses: XRPLF/actions/prepare-runner@c83c0e6a4d270cb022277b48cfdaa68c906e9ded with: enable_ccache: false diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml index 184f13cc5e..99e25c8914 100644 --- a/.github/workflows/upload-conan-deps.yml +++ b/.github/workflows/upload-conan-deps.yml @@ -68,7 +68,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + uses: XRPLF/actions/prepare-runner@c83c0e6a4d270cb022277b48cfdaa68c906e9ded with: enable_ccache: false From 8ce4f71427af7acc25fe8964b9a5a337163ebf40 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:48:32 +0000 Subject: [PATCH 29/40] docs: Use consistent heading levels in PR template (#8155) --- .github/pull_request_template.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 95d75c04b4..e0d511c467 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,7 +18,7 @@ If too broad, please consider splitting into multiple PRs. If there is a relevant task or issue, please link it here. --> -### Context of Change +## Context of Change -### API Impact +## API Impact