From b1ac8912101f161dc22775cde546652503e07bb1 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 20 Aug 2026 11:47:27 +0000 Subject: [PATCH 01/43] ci: Do not cache cargo binaries (#8062) --- .github/actions/cargo-cache/action.yml | 38 +++++++++++++++++++ .github/dependabot.yml | 1 + .github/workflows/build-nix-images.yml | 2 +- .github/workflows/build-packaging-images.yml | 2 +- .github/workflows/build-pre-commit-image.yml | 2 +- .github/workflows/check-tools.yml | 2 +- .github/workflows/pre-commit.yml | 2 +- .github/workflows/publish-docs.yml | 2 +- .../workflows/reusable-build-test-config.yml | 4 +- .github/workflows/reusable-clang-tidy.yml | 6 +-- .github/workflows/reusable-rust.yml | 12 ++---- .github/workflows/upload-conan-deps.yml | 2 +- 12 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 .github/actions/cargo-cache/action.yml diff --git a/.github/actions/cargo-cache/action.yml b/.github/actions/cargo-cache/action.yml new file mode 100644 index 0000000000..1923d8cf64 --- /dev/null +++ b/.github/actions/cargo-cache/action.yml @@ -0,0 +1,38 @@ +name: Use cargo artifacts cache +description: > + Cache the cargo build artifacts with rust-cache. Never caches ~/.cargo/bin: + when saving the cache, rust-cache deletes all binaries that were already + present there, which on persistent self-hosted runners wipes the tools + installed by prepare-runner. Harmless on ephemeral runners, but kept + consistent everywhere. + +inputs: + workspaces: + description: "Workspaces to cache, as 'workspace -> target' lines." + required: false + default: crates + key: + description: "Additional part of the cache key." + required: false + default: "" + cache-directories: + description: "Additional non-workspace directories to cache." + required: false + default: "" + save-if: + description: "Condition for saving the cache after the job." + required: false + default: "true" + +runs: + using: composite + + steps: + - name: Use cargo artifacts cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + cache-bin: "false" + cache-directories: ${{ inputs.cache-directories }} + key: ${{ inputs.key }} + save-if: ${{ inputs.save-if }} + workspaces: ${{ inputs.workspaces }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index da37f79007..7361a3db63 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,7 @@ updates: directories: - / - .github/actions/build-deps/ + - .github/actions/cargo-cache/ - .github/actions/release-info/ - .github/actions/set-compiler-env/ - .github/actions/setup-conan/ diff --git a/.github/workflows/build-nix-images.yml b/.github/workflows/build-nix-images.yml index fe2f43fdcc..813edd8aff 100644 --- a/.github/workflows/build-nix-images.yml +++ b/.github/workflows/build-nix-images.yml @@ -58,7 +58,7 @@ jobs: base_image: debian:bookworm - name: rhel base_image: registry.access.redhat.com/ubi9/ubi:latest - uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@9e7e4e80af9e684c116b38369add8eea64451f32 + uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/nix-${{ matrix.distro.name }} dockerfile: nix/docker/Dockerfile diff --git a/.github/workflows/build-packaging-images.yml b/.github/workflows/build-packaging-images.yml index fbabc25ac3..c927942fca 100644 --- a/.github/workflows/build-packaging-images.yml +++ b/.github/workflows/build-packaging-images.yml @@ -39,7 +39,7 @@ jobs: # AlmaLinux rather than UBI9, which does not ship rpm-sign. - name: rhel base_image: almalinux:9 - uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@9e7e4e80af9e684c116b38369add8eea64451f32 + uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/packaging-${{ matrix.distro.name }} dockerfile: package/Dockerfile diff --git a/.github/workflows/build-pre-commit-image.yml b/.github/workflows/build-pre-commit-image.yml index d0eba6b495..71f083b686 100644 --- a/.github/workflows/build-pre-commit-image.yml +++ b/.github/workflows/build-pre-commit-image.yml @@ -30,7 +30,7 @@ jobs: permissions: contents: read packages: write - uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@9e7e4e80af9e684c116b38369add8eea64451f32 + uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/pre-commit dockerfile: bin/pre-commit/Dockerfile diff --git a/.github/workflows/check-tools.yml b/.github/workflows/check-tools.yml index c7a00e8b49..1169140481 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@51af40f99ea91a08c3528ddf16d98132dcc7e63c + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: false diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index ac5fe46722..905e910591 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -14,7 +14,7 @@ on: jobs: # Call the workflow in the XRPLF/actions repo that runs the pre-commit hooks. run-hooks: - uses: XRPLF/actions/.github/workflows/pre-commit.yml@3ba08d6ddf114092891d48491fc2e26c3ba15552 + uses: XRPLF/actions/.github/workflows/pre-commit.yml@f1952595d212e86169935135efc66294b4574131 with: runs_on: ubuntu-latest container: '{ "image": "ghcr.io/xrplf/xrpld/pre-commit:sha-f56b79f" }' diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 3b863f2b33..b8ca7751ab 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@51af40f99ea91a08c3528ddf16d98132dcc7e63c + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: false diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 89bfc7463b..656e6ec85b 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@51af40f99ea91a08c3528ddf16d98132dcc7e63c + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: ${{ inputs.ccache_enabled }} @@ -163,7 +163,7 @@ jobs: compiler: ${{ inputs.compiler }} - name: Use cargo artifacts cache - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + uses: ./.github/actions/cargo-cache with: cache-directories: ${{ env.BUILD_DIR }}/corrosion key: ${{ inputs.config_name }} diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index 8dd1af9d99..6847ff9b57 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -27,7 +27,7 @@ jobs: determine-files: permissions: contents: read - uses: XRPLF/actions/.github/workflows/determine-tidy-files.yml@d041ac9f1fa9f07a4ba335eb4c1c82233fb3fef6 + uses: XRPLF/actions/.github/workflows/determine-tidy-files.yml@70145243b905dc3e040a61d39c00e178cfb96f71 run-clang-tidy: name: Run clang tidy @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: false @@ -60,7 +60,7 @@ jobs: compiler: ${{ env.COMPILER }} - name: Use cargo artifacts cache - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + uses: ./.github/actions/cargo-cache with: cache-directories: ${{ env.BUILD_DIR }}/corrosion save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }} diff --git a/.github/workflows/reusable-rust.yml b/.github/workflows/reusable-rust.yml index e9d281c692..83301f97ad 100644 --- a/.github/workflows/reusable-rust.yml +++ b/.github/workflows/reusable-rust.yml @@ -33,9 +33,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Use cargo artifacts cache - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: crates + uses: ./.github/actions/cargo-cache - name: Run clippy run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings @@ -48,9 +46,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Use cargo artifacts cache - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: crates + uses: ./.github/actions/cargo-cache - name: Generate coverage report run: cargo llvm-cov nextest --workspace --all-features --locked --no-tests=warn --lcov --output-path lcov.info @@ -76,9 +72,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Use cargo artifacts cache - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: crates + uses: ./.github/actions/cargo-cache - name: Build documentation env: diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml index 65a3f9c5b6..184f13cc5e 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@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: false From a3a2c85c41e4b241039a62ccf1462e9183512fff Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 20 Aug 2026 13:05:18 +0000 Subject: [PATCH 02/43] build: Use debian `any` distribution & signed (hosted) rpm repo (#8053) --- docs/install.md | 6 +++--- package/README.md | 31 +++++++++++++++++-------------- package/publish_pkg.sh | 7 +++++-- package/sign_rpm.sh | 12 +++++++----- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/docs/install.md b/docs/install.md index 9699150fdb..a3e2fefa02 100644 --- a/docs/install.md +++ b/docs/install.md @@ -65,7 +65,7 @@ wherever it appears in the repository configuration. 4. Add the repository, using the channel you picked in [Release channels](#release-channels): ```bash - echo "deb [signed-by=/etc/apt/keyrings/xrplf.asc] https://packages.xrplf.org/repository/deb-stable focal main" | \ + echo "deb [signed-by=/etc/apt/keyrings/xrplf.asc] https://packages.xrplf.org/repository/deb-stable any main" | \ sudo tee /etc/apt/sources.list.d/xrplf.list ``` @@ -98,13 +98,13 @@ wherever it appears in the repository configuration. enabled=1 baseurl=https://packages.xrplf.org/repository/rpm-stable/ gpgcheck=1 - repo_gpgcheck=0 + repo_gpgcheck=1 gpgkey=https://packages.xrplf.org/xrplf.asc REPOFILE ``` `gpgcheck=1` verifies each package against the key above. - `repo_gpgcheck` is off because the repository metadata is generated by the server and is not signed. + `repo_gpgcheck=1` verifies the repository metadata, which the server signs with the same key. 3. Install the `xrpld` package: diff --git a/package/README.md b/package/README.md index 9c40861530..54b1e57204 100644 --- a/package/README.md +++ b/package/README.md @@ -126,15 +126,15 @@ release defaults to 1 and is overridable with `-Dpkg_release=N`. Packages are published to the XRPLF repositories on Sonatype Nexus at `https://packages.xrplf.org`. The `release-info` action decides the channel from -the event, and `publish_pkg.sh` maps that channel to a repository pair: +the event, and `publish_pkg.sh` maps that channel to its repositories: -| Event | Version | Channel | DEB repository | RPM repository | -| ------------------------ | ----------------- | -------------- | ------------------ | ------------------ | -| tag | `X.Y.Z` | `stable` | `deb-stable` | `rpm-stable` | -| tag | `X.Y.Z-rcN` | `unstable` | `deb-unstable` | `rpm-unstable` | -| tag | `X.Y.Z-bN` | `experimental` | `deb-experimental` | `rpm-experimental` | -| push to `develop` | `xrpld --version` | `develop` | `deb-develop` | `rpm-develop` | -| tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private` | +| Event | Version | Channel | DEB repository | RPM upload repository | +| ------------------------ | ----------------- | -------------- | ------------------ | ------------------------- | +| tag | `X.Y.Z` | `stable` | `deb-stable` | `rpm-stable-hosted` | +| tag | `X.Y.Z-rcN` | `unstable` | `deb-unstable` | `rpm-unstable-hosted` | +| tag | `X.Y.Z-bN` | `experimental` | `deb-experimental` | `rpm-experimental-hosted` | +| push to `develop` | `xrpld --version` | `develop` | `deb-develop` | `rpm-develop-hosted` | +| tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private-hosted` | Only a tag names a channel — do not extend that to `develop`, where `BuildInfo.cpp`'s `versionString` moves through `-bN`, `-rcN` and even the final @@ -155,12 +155,15 @@ Conan remote. Nexus owns the repository metadata; nothing here indexes anything. Worth knowing: -- Each apt-hosted repository needs a distribution and a PGP signing keypair - configured in Nexus, which rejects one created without a keypair. Nexus signs - the apt metadata with it, never the packages. -- Hosted yum repositories cannot be signed by Nexus at all, so `sign_rpm.sh` - signs the RPMs before they are uploaded, and rpm clients verify with - `gpgcheck=1` rather than `repo_gpgcheck=1`. +- Each apt-hosted repository needs a distribution (ours use `any`) and a PGP + signing keypair configured in Nexus, which rejects one created without a + keypair. Nexus signs the apt metadata with it, never the packages. +- Hosted yum repositories cannot be signed by Nexus, so each `rpm--hosted` + repository sits behind a `rpm-` yum group repository whose metadata + Nexus signs. Uploads go to the hosted repository; clients point at the group + and verify the metadata with `repo_gpgcheck=1`. Nexus never signs the RPMs + themselves, so `sign_rpm.sh` signs them before they are uploaded, and clients + verify them with `gpgcheck=1`. - yum metadata is rebuilt asynchronously, so a successful publish is not immediately installable. - Each job uploads only what it built, and uploads are not transactional, so a diff --git a/package/publish_pkg.sh b/package/publish_pkg.sh index be36b531de..8ea9b189f4 100755 --- a/package/publish_pkg.sh +++ b/package/publish_pkg.sh @@ -7,10 +7,13 @@ set -euo pipefail # Usage: publish_pkg.sh [package-dir] # # channel release channel, selecting the 'deb-' and -# 'rpm-' repository pair +# 'rpm--hosted' repositories # package-dir searched recursively for *.deb, *.ddeb and *.rpm ('build' by # default) # +# RPMs are uploaded to the hosted repository, but yum clients install from the +# 'rpm-' group repository in front of it, which serves signed metadata. +# # NEXUS_USERNAME and NEXUS_PASSWORD are required. NEXUS_URL overrides the target # instance, and DRY_RUN=1 lists the uploads without performing them. @@ -24,7 +27,7 @@ if [[ -z "${channel}" ]]; then fi deb_repo="deb-${channel}" -rpm_repo="rpm-${channel}" +rpm_repo="rpm-${channel}-hosted" if [[ -z "${DRY_RUN:-}" ]]; then : "${NEXUS_USERNAME:?is required}" "${NEXUS_PASSWORD:?is required}" diff --git a/package/sign_rpm.sh b/package/sign_rpm.sh index 7a1d6f00e3..250e806dd7 100755 --- a/package/sign_rpm.sh +++ b/package/sign_rpm.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -# Sign the RPMs built by build_pkg.sh. Nexus cannot sign hosted yum metadata, so -# the packages carry the signature themselves and rpm clients verify them with -# gpgcheck=1. +# Sign the RPMs built by build_pkg.sh. Nexus signs the yum repository metadata +# (via the 'rpm-' group repository), but never the packages themselves, +# so they carry their own signature. Clients verify the packages with gpgcheck=1 +# and the metadata with repo_gpgcheck=1. # # Usage: sign_rpm.sh [package-dir] # @@ -12,8 +13,9 @@ set -euo pipefail # PKG_SIGNING_KEY must hold an armoured PGP private key. It has no flag, to keep # the key out of the process list. # -# There is no DEB equivalent: apt trusts the repository metadata, which Nexus -# signs, rather than the packages themselves. +# The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs), +# but apt does not verify them by default and trusts the repository metadata, +# which Nexus signs, instead. pkg_dir="${1:-build}" From 3b1c9e4320f0e2f4981ba09c6828b412fb7ad999 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:12:14 +0000 Subject: [PATCH 03/43] chore: [DEPENDABOT] Bump cxx from 1.0.198 to 1.0.199 in /crates in the rust-dependencies group across 1 directory (#8050) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- crates/Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/Cargo.lock b/crates/Cargo.lock index bc38558c16..70247f8e19 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -57,9 +57,9 @@ dependencies = [ [[package]] name = "cxx" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291" +checksum = "824894a4a85dca76d4c95c2b9098c036f5a29f627b30c12780774f6654e60974" dependencies = [ "cc", "cxx-build", @@ -72,9 +72,9 @@ dependencies = [ [[package]] name = "cxx-build" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2" +checksum = "f1ae0b651ea5b0000b19513aef5a03f194d7e3486f2d9258b658da8677fe9036" dependencies = [ "cc", "codespan-reporting", @@ -87,9 +87,9 @@ dependencies = [ [[package]] name = "cxxbridge-cmd" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d" +checksum = "fb05f91d3fb8435d9bab6ac5ce6ac1868be774325fb7fb2a91be39393b21388e" dependencies = [ "clap", "codespan-reporting", @@ -101,15 +101,15 @@ dependencies = [ [[package]] name = "cxxbridge-flags" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89" +checksum = "bf293202e0e3e98495785745389e8d0755b217e66f19194a5c695c25e03282ef" [[package]] name = "cxxbridge-macro" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c" +checksum = "ca001d746947c7249ed9d332a10f7a59daedbafeb0ec68c5c18a7db7a93f6ccc" dependencies = [ "indexmap", "proc-macro2", From 959a186a0f05a9ca30c1def2f911618cd17aabe5 Mon Sep 17 00:00:00 2001 From: Jingchen Date: Thu, 20 Aug 2026 13:19:07 +0000 Subject: [PATCH 04/43] build: Suppress MSVC linker warning LNK4099 (#8049) --- cmake/XrplCompiler.cmake | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index 2b46739d97..29c1dfe478 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -120,7 +120,10 @@ if(MSVC) _SILENCE_ALL_CXX17_DEPRECATION_WARNINGS $<$,$>:_CRTDBG_MAP_ALLOC> ) - target_link_libraries(common INTERFACE -errorreport:none -machine:X64) + target_link_libraries( + common + INTERFACE -errorreport:none -machine:X64 -ignore:4099 + ) else() target_compile_options( common From e3ba569187c7e435069fa0f03eefe40adf166431 Mon Sep 17 00:00:00 2001 From: Shawn Xie <35279399+shawnxie999@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:33:46 +0000 Subject: [PATCH 05/43] fix: Check credential for LoanBrokerCoverWithdraw and VaultWithdraw (#7107) Co-authored-by: Peter Chen Co-authored-by: Ayaz Salikhov --- include/xrpl/ledger/View.h | 25 +++- .../xrpl/protocol/detail/transactions.macro | 2 + .../transactions/LoanBrokerCoverWithdraw.h | 37 +++++ .../transactions/VaultWithdraw.h | 37 +++++ .../xrpl/tx/transactors/vault/VaultWithdraw.h | 3 + src/libxrpl/ledger/View.cpp | 35 ++++- .../lending/LoanBrokerCoverWithdraw.cpp | 16 ++- .../tx/transactors/vault/VaultWithdraw.cpp | 20 ++- src/test/app/lending/LoanBroker_test.cpp | 131 ++++++++++++++++++ src/test/app/vault/VaultDomain_test.cpp | 110 +++++++++++++++ .../LoanBrokerCoverWithdrawTests.cpp | 21 +++ .../transactions/VaultWithdrawTests.cpp | 21 +++ 12 files changed, 445 insertions(+), 13 deletions(-) diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index f7fd5b5a8c..bb0817673c 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -24,6 +24,7 @@ #include #include #include +#include namespace xrpl { @@ -198,7 +199,10 @@ dirLink( * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials passed in to already exist in the ledger, and + * returns an internal error otherwise. Validate them beforehand with + * credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ @@ -209,7 +213,8 @@ canWithdraw( AccountID const& to, SLE::const_ref toSle, STAmount const& amount, - bool hasDestinationTag); + bool hasDestinationTag, + std::optional> const& credentialIDs = std::nullopt); /** * Checks that can withdraw funds from an object to itself or a destination. @@ -222,7 +227,10 @@ canWithdraw( * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials passed in to already exist in the ledger, and + * returns an internal error otherwise. Validate them beforehand with + * credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ @@ -232,20 +240,25 @@ canWithdraw( AccountID const& from, AccountID const& to, STAmount const& amount, - bool hasDestinationTag); + bool hasDestinationTag, + std::optional> const& credentialIDs = std::nullopt); /** * Checks that can withdraw funds from an object to itself or a destination. * * The receiver may be either the submitting account (sfAccount) or a different - * destination account (sfDestination). + * destination account (sfDestination). Credentials, if any, are taken from the + * transaction's sfCredentialIDs field. * * - Checks that the receiver account exists. * - If the receiver requires a destination tag, check that one exists, even * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials in sfCredentialIDs to already exist in the + * ledger, and returns an internal error otherwise. Validate them + * beforehand with credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index f8676d3b63..997f368638 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -921,6 +921,7 @@ TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}, })) /** This transaction claws back tokens from a vault. */ @@ -1004,6 +1005,7 @@ TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}, })) /** This transaction claws back First Loss Capital from a Loan Broker to diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h index 56a93acbb4..148db4292c 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h @@ -121,6 +121,32 @@ public: { return this->tx_->isFieldPresent(sfDestinationTag); } + + /** + * @brief Get sfCredentialIDs (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getCredentialIDs() const + { + if (hasCredentialIDs()) + { + return this->tx_->at(sfCredentialIDs); + } + return std::nullopt; + } + + /** + * @brief Check if sfCredentialIDs is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasCredentialIDs() const + { + return this->tx_->isFieldPresent(sfCredentialIDs); + } }; /** @@ -214,6 +240,17 @@ public: return *this; } + /** + * @brief Set sfCredentialIDs (SoeOptional) + * @return Reference to this builder for method chaining. + */ + LoanBrokerCoverWithdrawBuilder& + setCredentialIDs(std::decay_t const& value) + { + object_[sfCredentialIDs] = value; + return *this; + } + /** * @brief Build and return the LoanBrokerCoverWithdraw wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h b/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h index 3211524e1f..17208cd76c 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h @@ -121,6 +121,32 @@ public: { return this->tx_->isFieldPresent(sfDestinationTag); } + + /** + * @brief Get sfCredentialIDs (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getCredentialIDs() const + { + if (hasCredentialIDs()) + { + return this->tx_->at(sfCredentialIDs); + } + return std::nullopt; + } + + /** + * @brief Check if sfCredentialIDs is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasCredentialIDs() const + { + return this->tx_->isFieldPresent(sfCredentialIDs); + } }; /** @@ -214,6 +240,17 @@ public: return *this; } + /** + * @brief Set sfCredentialIDs (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultWithdrawBuilder& + setCredentialIDs(std::decay_t const& value) + { + object_[sfCredentialIDs] = value; + return *this; + } + /** * @brief Build and return the VaultWithdraw wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/tx/transactors/vault/VaultWithdraw.h b/include/xrpl/tx/transactors/vault/VaultWithdraw.h index 22ad39d26d..b61af8b323 100644 --- a/include/xrpl/tx/transactors/vault/VaultWithdraw.h +++ b/include/xrpl/tx/transactors/vault/VaultWithdraw.h @@ -20,6 +20,9 @@ public: { } + static bool + checkExtraFeatures(PreflightContext const& ctx); + static NotTEC preflight(PreflightContext const& ctx); diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index e01ae2e492..0cd082ff47 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -35,6 +35,7 @@ #include #include #include +#include namespace xrpl { @@ -467,7 +468,8 @@ canWithdraw( AccountID const& to, SLE::const_ref toSle, STAmount const& amount, - bool hasDestinationTag) + bool hasDestinationTag, + std::optional> const& credentialIDs) { if (auto const ret = checkDestinationAndTag(toSle, hasDestinationTag)) return ret; @@ -478,7 +480,28 @@ canWithdraw( if (toSle->isFlag(lsfDepositAuth)) { if (!view.exists(keylet::depositPreauth(to, from))) - return tecNO_PERMISSION; + { + if (credentialIDs.has_value()) + { + STVector256 const credIDs{*credentialIDs}; + + // Callers must have validated these in preclaim, so a missing + // credential here is an invariant violation. + for (auto const& h : credIDs) + { + if (!view.exists(keylet::credential(h))) + return tecINTERNAL; // LCOV_EXCL_LINE + } + + if (auto const ret = credentials::authorizedDepositPreauth(view, credIDs, to); + !isTesSuccess(ret)) + return ret; + } + else + { + return tecNO_PERMISSION; + } + } } return withdrawToDestExceedsLimit(view, from, to, amount); @@ -490,11 +513,12 @@ canWithdraw( AccountID const& from, AccountID const& to, STAmount const& amount, - bool hasDestinationTag) + bool hasDestinationTag, + std::optional> const& credentialIDs) { auto const toSle = view.read(keylet::account(to)); - return canWithdraw(view, from, to, toSle, amount, hasDestinationTag); + return canWithdraw(view, from, to, toSle, amount, hasDestinationTag, credentialIDs); } [[nodiscard]] TER @@ -503,7 +527,8 @@ canWithdraw(ReadView const& view, STTx const& tx) auto const from = tx[sfAccount]; auto const to = tx[~sfDestination].value_or(from); - return canWithdraw(view, from, to, tx[sfAmount], tx.isFieldPresent(sfDestinationTag)); + return canWithdraw( + view, from, to, tx[sfAmount], tx.isFieldPresent(sfDestinationTag), tx[~sfCredentialIDs]); } TER diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp index 498f3c99eb..e914596599 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -25,7 +26,11 @@ namespace xrpl { bool LoanBrokerCoverWithdraw::checkExtraFeatures(PreflightContext const& ctx) { - return checkLendingProtocolDependencies(ctx.rules, ctx.tx); + if (!checkLendingProtocolDependencies(ctx.rules, ctx.tx)) + return false; + + return !ctx.tx.isFieldPresent(sfCredentialIDs) || + (ctx.rules.enabled(featureCredentials) && ctx.rules.enabled(fixCleanup3_4_0)); } NotTEC @@ -49,6 +54,9 @@ LoanBrokerCoverWithdraw::preflight(PreflightContext const& ctx) } } + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) + return err; + return tesSUCCESS; } @@ -109,6 +117,12 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx) if (auto const ret = canTransfer(ctx.view, vaultAsset, pseudoAccountID, dstAcct, waive)) return ret; + // Validate credentials (if any) before canWithdraw, since canWithdraw may + // call credentials::authorizedDepositPreauth which assumes credentials + // already exist. + if (auto const err = credentials::valid(ctx.tx, ctx.view, account, ctx.j); !isTesSuccess(err)) + return err; + // Withdrawal to a 3rd party destination account is essentially a transfer. // Enforce all the usual asset transfer checks. AuthType authType = AuthType::WeakAuth; diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 7e32e720d6..40689572a0 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,13 @@ namespace xrpl { +bool +VaultWithdraw::checkExtraFeatures(PreflightContext const& ctx) +{ + return !ctx.tx.isFieldPresent(sfCredentialIDs) || + (ctx.rules.enabled(featureCredentials) && ctx.rules.enabled(fixCleanup3_4_0)); +} + static WaiveUnrealizedLoss shouldWaiveWithdrawal(ReadView const& view, AccountID const& account, SLE::const_ref issuance) { @@ -59,6 +67,9 @@ VaultWithdraw::preflight(PreflightContext const& ctx) } } + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) + return err; + return tesSUCCESS; } @@ -113,6 +124,12 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) // LCOV_EXCL_STOP } + // Validate credentials (if any) before canWithdraw, since canWithdraw may + // call credentials::authorizedDepositPreauth which assumes credentials + // already exist. + if (auto const err = credentials::valid(ctx.tx, ctx.view, account, ctx.j); !isTesSuccess(err)) + return err; + if (fix313Enabled && amount.asset() == vaultShare) { // Post-fixCleanup3_1_3: if the user specified shares, convert @@ -144,7 +161,8 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) account, dstAcct, *maybeAssets, - ctx.tx.isFieldPresent(sfDestinationTag))) + ctx.tx.isFieldPresent(sfDestinationTag), + ctx.tx[~sfCredentialIDs])) return ret; } catch (std::overflow_error const&) diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp index 5efa65d506..321ed5168f 100644 --- a/src/test/app/lending/LoanBroker_test.cpp +++ b/src/test/app/lending/LoanBroker_test.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -2532,6 +2534,132 @@ class LoanBroker_test : public beast::unit_test::Suite testRIPD4274MPT(); } + void + testCoverWithdrawCredentialDepositPreauth(FeatureBitset features) + { + testcase( + std::string{"CoverWithdraw with credential-based deposit preauth "} + + (features[fixCleanup3_4_0] ? "post-fix" : "pre-fix")); + using namespace jtx; + using namespace std::chrono_literals; + + bool const fixEnabled = features[fixCleanup3_4_0]; + + Env env(*this, features); + + Account const broker{"broker"}; + Account const dest{"dest"}; + Account const credIssuer{"credIssuer"}; + char const credType[] = "abcde"; + + env.fund(XRP(10'000), broker, dest, credIssuer); + env(fset(dest, asfDepositAuth)); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1'000'000}; + + Vault const vault(env); + auto const [vaultTx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); + env(vaultTx); + env.close(); + + env(vault.deposit({.depositor = broker, .id = vaultKeylet.key, .amount = asset(1'000)})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(broker.id(), SeqProxy::rawSequence(env.seq(broker))); + env(loan_broker::set(broker, vaultKeylet.key)); + env.close(); + + env(loan_broker::coverDeposit(broker, brokerKeylet.key, asset(500))); + env.close(); + + auto coverWithdrawToDest = [&]() { + return loan_broker::coverWithdraw(broker, brokerKeylet.key, asset(10)); + }; + + // Without any preauth, coverWithdraw to dest fails + env(coverWithdrawToDest(), loan_broker::kDestination(dest), Ter{tecNO_PERMISSION}); + env.close(); + + // Issue and accept a credential for the broker (with expiration) + auto jv = credentials::create(broker, credIssuer, credType); + std::uint32_t const expiration = + env.current()->header().parentCloseTime.time_since_epoch().count() + 100; + jv[sfExpiration.jsonName] = expiration; + env(jv); + env(credentials::accept(broker, credIssuer, credType)); + env.close(); + + auto const credKeylet = credentials::keylet(broker, credIssuer, credType); + auto const credIdx = + credentials::ledgerEntry(env, broker, credIssuer, credType)[jss::result][jss::index] + .asString(); + + // dest authorizes deposits from holders of credentials issued by credIssuer + env(deposit::authCredentials(dest, {{.issuer = credIssuer, .credType = credType}})); + env.close(); + + // Without supplying credentials, still fails + env(coverWithdrawToDest(), loan_broker::kDestination(dest), Ter{tecNO_PERMISSION}); + env.close(); + + if (!fixEnabled) + { + // Pre-fix: sfCredentialIDs in LoanBrokerCoverWithdraw is disabled + env(coverWithdrawToDest(), + loan_broker::kDestination(dest), + credentials::Ids({credIdx}), + Ter{temDISABLED}); + env.close(); + return; + } + + // With credentials, succeeds + env(coverWithdrawToDest(), loan_broker::kDestination(dest), credentials::Ids({credIdx})); + env.close(); + + // Bad credential id is rejected + std::string const invalidIdx = + "0E0B04ED60588A758B67E21FBBE95AC5A63598BA951761DC0EC9C08D7E01E034"; + env(coverWithdrawToDest(), + loan_broker::kDestination(dest), + credentials::Ids({invalidIdx}), + Ter{tecBAD_CREDENTIALS}); + env.close(); + + // Malformed credential array (duplicates) is rejected by checkFields + env(coverWithdrawToDest(), + loan_broker::kDestination(dest), + credentials::Ids({credIdx, credIdx}), + Ter{temMALFORMED}); + env.close(); + + // Valid credential not authorized by dest hits authorizedDepositPreauth error path + char const credType2[] = "fghij"; + env(credentials::create(broker, credIssuer, credType2)); + env(credentials::accept(broker, credIssuer, credType2)); + env.close(); + auto const credIdx2 = + credentials::ledgerEntry(env, broker, credIssuer, credType2)[jss::result][jss::index] + .asString(); + env(coverWithdrawToDest(), + loan_broker::kDestination(dest), + credentials::Ids({credIdx2}), + Ter{tecNO_PERMISSION}); + env.close(); + + // Advance time past expiration: credentials yield tecEXPIRED and are deleted + env.close(150s); + BEAST_EXPECT(env.le(credKeylet)); + env(coverWithdrawToDest(), + loan_broker::kDestination(dest), + credentials::Ids({credIdx}), + Ter{tecEXPIRED}); + env.close(); + BEAST_EXPECT(!env.le(credKeylet)); + } + // Exercises canApplyToBrokerCover (fixCleanup3_2_0): a deposit, withdraw, // or clawback whose amount rounds to zero at sfCoverAvailable's precision // scale must be rejected with tecPRECISION_LOSS once the amendment is on, @@ -2770,6 +2898,9 @@ public: testRIPD4274(); + testCoverWithdrawCredentialDepositPreauth(all_ - fixCleanup3_4_0); + testCoverWithdrawCredentialDepositPreauth(all_); + testLoanBrokerDeleteLockedMPT(all_); testLoanBrokerDeleteLockedMPT(all_ - fixCleanup3_2_0); diff --git a/src/test/app/vault/VaultDomain_test.cpp b/src/test/app/vault/VaultDomain_test.cpp index db8943921b..5af0842962 100644 --- a/src/test/app/vault/VaultDomain_test.cpp +++ b/src/test/app/vault/VaultDomain_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -570,6 +572,112 @@ private: } } + void + testWithdrawCredentialDepositPreauth(FeatureBitset features) + { + testcase( + "withdraw with credential-based deposit preauth " + + std::string{features[fixCleanup3_4_0] ? "post-fix" : "pre-fix"}); + using namespace test::jtx; + using namespace std::chrono_literals; + + bool const fixEnabled = features[fixCleanup3_4_0]; + + Env env{*this, features}; + + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const dest{"dest"}; + Account const credIssuer{"credIssuer"}; + char const credType[] = "abcde"; + + env.fund(XRP(1000), owner, depositor, dest, credIssuer); + env(fset(dest, asfDepositAuth)); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1'000'000}; + Vault vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)})); + env.close(); + + auto withdrawToDest = [&]() { + auto wtx = + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)}); + wtx[sfDestination] = dest.human(); + return wtx; + }; + + // Without any preauth, withdraw to dest fails + env(withdrawToDest(), Ter{tecNO_PERMISSION}); + env.close(); + + // Issue and accept a credential for the depositor (with expiration) + auto jv = credentials::create(depositor, credIssuer, credType); + std::uint32_t const expiration = + env.current()->header().parentCloseTime.time_since_epoch().count() + 100; + jv[sfExpiration.jsonName] = expiration; + env(jv); + env(credentials::accept(depositor, credIssuer, credType)); + env.close(); + + auto const credKeylet = credentials::keylet(depositor, credIssuer, credType); + auto const credIdx = + credentials::ledgerEntry(env, depositor, credIssuer, credType)[jss::result][jss::index] + .asString(); + + // dest authorizes deposits from holders of credentials issued by credIssuer + env(deposit::authCredentials(dest, {{.issuer = credIssuer, .credType = credType}})); + env.close(); + + // Withdraw without supplying credentials still fails + env(withdrawToDest(), Ter{tecNO_PERMISSION}); + env.close(); + + if (!fixEnabled) + { + // Pre-fix: sfCredentialIDs in VaultWithdraw is rejected as disabled + env(withdrawToDest(), credentials::Ids({credIdx}), Ter{temDISABLED}); + env.close(); + return; + } + + // Withdraw with credentials succeeds + env(withdrawToDest(), credentials::Ids({credIdx})); + env.close(); + + // Bad credential id is rejected + std::string const invalidIdx = + "0E0B04ED60588A758B67E21FBBE95AC5A63598BA951761DC0EC9C08D7E01E034"; + env(withdrawToDest(), credentials::Ids({invalidIdx}), Ter{tecBAD_CREDENTIALS}); + env.close(); + + // Malformed credential array (duplicates) is rejected by checkFields + env(withdrawToDest(), credentials::Ids({credIdx, credIdx}), Ter{temMALFORMED}); + env.close(); + + // Valid credential not authorized by dest hits authorizedDepositPreauth error path + char const credType2[] = "fghij"; + env(credentials::create(depositor, credIssuer, credType2)); + env(credentials::accept(depositor, credIssuer, credType2)); + env.close(); + auto const credIdx2 = + credentials::ledgerEntry(env, depositor, credIssuer, credType2)[jss::result][jss::index] + .asString(); + env(withdrawToDest(), credentials::Ids({credIdx2}), Ter{tecNO_PERMISSION}); + env.close(); + + // Advance time past expiration: credentials yield tecEXPIRED and are deleted + env.close(150s); + BEAST_EXPECT(env.le(credKeylet)); + env(withdrawToDest(), credentials::Ids({credIdx}), Ter{tecEXPIRED}); + env.close(); + BEAST_EXPECT(!env.le(credKeylet)); + } + public: void run() override @@ -578,6 +686,8 @@ public: testDomainLossAfterAcquisition(); testDomainCheckBuyerSideOffer(); testWithDomainChecXRP(); + testWithdrawCredentialDepositPreauth(all_ - fixCleanup3_4_0); + testWithdrawCredentialDepositPreauth(all_); } }; diff --git a/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp index 5b0a8c9146..043ab0a252 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp @@ -33,6 +33,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip) auto const amountValue = canonical_AMOUNT(); auto const destinationValue = canonical_ACCOUNT(); auto const destinationTagValue = canonical_UINT32(); + auto const credentialIDsValue = canonical_VECTOR256(); LoanBrokerCoverWithdrawBuilder builder{ accountValue, @@ -45,6 +46,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip) // Set optional fields builder.setDestination(destinationValue); builder.setDestinationTag(destinationTagValue); + builder.setCredentialIDs(credentialIDsValue); auto tx = builder.build(publicKey, secretKey); @@ -90,6 +92,14 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasDestinationTag()); } + { + auto const& expected = credentialIDsValue; + auto const actualOpt = tx.getCredentialIDs(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present"; + expectEqualField(expected, *actualOpt, "sfCredentialIDs"); + EXPECT_TRUE(tx.hasCredentialIDs()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -110,6 +120,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip) auto const amountValue = canonical_AMOUNT(); auto const destinationValue = canonical_ACCOUNT(); auto const destinationTagValue = canonical_UINT32(); + auto const credentialIDsValue = canonical_VECTOR256(); // Build an initial transaction LoanBrokerCoverWithdrawBuilder initialBuilder{ @@ -122,6 +133,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip) initialBuilder.setDestination(destinationValue); initialBuilder.setDestinationTag(destinationTagValue); + initialBuilder.setCredentialIDs(credentialIDsValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -166,6 +178,13 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfDestinationTag"); } + { + auto const& expected = credentialIDsValue; + auto const actualOpt = rebuiltTx.getCredentialIDs(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present"; + expectEqualField(expected, *actualOpt, "sfCredentialIDs"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -229,6 +248,8 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getDestination().has_value()); EXPECT_FALSE(tx.hasDestinationTag()); EXPECT_FALSE(tx.getDestinationTag().has_value()); + EXPECT_FALSE(tx.hasCredentialIDs()); + EXPECT_FALSE(tx.getCredentialIDs().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp index 4067a6551d..518957d47b 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp @@ -33,6 +33,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip) auto const amountValue = canonical_AMOUNT(); auto const destinationValue = canonical_ACCOUNT(); auto const destinationTagValue = canonical_UINT32(); + auto const credentialIDsValue = canonical_VECTOR256(); VaultWithdrawBuilder builder{ accountValue, @@ -45,6 +46,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip) // Set optional fields builder.setDestination(destinationValue); builder.setDestinationTag(destinationTagValue); + builder.setCredentialIDs(credentialIDsValue); auto tx = builder.build(publicKey, secretKey); @@ -90,6 +92,14 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasDestinationTag()); } + { + auto const& expected = credentialIDsValue; + auto const actualOpt = tx.getCredentialIDs(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present"; + expectEqualField(expected, *actualOpt, "sfCredentialIDs"); + EXPECT_TRUE(tx.hasCredentialIDs()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -110,6 +120,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip) auto const amountValue = canonical_AMOUNT(); auto const destinationValue = canonical_ACCOUNT(); auto const destinationTagValue = canonical_UINT32(); + auto const credentialIDsValue = canonical_VECTOR256(); // Build an initial transaction VaultWithdrawBuilder initialBuilder{ @@ -122,6 +133,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip) initialBuilder.setDestination(destinationValue); initialBuilder.setDestinationTag(destinationTagValue); + initialBuilder.setCredentialIDs(credentialIDsValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -166,6 +178,13 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfDestinationTag"); } + { + auto const& expected = credentialIDsValue; + auto const actualOpt = rebuiltTx.getCredentialIDs(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present"; + expectEqualField(expected, *actualOpt, "sfCredentialIDs"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -229,6 +248,8 @@ TEST(TransactionsVaultWithdrawTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getDestination().has_value()); EXPECT_FALSE(tx.hasDestinationTag()); EXPECT_FALSE(tx.getDestinationTag().has_value()); + EXPECT_FALSE(tx.hasCredentialIDs()); + EXPECT_FALSE(tx.getCredentialIDs().has_value()); } } From 422e5245e4a6d0b5360abc26ddc28c3ea70b6e36 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Thu, 20 Aug 2026 16:02:26 +0000 Subject: [PATCH 06/43] ci: Save cargo cache only from develop by default (#8063) --- .github/actions/cargo-cache/action.yml | 5 +++-- .github/workflows/reusable-build-test-config.yml | 1 - .github/workflows/reusable-clang-tidy.yml | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/actions/cargo-cache/action.yml b/.github/actions/cargo-cache/action.yml index 1923d8cf64..f716d3e4a4 100644 --- a/.github/actions/cargo-cache/action.yml +++ b/.github/actions/cargo-cache/action.yml @@ -20,9 +20,10 @@ inputs: required: false default: "" save-if: - description: "Condition for saving the cache after the job." + description: > + Condition for saving the cache after the job. Defaults to save only from develop branch required: false - default: "true" + default: ${{ github.ref == 'refs/heads/develop' }} runs: using: composite diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 656e6ec85b..2846c3fb85 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -167,7 +167,6 @@ jobs: with: cache-directories: ${{ env.BUILD_DIR }}/corrosion key: ${{ inputs.config_name }} - save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }} # two workspaces here because build artifacts are located in 2 places: # - crates/target when cargo is called directly # - build/cargo when cargo is called by cmake diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index 6847ff9b57..ac21c83ea0 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -63,7 +63,6 @@ jobs: uses: ./.github/actions/cargo-cache with: cache-directories: ${{ env.BUILD_DIR }}/corrosion - save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }} workspaces: crates -> ../${{ env.BUILD_DIR }}/cargo - name: Setup Conan From cc767085633a6617d7013a5a6dd9d1fafd760d15 Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:05:18 +0000 Subject: [PATCH 07/43] fix: Return specific and consistent errors from vault_info (#8015) Co-authored-by: Cursor --- API-CHANGELOG.md | 3 + src/test/app/vault/VaultRPC_test.cpp | 115 ++++++++++++++++++++++----- src/xrpld/rpc/handlers/VaultInfo.cpp | 55 ++++++++----- 3 files changed, 135 insertions(+), 38 deletions(-) diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index c853cfb07c..d521f9c024 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -54,6 +54,9 @@ This section contains changes targeting a future version. - `submit`: The `fail_hard` field now returns an error if the value is not a boolean. [#6529](https://github.com/XRPLF/rippled/pull/6529) - `subscribe`: The `taker` field in the `books` array now returns `actMalformed` instead of `badIssuer` if the value is not a valid account. [#6529](https://github.com/XRPLF/rippled/pull/6529) - Fixed a bug in `Forwarded` HTTP header parsing where the extracted IP address could be incorrect when no comma or semicolon delimiter follows the address. This could cause the server to misidentify a client's IP address when operating behind a reverse proxy. [#6529](https://github.com/XRPLF/rippled/pull/6529) +- `vault_info`: Errors now identify what the request got wrong instead of reporting every failure as the unregistered token `malformedRequest`, and the `error`, `error_code` and `error_message` fields now agree with each other. An invalid `vault_id` or `seq` returns `invalidParams`, an invalid `owner` returns `actMalformed`, and a request that mixes `vault_id` with `owner`/`seq` or supplies neither returns `invalidParams` with a message naming the accepted combinations. [#8015](https://github.com/XRPLF/rippled/pull/8015) +- `vault_info`: A well-formed all-zero `vault_id` now returns `entryNotFound` instead of being rejected as malformed, and `entryNotFound` responses now include `error_code` and `error_message`. Clients that request `ripplerpc` 3.0 or above therefore receive HTTP 400 with that error rather than HTTP 200. [#8015](https://github.com/XRPLF/rippled/pull/8015) +- `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) diff --git a/src/test/app/vault/VaultRPC_test.cpp b/src/test/app/vault/VaultRPC_test.cpp index 2ac092b5a7..dbceb1cb9c 100644 --- a/src/test/app/vault/VaultRPC_test.cpp +++ b/src/test/app/vault/VaultRPC_test.cpp @@ -9,11 +9,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -122,6 +124,22 @@ private: } }; + // An error response must carry a registered token together with the matching code and + // message, so that clients dispatching on either of them reach the same conclusion. + auto const checkError = [this]( + json::Value const& result, + std::string const& token, + ErrorCodeI const code, + std::string const& message) { + BEAST_EXPECT(result[jss::error].asString() == token); + BEAST_EXPECT(result[jss::error_code].asInt() == code); + BEAST_EXPECT(result[jss::error_message].asString() == message); + }; + + std::string const badSeqMessage = "Invalid field 'seq', not a positive 32-bit integer."; + std::string const badFieldsMessage = + "Must specify either 'vault_id' or both 'owner' and 'seq'."; + { testcase("RPC ledger_entry selected by key"); json::Value jvParams; @@ -276,16 +294,57 @@ private: jvParams[jss::ledger_index] = jss::validated; jvParams[jss::vault_id] = "foobar"; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError( + jv[jss::result], + "invalidParams", + RpcInvalidParams, + "Invalid field 'vault_id', not hex string."); } { - testcase("RPC vault_info json invalid index"); + testcase("RPC vault_info json numeric vault_id"); json::Value jvParams; jvParams[jss::ledger_index] = jss::validated; jvParams[jss::vault_id] = 0; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError( + jv[jss::result], + "invalidParams", + RpcInvalidParams, + "Invalid field 'vault_id', not hex string."); + } + + { + testcase("RPC vault_info json object vault_id"); + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault_id] = json::Value(json::ValueType::Object); + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + checkError( + jv[jss::result], + "invalidParams", + RpcInvalidParams, + "Invalid field 'vault_id', not hex string."); + } + + { + // An all-zero key is a well-formed request for a vault that cannot exist, not a + // malformed one. parseHex accepts both the padded form and the short "0". + testcase("RPC vault_info json all zero vault_id"); + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault_id] = strHex(uint256(beast::kZero)); + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found."); + } + + { + testcase("RPC vault_info json short zero vault_id"); + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault_id] = "0"; + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found."); } { @@ -308,7 +367,7 @@ private: jvParams[jss::owner] = owner.human(); jvParams[jss::seq] = "foobar"; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage); } { @@ -318,7 +377,7 @@ private: jvParams[jss::owner] = owner.human(); jvParams[jss::seq] = 0; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage); } { @@ -328,7 +387,7 @@ private: jvParams[jss::owner] = owner.human(); jvParams[jss::seq] = -1; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage); } { @@ -338,7 +397,7 @@ private: jvParams[jss::owner] = owner.human(); jvParams[jss::seq] = 1e20; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage); } { @@ -348,7 +407,7 @@ private: jvParams[jss::owner] = owner.human(); jvParams[jss::seq] = true; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage); } { @@ -358,7 +417,25 @@ private: jvParams[jss::owner] = "foobar"; jvParams[jss::seq] = sequence; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError( + jv[jss::result], + "actMalformed", + RpcActMalformed, + "Invalid field 'owner', not AccountID."); + } + + { + testcase("RPC vault_info json array owner"); + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::owner] = json::Value(json::ValueType::Array); + jvParams[jss::seq] = sequence; + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + checkError( + jv[jss::result], + "actMalformed", + RpcActMalformed, + "Invalid field 'owner', not AccountID."); } { @@ -367,7 +444,7 @@ private: jvParams[jss::ledger_index] = jss::validated; jvParams[jss::owner] = owner.human(); auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -376,7 +453,7 @@ private: jvParams[jss::ledger_index] = jss::validated; jvParams[jss::seq] = sequence; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -386,7 +463,7 @@ private: jvParams[jss::vault_id] = strHex(keylet.key); jvParams[jss::seq] = sequence; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -396,7 +473,7 @@ private: jvParams[jss::vault_id] = strHex(keylet.key); jvParams[jss::owner] = owner.human(); auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -409,7 +486,7 @@ private: jvParams[jss::seq] = sequence; jvParams[jss::owner] = owner.human(); auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -417,7 +494,7 @@ private: json::Value jvParams; jvParams[jss::ledger_index] = jss::validated; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -427,15 +504,15 @@ private: } { - testcase("RPC vault_info command line invalid index"); + testcase("RPC vault_info command line zero index"); json::Value jv = env.rpc("vault_info", "0", "validated"); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found."); } { - testcase("RPC vault_info command line invalid index"); + testcase("RPC vault_info command line unknown index"); json::Value jv = env.rpc("vault_info", strHex(uint256(42)), "validated"); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "entryNotFound"); + checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found."); } { diff --git a/src/xrpld/rpc/handlers/VaultInfo.cpp b/src/xrpld/rpc/handlers/VaultInfo.cpp index c216192ab3..0aa5334bd2 100644 --- a/src/xrpld/rpc/handlers/VaultInfo.cpp +++ b/src/xrpld/rpc/handlers/VaultInfo.cpp @@ -26,36 +26,48 @@ parseVault(json::Value const& params, json::Value& jvResult) uint256 uNodeIndex = beast::kZero; if (hasVaultId && !hasOwner && !hasSeq) { - if (!uNodeIndex.parseHex(params[jss::vault_id].asString())) + // asString() throws on an object or an array, so the type comes first. + auto const& vaultId = params[jss::vault_id]; + if (!vaultId.isString() || !uNodeIndex.parseHex(vaultId.asString())) { - rpc::injectError(RpcInvalidParams, jvResult); + rpc::injectError( + RpcInvalidParams, rpc::expectedFieldMessage(jss::vault_id, "hex string"), jvResult); return std::nullopt; } // else uNodeIndex holds the value we need } else if (!hasVaultId && hasOwner && hasSeq) { - auto const id = parseBase58(params[jss::owner].asString()); + auto const& owner = params[jss::owner]; + auto const id = owner.isString() ? parseBase58(owner.asString()) + : std::optional{}; if (!id) { - rpc::injectError(RpcActMalformed, jvResult); - return std::nullopt; - } - if (!(params[jss::seq].isInt() || params[jss::seq].isUInt()) || - params[jss::seq].asDouble() <= 0.0 || - params[jss::seq].asDouble() > double(json::Value::kMaxUInt)) - { - rpc::injectError(RpcInvalidParams, jvResult); + rpc::injectError( + RpcActMalformed, rpc::expectedFieldMessage(jss::owner, "AccountID"), jvResult); return std::nullopt; } - auto const seq = SeqProxy::rawSequence(params[jss::seq].asUInt()); + // Int and UInt are both 32 bits wide, so the type check is the only upper bound needed. + auto const& seqField = params[jss::seq]; + if (!(seqField.isInt() || seqField.isUInt()) || seqField.asDouble() <= 0.0) + { + rpc::injectError( + RpcInvalidParams, + rpc::expectedFieldMessage(jss::seq, "a positive 32-bit integer"), + jvResult); + return std::nullopt; + } + + auto const seq = SeqProxy::rawSequence(seqField.asUInt()); uNodeIndex = keylet::vault(*id, seq).key; } else { - // Invalid combination of fields vault_id/owner/seq - rpc::injectError(RpcInvalidParams, jvResult); + rpc::injectError( + RpcInvalidParams, + "Must specify either 'vault_id' or both 'owner' and 'seq'.", + jvResult); return std::nullopt; } @@ -71,20 +83,25 @@ doVaultInfo(rpc::JsonContext& context) if (!lpLedger) return jvResult; - auto const uNodeIndex = parseVault(context.params, jvResult).value_or(beast::kZero); - if (uNodeIndex == beast::kZero) + // No key means the request could not be turned into one, and parseVault has already said why. + auto const uNodeIndex = parseVault(context.params, jvResult); + if (!uNodeIndex) + return jvResult; + + // A zero key names an entry that cannot exist, and the ledger refuses to be asked for one. + if (*uNodeIndex == beast::kZero) { - jvResult[jss::error] = "malformedRequest"; + rpc::injectError(RpcEntryNotFound, jvResult); return jvResult; } - auto const sleVault = lpLedger->read(keylet::vault(uNodeIndex)); + auto const sleVault = lpLedger->read(keylet::vault(*uNodeIndex)); auto const sleIssuance = sleVault == nullptr // ? nullptr : lpLedger->read(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); if (!sleVault || !sleIssuance) { - jvResult[jss::error] = "entryNotFound"; + rpc::injectError(RpcEntryNotFound, jvResult); return jvResult; } From a1478fac39084c1d1bf4e9c2113eddbb606b70f2 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 20 Aug 2026 16:28:43 +0000 Subject: [PATCH 08/43] docs: Fix yum installation baseurl (#8066) --- docs/install.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install.md b/docs/install.md index a3e2fefa02..ee9c31868b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -92,11 +92,11 @@ wherever it appears in the repository configuration. 2. Add the repository, using the channel you picked in [Release channels](#release-channels): ```bash - cat << REPOFILE | sudo tee /etc/yum.repos.d/xrplf.repo + cat << 'REPOFILE' | sudo tee /etc/yum.repos.d/xrplf.repo [xrplf-stable] name=XRP Ledger Packages enabled=1 - baseurl=https://packages.xrplf.org/repository/rpm-stable/ + baseurl=https://packages.xrplf.org/repository/rpm-stable/$basearch/ gpgcheck=1 repo_gpgcheck=1 gpgkey=https://packages.xrplf.org/xrplf.asc From d0dbf9163c66288e37d1c5bc9dce313d457f732b Mon Sep 17 00:00:00 2001 From: Kassaking7 <96991820+Kassaking7@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:04:04 +0000 Subject: [PATCH 09/43] fix: Prevent AMM auction slots from being acquired at zero cost when trading fee is zero (#7430) --- include/xrpl/protocol/AMMCore.h | 11 +++++ src/libxrpl/tx/transactors/dex/AMMBid.cpp | 30 +++++++------ src/test/app/AMMMPT_test.cpp | 25 +++++++---- src/test/app/AMM_test.cpp | 51 +++++++++++++++++++---- 4 files changed, 87 insertions(+), 30 deletions(-) diff --git a/include/xrpl/protocol/AMMCore.h b/include/xrpl/protocol/AMMCore.h index 1e11f6cd8b..3f6b12f460 100644 --- a/include/xrpl/protocol/AMMCore.h +++ b/include/xrpl/protocol/AMMCore.h @@ -91,6 +91,17 @@ getFee(std::uint16_t tfee) return Number{tfee} / kAuctionSlotFeeScaleFactor; } +/** + * Minimum auction slot price: LPTokens * TradingFee / kAuctionSlotMinFeeFraction + * @param lptAMMBalance AMM LP token balance + * @param tradingFee trading fee in {0, 1000} + */ +inline Number +ammAuctionMinSlotPrice(Number const& lptAMMBalance, std::uint16_t tradingFee) +{ + return lptAMMBalance * getFee(tradingFee) / kAuctionSlotMinFeeFraction; +} + /** * Get fee multiplier (1 - tfee) * @tfee trading fee in basis points diff --git a/src/libxrpl/tx/transactors/dex/AMMBid.cpp b/src/libxrpl/tx/transactors/dex/AMMBid.cpp index 3454559e82..154e64ca8e 100644 --- a/src/libxrpl/tx/transactors/dex/AMMBid.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMBid.cpp @@ -193,10 +193,10 @@ applyBid(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Journa auto const current = duration_cast(ctx.view().header().parentCloseTime.time_since_epoch()).count(); // Auction slot discounted fee - auto const discountedFee = (*ammSle)[sfTradingFee] / kAuctionSlotDiscountedFeeFraction; - auto const tradingFee = getFee((*ammSle)[sfTradingFee]); + auto const ammTradingFee = (*ammSle)[sfTradingFee]; + auto const discountedFee = ammTradingFee / kAuctionSlotDiscountedFeeFraction; // Min price - auto const minSlotPrice = lptAMMBalance * tradingFee / kAuctionSlotMinFeeFraction; + auto const minSlotPrice = ammAuctionMinSlotPrice(lptAMMBalance, ammTradingFee); static constexpr std::uint32_t kTailingSlot = kAuctionSlotTimeIntervals - 1; @@ -260,31 +260,37 @@ applyBid(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Journa auto const bidMax = ctx.tx[~sfBidMax]; auto getPayPrice = [&](Number const& computedPrice) -> std::expected { + auto effectivePrice = computedPrice; + if (ctx.view().rules().enabled(fixCleanup3_4_0) && ammTradingFee == 0) + { + // Prevent zero-fee pools from granting auction slots at zero or dust prices. + effectivePrice = std::max(effectivePrice, ammAuctionMinSlotPrice(lptAMMBalance, 1)); + } auto const payPrice = [&]() -> std::optional { // Both min/max bid price are defined if (bidMin && bidMax) { - if (computedPrice <= *bidMax) - return std::max(computedPrice, Number(*bidMin)); - JLOG(ctx.journal.debug()) << "AMM Bid: not in range " << computedPrice << " " + if (effectivePrice <= *bidMax) + return std::max(effectivePrice, Number(*bidMin)); + JLOG(ctx.journal.debug()) << "AMM Bid: not in range " << effectivePrice << " " << *bidMin << " " << *bidMax; return std::nullopt; } - // Bidder pays max(bidPrice, computedPrice) + // Bidder pays max(bidPrice, effectivePrice) if (bidMin) { - return std::max(computedPrice, Number(*bidMin)); + return std::max(effectivePrice, Number(*bidMin)); } if (bidMax) { - if (computedPrice <= *bidMax) - return computedPrice; + if (effectivePrice <= *bidMax) + return effectivePrice; JLOG(ctx.journal.debug()) - << "AMM Bid: not in range " << computedPrice << " " << *bidMax; + << "AMM Bid: not in range " << effectivePrice << " " << *bidMax; return std::nullopt; } - return computedPrice; + return effectivePrice; }(); if (!payPrice) { diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp index bfd2d529b5..ac9728ede1 100644 --- a/src/test/app/AMMMPT_test.cpp +++ b/src/test/app/AMMMPT_test.cpp @@ -3992,24 +3992,30 @@ private: [&](AMM& ammAlice, Env& env) { // Bid a tiny amount auto const tiny = Number{STAmount::kMinValue, STAmount::kMinOffset}; + auto const cleanup340 = env.current()->rules().enabled(fixCleanup3_4_0); + auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)}; + auto const firstPrice = cleanup340 ? minBidPrice : IOUAmount{tiny}; env(ammAlice.bid({.account = alice_, .bidMin = IOUAmount{tiny}})); - // Auction slot purchase price is equal to the tiny amount - // since the minSlotPrice is 0 with no trading fee. - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny})); - // The purchase price is too small to affect the total tokens + BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, firstPrice)); BEAST_EXPECT(ammAlice.expectBalances( - MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens())); + MPT(ammAlice[0])(10'000'000'000), + USD(10'000), + cleanup340 ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}} + : ammAlice.tokens())); // Bid the tiny amount env(ammAlice.bid({ .account = alice_, .bidMin = IOUAmount{STAmount::kMinValue, STAmount::kMinOffset}, })); // Pay slightly higher price - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny * Number{105, -2}})); - // The purchase price is still too small to affect the total - // tokens + BEAST_EXPECT(ammAlice.expectAuctionSlot( + 0, 0, IOUAmount{Number{firstPrice} * Number{105, -2}})); BEAST_EXPECT(ammAlice.expectBalances( - MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens())); + MPT(ammAlice[0])(10'000'000'000), + USD(10'000), + cleanup340 + ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice} * Number{11, -1}} + : ammAlice.tokens())); }, {{gAmmmpt(10'000'000'000), USD(10'000)}}); @@ -7489,6 +7495,7 @@ private: testFeeVote(); testInvalidBid(); testBid(all); + testBid(all - fixCleanup3_4_0); testClawback(); testClawbackFromAMMAccount(all); testClawbackFromAMMAccount(all - featureSingleAssetVault); diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index e1732aaf0e..0212035c6e 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -3127,27 +3127,59 @@ private: std::nullopt, {features}); + // Zero-fee bid without an explicit price pays a floor with fixCleanup3_4_0. + testAMM( + [&](AMM& ammAlice, Env& env) { + auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)}; + auto const cleanup340 = features[fixCleanup3_4_0]; + auto const expectedPrice = cleanup340 ? minBidPrice : IOUAmount{0}; + auto const expectedTokens = cleanup340 + ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}} + : ammAlice.tokens(); + + env.close(seconds(kTotalTimeSlotSecs + 1)); + env.close(); + env(ammAlice.bid({.account = alice_})); + BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, expectedPrice)); + BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), expectedTokens)); + + ammAlice.vote(alice_, 1'000); + BEAST_EXPECT(ammAlice.expectAuctionSlot(100, 0, expectedPrice)); + }, + std::nullopt, + 0, + std::nullopt, + {features}); + // Bid tiny amount testAMM( [&](AMM& ammAlice, Env& env) { // Bid a tiny amount auto const tiny = Number{STAmount::kMinValue, STAmount::kMinOffset}; + auto const cleanup340 = features[fixCleanup3_4_0]; + auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)}; + auto const firstPrice = cleanup340 ? minBidPrice : IOUAmount{tiny}; env(ammAlice.bid({.account = alice_, .bidMin = IOUAmount{tiny}})); - // Auction slot purchase price is equal to the tiny amount - // since the minSlotPrice is 0 with no trading fee. - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny})); - // The purchase price is too small to affect the total tokens - BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), ammAlice.tokens())); + BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, firstPrice)); + BEAST_EXPECT(ammAlice.expectBalances( + XRP(10'000), + USD(10'000), + cleanup340 ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}} + : ammAlice.tokens())); // Bid the tiny amount env(ammAlice.bid({ .account = alice_, .bidMin = IOUAmount{STAmount::kMinValue, STAmount::kMinOffset}, })); // Pay slightly higher price - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny * Number{105, -2}})); - // The purchase price is still too small to affect the total - // tokens - BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), ammAlice.tokens())); + BEAST_EXPECT(ammAlice.expectAuctionSlot( + 0, 0, IOUAmount{Number{firstPrice} * Number{105, -2}})); + BEAST_EXPECT(ammAlice.expectBalances( + XRP(10'000), + USD(10'000), + cleanup340 + ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice} * Number{11, -1}} + : ammAlice.tokens())); }, std::nullopt, 0, @@ -7436,6 +7468,7 @@ private: testFeeVote(); testInvalidBid(); testBid(all); + testBid(all - fixCleanup3_4_0); testBid(all - fixAMMv1_3); testBid(all - fixAMMv1_1 - fixAMMv1_3); testInvalidAMMPayment(); From 3ab5288ef24e2c822ec8c95699558f402438e706 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Thu, 20 Aug 2026 19:05:28 +0000 Subject: [PATCH 10/43] fix: Enforce MPT balance invariants under the latest cleanup amendment (#7889) --- src/libxrpl/tx/invariants/MPTInvariant.cpp | 64 +++- src/test/app/Invariants_test.cpp | 377 ++++++++++++++++++--- 2 files changed, 390 insertions(+), 51 deletions(-) diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 9a7e96e44f..045d03ab02 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -144,6 +144,8 @@ ValidMPTIssuance::finalize( // must not dangle outside that controlled lifecycle. if (rules.enabled(fixCleanup3_2_0)) { + // Not an amendment gate like the same-named flags below, just an + // accumulator, so that every violation gets logged before returning. bool invariantPasses = true; if (referenceHoldingMutated_) { @@ -474,7 +476,9 @@ ValidMPTBalanceChanges::finalize( ReadView const& view, beast::Journal const& j) { - if (isTesSuccess(result)) + auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0); + + if (isTesSuccess(result) || fix340Enabled) { // Confidential transactions are validated by ValidConfidentialMPToken. // They modify encrypted fields and sfConfidentialOutstandingAmount @@ -486,7 +490,9 @@ ValidMPTBalanceChanges::finalize( return true; } - bool const invariantPasses = !view.rules().enabled(featureMPTokensV2); + // Returned when a violation is found below, so this is the log-only + // condition. Either amendment makes the checks enforcing. + auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled); if (overflow_) { JLOG(j.fatal()) << "Invariant failed: OutstandingAmount overflow"; @@ -510,6 +516,18 @@ ValidMPTBalanceChanges::finalize( << " " << data.mptAmount; return invariantPasses; } + + // A failed transaction must not have moved MPT value; the check + // above ties mptAmount to the OutstandingAmount delta. No result + // code is exempt: on any tec the transactor discards the view and + // re-applies only offer, trust line, NFT offer and credential + // deletions (Transactor::typesForResult), none of which touch MPTs. + if (!isTesSuccess(result) && data.mptAmount != 0) + { + JLOG(j.fatal()) << "Invariant failed: OutstandingAmount balance changed on failure " + << tx.getTxnType() << " " << result; + return invariantPasses; + } } } @@ -833,7 +851,7 @@ ValidMPTTransfer::isAuthorized( bool ValidMPTTransfer::finalize( STTx const& tx, - TER const, + TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) @@ -864,9 +882,19 @@ ValidMPTTransfer::finalize( return txnType == ttAMM_CREATE || txnType == ttAMM_DEPOSIT || txnType == ttOFFER_CREATE; }(); - // Only enforce once MPTokensV2 is enabled to preserve consensus with non-V2 nodes. - // Log invariant failure error even if MPTokensV2 is disabled. - auto const invariantPasses = !view.rules().enabled(featureMPTokensV2); + auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0); + // Returned when a violation is found below, so this is the log-only + // condition. Either amendment makes the checks enforcing. + auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled); + + // A failed transaction must not persist an MPToken deletion. Pre-loop + // because deletedAuthorized_ is not issuance-scoped and orphans continue. + if (fix340Enabled && !isTesSuccess(result) && !deletedAuthorized_.empty()) + { + JLOG(j.fatal()) << "Invariant failed: MPToken deleted on failure " << txnType << " " + << result; + return invariantPasses; + } for (auto const& [mptID, values] : amount_) { @@ -876,6 +904,20 @@ ValidMPTTransfer::finalize( auto const sleIssuance = view.read(keylet::mptokenIssuance(mptID)); if (!sleIssuance) { + // MPTokenIssuanceDestroy only requires a zero OutstandingAmount, so + // an orphaned MPToken can outlive its issuance and be cleaned up + // later by a transaction of any type. There are no transfer rules + // left to check, but its balance is zero and nothing can raise it, + // so any change other than deletion is a bug. + for (auto const& [account, value] : values) + { + if (value.amtAfter.has_value() && value.amtBefore.value_or(0) != *value.amtAfter) + { + JLOG(j.fatal()) << "Invariant failed: orphaned MPToken balance changed " + << txnType << " " << result; + return invariantPasses; + } + } continue; } @@ -939,6 +981,16 @@ ValidMPTTransfer::finalize( JLOG(j.fatal()) << "Invariant failed: invalid MPToken transfer between holders"; return invariantPasses; } + + // A failed transaction must not have changed a holder's balance. One + // side is enough, unlike the transfer check above, so this also catches + // a lock/unlock moving value between sfMPTAmount and sfLockedAmount. + if (fix340Enabled && !isTesSuccess(result) && (senders > 0 || receivers > 0)) + { + JLOG(j.fatal()) << "Invariant failed: MPToken balance changed on failure " << txnType + << " " << result; + return invariantPasses; + } } return true; diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index ced2dea9bb..dcd22ffda6 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -142,7 +142,11 @@ class Invariants_test : public beast::unit_test::Suite std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, Preclose const& preclose = {}, TxAccount setTxAccount = TxAccount::None, - std::source_location const& loc = std::source_location::current()) + std::source_location const& loc = std::source_location::current(), + // Result fed to the invariant checker on the first pass. Set it to a + // tec to exercise result-dependent invariants; the harness runs no + // transactor, so one never arises on its own. + TER initialResult = tesSUCCESS) { doInvariantCheck( makeEnv(defaultAmendments()), @@ -153,7 +157,8 @@ class Invariants_test : public beast::unit_test::Suite ters, preclose, setTxAccount, - loc); + loc, + initialResult); } void @@ -166,7 +171,8 @@ class Invariants_test : public beast::unit_test::Suite std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, Preclose const& preclose = {}, TxAccount setTxAccount = TxAccount::None, - std::source_location const& loc = std::source_location::current()) + std::source_location const& loc = std::source_location::current(), + TER initialResult = tesSUCCESS) { using namespace test::jtx; @@ -180,7 +186,8 @@ class Invariants_test : public beast::unit_test::Suite if (setTxAccount != TxAccount::None) tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id()); - doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc); + doInvariantCheck( + std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc, initialResult); } void @@ -194,7 +201,8 @@ class Invariants_test : public beast::unit_test::Suite XRPAmount fee = XRPAmount{}, STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - std::source_location const& loc = std::source_location::current()) + std::source_location const& loc = std::source_location::current(), + TER initialResult = tesSUCCESS) { using namespace test::jtx; @@ -213,13 +221,18 @@ class Invariants_test : public beast::unit_test::Suite if (!BEAST_EXPECT(transactor)) return; - // invoke check twice to cover tec and tef cases + // Invoke the check twice to cover the tec and tef cases. Both passes run + // against the same view -- production would discard it in between -- so + // the second sees the same violation and escalates tec -> tef. A + // {tec, tef} pair therefore means "enforced whatever the incoming + // result", not that the transaction ends in tef on ledger. if (!BEAST_EXPECT(ters.size() == 2)) return; - TER terActual = tesSUCCESS; + TER terActual = initialResult; for (TER const& terExpect : ters) { + TER const terInput = terActual; terActual = transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full); expect( @@ -229,7 +242,10 @@ class Invariants_test : public beast::unit_test::Suite loc.line()); auto const messages = sink.messages().str(); - if (!isTesSuccess(terActual)) + // checkInvariants returns its input unchanged unless something + // fires, so a changed result means an invariant fired, and a firing + // invariant must log. + if (terActual != terInput) { expect( messages.starts_with("Invariant failed:") || @@ -3441,7 +3457,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -3522,7 +3538,7 @@ class Invariants_test : public beast::unit_test::Suite XRPAmount{}, STTx{ ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -3608,7 +3624,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -3629,7 +3645,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -3738,6 +3754,7 @@ class Invariants_test : public beast::unit_test::Suite { "created vault must be empty", "create operation must not have updated a vault", + "invalid OutstandingAmount balance 0 9 0", }, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); @@ -3754,7 +3771,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, [&](Account const& a1, Account const& a2, Env& env) { Vault const vault{env}; auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); @@ -3998,7 +4015,7 @@ class Invariants_test : public beast::unit_test::Suite XRPAmount{}, STTx{ ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4029,7 +4046,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfFee] = XRPAmount(100); tx[sfAccount] = a3.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); doInvariantCheck( @@ -4055,7 +4072,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4077,7 +4094,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4091,7 +4108,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4106,7 +4123,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4124,7 +4141,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(5); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4148,7 +4165,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfDelegate] = a3.id(); tx[sfFee] = XRPAmount(2000); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4164,7 +4181,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4211,7 +4228,7 @@ class Invariants_test : public beast::unit_test::Suite // This commented out line causes the invariant violation. // tx[sfDestination] = A4.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); doInvariantCheck( @@ -4239,7 +4256,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4260,7 +4277,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx.setAccountID(sfDestination, a3.id()); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4274,7 +4291,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4288,7 +4305,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4305,7 +4322,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4321,7 +4338,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4345,7 +4362,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfDelegate] = a3.id(); tx[sfFee] = XRPAmount(2000); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4408,7 +4425,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseMpt, TxAccount::A2); @@ -4424,7 +4441,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseMpt); // Not the same as below check: attempt to clawback XRP @@ -4470,7 +4487,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfAccount] = a3.id(); tx[sfHolder] = a4.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseMpt); doInvariantCheck( @@ -4489,7 +4506,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfAccount] = a3.id(); tx[sfHolder] = a4.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseMpt); doInvariantCheck( @@ -4512,7 +4529,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfAccount] = a3.id(); tx[sfHolder] = a4.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseMpt); // ───────────────────────────────────────────────────────────── @@ -4686,7 +4703,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), TxAccount::A2); @@ -4701,7 +4718,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), TxAccount::A2); @@ -4910,7 +4927,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttPAYMENT, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, [&](Account const& a1, Account const& a2, Env& env) { Account const gw("gw"); env.fund(XRP(1'000), gw); @@ -4940,6 +4957,199 @@ class Invariants_test : public beast::unit_test::Suite return true; }); + // The on-failure MPT checks (OutstandingAmount balance / transfer) apply + // to every non-tesSUCCESS result, with no per-result exemption: on a tec + // the transactor discards the view and re-applies only offer, trust + // line, NFT offer and credential deletions, so an MPT change reaching + // the invariant is a bug whatever the code. Seeded via initialResult. + { + MPTID id; + // preclose: gw issues an MPT held by A1 and A2. + auto const setup = [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt( + {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 50, .maxAmt = 1'000}); + id = mpt.issuanceID(); + return true; + }; + + // Consistent mint: OutstandingAmount and A1's balance both grow by + // 10, so conservation holds and only the on-failure check fires. + Precheck const mint = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); + auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); + if (!sleIss || !sleTok) + return false; + (*sleIss)[sfOutstandingAmount] = (*sleIss)[sfOutstandingAmount] + 10; + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; + ac.view().update(sleIss); + ac.view().update(sleTok); + return true; + }; + + // Holder-to-holder transfer (A1 -> A2 by 10). OutstandingAmount is + // unchanged, and CanTransfer keeps the ordinary transfer check + // quiet, so only the on-failure check fires. + Precheck const transfer = [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); + auto sleA = ac.view().peek(keylet::mptoken(id, a1.id())); + auto sleB = ac.view().peek(keylet::mptoken(id, a2.id())); + if (!sleIss || !sleA || !sleB) + return false; + (*sleIss)[sfFlags] = (*sleIss)[sfFlags] | lsfMPTCanTransfer; + (*sleA)[sfMPTAmount] = (*sleA)[sfMPTAmount] - 10; + (*sleB)[sfMPTAmount] = (*sleB)[sfMPTAmount] + 10; + ac.view().update(sleIss); + ac.view().update(sleA); + ac.view().update(sleB); + return true; + }; + + STTx const payment{ttPAYMENT, [](STObject&) {}}; + + // Negative controls: nothing fires on tesSUCCESS. Without these, the + // cases below would still pass if the result guard were dropped. + doInvariantCheck({}, mint, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + doInvariantCheck({}, transfer, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + + // tecKILLED and tecINCOMPLETE are not special: an MPT change paired + // with either fires, as with any other failure. + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecINCOMPLETE); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecINCOMPLETE); + // The same change under a third failure result: the check keys off + // "not tesSUCCESS", nothing finer. + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + + // A lock moves value within one holder, so it is not a two-sided + // transfer and the `senders || receivers` form is what catches it. + // OutstandingAmount and the holder total are unchanged, so the + // balance check stays quiet. + Precheck const lock = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] < 10) + return false; + // A fresh MPToken has no locked amount, so set it directly. + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] - 10; + sleTok->setFieldU64(sfLockedAmount, 10); + ac.view().update(sleTok); + return true; + }; + // Negative control: a lock is legitimate on tesSUCCESS. + doInvariantCheck({}, lock, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + lock, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + // The lock is caught under any failure result. + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + lock, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + + // A deleted MPToken has no amtAfter, so the sender/receiver counts + // skip it and only the deletedAuthorized_ term can catch it. That + // needs holders authorized but never paid, so the MPToken can be + // erased with a zero balance and OutstandingAmount untouched -- + // otherwise the holder would register as a sender instead. + MPTID emptyId; + auto const setupEmpty = [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}, .maxAmt = 100}); + emptyId = mpt.issuanceID(); + return true; + }; + Precheck const eraseToken = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(emptyId, a1.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] != 0) + return false; + ac.view().erase(sleTok); + return true; + }; + // ValidMPTIssuance also reports the deletion, so assert on + // ValidMPTTransfer's message, which only the new check can produce. + doInvariantCheck( + {{"MPToken deleted on failure"}}, + eraseToken, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupEmpty, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + } + // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation. { Env env(*this, defaultAmendments()); @@ -5635,7 +5845,13 @@ class Invariants_test : public beast::unit_test::Suite std::make_pair(ttAMM_WITHDRAW, false), std::make_pair(ttPAYMENT, false), std::make_pair(ttPAYMENT, true)}; - for (auto const enabled : {true, false}) + // The two amendments that gate enforcement, in all four combinations. + FeatureBitset const gatesEnabled{featureMPTokensV2, fixCleanup3_4_0}; + for (auto const gates : + {gatesEnabled, + gatesEnabled - featureMPTokensV2, + gatesEnabled - fixCleanup3_4_0, + FeatureBitset{}}) { for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests) { @@ -5646,7 +5862,7 @@ class Invariants_test : public beast::unit_test::Suite 0u}) { MPTID id{}; - auto const isSuccess = !enabled || flag == 0 || + auto const isSuccess = !gates.any() || flag == 0 || (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) || (tx == ttAMM_WITHDRAW && (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer)); @@ -5697,16 +5913,83 @@ class Invariants_test : public beast::unit_test::Suite MPTTester const usd( {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 100}); id = usd.issuanceID(); - if (!enabled) - { + // Either gate enforces, so both must be off to stay + // advisory. Disable after setting up the MPT; the + // next env.close() is what makes it take effect. + if (!gates[featureMPTokensV2]) env.disableFeature(featureMPTokensV2); - } + if (!gates[fixCleanup3_4_0]) + env.disableFeature(fixCleanup3_4_0); return true; }); } } } + // An orphan has a zero balance, so only deletion is legitimate (see + // "Skipping Deleted MPTs" in testConfidentialMPTTransfer). + { + MPTID orphanID; + auto const setupOrphan = [&](Account const& a1, Account const& a2, Env& env) { + MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer}); + orphanID = mpt.issuanceID(); + // A2 is authorized but never paid, so its balance is zero and + // the issuance can be destroyed while its MPToken lives on. + mpt.authorize({.account = a2}); + mpt.destroy(); + return true; + }; + // ValidMPTBalanceChanges also reports this, so assert on the + // orphan message, which only the missing-issuance branch produces. + doInvariantCheck( + {{"orphaned MPToken balance changed"}}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] != 0) + return false; + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; + ac.view().update(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupOrphan); + // Negative control: erasing the orphan is how it gets cleaned up. + doInvariantCheck( + {}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok) + return false; + ac.view().erase(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tesSUCCESS, tesSUCCESS}, + setupOrphan); + // The same erase on a failure. The orphan branch continues, so only + // the pre-loop deletion check can report this one. + doInvariantCheck( + {{"MPToken deleted on failure"}}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok) + return false; + ac.view().erase(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupOrphan, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + } + // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends // through sfReferenceHolding to test the vault's underlying asset for // each changed holder. @@ -5881,7 +6164,9 @@ class Invariants_test : public beast::unit_test::Suite for (bool const isMPT : {false, true}) { - auto const error = isMPT ? TER(tecINVARIANT_FAILED) : TER(tefINVARIANT_FAILED); + // Under fixCleanup3_4_0 the MPT balance invariants also fire on the + // second pass, so both IOU and MPT pools now escalate to tef. + auto const error = TER(tefINVARIANT_FAILED); for (auto txType : {ttAMM_CREATE, ttAMM_DEPOSIT, ttAMM_CLAWBACK, ttAMM_WITHDRAW}) { test(txType, deleteAMMAccount, isMPT, tefINVARIANT_FAILED); @@ -6700,7 +6985,9 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + // Second pass is tef: the bumped MPTAmount also trips + // ValidMPTTransfer's on-failure check, which escalates the tec. + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseConfidential); // badVersion From 85512541ad78f61555e6f06b8463190a0bbcf908 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 20 Aug 2026 19:25:27 +0000 Subject: [PATCH 11/43] refactor: Collapse transactions.macro settings into a TxSettings struct (#8001) 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 --- cmake/scripts/codegen/generate_tx_classes.py | 82 ++- include/xrpl/protocol/Permissions.h | 8 +- include/xrpl/protocol/TxSettings.h | 96 ++++ .../xrpl/protocol/detail/transactions.macro | 500 ++++++++---------- .../protocol_autogen/transactions/AMMBid.h | 2 +- .../transactions/AMMClawback.h | 2 +- .../protocol_autogen/transactions/AMMCreate.h | 2 +- .../protocol_autogen/transactions/AMMDelete.h | 2 +- .../transactions/AMMDeposit.h | 2 +- .../protocol_autogen/transactions/AMMVote.h | 2 +- .../transactions/AMMWithdraw.h | 2 +- .../transactions/AccountDelete.h | 2 +- .../transactions/AccountSet.h | 2 +- .../protocol_autogen/transactions/Batch.h | 2 +- .../transactions/CheckCancel.h | 2 +- .../protocol_autogen/transactions/CheckCash.h | 2 +- .../transactions/CheckCreate.h | 2 +- .../protocol_autogen/transactions/Clawback.h | 2 +- .../transactions/ConfidentialMPTClawback.h | 2 +- .../transactions/ConfidentialMPTConvert.h | 2 +- .../transactions/ConfidentialMPTConvertBack.h | 2 +- .../transactions/ConfidentialMPTMergeInbox.h | 2 +- .../transactions/ConfidentialMPTSend.h | 2 +- .../transactions/CredentialAccept.h | 2 +- .../transactions/CredentialCreate.h | 2 +- .../transactions/CredentialDelete.h | 2 +- .../protocol_autogen/transactions/DIDDelete.h | 2 +- .../protocol_autogen/transactions/DIDSet.h | 2 +- .../transactions/DelegateSet.h | 2 +- .../transactions/DepositPreauth.h | 2 +- .../transactions/EnableAmendment.h | 2 +- .../transactions/EscrowCancel.h | 2 +- .../transactions/EscrowCreate.h | 2 +- .../transactions/EscrowFinish.h | 2 +- .../transactions/LedgerStateFix.h | 2 +- .../transactions/LoanBrokerCoverClawback.h | 2 +- .../transactions/LoanBrokerCoverDeposit.h | 2 +- .../transactions/LoanBrokerCoverWithdraw.h | 2 +- .../transactions/LoanBrokerDelete.h | 2 +- .../transactions/LoanBrokerSet.h | 2 +- .../transactions/LoanDelete.h | 2 +- .../transactions/LoanManage.h | 2 +- .../protocol_autogen/transactions/LoanPay.h | 2 +- .../protocol_autogen/transactions/LoanSet.h | 2 +- .../transactions/MPTokenAuthorize.h | 2 +- .../transactions/MPTokenIssuanceCreate.h | 2 +- .../transactions/MPTokenIssuanceDestroy.h | 2 +- .../transactions/MPTokenIssuanceSet.h | 2 +- .../transactions/NFTokenAcceptOffer.h | 2 +- .../transactions/NFTokenBurn.h | 2 +- .../transactions/NFTokenCancelOffer.h | 2 +- .../transactions/NFTokenCreateOffer.h | 2 +- .../transactions/NFTokenMint.h | 2 +- .../transactions/NFTokenModify.h | 2 +- .../transactions/OfferCancel.h | 2 +- .../transactions/OfferCreate.h | 2 +- .../transactions/OracleDelete.h | 2 +- .../protocol_autogen/transactions/OracleSet.h | 2 +- .../protocol_autogen/transactions/Payment.h | 2 +- .../transactions/PaymentChannelClaim.h | 2 +- .../transactions/PaymentChannelCreate.h | 2 +- .../transactions/PaymentChannelFund.h | 2 +- .../transactions/PermissionedDomainDelete.h | 2 +- .../transactions/PermissionedDomainSet.h | 2 +- .../protocol_autogen/transactions/SetFee.h | 2 +- .../transactions/SetRegularKey.h | 2 +- .../transactions/SignerListSet.h | 2 +- .../transactions/SponsorshipSet.h | 2 +- .../transactions/SponsorshipTransfer.h | 2 +- .../transactions/TicketCreate.h | 2 +- .../protocol_autogen/transactions/TrustSet.h | 2 +- .../protocol_autogen/transactions/UNLModify.h | 2 +- .../transactions/VaultClawback.h | 2 +- .../transactions/VaultCreate.h | 2 +- .../transactions/VaultDelete.h | 2 +- .../transactions/VaultDeposit.h | 2 +- .../protocol_autogen/transactions/VaultSet.h | 2 +- .../transactions/VaultWithdraw.h | 2 +- .../transactions/XChainAccountCreateCommit.h | 2 +- .../XChainAddAccountCreateAttestation.h | 2 +- .../transactions/XChainAddClaimAttestation.h | 2 +- .../transactions/XChainClaim.h | 2 +- .../transactions/XChainCommit.h | 2 +- .../transactions/XChainCreateBridge.h | 2 +- .../transactions/XChainCreateClaimID.h | 2 +- .../transactions/XChainModifyBridge.h | 2 +- .../tx/invariants/InvariantCheckPrivilege.h | 37 +- src/libxrpl/protocol/Permissions.cpp | 15 +- src/libxrpl/protocol/TxFormats.cpp | 2 +- src/libxrpl/tx/invariants/FreezeInvariant.cpp | 3 +- src/libxrpl/tx/invariants/InvariantCheck.cpp | 21 +- src/libxrpl/tx/invariants/MPTInvariant.cpp | 17 +- src/libxrpl/tx/invariants/NFTInvariant.cpp | 2 +- src/libxrpl/tx/invariants/VaultInvariant.cpp | 5 +- src/test/app/Delegate_test.cpp | 14 +- 95 files changed, 520 insertions(+), 446 deletions(-) create mode 100644 include/xrpl/protocol/TxSettings.h diff --git a/cmake/scripts/codegen/generate_tx_classes.py b/cmake/scripts/codegen/generate_tx_classes.py index 07baefd8b6..09fb898840 100644 --- a/cmake/scripts/codegen/generate_tx_classes.py +++ b/cmake/scripts/codegen/generate_tx_classes.py @@ -8,6 +8,7 @@ Uses pcpp to preprocess the macro file and pyparsing to parse the DSL. import io import argparse +import re from pathlib import Path import pyparsing as pp @@ -53,28 +54,89 @@ def create_transaction_parser(): return macro_parser +# Defaults for xrpl::TxSettings members, mirroring +# include/xrpl/protocol/TxSettings.h. A transaction's settings blob only names +# the members that differ from these. +SETTING_DEFAULTS = { + "delegable": "Delegation::NotDelegable", + "amendment": "uint256{}", + "privileges": "Privilege::NoPriv", +} + + +def parse_settings(settings_str): + """Parse a TxSettings blob into a dict, filling in defaults. + + Args: + settings_str: A string like '({.delegable = Delegation::NotDelegable, + .privileges = Privilege::CreateAcct})', or '({})'. + + Returns: + A dict with a value for every key in SETTING_DEFAULTS. + """ + body = settings_str.strip() + if not (body.startswith("(") and body.endswith(")")): + raise ValueError( + f"Malformed settings blob, expected '({{...}})': {settings_str!r}" + ) + body = body[1:-1].strip() + if not (body.startswith("{") and body.endswith("}")): + raise ValueError( + f"Malformed settings blob, expected '({{...}})': {settings_str!r}" + ) + body = body[1:-1] + + # Strip comments, which may be interleaved with the designated initializers. + body = re.sub(r"//[^\n]*", "", body) + + settings = dict(SETTING_DEFAULTS) + seen = set() + # Each entry runs from '.key =' up to the next '.key =' or the end. + for key, value in re.findall( + r"\.(\w+)\s*=\s*(.*?)(?=,\s*\.\w+\s*=|,?\s*$)", body, re.S + ): + if key not in SETTING_DEFAULTS: + raise ValueError(f"Unknown TxSettings member '.{key}' in {settings_str!r}") + settings[key] = " ".join(value.split()).rstrip(",") + seen.add(key) + + # Catch a typo'd or unparsed initializer rather than silently defaulting it. + # Every '.member' in the blob must have been consumed above. + if len(re.findall(r"\.\w+", body)) != len(seen): + raise ValueError(f"Could not parse every setting in {settings_str!r}") + + # A blob with content but no designated initializer is positional, which + # would otherwise be read as "all defaults" and silently generate the + # wrong output. + if body.strip() and not seen: + raise ValueError( + "TxSettings requires designated initializers (.member = value), " + f"got {settings_str!r}" + ) + + return settings + + def parse_transaction_args(args_list): """Parse the arguments of a TRANSACTION macro call. Args: args_list: A list of parsed arguments from pyparsing, e.g., - ['ttPAYMENT', '0', 'Payment', 'Delegation::delegable', - 'uint256{}', 'createAcct', '({...})'] + ['ttPAYMENT', '0', 'Payment', + '({.privileges = Privilege::CreateAcct})', '({...})'] Returns: A dict with parsed transaction information. """ - if len(args_list) < 7: + if len(args_list) < 5: raise ValueError( - f"Expected at least 7 parts in TRANSACTION, got {len(args_list)}: {args_list}" + f"Expected at least 5 parts in TRANSACTION, got {len(args_list)}: {args_list}" ) tag = args_list[0] value = args_list[1] name = args_list[2] - delegable = args_list[3] - amendments = args_list[4] - privileges = args_list[5] + settings = parse_settings(args_list[3]) fields_str = args_list[-1] # Parse fields: ({field1, field2, ...}) @@ -84,9 +146,9 @@ def parse_transaction_args(args_list): "tag": tag, "value": value, "name": name, - "delegable": delegable, - "amendments": amendments, - "privileges": privileges, + "delegable": settings["delegable"], + "amendments": settings["amendment"], + "privileges": settings["privileges"], "fields": fields, } diff --git a/include/xrpl/protocol/Permissions.h b/include/xrpl/protocol/Permissions.h index 703a0939c9..2a3f561a10 100644 --- a/include/xrpl/protocol/Permissions.h +++ b/include/xrpl/protocol/Permissions.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -38,11 +39,6 @@ enum GranularPermissionType : std::uint32_t { #pragma pop_macro("GRANULAR_PERMISSION") }; -// Injected bare enumerators (xrpl::delegable / xrpl::notDelegable) are required by preprocessor -// tricks in tests and macro-generated code; enum class would break that. -// NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) -enum Delegation { Delegable, NotDelegable }; - class Permission { private: @@ -65,7 +61,7 @@ private: struct TxDelegationEntry { uint256 amendment; - Delegation delegable{NotDelegable}; + Delegation delegable{Delegation::NotDelegable}; }; std::unordered_set granularTxTypes_; diff --git a/include/xrpl/protocol/TxSettings.h b/include/xrpl/protocol/TxSettings.h new file mode 100644 index 0000000000..8ea249856a --- /dev/null +++ b/include/xrpl/protocol/TxSettings.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include + +#include +#include + +namespace xrpl { + +enum class Delegation { Delegable, NotDelegable }; + +/** + * Operations a transaction is permitted to perform, as a bitfield. + * + * These are declared per-transaction in transactions.macro (via + * TxSettings::privileges) and enforced in InvariantCheck.cpp. + */ +enum class Privilege : std::uint16_t { + NoPriv = 0x0000, // The transaction can not do any of the enumerated operations + CreateAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object. + CreatePseudoAcct = 0x0002, // The transaction can create a pseudo account, + // which implies createAcct + MustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object + MayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT + // object, but does not have to + OverrideFreeze = 0x0010, // The transaction can override some freeze rules + ChangeNftCounts = 0x0020, // The transaction can mint or burn an NFT + CreateMptIssuance = 0x0040, // The transaction can create a new MPT issuance + DestroyMptIssuance = 0x0080, // The transaction can destroy an MPT issuance + MustAuthorizeMpt = 0x0100, // The transaction MUST create or delete an MPT + // object (except by issuer) + MayAuthorizeMpt = 0x0200, // The transaction MAY create or delete an MPT + // object (except by issuer) + MayDeleteMpt = 0x0400, // The transaction MAY delete an MPT object. May not create. + MustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault + MayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault + MayCreateMpt = 0x2000, // The transaction MAY create an MPT object, except for issuer. +}; + +// The inner static_cast is not redundant: the underlying type is narrower than +// `int`, so the operands integer-promote and the result has to be narrowed back. +// safeCast rejects that narrowing, but every input bit is a Privilege bit by +// construction, so the result is always representable. +constexpr Privilege +operator|(Privilege lhs, Privilege rhs) +{ + using Underlying = std::underlying_type_t; + return static_cast( + static_cast(safeCast(lhs) | safeCast(rhs))); +} + +constexpr Privilege +operator&(Privilege lhs, Privilege rhs) +{ + using Underlying = std::underlying_type_t; + return static_cast( + static_cast(safeCast(lhs) & safeCast(rhs))); +} + +/** + * Per-transaction metadata declared in transactions.macro. + * + * Every member has a default, so a transaction only needs to name the settings + * that differ from the common case. See the documentation at the top of + * transactions.macro for the authoring syntax. + * + * This is deliberately not a constexpr-friendly type: amendment identifiers are + * runtime-initialized `extern uint256 const` globals (see Feature.h), so a + * TxSettings can only be built at runtime. + */ +struct TxSettings +{ + /** + * Whether an account may delegate this transaction to another account. + */ + Delegation delegable{Delegation::NotDelegable}; + + /** + * The amendment gating this transaction, or uint256{} if always available. + */ + // The `{}` looks redundant, because BaseUInt's default constructor already + // zeroes the value. It is not: without a default member initializer here, + // every partial designated initializer in transactions.macro trips the + // missing-designated-field-initializers warning, which the build treats as + // an error. + // NOLINTNEXTLINE(readability-redundant-member-init) + uint256 amendment{}; + + /** + * Operations this transaction is permitted to perform. + */ + Privilege privileges{Privilege::NoPriv}; +}; + +} // namespace xrpl diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 997f368638..dbf9b66ac7 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -3,7 +3,7 @@ #endif /** - * TRANSACTION(tag, value, name, delegable, amendments, privileges, fields) + * TRANSACTION(tag, value, name, settings, fields) * * To ease maintenance, you may replace any unneeded values with "..." * e.g. #define TRANSACTION(tag, value, name, ...) @@ -15,9 +15,31 @@ * # include * #endif * - * The `privileges` parameter of the TRANSACTION macro is a bitfield - * defining which operations the transaction can perform. - * The values are defined and used in InvariantCheck.cpp + * `settings` is a parenthesized brace-init-list for xrpl::TxSettings, declared + * in : + * + * struct TxSettings + * { + * Delegation delegable{Delegation::NotDelegable}; + * uint256 amendment{}; + * Privilege privileges{Privilege::NoPriv}; + * }; + * + * Name only the settings that differ from those defaults, in declaration + * order; use `({})` when none of them do: + * + * ({.delegable = Delegation::Delegable, .amendment = featureFoo}) + * + * You must use designated initializers, as shown above. Positional + * initialization such as `({Delegation::NotDelegable})` is not supported, + * because the code generator reads these settings by member name. + * + * The `privileges` setting is a bitfield defining which operations the + * transaction can perform. The values are defined in TxSettings.h and + * enforced in InvariantCheck.cpp. + * + * A consumer that only needs some of the settings can unwrap the blob with + * `#define UNWRAP(...) __VA_ARGS__` and write `TxSettings UNWRAP settings`. */ /** This transaction type executes a payment. */ @@ -25,9 +47,7 @@ # include #endif TRANSACTION(ttPAYMENT, 0, Payment, - Delegation::Delegable, - uint256{}, - CreateAcct | MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::CreateAcct | Privilege::MayCreateMpt}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -44,11 +64,7 @@ TRANSACTION(ttPAYMENT, 0, Payment, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfCondition, SoeOptional}, @@ -61,11 +77,7 @@ TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, {sfFulfillment, SoeOptional}, @@ -79,9 +91,7 @@ TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, # include #endif TRANSACTION(ttACCOUNT_SET, 3, AccountSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfEmailHash, SoeOptional}, {sfWalletLocator, SoeOptional}, @@ -99,11 +109,7 @@ TRANSACTION(ttACCOUNT_SET, 3, AccountSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, })) @@ -113,9 +119,7 @@ TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, # include #endif TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfRegularKey, SoeOptional}, })) @@ -127,9 +131,7 @@ TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, # include #endif TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfTakerPays, SoeRequired, SoeMptSupported}, {sfTakerGets, SoeRequired, SoeMptSupported}, @@ -142,11 +144,7 @@ TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, ({.delegable = Delegation::Delegable}), ({ {sfOfferSequence, SoeRequired}, })) @@ -156,11 +154,7 @@ TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, ({.delegable = Delegation::Delegable}), ({ {sfTicketCount, SoeRequired}, })) @@ -173,9 +167,7 @@ TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, # include #endif TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfSignerQuorum, SoeRequired}, {sfSignerEntries, SoeOptional}, @@ -185,11 +177,7 @@ TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired}, {sfSettleDelay, SoeRequired}, @@ -202,11 +190,7 @@ TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeRequired}, {sfExpiration, SoeOptional}, @@ -216,11 +200,7 @@ TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeOptional}, {sfBalance, SoeOptional}, @@ -233,11 +213,7 @@ TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfSendMax, SoeRequired, SoeMptSupported}, {sfExpiration, SoeOptional}, @@ -250,9 +226,7 @@ TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, # include #endif TRANSACTION(ttCHECK_CASH, 17, CheckCash, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfCheckID, SoeRequired}, {sfAmount, SoeOptional, SoeMptSupported}, @@ -263,11 +237,7 @@ TRANSACTION(ttCHECK_CASH, 17, CheckCash, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, ({.delegable = Delegation::Delegable}), ({ {sfCheckID, SoeRequired}, })) @@ -275,11 +245,7 @@ TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, ({.delegable = Delegation::Delegable}), ({ {sfAuthorize, SoeOptional}, {sfUnauthorize, SoeOptional}, {sfAuthorizeCredentials, SoeOptional}, @@ -290,11 +256,7 @@ TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTRUST_SET, 20, TrustSet, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTRUST_SET, 20, TrustSet, ({.delegable = Delegation::Delegable}), ({ {sfLimitAmount, SoeOptional}, {sfQualityIn, SoeOptional}, {sfQualityOut, SoeOptional}, @@ -305,9 +267,9 @@ TRANSACTION(ttTRUST_SET, 20, TrustSet, # include #endif TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, - Delegation::NotDelegable, - uint256{}, - MustDeleteAcct, + ({ + .privileges = Privilege::MustDeleteAcct, + }), ({ {sfDestination, SoeRequired}, {sfDestinationTag, SoeOptional}, @@ -321,9 +283,7 @@ TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, # include #endif TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenTaxon, SoeRequired}, {sfTransferFee, SoeOptional}, @@ -339,9 +299,7 @@ TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, # include #endif TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -351,11 +309,7 @@ TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenID, SoeRequired}, {sfAmount, SoeRequired}, {sfDestination, SoeOptional}, @@ -367,11 +321,7 @@ TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenOffers, SoeRequired}, })) @@ -379,11 +329,7 @@ TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenBuyOffer, SoeOptional}, {sfNFTokenSellOffer, SoeOptional}, {sfNFTokenBrokerFee, SoeOptional}, @@ -393,11 +339,7 @@ TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCLAWBACK, 30, Clawback, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCLAWBACK, 30, Clawback, ({.delegable = Delegation::Delegable}), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfHolder, SoeOptional}, })) @@ -407,9 +349,12 @@ TRANSACTION(ttCLAWBACK, 30, Clawback, # include #endif TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, - Delegation::Delegable, - featureAMMClawback, - MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMMClawback, + .privileges = Privilege::MayDeleteAcct | Privilege::OverrideFreeze | + Privilege::MayAuthorizeMpt, + }), ({ {sfHolder, SoeRequired}, {sfAsset, SoeRequired, SoeMptSupported}, @@ -422,9 +367,11 @@ TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, # include #endif TRANSACTION(ttAMM_CREATE, 35, AMMCreate, - Delegation::Delegable, - featureAMM, - CreatePseudoAcct | MayCreateMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayCreateMpt, + }), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfAmount2, SoeRequired, SoeMptSupported}, @@ -436,9 +383,7 @@ TRANSACTION(ttAMM_CREATE, 35, AMMCreate, # include #endif TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -454,9 +399,11 @@ TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, # include #endif TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, - Delegation::Delegable, - featureAMM, - MayDeleteAcct | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MayDeleteAcct | Privilege::MayAuthorizeMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -471,9 +418,7 @@ TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, # include #endif TRANSACTION(ttAMM_VOTE, 38, AMMVote, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -485,9 +430,7 @@ TRANSACTION(ttAMM_VOTE, 38, AMMVote, # include #endif TRANSACTION(ttAMM_BID, 39, AMMBid, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -501,9 +444,11 @@ TRANSACTION(ttAMM_BID, 39, AMMBid, # include #endif TRANSACTION(ttAMM_DELETE, 40, AMMDelete, - Delegation::Delegable, - featureAMM, - MustDeleteAcct | MayDeleteMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MustDeleteAcct | Privilege::MayDeleteMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -514,9 +459,7 @@ TRANSACTION(ttAMM_DELETE, 40, AMMDelete, # include #endif TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -525,9 +468,7 @@ TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, /** This transactions initiates a crosschain transaction */ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -537,9 +478,7 @@ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, /** This transaction completes a crosschain transaction */ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -550,9 +489,7 @@ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, /** This transaction initiates a crosschain account create transaction */ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfDestination, SoeRequired}, @@ -562,9 +499,11 @@ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, /** This transaction adds an attestation to a claim */ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -581,11 +520,12 @@ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, })) /** This transaction adds an attestation to an account */ -TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, - XChainAddAccountCreateAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, +TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, XChainAddAccountCreateAttestation, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -604,9 +544,7 @@ TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, /** This transaction modifies a sidechain */ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeOptional}, @@ -615,9 +553,7 @@ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, /** This transactions creates a sidechain */ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -629,9 +565,7 @@ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, # include #endif TRANSACTION(ttDID_SET, 49, DIDSet, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({ {sfDIDDocument, SoeOptional}, {sfURI, SoeOptional}, @@ -643,9 +577,7 @@ TRANSACTION(ttDID_SET, 49, DIDSet, # include #endif TRANSACTION(ttDID_DELETE, 50, DIDDelete, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({})) /** This transaction type creates an Oracle instance */ @@ -653,9 +585,7 @@ TRANSACTION(ttDID_DELETE, 50, DIDDelete, # include #endif TRANSACTION(ttORACLE_SET, 51, OracleSet, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, {sfProvider, SoeOptional}, @@ -670,9 +600,7 @@ TRANSACTION(ttORACLE_SET, 51, OracleSet, # include #endif TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, })) @@ -682,9 +610,7 @@ TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, # include #endif TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, - Delegation::Delegable, - fixNFTokenPageLinks, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = fixNFTokenPageLinks}), ({ {sfLedgerFixType, SoeRequired}, {sfOwner, SoeOptional}, @@ -696,9 +622,11 @@ TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, - Delegation::Delegable, - featureMPTokensV1, - CreateMptIssuance, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::CreateMptIssuance, + }), ({ {sfAssetScale, SoeOptional}, {sfTransferFee, SoeOptional}, @@ -713,9 +641,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, - Delegation::Delegable, - featureMPTokensV1, - DestroyMptIssuance, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::DestroyMptIssuance, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -725,9 +655,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, - Delegation::Delegable, - featureMPTokensV1, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureMPTokensV1}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -744,9 +672,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, # include #endif TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, - Delegation::Delegable, - featureMPTokensV1, - MustAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::MustAuthorizeMpt, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -757,9 +687,7 @@ TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, # include #endif TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -772,9 +700,7 @@ TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, # include #endif TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfIssuer, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -785,9 +711,7 @@ TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, # include #endif TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeOptional}, {sfIssuer, SoeOptional}, @@ -799,9 +723,7 @@ TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, # include #endif TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, - Delegation::Delegable, - featureDynamicNFT, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDynamicNFT}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -813,9 +735,7 @@ TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeOptional}, {sfAcceptedCredentials, SoeRequired}, @@ -826,9 +746,7 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeRequired}, })) @@ -838,9 +756,9 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, # include #endif TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, - Delegation::NotDelegable, - featurePermissionDelegationV1_1, - NoPriv, + ({ + .amendment = featurePermissionDelegationV1_1, + }), ({ {sfAuthorize, SoeRequired}, {sfPermissions, SoeRequired}, @@ -851,9 +769,11 @@ TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, # include #endif TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, - Delegation::NotDelegable, - featureSingleAssetVault, - CreatePseudoAcct | CreateMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::CreatePseudoAcct | Privilege::CreateMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAssetsMaximum, SoeOptional}, @@ -872,9 +792,10 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, # include #endif TRANSACTION(ttVAULT_SET, 66, VaultSet, - Delegation::NotDelegable, - featureSingleAssetVault, - MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAssetsMaximum, SoeOptional}, @@ -887,9 +808,11 @@ TRANSACTION(ttVAULT_SET, 66, VaultSet, # include #endif TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, - Delegation::NotDelegable, - featureSingleAssetVault, - MustDeleteAcct | DestroyMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustDeleteAcct | Privilege::DestroyMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfMemoData, SoeOptional}, @@ -900,9 +823,10 @@ TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, # include #endif TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, - Delegation::NotDelegable, - featureSingleAssetVault, - MayAuthorizeMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -913,9 +837,11 @@ TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, # include #endif TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, - Delegation::NotDelegable, - featureSingleAssetVault, - MayDeleteMpt | MayAuthorizeMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayDeleteMpt | Privilege::MayAuthorizeMpt | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -929,9 +855,10 @@ TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, # include #endif TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, - Delegation::NotDelegable, - featureSingleAssetVault, - MayDeleteMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayDeleteMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfHolder, SoeRequired}, @@ -943,9 +870,9 @@ TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, # include #endif TRANSACTION(ttBATCH, 71, Batch, - Delegation::NotDelegable, - featureBatchV1_1, - NoPriv, + ({ + .amendment = featureBatchV1_1, + }), ({ {sfRawTransactions, SoeRequired}, {sfBatchSigners, SoeOptional}, @@ -958,9 +885,11 @@ TRANSACTION(ttBATCH, 71, Batch, # include #endif TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, - Delegation::NotDelegable, - featureLendingProtocol, - CreatePseudoAcct | MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayAuthorizeMpt, + }), + ({ {sfVaultID, SoeRequired}, {sfLoanBrokerID, SoeOptional}, {sfData, SoeOptional}, @@ -975,9 +904,11 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, # include #endif TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, - Delegation::NotDelegable, - featureLendingProtocol, - MustDeleteAcct | MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MustDeleteAcct | Privilege::MayAuthorizeMpt, + }), + ({ {sfLoanBrokerID, SoeRequired}, })) @@ -986,9 +917,10 @@ TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, })) @@ -998,9 +930,11 @@ TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, @@ -1014,9 +948,10 @@ TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanBrokerID, SoeOptional}, {sfAmount, SoeOptional, SoeMptSupported}, })) @@ -1026,9 +961,11 @@ TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, # include #endif TRANSACTION(ttLOAN_SET, 80, LoanSet, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfData, SoeOptional}, {sfCounterparty, SoeOptional}, @@ -1053,9 +990,10 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, # include #endif TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanID, SoeRequired}, })) @@ -1064,12 +1002,14 @@ TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, # include #endif TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, - Delegation::NotDelegable, - featureLendingProtocol, - // All of the LoanManage options will modify the vault, but the - // transaction can succeed without options, essentially making it - // a noop. - MayModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + // All of the LoanManage options will modify the vault, but the + // transaction can succeed without options, essentially making it + // a noop. + .privileges = Privilege::MayModifyVault, + }), + ({ {sfLoanID, SoeRequired}, })) @@ -1078,9 +1018,11 @@ TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, # include #endif TRANSACTION(ttLOAN_PAY, 84, LoanPay, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), + ({ {sfLoanID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, })) @@ -1090,9 +1032,9 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, - Delegation::NotDelegable, - featureConfidentialTransfer, - NoPriv, + ({ + .amendment = featureConfidentialTransfer, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1109,9 +1051,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -1121,9 +1061,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1139,9 +1077,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfDestination, SoeRequired}, @@ -1160,9 +1096,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeRequired}, @@ -1175,9 +1109,9 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, # include #endif TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, - Delegation::NotDelegable, - featureSponsor, - NoPriv, + ({ + .amendment = featureSponsor, + }), ({ {sfObjectID, SoeOptional}, {sfSponsee, SoeOptional}, @@ -1188,9 +1122,7 @@ TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, # include #endif TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, - Delegation::Delegable, - featureSponsor, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureSponsor}), ({ {sfCounterpartySponsor, SoeOptional}, {sfSponsee, SoeOptional}, @@ -1207,9 +1139,7 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, # include #endif TRANSACTION(ttAMENDMENT, 100, EnableAmendment, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfLedgerSequence, SoeRequired}, {sfAmendment, SoeRequired}, @@ -1219,9 +1149,7 @@ TRANSACTION(ttAMENDMENT, 100, EnableAmendment, For details, see: https://xrpl.org/fee-voting.html */ TRANSACTION(ttFEE, 101, SetFee, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfLedgerSequence, SoeOptional}, // Old version uses raw numbers @@ -1240,9 +1168,7 @@ TRANSACTION(ttFEE, 101, SetFee, For details, see: https://xrpl.org/negative-unl.html */ TRANSACTION(ttUNL_MODIFY, 102, UNLModify, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfUNLModifyDisabling, SoeRequired}, {sfLedgerSequence, SoeRequired}, diff --git a/include/xrpl/protocol_autogen/transactions/AMMBid.h b/include/xrpl/protocol_autogen/transactions/AMMBid.h index 30a2b6f2ab..94d0672699 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMBid.h +++ b/include/xrpl/protocol_autogen/transactions/AMMBid.h @@ -21,7 +21,7 @@ class AMMBidBuilder; * Type: ttAMM_BID (39) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMBidBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMClawback.h b/include/xrpl/protocol_autogen/transactions/AMMClawback.h index 38aba892c4..c837b5cee6 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMClawback.h +++ b/include/xrpl/protocol_autogen/transactions/AMMClawback.h @@ -21,7 +21,7 @@ class AMMClawbackBuilder; * Type: ttAMM_CLAWBACK (31) * Delegable: Delegation::Delegable * Amendment: featureAMMClawback - * Privileges: MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt + * Privileges: Privilege::MayDeleteAcct | Privilege::OverrideFreeze | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMCreate.h b/include/xrpl/protocol_autogen/transactions/AMMCreate.h index c6ccd4e860..e2e50f87ff 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMCreate.h +++ b/include/xrpl/protocol_autogen/transactions/AMMCreate.h @@ -21,7 +21,7 @@ class AMMCreateBuilder; * Type: ttAMM_CREATE (35) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: CreatePseudoAcct | MayCreateMpt + * Privileges: Privilege::CreatePseudoAcct | Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMDelete.h b/include/xrpl/protocol_autogen/transactions/AMMDelete.h index 05899a46c8..86e91bf52b 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMDelete.h +++ b/include/xrpl/protocol_autogen/transactions/AMMDelete.h @@ -21,7 +21,7 @@ class AMMDeleteBuilder; * Type: ttAMM_DELETE (40) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: MustDeleteAcct | MayDeleteMpt + * Privileges: Privilege::MustDeleteAcct | Privilege::MayDeleteMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMDeposit.h b/include/xrpl/protocol_autogen/transactions/AMMDeposit.h index 5416547dab..fed1bd3195 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/AMMDeposit.h @@ -21,7 +21,7 @@ class AMMDepositBuilder; * Type: ttAMM_DEPOSIT (36) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMVote.h b/include/xrpl/protocol_autogen/transactions/AMMVote.h index 7dce3c252f..3fca42a232 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMVote.h +++ b/include/xrpl/protocol_autogen/transactions/AMMVote.h @@ -21,7 +21,7 @@ class AMMVoteBuilder; * Type: ttAMM_VOTE (38) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMVoteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h b/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h index 81258f22d6..e177011801 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h @@ -21,7 +21,7 @@ class AMMWithdrawBuilder; * Type: ttAMM_WITHDRAW (37) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: MayDeleteAcct | MayAuthorizeMpt + * Privileges: Privilege::MayDeleteAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMWithdrawBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AccountDelete.h b/include/xrpl/protocol_autogen/transactions/AccountDelete.h index cf6e97bb63..87ecab0c7b 100644 --- a/include/xrpl/protocol_autogen/transactions/AccountDelete.h +++ b/include/xrpl/protocol_autogen/transactions/AccountDelete.h @@ -21,7 +21,7 @@ class AccountDeleteBuilder; * Type: ttACCOUNT_DELETE (21) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: MustDeleteAcct + * Privileges: Privilege::MustDeleteAcct * * Immutable wrapper around STTx providing type-safe field access. * Use AccountDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AccountSet.h b/include/xrpl/protocol_autogen/transactions/AccountSet.h index 55c449e78e..9f85603e22 100644 --- a/include/xrpl/protocol_autogen/transactions/AccountSet.h +++ b/include/xrpl/protocol_autogen/transactions/AccountSet.h @@ -21,7 +21,7 @@ class AccountSetBuilder; * Type: ttACCOUNT_SET (3) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AccountSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Batch.h b/include/xrpl/protocol_autogen/transactions/Batch.h index 1a59d2b4c0..f92aaa5348 100644 --- a/include/xrpl/protocol_autogen/transactions/Batch.h +++ b/include/xrpl/protocol_autogen/transactions/Batch.h @@ -21,7 +21,7 @@ class BatchBuilder; * Type: ttBATCH (71) * Delegable: Delegation::NotDelegable * Amendment: featureBatchV1_1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use BatchBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCancel.h b/include/xrpl/protocol_autogen/transactions/CheckCancel.h index b75b717e3f..cf300d3b9b 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCancel.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCancel.h @@ -21,7 +21,7 @@ class CheckCancelBuilder; * Type: ttCHECK_CANCEL (18) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCash.h b/include/xrpl/protocol_autogen/transactions/CheckCash.h index c742a15154..b80429875f 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCash.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCash.h @@ -21,7 +21,7 @@ class CheckCashBuilder; * Type: ttCHECK_CASH (17) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: MayCreateMpt + * Privileges: Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCashBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCreate.h b/include/xrpl/protocol_autogen/transactions/CheckCreate.h index 63e55f8604..db51b5eb5f 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCreate.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCreate.h @@ -21,7 +21,7 @@ class CheckCreateBuilder; * Type: ttCHECK_CREATE (16) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Clawback.h b/include/xrpl/protocol_autogen/transactions/Clawback.h index 9a3a7f9feb..ad79f1d1fe 100644 --- a/include/xrpl/protocol_autogen/transactions/Clawback.h +++ b/include/xrpl/protocol_autogen/transactions/Clawback.h @@ -21,7 +21,7 @@ class ClawbackBuilder; * Type: ttCLAWBACK (30) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h index c80fc81dc5..bf204a35cb 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h @@ -21,7 +21,7 @@ class ConfidentialMPTClawbackBuilder; * Type: ttCONFIDENTIAL_MPT_CLAWBACK (89) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h index 284b7f9e70..d23e6409d9 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h @@ -21,7 +21,7 @@ class ConfidentialMPTConvertBuilder; * Type: ttCONFIDENTIAL_MPT_CONVERT (85) * Delegable: Delegation::NotDelegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTConvertBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h index 53a8e64125..80ec81e6f3 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h @@ -21,7 +21,7 @@ class ConfidentialMPTConvertBackBuilder; * Type: ttCONFIDENTIAL_MPT_CONVERT_BACK (87) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTConvertBackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h index 848da42a41..e3ec886acf 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h @@ -21,7 +21,7 @@ class ConfidentialMPTMergeInboxBuilder; * Type: ttCONFIDENTIAL_MPT_MERGE_INBOX (86) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTMergeInboxBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h index 806a2586e9..b8aac2bd48 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h @@ -21,7 +21,7 @@ class ConfidentialMPTSendBuilder; * Type: ttCONFIDENTIAL_MPT_SEND (88) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTSendBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h index f2ab546320..7ee2464460 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h @@ -21,7 +21,7 @@ class CredentialAcceptBuilder; * Type: ttCREDENTIAL_ACCEPT (59) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialAcceptBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h index 6cf09c852b..6ccc4e3059 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h @@ -21,7 +21,7 @@ class CredentialCreateBuilder; * Type: ttCREDENTIAL_CREATE (58) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h index 24a2bfa62a..74039e50bf 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h @@ -21,7 +21,7 @@ class CredentialDeleteBuilder; * Type: ttCREDENTIAL_DELETE (60) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDDelete.h b/include/xrpl/protocol_autogen/transactions/DIDDelete.h index 304287883d..885f84718d 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDDelete.h +++ b/include/xrpl/protocol_autogen/transactions/DIDDelete.h @@ -21,7 +21,7 @@ class DIDDeleteBuilder; * Type: ttDID_DELETE (50) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDSet.h b/include/xrpl/protocol_autogen/transactions/DIDSet.h index 67e5ba23c5..0679170780 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDSet.h +++ b/include/xrpl/protocol_autogen/transactions/DIDSet.h @@ -21,7 +21,7 @@ class DIDSetBuilder; * Type: ttDID_SET (49) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DelegateSet.h b/include/xrpl/protocol_autogen/transactions/DelegateSet.h index 592a778952..1d70166920 100644 --- a/include/xrpl/protocol_autogen/transactions/DelegateSet.h +++ b/include/xrpl/protocol_autogen/transactions/DelegateSet.h @@ -21,7 +21,7 @@ class DelegateSetBuilder; * Type: ttDELEGATE_SET (64) * Delegable: Delegation::NotDelegable * Amendment: featurePermissionDelegationV1_1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DelegateSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h index b5d575aac5..66c5b390e6 100644 --- a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h +++ b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h @@ -21,7 +21,7 @@ class DepositPreauthBuilder; * Type: ttDEPOSIT_PREAUTH (19) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DepositPreauthBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h index e811ca16df..08a57540ec 100644 --- a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h +++ b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h @@ -21,7 +21,7 @@ class EnableAmendmentBuilder; * Type: ttAMENDMENT (100) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EnableAmendmentBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h index e7e49eca0d..3727bbaa2a 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h @@ -21,7 +21,7 @@ class EscrowCancelBuilder; * Type: ttESCROW_CANCEL (4) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCreate.h b/include/xrpl/protocol_autogen/transactions/EscrowCreate.h index b994e4ec07..3d28a12cee 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowCreate.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowCreate.h @@ -21,7 +21,7 @@ class EscrowCreateBuilder; * Type: ttESCROW_CREATE (1) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowFinish.h b/include/xrpl/protocol_autogen/transactions/EscrowFinish.h index 2476def5c2..1cbc60c738 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowFinish.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowFinish.h @@ -21,7 +21,7 @@ class EscrowFinishBuilder; * Type: ttESCROW_FINISH (2) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowFinishBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h index af86dea0b0..4c02989f09 100644 --- a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h +++ b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h @@ -21,7 +21,7 @@ class LedgerStateFixBuilder; * Type: ttLEDGER_STATE_FIX (53) * Delegable: Delegation::Delegable * Amendment: fixNFTokenPageLinks - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LedgerStateFixBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h index 875e0a4c5e..468ce054c2 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h @@ -21,7 +21,7 @@ class LoanBrokerCoverClawbackBuilder; * Type: ttLOAN_BROKER_COVER_CLAWBACK (78) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h index 38cc113844..0fe1bd7b91 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h @@ -21,7 +21,7 @@ class LoanBrokerCoverDepositBuilder; * Type: ttLOAN_BROKER_COVER_DEPOSIT (76) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h index 148db4292c..4992fb8bbd 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h @@ -21,7 +21,7 @@ class LoanBrokerCoverWithdrawBuilder; * Type: ttLOAN_BROKER_COVER_WITHDRAW (77) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt + * Privileges: Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverWithdrawBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h index 29b3a787fd..c449ebaff0 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h @@ -21,7 +21,7 @@ class LoanBrokerDeleteBuilder; * Type: ttLOAN_BROKER_DELETE (75) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MustDeleteAcct | MayAuthorizeMpt + * Privileges: Privilege::MustDeleteAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h index 41c87c281d..18f14b7a37 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h @@ -21,7 +21,7 @@ class LoanBrokerSetBuilder; * Type: ttLOAN_BROKER_SET (74) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: CreatePseudoAcct | MayAuthorizeMpt + * Privileges: Privilege::CreatePseudoAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanDelete.h b/include/xrpl/protocol_autogen/transactions/LoanDelete.h index 8ed537b37a..2696b542da 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanDelete.h +++ b/include/xrpl/protocol_autogen/transactions/LoanDelete.h @@ -21,7 +21,7 @@ class LoanDeleteBuilder; * Type: ttLOAN_DELETE (81) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanManage.h b/include/xrpl/protocol_autogen/transactions/LoanManage.h index 5eb95d21b1..4a665b372f 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanManage.h +++ b/include/xrpl/protocol_autogen/transactions/LoanManage.h @@ -21,7 +21,7 @@ class LoanManageBuilder; * Type: ttLOAN_MANAGE (82) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayModifyVault + * Privileges: Privilege::MayModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanManageBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanPay.h b/include/xrpl/protocol_autogen/transactions/LoanPay.h index 8e1faeb981..c9224fd697 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanPay.h +++ b/include/xrpl/protocol_autogen/transactions/LoanPay.h @@ -21,7 +21,7 @@ class LoanPayBuilder; * Type: ttLOAN_PAY (84) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanPayBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanSet.h b/include/xrpl/protocol_autogen/transactions/LoanSet.h index 2cadebd02e..eb04a468f0 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanSet.h +++ b/include/xrpl/protocol_autogen/transactions/LoanSet.h @@ -21,7 +21,7 @@ class LoanSetBuilder; * Type: ttLOAN_SET (80) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h b/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h index 2fb93eaf35..89d026928d 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h @@ -21,7 +21,7 @@ class MPTokenAuthorizeBuilder; * Type: ttMPTOKEN_AUTHORIZE (57) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: MustAuthorizeMpt + * Privileges: Privilege::MustAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenAuthorizeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h index 82ffba9996..b83de9d843 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h @@ -21,7 +21,7 @@ class MPTokenIssuanceCreateBuilder; * Type: ttMPTOKEN_ISSUANCE_CREATE (54) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: CreateMptIssuance + * Privileges: Privilege::CreateMptIssuance * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h index cbcd206097..6d1c9b1eaa 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h @@ -21,7 +21,7 @@ class MPTokenIssuanceDestroyBuilder; * Type: ttMPTOKEN_ISSUANCE_DESTROY (55) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: DestroyMptIssuance + * Privileges: Privilege::DestroyMptIssuance * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceDestroyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h index ed7e1f0f6c..43def05194 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h @@ -21,7 +21,7 @@ class MPTokenIssuanceSetBuilder; * Type: ttMPTOKEN_ISSUANCE_SET (56) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h index 325d2d7fbd..6c858be721 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h @@ -21,7 +21,7 @@ class NFTokenAcceptOfferBuilder; * Type: ttNFTOKEN_ACCEPT_OFFER (29) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenAcceptOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h b/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h index ec423ea468..ac831bf45e 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h @@ -21,7 +21,7 @@ class NFTokenBurnBuilder; * Type: ttNFTOKEN_BURN (26) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: ChangeNftCounts + * Privileges: Privilege::ChangeNftCounts * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenBurnBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h index 4c4fb1dc65..81f4f3a848 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h @@ -21,7 +21,7 @@ class NFTokenCancelOfferBuilder; * Type: ttNFTOKEN_CANCEL_OFFER (28) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenCancelOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h index a535a578e0..683436f4fd 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h @@ -21,7 +21,7 @@ class NFTokenCreateOfferBuilder; * Type: ttNFTOKEN_CREATE_OFFER (27) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenCreateOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenMint.h b/include/xrpl/protocol_autogen/transactions/NFTokenMint.h index 5af41eb3dd..5a4e3b5b1c 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenMint.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenMint.h @@ -21,7 +21,7 @@ class NFTokenMintBuilder; * Type: ttNFTOKEN_MINT (25) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: ChangeNftCounts + * Privileges: Privilege::ChangeNftCounts * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenMintBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenModify.h b/include/xrpl/protocol_autogen/transactions/NFTokenModify.h index 9b9701fed6..84f1e395d4 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenModify.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenModify.h @@ -21,7 +21,7 @@ class NFTokenModifyBuilder; * Type: ttNFTOKEN_MODIFY (61) * Delegable: Delegation::Delegable * Amendment: featureDynamicNFT - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenModifyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OfferCancel.h b/include/xrpl/protocol_autogen/transactions/OfferCancel.h index 5e6010e0dd..3e52ebf24b 100644 --- a/include/xrpl/protocol_autogen/transactions/OfferCancel.h +++ b/include/xrpl/protocol_autogen/transactions/OfferCancel.h @@ -21,7 +21,7 @@ class OfferCancelBuilder; * Type: ttOFFER_CANCEL (8) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OfferCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OfferCreate.h b/include/xrpl/protocol_autogen/transactions/OfferCreate.h index ffc1216297..774921d87a 100644 --- a/include/xrpl/protocol_autogen/transactions/OfferCreate.h +++ b/include/xrpl/protocol_autogen/transactions/OfferCreate.h @@ -21,7 +21,7 @@ class OfferCreateBuilder; * Type: ttOFFER_CREATE (7) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: MayCreateMpt + * Privileges: Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use OfferCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OracleDelete.h b/include/xrpl/protocol_autogen/transactions/OracleDelete.h index ebdc8fb7e9..e50b6f6b02 100644 --- a/include/xrpl/protocol_autogen/transactions/OracleDelete.h +++ b/include/xrpl/protocol_autogen/transactions/OracleDelete.h @@ -21,7 +21,7 @@ class OracleDeleteBuilder; * Type: ttORACLE_DELETE (52) * Delegable: Delegation::Delegable * Amendment: featurePriceOracle - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OracleDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OracleSet.h b/include/xrpl/protocol_autogen/transactions/OracleSet.h index 0ec6d5cad0..03e4ffc518 100644 --- a/include/xrpl/protocol_autogen/transactions/OracleSet.h +++ b/include/xrpl/protocol_autogen/transactions/OracleSet.h @@ -21,7 +21,7 @@ class OracleSetBuilder; * Type: ttORACLE_SET (51) * Delegable: Delegation::Delegable * Amendment: featurePriceOracle - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OracleSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Payment.h b/include/xrpl/protocol_autogen/transactions/Payment.h index 389900bf12..cb177a8d08 100644 --- a/include/xrpl/protocol_autogen/transactions/Payment.h +++ b/include/xrpl/protocol_autogen/transactions/Payment.h @@ -21,7 +21,7 @@ class PaymentBuilder; * Type: ttPAYMENT (0) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: CreateAcct | MayCreateMpt + * Privileges: Privilege::CreateAcct | Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h index 4c567b13f4..06892955db 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h @@ -21,7 +21,7 @@ class PaymentChannelClaimBuilder; * Type: ttPAYCHAN_CLAIM (15) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelClaimBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h index 0a513d575a..2a3aebca4c 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h @@ -21,7 +21,7 @@ class PaymentChannelCreateBuilder; * Type: ttPAYCHAN_CREATE (13) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h index 51210dd796..9a8c452b0b 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h @@ -21,7 +21,7 @@ class PaymentChannelFundBuilder; * Type: ttPAYCHAN_FUND (14) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelFundBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h b/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h index 3db921776c..1b16b13116 100644 --- a/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h +++ b/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h @@ -21,7 +21,7 @@ class PermissionedDomainDeleteBuilder; * Type: ttPERMISSIONED_DOMAIN_DELETE (63) * Delegable: Delegation::Delegable * Amendment: featurePermissionedDomains - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PermissionedDomainDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h b/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h index 3e352cad76..30832aec8c 100644 --- a/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h +++ b/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h @@ -21,7 +21,7 @@ class PermissionedDomainSetBuilder; * Type: ttPERMISSIONED_DOMAIN_SET (62) * Delegable: Delegation::Delegable * Amendment: featurePermissionedDomains - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PermissionedDomainSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SetFee.h b/include/xrpl/protocol_autogen/transactions/SetFee.h index 177f39199b..9513723e94 100644 --- a/include/xrpl/protocol_autogen/transactions/SetFee.h +++ b/include/xrpl/protocol_autogen/transactions/SetFee.h @@ -21,7 +21,7 @@ class SetFeeBuilder; * Type: ttFEE (101) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SetFeeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SetRegularKey.h b/include/xrpl/protocol_autogen/transactions/SetRegularKey.h index a943bb0279..042676251b 100644 --- a/include/xrpl/protocol_autogen/transactions/SetRegularKey.h +++ b/include/xrpl/protocol_autogen/transactions/SetRegularKey.h @@ -21,7 +21,7 @@ class SetRegularKeyBuilder; * Type: ttREGULAR_KEY_SET (5) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SetRegularKeyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SignerListSet.h b/include/xrpl/protocol_autogen/transactions/SignerListSet.h index 6e9d0e41ba..253bcccc1a 100644 --- a/include/xrpl/protocol_autogen/transactions/SignerListSet.h +++ b/include/xrpl/protocol_autogen/transactions/SignerListSet.h @@ -21,7 +21,7 @@ class SignerListSetBuilder; * Type: ttSIGNER_LIST_SET (12) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SignerListSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h index dfd12a329f..bb3eb2ccf0 100644 --- a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h +++ b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h @@ -21,7 +21,7 @@ class SponsorshipSetBuilder; * Type: ttSPONSORSHIP_SET (91) * Delegable: Delegation::Delegable * Amendment: featureSponsor - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SponsorshipSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h b/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h index ab26e887e3..5bd5bc1319 100644 --- a/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h +++ b/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h @@ -21,7 +21,7 @@ class SponsorshipTransferBuilder; * Type: ttSPONSORSHIP_TRANSFER (90) * Delegable: Delegation::NotDelegable * Amendment: featureSponsor - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SponsorshipTransferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/TicketCreate.h b/include/xrpl/protocol_autogen/transactions/TicketCreate.h index 0d8670a76a..4cb109b8f2 100644 --- a/include/xrpl/protocol_autogen/transactions/TicketCreate.h +++ b/include/xrpl/protocol_autogen/transactions/TicketCreate.h @@ -21,7 +21,7 @@ class TicketCreateBuilder; * Type: ttTICKET_CREATE (10) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use TicketCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/TrustSet.h b/include/xrpl/protocol_autogen/transactions/TrustSet.h index 22891b94ec..9d939eb1d0 100644 --- a/include/xrpl/protocol_autogen/transactions/TrustSet.h +++ b/include/xrpl/protocol_autogen/transactions/TrustSet.h @@ -21,7 +21,7 @@ class TrustSetBuilder; * Type: ttTRUST_SET (20) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use TrustSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/UNLModify.h b/include/xrpl/protocol_autogen/transactions/UNLModify.h index 6569e4bf7d..f5c94071d7 100644 --- a/include/xrpl/protocol_autogen/transactions/UNLModify.h +++ b/include/xrpl/protocol_autogen/transactions/UNLModify.h @@ -21,7 +21,7 @@ class UNLModifyBuilder; * Type: ttUNL_MODIFY (102) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use UNLModifyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultClawback.h b/include/xrpl/protocol_autogen/transactions/VaultClawback.h index 270ccc94bb..d859b4a446 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultClawback.h +++ b/include/xrpl/protocol_autogen/transactions/VaultClawback.h @@ -21,7 +21,7 @@ class VaultClawbackBuilder; * Type: ttVAULT_CLAWBACK (70) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MayDeleteMpt | MustModifyVault + * Privileges: Privilege::MayDeleteMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultCreate.h b/include/xrpl/protocol_autogen/transactions/VaultCreate.h index e206925e02..2925302dec 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultCreate.h +++ b/include/xrpl/protocol_autogen/transactions/VaultCreate.h @@ -21,7 +21,7 @@ class VaultCreateBuilder; * Type: ttVAULT_CREATE (65) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: CreatePseudoAcct | CreateMptIssuance | MustModifyVault + * Privileges: Privilege::CreatePseudoAcct | Privilege::CreateMptIssuance | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultDelete.h b/include/xrpl/protocol_autogen/transactions/VaultDelete.h index 67cc32f543..3cef0ce599 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultDelete.h +++ b/include/xrpl/protocol_autogen/transactions/VaultDelete.h @@ -21,7 +21,7 @@ class VaultDeleteBuilder; * Type: ttVAULT_DELETE (67) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MustDeleteAcct | DestroyMptIssuance | MustModifyVault + * Privileges: Privilege::MustDeleteAcct | Privilege::DestroyMptIssuance | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultDeposit.h b/include/xrpl/protocol_autogen/transactions/VaultDeposit.h index 5bb5362114..099342aa0c 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/VaultDeposit.h @@ -21,7 +21,7 @@ class VaultDepositBuilder; * Type: ttVAULT_DEPOSIT (68) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultSet.h b/include/xrpl/protocol_autogen/transactions/VaultSet.h index 14df70f13b..33dfe8bf21 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultSet.h +++ b/include/xrpl/protocol_autogen/transactions/VaultSet.h @@ -21,7 +21,7 @@ class VaultSetBuilder; * Type: ttVAULT_SET (66) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MustModifyVault + * Privileges: Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h b/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h index 17208cd76c..dfa662f8fd 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h @@ -21,7 +21,7 @@ class VaultWithdrawBuilder; * Type: ttVAULT_WITHDRAW (69) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MayDeleteMpt | MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayDeleteMpt | Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultWithdrawBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h b/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h index b8d551c5e1..a9aa7c2343 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h +++ b/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h @@ -21,7 +21,7 @@ class XChainAccountCreateCommitBuilder; * Type: ttXCHAIN_ACCOUNT_CREATE_COMMIT (44) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainAccountCreateCommitBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h b/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h index 22b57803dc..9cb1f2eaaf 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h +++ b/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h @@ -21,7 +21,7 @@ class XChainAddAccountCreateAttestationBuilder; * Type: ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION (46) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: CreateAcct + * Privileges: Privilege::CreateAcct * * Immutable wrapper around STTx providing type-safe field access. * Use XChainAddAccountCreateAttestationBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h b/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h index 5e80c05aae..9184c83958 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h +++ b/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h @@ -21,7 +21,7 @@ class XChainAddClaimAttestationBuilder; * Type: ttXCHAIN_ADD_CLAIM_ATTESTATION (45) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: CreateAcct + * Privileges: Privilege::CreateAcct * * Immutable wrapper around STTx providing type-safe field access. * Use XChainAddClaimAttestationBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainClaim.h b/include/xrpl/protocol_autogen/transactions/XChainClaim.h index ec403b5eb8..e49434c878 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainClaim.h +++ b/include/xrpl/protocol_autogen/transactions/XChainClaim.h @@ -21,7 +21,7 @@ class XChainClaimBuilder; * Type: ttXCHAIN_CLAIM (43) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainClaimBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainCommit.h b/include/xrpl/protocol_autogen/transactions/XChainCommit.h index 48b2263645..471a58dc53 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainCommit.h +++ b/include/xrpl/protocol_autogen/transactions/XChainCommit.h @@ -21,7 +21,7 @@ class XChainCommitBuilder; * Type: ttXCHAIN_COMMIT (42) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainCommitBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h b/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h index 9614b0bd88..ae1269e825 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h +++ b/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h @@ -21,7 +21,7 @@ class XChainCreateBridgeBuilder; * Type: ttXCHAIN_CREATE_BRIDGE (48) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainCreateBridgeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h b/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h index d17759619f..4c6f98e48f 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h +++ b/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h @@ -21,7 +21,7 @@ class XChainCreateClaimIDBuilder; * Type: ttXCHAIN_CREATE_CLAIM_ID (41) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainCreateClaimIDBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h b/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h index e79c9139ce..a3f2930668 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h +++ b/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h @@ -21,7 +21,7 @@ class XChainModifyBridgeBuilder; * Type: ttXCHAIN_MODIFY_BRIDGE (47) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainModifyBridgeBuilder to construct new transactions. diff --git a/include/xrpl/tx/invariants/InvariantCheckPrivilege.h b/include/xrpl/tx/invariants/InvariantCheckPrivilege.h index b2f1c62a54..ca9755ea1c 100644 --- a/include/xrpl/tx/invariants/InvariantCheckPrivilege.h +++ b/include/xrpl/tx/invariants/InvariantCheckPrivilege.h @@ -1,9 +1,7 @@ #pragma once -#include #include - -#include +#include // IWYU pragma: export namespace xrpl { @@ -26,37 +24,8 @@ not have the relevant amendments enabled_. It's intentionally a pain in the neck so that bad code gets caught and fixed as early as possible. */ -// Bitwise flags, 86 files, used in macros files -// NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) -enum Privilege { - NoPriv = 0x0000, // The transaction can not do any of the enumerated operations - CreateAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object. - CreatePseudoAcct = 0x0002, // The transaction can create a pseudo account, - // which implies createAcct - MustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object - MayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT - // object, but does not have to - OverrideFreeze = 0x0010, // The transaction can override some freeze rules - ChangeNftCounts = 0x0020, // The transaction can mint or burn an NFT - CreateMptIssuance = 0x0040, // The transaction can create a new MPT issuance - DestroyMptIssuance = 0x0080, // The transaction can destroy an MPT issuance - MustAuthorizeMpt = 0x0100, // The transaction MUST create or delete an MPT - // object (except by issuer) - MayAuthorizeMpt = 0x0200, // The transaction MAY create or delete an MPT - // object (except by issuer) - MayDeleteMpt = 0x0400, // The transaction MAY delete an MPT object. May not create. - MustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault - MayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault - MayCreateMpt = 0x2000, // The transaction MAY create an MPT object, except for issuer. -}; - -constexpr Privilege -operator|(Privilege lhs, Privilege rhs) -{ - return safeCast( - safeCast>(lhs) | - safeCast>(rhs)); -} +// `enum Privilege` and its `operator|` live in , +// alongside the TxSettings struct that carries them out of transactions.macro. bool hasPrivilege(STTx const& tx, Privilege priv); diff --git a/src/libxrpl/protocol/Permissions.cpp b/src/libxrpl/protocol/Permissions.cpp index 2f3e25f823..a5adb294e9 100644 --- a/src/libxrpl/protocol/Permissions.cpp +++ b/src/libxrpl/protocol/Permissions.cpp @@ -10,6 +10,7 @@ #include #include // IWYU pragma: keep #include +#include #include #include @@ -40,16 +41,24 @@ Permission::GranularPermissionEntry::GranularPermissionEntry( Permission::Permission() { { +#pragma push_macro("UNWRAP") +#undef UNWRAP #pragma push_macro("TRANSACTION") #undef TRANSACTION -#define TRANSACTION(tag, value, name, delegable, amendment, ...) \ - txDelegationMap_[static_cast(value)] = {amendment, delegable}; +#define UNWRAP(...) __VA_ARGS__ +#define TRANSACTION(tag, value, name, settings, ...) \ + { \ + TxSettings const s = UNWRAP settings; \ + txDelegationMap_[static_cast(value)] = {s.amendment, s.delegable}; \ + } #include #undef TRANSACTION #pragma pop_macro("TRANSACTION") +#undef UNWRAP +#pragma pop_macro("UNWRAP") } granularPermissionsByName_ = { @@ -242,7 +251,7 @@ Permission::isDelegable(std::uint32_t permissionValue, Rules const& rules) const // Tx-level permissions require the transaction type itself to be delegable, and // the corresponding amendment enabled. - return txIt != txDelegationMap_.end() && txIt->second.delegable != NotDelegable && + return txIt != txDelegationMap_.end() && txIt->second.delegable != Delegation::NotDelegable && amendmentEnabled(txIt->second); } diff --git a/src/libxrpl/protocol/TxFormats.cpp b/src/libxrpl/protocol/TxFormats.cpp index e4d4c4b03c..c393c606fe 100644 --- a/src/libxrpl/protocol/TxFormats.cpp +++ b/src/libxrpl/protocol/TxFormats.cpp @@ -45,7 +45,7 @@ TxFormats::TxFormats() #undef TRANSACTION #define UNWRAP(...) __VA_ARGS__ -#define TRANSACTION(tag, value, name, delegable, amendment, privileges, fields) \ +#define TRANSACTION(tag, value, name, settings, fields) \ add(jss::name, tag, UNWRAP fields, getCommonFields()); #include diff --git a/src/libxrpl/tx/invariants/FreezeInvariant.cpp b/src/libxrpl/tx/invariants/FreezeInvariant.cpp index d6039eabd8..272e52f09a 100644 --- a/src/libxrpl/tx/invariants/FreezeInvariant.cpp +++ b/src/libxrpl/tx/invariants/FreezeInvariant.cpp @@ -288,7 +288,8 @@ TransfersNotFrozen::validateFrozenState( // individually-frozen or deep-frozen AMM trust lines. // Post-fixCleanup3_4_0: AMMClawbacks are allowed to override all freeze types. bool const isAMMLine = change.line->isFlag(lsfAMMNode); - if ((fixOverrideFreeze || !isAMMLine || globalFreeze) && hasPrivilege(tx, OverrideFreeze)) + if ((fixOverrideFreeze || !isAMMLine || globalFreeze) && + hasPrivilege(tx, Privilege::OverrideFreeze)) { JLOG(j.debug()) << "Invariant check allowing funds to be moved " << (change.balanceChangeSign > 0 ? "to" : "from") diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 369206d9e6..aa4df8db42 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -40,12 +41,15 @@ namespace xrpl { +#pragma push_macro("UNWRAP") +#undef UNWRAP #pragma push_macro("TRANSACTION") #undef TRANSACTION -#define TRANSACTION(tag, value, name, delegable, amendment, privileges, ...) \ - case tag: { \ - return (privileges) & priv; \ +#define UNWRAP(...) __VA_ARGS__ +#define TRANSACTION(tag, value, name, settings, ...) \ + case tag: { \ + return ((TxSettings UNWRAP settings).privileges & priv) != Privilege::NoPriv; \ } bool @@ -63,6 +67,8 @@ hasPrivilege(STTx const& tx, Privilege priv) #undef TRANSACTION #pragma pop_macro("TRANSACTION") +#undef UNWRAP +#pragma pop_macro("UNWRAP") // Returns the human-readable name of a ledger entry's type, falling back to // the numeric type if the format is somehow unknown. @@ -436,7 +442,7 @@ AccountRootsNotDeleted::finalize( // transaction when the total AMM LP Tokens balance goes to 0. // A successful AccountDelete or AMMDelete MUST delete exactly // one account root. - if (hasPrivilege(tx, MustDeleteAcct) && isTesSuccess(result)) + if (hasPrivilege(tx, Privilege::MustDeleteAcct) && isTesSuccess(result)) { if (accountsDeleted_ == 1) return true; @@ -457,7 +463,7 @@ AccountRootsNotDeleted::finalize( // A successful AMMWithdraw/AMMClawback MAY delete one account root // when the total AMM LP Tokens balance goes to 0. Not every AMM withdraw // deletes the AMM account, accountsDeleted_ is set if it is deleted. - if (hasPrivilege(tx, MayDeleteAcct) && isTesSuccess(result) && accountsDeleted_ == 1) + if (hasPrivilege(tx, Privilege::MayDeleteAcct) && isTesSuccess(result) && accountsDeleted_ == 1) return true; if (accountsDeleted_ == 0) @@ -760,14 +766,15 @@ ValidNewAccountRoot::finalize( } // From this point on we know exactly one account was created. - if (hasPrivilege(tx, CreateAcct | CreatePseudoAcct) && isTesSuccess(result)) + if (hasPrivilege(tx, Privilege::CreateAcct | Privilege::CreatePseudoAcct) && + isTesSuccess(result)) { bool const pseudoAccount = (pseudoAccount_ && (view.rules().enabled(featureSingleAssetVault) || view.rules().enabled(featureLendingProtocol))); - if (pseudoAccount && !hasPrivilege(tx, CreatePseudoAcct)) + if (pseudoAccount && !hasPrivilege(tx, Privilege::CreatePseudoAcct)) { JLOG(j.fatal()) << "Invariant failed: pseudo-account created by a " "wrong transaction type"; diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 045d03ab02..89ade024e6 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -211,7 +211,7 @@ ValidMPTIssuance::finalize( } auto const txnType = tx.getTxnType(); - if (hasPrivilege(tx, CreateMptIssuance)) + if (hasPrivilege(tx, Privilege::CreateMptIssuance)) { if (mptIssuancesCreated_ == 0) { @@ -232,7 +232,7 @@ ValidMPTIssuance::finalize( return mptIssuancesCreated_ == 1 && mptIssuancesDeleted_ == 0; } - if (hasPrivilege(tx, DestroyMptIssuance)) + if (hasPrivilege(tx, Privilege::DestroyMptIssuance)) { if (mptIssuancesDeleted_ == 0) { @@ -259,7 +259,8 @@ ValidMPTIssuance::finalize( // non-amendment-gated side effects. bool const enforceEscrowFinish = (txnType == ttESCROW_FINISH) && (rules.enabled(featureSingleAssetVault) || lendingProtocolEnabled); - if (hasPrivilege(tx, MustAuthorizeMpt | MayAuthorizeMpt) || enforceEscrowFinish) + if (hasPrivilege(tx, Privilege::MustAuthorizeMpt | Privilege::MayAuthorizeMpt) || + enforceEscrowFinish) { bool const submittedByIssuer = tx.isFieldPresent(sfHolder); @@ -275,7 +276,7 @@ ValidMPTIssuance::finalize( "succeeded but deleted issuances"; return false; } - if (mptV2Enabled && hasPrivilege(tx, MayAuthorizeMpt) && + if (mptV2Enabled && hasPrivilege(tx, Privilege::MayAuthorizeMpt) && (txnType == ttAMM_WITHDRAW || txnType == ttAMM_CLAWBACK)) { if (submittedByIssuer && txnType == ttAMM_WITHDRAW && mptokensCreated_ > 0) @@ -311,7 +312,7 @@ ValidMPTIssuance::finalize( return false; } else if ( - !submittedByIssuer && hasPrivilege(tx, MustAuthorizeMpt) && + !submittedByIssuer && hasPrivilege(tx, Privilege::MustAuthorizeMpt) && (mptokensCreated_ + mptokensDeleted_ != 1)) { // if the holder submitted this tx, then a mptoken must be @@ -324,7 +325,7 @@ ValidMPTIssuance::finalize( return true; } - if (hasPrivilege(tx, MayCreateMpt)) + if (hasPrivilege(tx, Privilege::MayCreateMpt)) { bool const submittedByIssuer = tx.isFieldPresent(sfHolder); @@ -379,7 +380,7 @@ ValidMPTIssuance::finalize( return true; } - if (hasPrivilege(tx, MayDeleteMpt) && + if (hasPrivilege(tx, Privilege::MayDeleteMpt) && ((txnType == ttAMM_DELETE && mptokensDeleted_ <= 2) || mptokensDeleted_ == 1) && mptokensCreated_ == 0 && mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 0) return true; @@ -856,7 +857,7 @@ ValidMPTTransfer::finalize( ReadView const& view, beast::Journal const& j) { - if (hasPrivilege(tx, OverrideFreeze)) + if (hasPrivilege(tx, Privilege::OverrideFreeze)) return true; // XLS-0066: a broker must be able to default an already-late loan diff --git a/src/libxrpl/tx/invariants/NFTInvariant.cpp b/src/libxrpl/tx/invariants/NFTInvariant.cpp index 52ecbcd9d1..b3b1601018 100644 --- a/src/libxrpl/tx/invariants/NFTInvariant.cpp +++ b/src/libxrpl/tx/invariants/NFTInvariant.cpp @@ -206,7 +206,7 @@ NFTokenCountTracking::finalize( ReadView const& view, beast::Journal const& j) const { - if (!hasPrivilege(tx, ChangeNftCounts)) + if (!hasPrivilege(tx, Privilege::ChangeNftCounts)) { if (beforeMintedTotal_ != afterMintedTotal_) { diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 5c25a22987..7ba42383ad 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -346,7 +346,7 @@ ValidVault::finalize( if (afterVault_.empty() && beforeVault_.empty()) { - if (hasPrivilege(tx, MustModifyVault)) + if (hasPrivilege(tx, Privilege::MustModifyVault)) { JLOG(j.fatal()) << // "Invariant failed: vault operation succeeded without modifying " @@ -357,7 +357,8 @@ ValidVault::finalize( return true; // Not a vault operation } - if (!(hasPrivilege(tx, MustModifyVault) || hasPrivilege(tx, MayModifyVault))) + if (!(hasPrivilege(tx, Privilege::MustModifyVault) || + hasPrivilege(tx, Privilege::MayModifyVault))) { JLOG(j.fatal()) << // "Invariant failed: vault updated by a wrong transaction type"; diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index 1166816115..3ff90c2a8f 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -47,6 +47,7 @@ #include #include #include +#include #include #include @@ -2718,19 +2719,24 @@ class Delegate_test : public beast::unit_test::Suite std::size_t delegableCount = 0; +#pragma push_macro("UNWRAP") +#undef UNWRAP #pragma push_macro("TRANSACTION") #undef TRANSACTION -#define TRANSACTION(tag, value, name, txDelegable, ...) \ - if (txDelegable == xrpl::Delegable) \ - { \ - delegableCount++; \ +#define UNWRAP(...) __VA_ARGS__ +#define TRANSACTION(tag, value, name, settings, ...) \ + if ((xrpl::TxSettings UNWRAP settings).delegable == xrpl::Delegation::Delegable) \ + { \ + delegableCount++; \ } #include #undef TRANSACTION #pragma pop_macro("TRANSACTION") +#undef UNWRAP +#pragma pop_macro("UNWRAP") // ==================================================================== // IMPORTANT NOTICE: From d27beef500943c7fb920a9f51eda87a23ccf8ae3 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Thu, 20 Aug 2026 19:40:42 +0000 Subject: [PATCH 12/43] perf: Speed up addition time for drastically different exponents (#7825) --- src/libxrpl/basics/Number.cpp | 60 ++++++++-- src/test/protocol/STNumber_test.cpp | 80 +++++--------- src/tests/libxrpl/basics/Number.cpp | 165 ++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 61 deletions(-) diff --git a/src/libxrpl/basics/Number.cpp b/src/libxrpl/basics/Number.cpp index 1f2c41809a..0917627073 100644 --- a/src/libxrpl/basics/Number.cpp +++ b/src/libxrpl/basics/Number.cpp @@ -260,6 +260,11 @@ public: unsigned pop() noexcept; + // if true, there are no recoverable digits in the guard, though there may be dropped digits + // (xbit_) + [[nodiscard]] bool + unrecoverable() const noexcept; + // if true, there are no digits in the guard, including dropped digits (xbit_) [[nodiscard]] bool empty() const noexcept; @@ -277,6 +282,17 @@ public: void doDropDigit(T& mantissa, int& exponent) noexcept; + /** + * Drop a digit from the mantissa, and increment the exponent, storing the dropped digit in + * this Guard. + * + * If a drop will not do anything meaningful (there are no recoverable digits in the guard, and + * the mantissa is 0), and if targetExponent > exponent, simply set exponent to targetExponent. + */ + template + void + doDropDigitWithTarget(T& mantissa, int& exponent, int const targetExponent) noexcept; + // Modify the result to the correctly rounded value template void @@ -374,10 +390,16 @@ Number::Guard::pop() noexcept return d; } +inline bool +Number::Guard::unrecoverable() const noexcept +{ + return digits_ == 0; +} + inline bool Number::Guard::empty() const noexcept { - return digits_ == 0 && !xbit_; + return unrecoverable() && !xbit_; } template @@ -401,6 +423,25 @@ Number::Guard::doDropDigit(uint128_t& mantissa, int& exponent) noexce ++exponent; } +template +void +Number::Guard::doDropDigitWithTarget(T& mantissa, int& exponent, int const targetExponent) noexcept +{ + XRPL_ASSERT( + exponent < targetExponent, "xrpl::Number::Guard::doDropDigitWithTarget : something to do"); + while (exponent < targetExponent) + { + if (mantissa == 0 && unrecoverable()) + { + // No number of dropped digits is going to change anything except the exponent at this + // point, so just jump to the result + exponent = targetExponent; + return; + } + doDropDigit(mantissa, exponent); + } +} + template void Number::Guard::pushOverflow(T mantissa) @@ -928,6 +969,7 @@ Number::operator+=(Number const& y) // to match, if necessary. auto const adjust = [&g, &upperLimit]( uint128_t& expandM, int& expandE, uint128_t& shrinkM, int& shrinkE) { + XRPL_ASSERT(shrinkE < expandE, "xrpl::Number::operator+= : exponents ordered correctly"); // Adjust up and down until the exponents match if (g.cuspRoundingFix == MantissaRange::CuspRoundingFix::Enabled330) { @@ -935,6 +977,8 @@ Number::operator+=(Number const& y) // 1. First, shrink the mantissa of shrinkM/shrinkE while shrinkM ends in 0. while (shrinkE < expandE && shrinkM % 10 == 0) { + // Don't use doDropDigitWithTarget here, because the loop will stop before the + // mantissa gets to 0. g.doDropDigit(shrinkM, shrinkE); } @@ -950,10 +994,11 @@ Number::operator+=(Number const& y) // 3. Finally, shrink the mantissa of shrinkM/shrinkE until the exponents match. Any removed // digits will be put into the Guard. This is the only step for non-Enabled330 modes. - while (shrinkE < expandE) + if (shrinkE < expandE) { - g.doDropDigit(shrinkM, shrinkE); + g.doDropDigitWithTarget(shrinkM, shrinkE, expandE); } + XRPL_ASSERT(shrinkE == expandE, "xrpl::Number::operator+= : exponents are equal"); }; // Shrink the mantissa and raise the exponent of the value with the lower exponent. Store any @@ -996,7 +1041,7 @@ Number::operator+=(Number const& y) // round. XRPL_ASSERT( xm > maxMantissa || g.empty(), - "xrpl::Number::operator+ : rounding state expected after add"); + "xrpl::Number::operator+= : rounding state expected after add"); } else { @@ -1038,7 +1083,7 @@ Number::operator+=(Number const& y) } XRPL_ASSERT( xm > maxMantissa || g.empty(), - "xrpl::Number::operator+ : rounding state expected after subtract"); + "xrpl::Number::operator+= : rounding state expected after subtract"); } else { @@ -1330,9 +1375,10 @@ operator rep() const g.setNegative(); drops = -drops; } - while (offset < 0) + if (offset < 0) { - g.doDropDigit(drops, offset); + g.doDropDigitWithTarget(drops, offset, 0); + XRPL_ASSERT(offset == 0, "xrpl::Number::operator rep() : exponents are equal"); } for (; offset > 0; --offset) { diff --git a/src/test/protocol/STNumber_test.cpp b/src/test/protocol/STNumber_test.cpp index 74792e0a70..1e5027df49 100644 --- a/src/test/protocol/STNumber_test.cpp +++ b/src/test/protocol/STNumber_test.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -176,61 +177,32 @@ struct STNumber_test : public beast::unit_test::Suite numberFromJson(sfNumber, std::to_string(kUMax)) == STNumber(sfNumber, Number(kUMax, 0))); + auto const expectJsonThrows = [this]( + json::Value const& num, std::string const& expected) { + try + { + numberFromJson(sfNumber, num); + fail(); + } + catch (std::exception const& e) + { + std::ostringstream out; + out << "Json: " << num.asString() << " got exception: " << e.what() + << ", expected: " << expected; + BEAST_EXPECTS(std::string(e.what()) == expected, out.str()); + } + }; + + // Obvious overflows tested here + expectJsonThrows("1e2000000", "Number::normalize 2"); + expectJsonThrows("1e2000000000", "Number::normalize 2"); + // Obvious non-numbers tested here - try - { - auto _ = numberFromJson(sfNumber, ""); - BEAST_EXPECT(false); - } - catch (std::runtime_error const& e) - { - std::string const expected = "'' is not a number"; - BEAST_EXPECT(e.what() == expected); - } - - try - { - auto _ = numberFromJson(sfNumber, "e"); - BEAST_EXPECT(false); - } - catch (std::runtime_error const& e) - { - std::string const expected = "'e' is not a number"; - BEAST_EXPECT(e.what() == expected); - } - - try - { - auto _ = numberFromJson(sfNumber, "1e"); - BEAST_EXPECT(false); - } - catch (std::runtime_error const& e) - { - std::string const expected = "'1e' is not a number"; - BEAST_EXPECT(e.what() == expected); - } - - try - { - auto _ = numberFromJson(sfNumber, "e2"); - BEAST_EXPECT(false); - } - catch (std::runtime_error const& e) - { - std::string const expected = "'e2' is not a number"; - BEAST_EXPECT(e.what() == expected); - } - - try - { - auto _ = numberFromJson(sfNumber, json::Value()); - BEAST_EXPECT(false); - } - catch (std::runtime_error const& e) - { - std::string const expected = "not a number"; - BEAST_EXPECT(e.what() == expected); - } + expectJsonThrows("", "'' is not a number"); + expectJsonThrows("e", "'e' is not a number"); + expectJsonThrows("1e", "'1e' is not a number"); + expectJsonThrows("e2", "'e2' is not a number"); + expectJsonThrows(json::Value(), "not a number"); try { diff --git a/src/tests/libxrpl/basics/Number.cpp b/src/tests/libxrpl/basics/Number.cpp index 32f93eb1f7..8e958b40d4 100644 --- a/src/tests/libxrpl/basics/Number.cpp +++ b/src/tests/libxrpl/basics/Number.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -183,6 +185,17 @@ TEST(NumberTest, limits) } EXPECT_TRUE(caught); + try + { + Number{1, 2000000, Number::Normalized{}}; + ADD_FAILURE(); + } + catch (std::overflow_error const& e) + { + std::string const expected = "Number::normalize 2"; + EXPECT_EQ(e.what(), expected) << e.what(); + } + if (scale == MantissaRange::MantissaScale::Large330) { // Normalization with the other scales, including the older large mantissa scales, will @@ -406,6 +419,158 @@ TEST(NumberTest, add) } } +TEST(NumberTest, add_sub_extreme_exponents) +{ + for (auto const mantissaScale : MantissaRange::getAllScales()) + { + NumberMantissaScaleGuard const sg(mantissaScale); + + auto const scale = Number::getMantissaScale(); + + EXPECT_EQ(Number::getround(), Number::RoundingMode::ToNearest) + << to_string(Number::getround()); + + // Special cases: Exponents at each end of the allowable range + for (auto const round : + {Number::RoundingMode::ToNearest, + Number::RoundingMode::TowardsZero, + Number::RoundingMode::Downward, + Number::RoundingMode::Upward}) + { + NumberRoundModeGuard const rg{round}; + + auto const bigMantissa = std::invoke([scale, round] { + auto m = Number::maxMantissa(); + if (scale != MantissaRange::MantissaScale::Small) + { + // At the large scales, the maxMantissa is not representable, so we need to + // shrink it down to a representable value. + m /= 10; + } + if (round == Number::RoundingMode::Upward) + { + // Rounding upward will overflow if the mantissa is at maxMantissa. Subtract an + // arbitrary small value to keep the mantissa near the limit, but with a + // little room to grow. 67 has no meaning, except that it's, you know, + // six seven. + m -= 67; + } + return m; + }); + auto const params = { + std::make_pair(Number::minMantissa(), 0), + // At the large scales, the maxMantissa is not representable, so we need to shrink + // it down to a representable value. Rounding upward will overflow if the mantissa + // is right at the all nines value. To keep things a little simpler, do those + // modifications unconditionally. + std::make_pair(bigMantissa, 1), + }; + for (auto const& [mantissa, exponentOffset] : params) + { + auto const x = Number{mantissa, Number::kMaxExponent, Number::Normalized{}}; + auto const y = + Number{mantissa, Number::kMinExponent + exponentOffset, Number::Normalized{}}; + + std::ostringstream detail; + detail << "Scale: " << to_string(scale) << ", round: " << to_string(round) + << ", x: " << x << ", y: " << y; + + EXPECT_EQ(x.mantissa(), mantissa); + EXPECT_EQ(x.exponent(), Number::kMaxExponent); + EXPECT_NE(x, beast::kZero); + EXPECT_EQ(y.mantissa(), mantissa); + EXPECT_EQ(y.exponent(), Number::kMinExponent + exponentOffset); + EXPECT_NE(y, beast::kZero); + + { + // x + y + auto const result = x + y; + + if (round == Number::RoundingMode::Upward) + { + // Rounding upward will take that little x-bit and round result up to the + // next representable value. + EXPECT_NE(result, x); + EXPECT_EQ(result, (Number{x.mantissa() + 1, x.exponent()})); + } + else + { + EXPECT_EQ(result, x); + } + } + { + // x - y + auto const result = x - y; + + switch (round) + { + case Number::RoundingMode::TowardsZero: + if (scale < MantissaRange::MantissaScale::Large330) + { + // Rounding TowardsZero was broken before Large330. + EXPECT_EQ(result, x) << detail.str(); + break; + } + [[fallthrough]]; + case Number::RoundingMode::Downward: + // Rounding downward (or toward zero in Large330) will take that little + // x-bit and round result down to the next representable value. + EXPECT_NE(result, x) << detail.str(); + EXPECT_EQ(result, (Number{x.mantissa() - 1, x.exponent()})) + << detail.str(); + break; + default: + // Rounding up and toNearest rounds back to the original value + EXPECT_EQ(result, x) << detail.str(); + } + } + { + // y + x + auto const result = y + x; + + if (round == Number::RoundingMode::Upward) + { + // Rounding upward will take that little x-bit and round result up to the + // next representable value. + EXPECT_NE(result, x); + EXPECT_EQ(result, (Number{x.mantissa() + 1, x.exponent()})); + } + else + { + EXPECT_EQ(result, x); + } + } + { + // y - x + auto const result = y - x; + + switch (round) + { + case Number::RoundingMode::TowardsZero: + if (scale < MantissaRange::MantissaScale::Large330) + { + // Rounding TowardsZero was broken before Large330. + EXPECT_EQ(result, -x) << detail.str(); + break; + } + [[fallthrough]]; + case Number::RoundingMode::Upward: + // Rounding upward (or toward zero in Large330) will take that little + // x-bit and round result up to the next representable negative value. + EXPECT_NE(result, -x) << detail.str(); + EXPECT_EQ(result, (Number{-x.mantissa() + 1, x.exponent()})) + << detail.str(); + break; + default: + // Rounding up and toNearest rounds back to the original value + EXPECT_EQ(result, -x) << detail.str(); + } + } + } + } + } +} + TEST(NumberTest, sub) { for (auto const mantissaScale : MantissaRange::getAllScales()) From 046d4dd4afca8c532962e8d5714e21b4762afe2c Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:30:17 +0000 Subject: [PATCH 13/43] fix: Reject vault deposits that move nothing from the depositor (#8014) Co-authored-by: Cursor --- .../tx/transactors/vault/VaultDeposit.cpp | 43 ++++ src/test/app/vault/VaultBugs_test.cpp | 183 +++++++++++++++++- 2 files changed, 225 insertions(+), 1 deletion(-) diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index a3c0a94eb5..5ee948bbba 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -2,12 +2,15 @@ #include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include @@ -47,6 +50,39 @@ roundToVaultScale(STAmount const& amount, SLE::const_ref vault) return roundToScale(amount, postScale, Number::RoundingMode::Downward); } +// True if debiting `assets` would leave the depositor's balance where it started, so the deposit +// would mint shares against a transfer that never happened. Asking the balance directly whether it +// notices the debit avoids having to infer the rounding step: it has to be the stored balance that +// answers, because that magnitude is what governs the rounding, and it is not the same as the +// spendable amount, which also counts what the counterparty's limit allows. +[[nodiscard]] +static bool +roundsToZeroForDepositor( + ReadView const& view, + AccountID const& account, + STAmount const& assets, + beast::Journal j) +{ + if (assets.integral()) + return false; + + auto const balance = accountHolds( + view, + account, + assets.asset(), + FreezeHandling::ZeroIfFrozen, + AuthHandling::ZeroIfUnauthorized, + j, + SpendableHandling::SimpleBalance); + + if (balance - assets != balance) + return false; + + JLOG(j.warn()) << "VaultDeposit: amount " << assets.getFullText() + << " leaves the depositor's balance " << balance.getFullText() << " unchanged"; + return true; +} + NotTEC VaultDeposit::preflight(PreflightContext const& ctx) { @@ -208,6 +244,7 @@ TER VaultDeposit::doApply() { bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0); + 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) @@ -308,6 +345,12 @@ 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; } catch (std::overflow_error const&) diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index 2dbd20f855..70a350a4f1 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -2,9 +2,12 @@ #include #include #include +#include #include +#include #include #include +#include #include #include #include @@ -15,13 +18,17 @@ #include #include #include +#include #include #include +#include #include #include +#include #include #include +#include #include #include #include @@ -408,10 +415,14 @@ private: }; { + // fixCleanup3_4_0 has to be off as well: its depositor-side check + // rejects alice's deposit for the same reason, so the invariant is + // only reachable with neither guard in place. testcase( "bug: VaultDeposit below Vault precision canonicalized to zero " "(pre-fixCleanup3_2_0)"); - runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED); + runScenario( + testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0, tecINVARIANT_FAILED); } { testcase( @@ -421,6 +432,175 @@ private: } } + // A deposit does not transfer the requested amount. It transfers the + // request truncated to a whole number of shares and converted back, which + // can be strictly smaller. When that smaller value is below half a ULP at + // the depositor's own trust-line scale, the debit rounds away to nothing: + // the depositor pays nothing, while the vault books the assets and mints + // shares. ValidVault catches the desync at finalize time. + // + // Only a non-power-of-ten assets-to-shares ratio is needed, and that + // happens through ordinary use: LoanPay books accrued interest into + // sfAssetsTotal without minting shares. + // + // The fixCleanup3_2_0 guard in preclaim does not help, because it tests the + // raw requested amount, which is large enough to survive the rounding. + // Post-fixCleanup3_4_0 the post-truncation value is checked as well and the + // deposit is rejected with tecPRECISION_LOSS before anything moves. + void + testBugDepositShareTruncationSubUlp() + { + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + // How bob's trust line is set up before he deposits. Holding is the plain case: a large + // positive balance whose ULP swallows the debit. InDebt is the case where the stored + // balance and the spendable amount diverge: bob owes the issuer 1e16, and the issuer's + // limit on the same line lets him spend 1000 anyway. Reading the spendable amount there + // reports a small, finely scaled number, while the rounding of the debit is still governed + // by the 1e16 he actually holds. + enum class Line { Holding, InDebt }; + + auto runScenario = [this](FeatureBitset features, Line line, TER expected) { + std::string logs; + Env env(*this, features, std::make_unique(&logs)); + + Account const issuer{"issuer"}; + Account const alice{"alice"}; + Account const carol{"carol"}; + Account const bob{"bob"}; + + env.fund(XRP(100'000), issuer, alice, carol, bob); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const usd{issuer["USD"]}; + PrettyAsset const bobUsd{bob["USD"]}; + STAmount const trustLimit{usd.raw(), Number{99'999'999'999'999'999LL}}; + // Bob's balance sits exactly on a multiple-of-10 boundary at the + // 1e16 IOU precision cusp, where one ULP is 10. + STAmount const bobEdge{usd.raw(), Number{10'000'000'000'000'010LL}}; + STAmount const bobDebt{bobUsd.raw(), Number{10'000'000'000'000'000LL}}; + STAmount const oppositeLimit{bobUsd.raw(), Number{10'000'000'000'001'000LL}}; + + env(trust(alice, trustLimit)); + env(trust(carol, trustLimit)); + env(trust(bob, trustLimit)); + env.close(); + + env(pay(issuer, alice, usd(1'000))); + env(pay(issuer, carol, usd(1'000))); + if (line == Line::Holding) + { + env(pay(issuer, bob, bobEdge)); + } + else + { + // The issuer trusts bob's own USD, so bob can issue 1e16 back and still have + // 1000 of spendable room left on the same line. + env(trust(issuer, oppositeLimit)); + env.close(); + env(pay(bob, issuer, bobDebt)); + } + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd}); + vaultTx[sfScale] = 0; + env(vaultTx); + env.close(); + + // Alice deposits 1000 USD, minting 1000 shares 1:1. + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)})); + env.close(); + + // A loan broker on the vault, then a bullet loan at 24% interest: + // a single payment, one year out. + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); + env(set(alice, vaultKeylet.key)); + env.close(); + + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + env(set(carol, brokerKeylet.key, usd(1'000).value()), + loan::kInterestRate(percentageToTenthBips(24)), + kGracePeriod(60), + kPaymentInterval(365 * 24 * 60 * 60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, alice), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // Advance to just before the single payment falls due and let carol + // repay principal plus interest. LoanPay is what books the accrued + // interest into sfAssetsTotal; under cash-basis accounting LoanSet + // alone does not. Share supply stays at 1000, so + // assetsTotal/sharesTotal becomes 1240/1000. + env.close(std::chrono::seconds{(365 * 24 * 60 * 60) - 3600}); + env(pay(carol, loanKeylet.key, usd(2'000).value()), Ter(tesSUCCESS)); + env.close(); + + // Pin the ratio the rest of the scenario reasons about, so the test cannot quietly + // stop exercising the bug if the setup drifts. + auto const sleVault = env.le(vaultKeylet); + BEAST_EXPECT(sleVault && sleVault->at(sfAssetsTotal) == Number{1'240}); + auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); + BEAST_EXPECT(sleIssuance && sleIssuance->at(sfOutstandingAmount) == 1'000); + + // Bob deposits 6 USD, which rounds to 10 at his own trust-line + // scale and so clears the fixCleanup3_2_0 guard. But + // floor(1000 * 6 / 1240) is 4 shares, worth 4 * 1240 / 1000 = 4.96, + // and that is below half a ULP of his balance, so it rounds away to + // nothing when subtracted. + env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(6)}), + Ter(expected)); + env.close(); + }; + + { + testcase( + "bug: VaultDeposit share truncation lets depositor debit " + "round away to zero (pre-fixCleanup3_4_0)"); + runScenario(testableAmendments() - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED); + } + { + testcase( + "bug: VaultDeposit share truncation lets depositor debit " + "round away to zero (pre-fixCleanup3_2_0 and pre-fixCleanup3_4_0)"); + runScenario( + testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0, + Line::Holding, + tecINVARIANT_FAILED); + } + { + testcase( + "bug: VaultDeposit share truncation rejected with " + "tecPRECISION_LOSS (post-fixCleanup3_4_0)"); + runScenario(testableAmendments(), Line::Holding, tecPRECISION_LOSS); + } + { + testcase( + "bug: VaultDeposit share truncation rejected with " + "tecPRECISION_LOSS (post-fixCleanup3_4_0, pre-fixCleanup3_2_0)"); + runScenario(testableAmendments() - fixCleanup3_2_0, Line::Holding, tecPRECISION_LOSS); + } + { + testcase( + "bug: VaultDeposit share truncation against a debt balance " + "round away to zero (pre-fixCleanup3_4_0)"); + runScenario(testableAmendments() - fixCleanup3_4_0, Line::InDebt, tecINVARIANT_FAILED); + } + { + testcase( + "bug: VaultDeposit share truncation against a debt balance rejected with " + "tecPRECISION_LOSS (post-fixCleanup3_4_0)"); + runScenario(testableAmendments(), Line::InDebt, tecPRECISION_LOSS); + } + } + // Bug: ValidVault::visitEntry computes destinationDelta.scale as // max(before_exponent, after_exponent) for RippleState entries. When a // withdrawal credits a destination whose IOU balance sits just below a @@ -801,6 +981,7 @@ public: testBugMakeDeltaPosteriorScale(); testBugMakeDeltaAnteriorScale(); testVaultDepositCanonicalizeToZero(); + testBugDepositShareTruncationSubUlp(); testVaultWithdrawCanonicalizeToZero(); testBugVaultDustDebitCanonicalizesToNoOp(); testVaultDepositNegativeBalanceFromOppositeLimit(); From fe4ccdf7500dfccadda8560261853da44a530cac Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Fri, 21 Aug 2026 15:20:44 +0000 Subject: [PATCH 14/43] fix: Add assert for account_info flags (#7987) --- .../rpc/handlers/account/AccountInfo.cpp | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp index 6b244af1a9..2276f98a0e 100644 --- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp +++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,7 @@ #include #include #include +#include #include namespace xrpl { @@ -115,29 +117,37 @@ doAccountInfo(rpc::JsonContext& context) } auto const accountID{id.value()}; - static constexpr std::array, 9> kLsFlags{ - {{"defaultRipple", lsfDefaultRipple}, - {"depositAuth", lsfDepositAuth}, - {"disableMasterKey", lsfDisableMaster}, - {"disallowIncomingXRP", lsfDisallowXRP}, - {"globalFreeze", lsfGlobalFreeze}, - {"noFreeze", lsfNoFreeze}, - {"passwordSpent", lsfPasswordSpent}, - {"requireAuthorization", lsfRequireAuth}, - {"requireDestinationTag", lsfRequireDestTag}}}; - - static constexpr std::array, 4> - kDisallowIncomingFlags{ - {{"disallowIncomingNFTokenOffer", lsfDisallowIncomingNFTokenOffer}, + // Flags that are always reported. + static constexpr auto kAccountRootFlags = + std::to_array>( + {{"allowTrustLineClawback", lsfAllowTrustLineClawback}, + {"defaultRipple", lsfDefaultRipple}, + {"depositAuth", lsfDepositAuth}, + {"disableMasterKey", lsfDisableMaster}, {"disallowIncomingCheck", lsfDisallowIncomingCheck}, + {"disallowIncomingNFTokenOffer", lsfDisallowIncomingNFTokenOffer}, {"disallowIncomingPayChan", lsfDisallowIncomingPayChan}, - {"disallowIncomingTrustline", lsfDisallowIncomingTrustline}}}; + {"disallowIncomingTrustline", lsfDisallowIncomingTrustline}, + {"disallowIncomingXRP", lsfDisallowXRP}, + {"globalFreeze", lsfGlobalFreeze}, + {"noFreeze", lsfNoFreeze}, + {"passwordSpent", lsfPasswordSpent}, + {"requireAuthorization", lsfRequireAuth}, + {"requireDestinationTag", lsfRequireDestTag}}); - static constexpr std::pair kAllowTrustLineClawbackFlag{ - "allowTrustLineClawback", lsfAllowTrustLineClawback}; + // Flags that are only reported when their amendment is enabled. This can't be `constexpr`, + // since the amendment IDs are computed at runtime. + static auto const kAmendmentGatedFlags = + std::to_array>( + {{"allowTrustLineLocking", lsfAllowTrustLineLocking, featureTokenEscrow}}); - static constexpr std::pair kAllowTrustLineLockingFlag{ - "allowTrustLineLocking", lsfAllowTrustLineLocking}; + // Every `AccountRoot` flag must be reported by `account_info`, so if a new flag is added, it + // needs to be added to one of the arrays above. This can't be a `static_assert` because + // `getAccountRootFlags()` builds its map at runtime. + XRPL_ASSERT_PARTS( + kAccountRootFlags.size() + kAmendmentGatedFlags.size() == getAccountRootFlags().size(), + "xrpl::doAccountInfo", + "number of account flags"); auto const sleAccepted = ledger->read(keylet::account(accountID)); if (sleAccepted) @@ -157,19 +167,13 @@ doAccountInfo(rpc::JsonContext& context) result[jss::account_data] = jvAccepted; json::Value acctFlags{json::ValueType::Object}; - for (auto const& lsf : kLsFlags) - acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second); + for (auto const& [name, flag] : kAccountRootFlags) + acctFlags[name.data()] = sleAccepted->isFlag(flag); - for (auto const& lsf : kDisallowIncomingFlags) - acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second); - - acctFlags[kAllowTrustLineClawbackFlag.first.data()] = - sleAccepted->isFlag(kAllowTrustLineClawbackFlag.second); - - if (ledger->rules().enabled(featureTokenEscrow)) + for (auto const& [name, flag, amendment] : kAmendmentGatedFlags) { - acctFlags[kAllowTrustLineLockingFlag.first.data()] = - sleAccepted->isFlag(kAllowTrustLineLockingFlag.second); + if (ledger->rules().enabled(amendment)) + acctFlags[name.data()] = sleAccepted->isFlag(flag); } result[jss::account_flags] = std::move(acctFlags); From a097ccebae3cb55b55a62d3fcbcae637dd19ae7f Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:50:57 +0000 Subject: [PATCH 15/43] fix: Tighten destination checks on vault withdrawal (#7977) Co-authored-by: Cursor Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> --- include/xrpl/ledger/helpers/VaultHelpers.h | 37 ++++ src/libxrpl/ledger/helpers/VaultHelpers.cpp | 24 +++ .../tx/transactors/vault/VaultDeposit.cpp | 24 +-- .../tx/transactors/vault/VaultWithdraw.cpp | 50 ++++- src/test/app/vault/VaultDomain_test.cpp | 191 ++++++++++++++++++ src/test/app/vault/VaultValidation_test.cpp | 112 ++++++++++ 6 files changed, 418 insertions(+), 20 deletions(-) diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index c898e9e148..e4ed6de0ef 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -238,4 +239,40 @@ getVaultPhase( std::optional subscriptionDate, std::optional redemptionDate); +/** + * Controls whether checkVaultDomain reports an expired credential as an + * error. A caller that deletes expired credentials later, in doApply, passes + * Yes and treats the subject as authorized; a caller with no such cleanup + * step must keep the error. + */ +enum class SuppressExpired : bool { No = false, Yes = true }; + +/** + * Checks that subject belongs to the permissioned domain governing a vault's + * shares. + * + * The domain is read from the share issuance rather than from the vault. Vault + * shares are issued by the vault's pseudo-account, which cannot grant an + * authorization explicitly, so domain membership is the only route to being + * authorized: a vault with no domain set has no authorized participants at + * all, and every subject fails with tecNO_AUTH. + * + * Which accounts to check, and whether to check at all, is left to the caller. + * This says nothing about vault privacy or about the roles of the accounts. + * + * @param view The ledger view. + * @param issuance The MPTokenIssuance SLE for the vault's shares. + * @param subject The account whose domain membership is checked. + * @param suppressExpired Whether an expired credential counts as authorized. + * + * @return tesSUCCESS if the subject is a domain member, otherwise the reason + * it is not. + */ +[[nodiscard]] TER +checkVaultDomain( + ReadView const& view, + SLE::const_ref issuance, + AccountID const& subject, + SuppressExpired suppressExpired); + } // namespace xrpl diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index b0d835a423..7f4a7ac03c 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include // IWYU pragma: keep @@ -13,6 +14,7 @@ #include #include // IWYU pragma: keep #include +#include #include #include @@ -242,4 +244,26 @@ getVaultPhase( return VaultPhase::Redemption; } +[[nodiscard]] TER +checkVaultDomain( + ReadView const& view, + SLE::const_ref issuance, + AccountID const& subject, + SuppressExpired suppressExpired) +{ + XRPL_ASSERT( + issuance && issuance->getType() == ltMPTOKEN_ISSUANCE, + "xrpl::checkVaultDomain : valid issuance SLE"); + + auto const maybeDomainID = issuance->at(~sfDomainID); + if (!maybeDomainID) + return tecNO_AUTH; + + auto const err = credentials::validDomain(view, *maybeDomainID, subject); + if (err == tecEXPIRED && suppressExpired == SuppressExpired::Yes) + return tesSUCCESS; + + return err; +} + } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index 5ee948bbba..27e590338c 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -175,26 +174,13 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) return tecLOCKED; } + // The vault owner is authorized to deposit unconditionally. An expired + // credential is tolerated here because doApply deletes it. if (vault->isFlag(lsfVaultPrivate) && account != vault->at(sfOwner)) { - auto const maybeDomainID = sleIssuance->at(~sfDomainID); - // Since this is a private vault and the account is not its owner, we - // perform authorization check based on DomainID read from sleIssuance. - // Had the vault shares been a regular MPToken, we would allow - // authorization granted by the Issuer explicitly, but Vault uses Issuer - // pseudo-account, which cannot grant an authorization. - if (maybeDomainID) - { - // As per validDomain documentation, we suppress tecEXPIRED error - // here, so we can delete any expired credentials inside doApply. - if (auto const err = credentials::validDomain(ctx.view, *maybeDomainID, account); - !isTesSuccess(err) && err != tecEXPIRED) - return err; - } - else - { - return tecNO_AUTH; - } + if (auto const err = checkVaultDomain(ctx.view, sleIssuance, account, SuppressExpired::Yes); + !isTesSuccess(err)) + return err; } // Source MPToken must exist (if asset is an MPT) diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 40689572a0..ffefa51d05 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -79,6 +80,7 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) auto const fix313Enabled = ctx.view.rules().enabled(fixCleanup3_1_3); 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 vault = ctx.view.read(keylet::vault(ctx.tx[sfVaultID])); if (!vault) @@ -130,6 +132,17 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (auto const err = credentials::valid(ctx.tx, ctx.view, account, ctx.j); !isTesSuccess(err)) return err; + // A pseudo-account belongs to a ledger object rather than to a person and + // must never receive funds from a user-initiated transaction. Deposit + // authorization, which every pseudo-account carries, already refuses the + // payout, but it reports only that the destination declines deposits and + // leaves the real reason unsaid. + if (fix340Enabled && isPseudoAccount(ctx.view, dstAcct)) + { + JLOG(ctx.j.debug()) << "VaultWithdraw: cannot withdraw into a pseudo-account."; + return tecPSEUDO_ACCOUNT; + } + if (fix313Enabled && amount.asset() == vaultShare) { // Post-fixCleanup3_1_3: if the user specified shares, convert @@ -191,6 +204,39 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType); !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 + // inside the vault's permissioned domain. VaultDeposit applies the same + // domain check on the way in. + // + // Two cases deliberately skip the check. Withdrawing to self is never + // restricted: losing vault access must not strand funds already deposited. + // The asset issuer is always allowed to receive, which keeps the return + // path for frozen assets open even for a submitter who lost access. + if (fix340Enabled && vault->isFlag(lsfVaultPrivate) && dstAcct != account && + dstAcct != vaultAsset.getIssuer()) + { + auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(vaultShare)); + if (!sleIssuance) + { + // LCOV_EXCL_START + JLOG(ctx.j.error()) << "VaultWithdraw: missing issuance of vault shares."; + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + // Unlike VaultDeposit we do not suppress tecEXPIRED: there is no + // doApply step here that would clean up the expired credential. + if (auto const ter = checkVaultDomain(ctx.view, sleIssuance, account, SuppressExpired::No); + !isTesSuccess(ter)) + return ter; + + if (auto const ter = checkVaultDomain(ctx.view, sleIssuance, dstAcct, SuppressExpired::No); + !isTesSuccess(ter)) + return ter; + } + if (fix330Enabled) { // checkWithdrawFreeze checks the underlying asset on the source @@ -239,7 +285,9 @@ VaultWithdraw::doApply() // Note, we intentionally do not check lsfVaultPrivate flag on the Vault. If // you have a share in the vault, it means you were at some point authorized // to deposit into it, and this means you are also indefinitely authorized - // to withdraw from it. + // to withdraw it to yourself. Sending the proceeds to somebody else is a + // different matter, and preclaim checks such a withdrawal against the + // vault's permissioned domain. auto const amount = ctx_.tx[sfAmount]; Asset const vaultAsset = vault->at(sfAsset); diff --git a/src/test/app/vault/VaultDomain_test.cpp b/src/test/app/vault/VaultDomain_test.cpp index 5af0842962..5e058a13a8 100644 --- a/src/test/app/vault/VaultDomain_test.cpp +++ b/src/test/app/vault/VaultDomain_test.cpp @@ -572,6 +572,195 @@ private: } } + // Withdrawing out of a private vault to a third party requires both the + // submitter and the destination to be members of the vault's permissioned + // domain. Withdrawal to self is exempt: revoking vault access must not + // trap already deposited funds. The asset issuer is exempt as a + // destination, so that frozen assets can always be returned. + void + testVaultWithdrawPrivateDestinationDomain(FeatureBitset features) + { + using namespace test::jtx; + + bool const withFix = features[fixCleanup3_4_0]; + testcase( + std::string{"VaultWithdraw private vault destination domain check"} + + (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const beneficiary{"beneficiary"}; + Account const outsider{"outsider"}; + Account const pdOwner{"pdOwner"}; + Account const credIssuer{"credIssuer"}; + std::string const credType = "credential"; + + Env env{*this, features}; + Vault const vault{env}; + + env.fund( + XRP(100'000), issuer, owner, depositor, beneficiary, outsider, pdOwner, credIssuer); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + // Everyone holds Layer 1 (asset) permission, so anything blocked below + // is blocked by the Layer 2 (vault) check alone. + for (auto const& account : {owner, depositor, beneficiary, outsider}) + { + env.trust(asset(1'000'000), account); + env(pay(issuer, account, asset(10'000))); + } + env.close(); + + auto const domainId = [&]() { + pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}}; + env(pdomain::setTx(pdOwner, credentials)); + env.close(); + return pdomain::getNewDomain(env.meta()); + }(); + + auto const joinDomain = [&](Account const& account) { + env(credentials::create(account, credIssuer, credType)); + env(credentials::accept(account, credIssuer, credType)); + env.close(); + }; + joinDomain(depositor); + joinDomain(beneficiary); + + auto [createTx, keylet] = + vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate}); + env(createTx); + env.close(); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfDomainID] = to_string(domainId); + env(tx); + env.close(); + } + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)})); + env.close(); + + auto const withdrawTo = [&, keylet = keylet](Account const& destination) { + auto tx = + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}); + tx[sfDestination] = destination.human(); + return tx; + }; + + { + // Destination holds both layers of permission. + env(withdrawTo(beneficiary)); + env.close(); + } + + { + // Destination may hold the asset but was never let into the vault. + env(withdrawTo(outsider), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS))); + env.close(); + } + + { + // The asset issuer can always receive, to keep the recovery path + // for frozen assets open. + env(withdrawTo(issuer)); + env.close(); + } + + { + // The vault owner gets no special treatment as a destination: it + // is a third party like any other and needs domain membership. + env(withdrawTo(owner), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS))); + env.close(); + } + + { + // Withdrawal to self needs no Destination and stays unaffected. + env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)})); + env.close(); + } + + { + // Naming yourself as the Destination is still a withdrawal to self. + env(withdrawTo(depositor)); + env.close(); + } + + { + testcase( + std::string{"VaultWithdraw private vault submitter lost vault access"} + + (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + env(credentials::deleteCred(credIssuer, depositor, credIssuer, credType)); + env.close(); + + // The exit of last resort: the submitter lost vault access but + // must still be able to redeem its own shares. + env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)})); + env.close(); + + // Moving funds to anyone else is not allowed any more, even to a + // destination that is itself a domain member. + env(withdrawTo(beneficiary), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS))); + env.close(); + + // Returning assets to the issuer stays open regardless. + env(withdrawTo(issuer)); + env.close(); + } + + { + testcase( + std::string{"VaultWithdraw private vault with no domain set"} + + (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + // Give the submitter its vault access back first, so that the + // vault having no domain is the only reason left to refuse. + env(credentials::create(depositor, credIssuer, credType)); + env(credentials::accept(depositor, credIssuer, credType)); + env.close(); + + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfDomainID] = "0"; + env(tx); + env.close(); + + // Clearing the domain leaves the vault with nobody it considers + // authorized, so a third-party destination cannot qualify even + // though both ends of the payout hold a credential. + env(withdrawTo(beneficiary), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS))); + env.close(); + + // The two exempt paths survive the domain going away. + env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)})); + env.close(); + + env(withdrawTo(issuer)); + env.close(); + } + + { + testcase( + std::string{"VaultWithdraw public vault destination unaffected"} + + (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + auto [publicTx, publicKeylet] = vault.create({.owner = owner, .asset = asset}); + env(publicTx); + env.close(); + + env(vault.deposit({.depositor = owner, .id = publicKeylet.key, .amount = asset(100)})); + env.close(); + + auto tx = + vault.withdraw({.depositor = owner, .id = publicKeylet.key, .amount = asset(1)}); + tx[sfDestination] = outsider.human(); + env(tx); + env.close(); + } + } + void testWithdrawCredentialDepositPreauth(FeatureBitset features) { @@ -686,6 +875,8 @@ public: testDomainLossAfterAcquisition(); testDomainCheckBuyerSideOffer(); testWithDomainChecXRP(); + testVaultWithdrawPrivateDestinationDomain(all_ - fixCleanup3_4_0); + testVaultWithdrawPrivateDestinationDomain(all_); testWithdrawCredentialDepositPreauth(all_ - fixCleanup3_4_0); testWithdrawCredentialDepositPreauth(all_); } diff --git a/src/test/app/vault/VaultValidation_test.cpp b/src/test/app/vault/VaultValidation_test.cpp index 4219ce4661..45f6d1deaf 100644 --- a/src/test/app/vault/VaultValidation_test.cpp +++ b/src/test/app/vault/VaultValidation_test.cpp @@ -5,10 +5,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -1068,6 +1070,113 @@ private: } } + // A pseudo-account belongs to a ledger object, so it must never be the + // destination of a withdrawal. The payout is refused either way, by the + // deposit authorization every pseudo-account carries, so the only change + // is a misleading tecNO_PERMISSION becoming tecPSEUDO_ACCOUNT. The check + // runs ahead of the private-vault domain check, which would otherwise + // report a domain problem against an account that can never join one. + void + testVaultWithdrawPseudoAccountDestination(FeatureBitset features) + { + using namespace test::jtx; + + bool const withFix = features[fixCleanup3_4_0]; + testcase( + std::string{"VaultWithdraw pseudo-account destination"} + + (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const pdOwner{"pdOwner"}; + Account const credIssuer{"credIssuer"}; + std::string const credType = "credential"; + + Env env{*this, features}; + Vault const vault{env}; + + env.fund(XRP(100'000), issuer, owner, depositor, pdOwner, credIssuer); + // Rippling plays no part in what is being tested here, and would + // otherwise stop the payout before it reaches the check under test. + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + for (auto const& account : {owner, depositor}) + { + env.trust(asset(1'000'000), account); + env(pay(issuer, account, asset(10'000))); + } + env.close(); + + // Another vault over the same asset supplies the destination. Its + // pseudo-account holds a trust line for the asset from creation, so + // the payout is refused for being a pseudo-account and nothing else. + auto const pseudoDestination = [&]() { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + return Account("otherVault", env.le(keylet)->at(sfAccount)); + }(); + + TER const expected = withFix ? TER(tecPSEUDO_ACCOUNT) : TER(tecNO_PERMISSION); + + auto const withdrawToPseudo = [&](uint256 const& vaultId) { + auto tx = vault.withdraw({.depositor = depositor, .id = vaultId, .amount = asset(1)}); + tx[sfDestination] = pseudoDestination.human(); + return tx; + }; + + { + auto [createTx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(createTx); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)})); + env.close(); + + env(withdrawToPseudo(keylet.key), Ter(expected)); + env.close(); + + // Withdrawing to self out of the same vault stays unaffected. + env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)})); + env.close(); + } + + { + auto const domainId = [&]() { + pdomain::Credentials const credentials{ + {.issuer = credIssuer, .credType = credType}}; + env(pdomain::setTx(pdOwner, credentials)); + env.close(); + return pdomain::getNewDomain(env.meta()); + }(); + + env(credentials::create(depositor, credIssuer, credType)); + env(credentials::accept(depositor, credIssuer, credType)); + env.close(); + + auto [createTx, keylet] = + vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate}); + env(createTx); + env.close(); + + auto setTx = vault.set({.owner = owner, .id = keylet.key}); + setTx[sfDomainID] = to_string(domainId); + env(setTx); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)})); + env.close(); + + // The domain check never gets a say: the destination is rejected + // for what it is, not for the domain it is missing. + env(withdrawToPseudo(keylet.key), Ter(expected)); + env.close(); + } + } + public: void run() override @@ -1078,6 +1187,9 @@ public: testCreateFailMPT(); testVaultDeleteMemoData(); testVaultCreateLEVersion(); + + testVaultWithdrawPseudoAccountDestination(all_ - fixCleanup3_4_0); + testVaultWithdrawPseudoAccountDestination(all_); } }; From 520650081bda1229b093b2fead94dc0d0066373c Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:06:44 +0000 Subject: [PATCH 16/43] fix: Remove credentials pinned to Vault, LoanBroker, and AMM pseudo-accounts (#7877) Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> --- .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 | 123 ++++++++++++++++++ src/test/app/vault/VaultBugs_test.cpp | 115 ++++++++++++++++ 12 files changed, 416 insertions(+), 4 deletions(-) diff --git a/.cspell.config.yaml b/.cspell.config.yaml index e8c5f3c30f..7b4a280c65 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -366,6 +366,7 @@ 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 8b1c819bf4..6d235b4316 100644 --- a/include/xrpl/ledger/helpers/CredentialHelpers.h +++ b/include/xrpl/ledger/helpers/CredentialHelpers.h @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -33,6 +34,32 @@ 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 345baef853..e6768efd76 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -396,6 +396,16 @@ 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 fcad22d2d5..20a793e4cb 100644 --- a/src/libxrpl/ledger/helpers/AMMHelpers.cpp +++ b/src/libxrpl/ledger/helpers/AMMHelpers.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -690,6 +691,12 @@ 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}; @@ -767,6 +774,8 @@ 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; @@ -908,6 +917,11 @@ 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 5ba832957d..9c3ca4ec78 100644 --- a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp +++ b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -127,6 +129,36 @@ 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 6bf99e567d..63092cc128 100644 --- a/src/libxrpl/tx/Transactor.cpp +++ b/src/libxrpl/tx/Transactor.cpp @@ -1246,7 +1246,7 @@ removeExpiredNFTokenOffers( } static void -removeExpiredCredentials(ApplyView& view, std::vector const& creds, beast::Journal viewJ) +removeDeletedCredentials(ApplyView& view, std::vector const& creds, beast::Journal viewJ) { for (auto const& index : creds) { @@ -1255,7 +1255,7 @@ removeExpiredCredentials(ApplyView& view, std::vector const& creds, bea if (auto const ter = credentials::deleteSLE(view, sle, viewJ); !isTesSuccess(ter)) { JLOG(viewJ.error()) - << "removeExpiredCredentials: failed to delete expired credential. Err: " + << "removeDeletedCredentials: failed to delete credential. Err: " << transToken(ter); } } @@ -1437,7 +1437,8 @@ 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 = [](TER const ter) { + auto typesForResult = [credentialCleanup = + view().rules().enabled(fixCleanup3_4_0)](TER const ter) { std::unordered_set types; if ((ter == tecOVERSIZE) || (ter == tecKILLED)) { @@ -1446,6 +1447,11 @@ 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) { @@ -1523,7 +1529,7 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee) removeDeletedTrustLines(view(), ids, viewJ); break; case ltCREDENTIAL: - removeExpiredCredentials(view(), ids, viewJ); + removeDeletedCredentials(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 89ade024e6..2cfd069420 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -234,6 +234,14 @@ 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 433d77806a..06907ce366 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp @@ -4,11 +4,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -140,6 +142,19 @@ 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 497a2f2465..f3a587d5a4 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -100,6 +101,19 @@ 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 0212035c6e..a1d5260606 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -5192,6 +5193,51 @@ 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() { @@ -7459,6 +7505,7 @@ 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 321ed5168f..437a0cea99 100644 --- a/src/test/app/lending/LoanBroker_test.cpp +++ b/src/test/app/lending/LoanBroker_test.cpp @@ -60,6 +60,7 @@ #include #include #include +#include #include #include #include @@ -2871,6 +2872,126 @@ 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 @@ -2889,6 +3010,8 @@ 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 70a350a4f1..0771d4a450 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,7 @@ #include #include +#include #include #include #include @@ -972,6 +974,117 @@ 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); + } + public: void run() override @@ -985,6 +1098,8 @@ public: testVaultWithdrawCanonicalizeToZero(); testBugVaultDustDebitCanonicalizesToNoOp(); testVaultDepositNegativeBalanceFromOppositeLimit(); + testCredentialPinsPseudoAccount(); + testCredentialPinOverflow(); testBug6LimitBypassWithShares(); } }; From 764cbe7c295637e56c383ccf0c83313961328d43 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Mon, 24 Aug 2026 14:16:03 +0000 Subject: [PATCH 17/43] perf: Pause online delete if there any gaps in recent ledger history (#5531) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cfg/xrpld-example.cfg | 20 +- include/xrpl/config/Constants.h | 1 + src/test/app/LedgerMaster_test.cpp | 70 ++++ src/test/app/SHAMapStore_test.cpp | 341 +++++++++++++++++-- src/test/jtx/envconfig.h | 14 + src/test/jtx/impl/envconfig.cpp | 11 + src/xrpld/app/ledger/LedgerMaster.h | 9 +- src/xrpld/app/ledger/detail/LedgerMaster.cpp | 29 +- src/xrpld/app/misc/SHAMapStore.h | 5 +- src/xrpld/app/misc/SHAMapStoreImp.cpp | 303 +++++++++++++--- src/xrpld/app/misc/SHAMapStoreImp.h | 32 +- 11 files changed, 741 insertions(+), 94 deletions(-) diff --git a/cfg/xrpld-example.cfg b/cfg/xrpld-example.cfg index 747bafe077..8c4ae07fb1 100644 --- a/cfg/xrpld-example.cfg +++ b/cfg/xrpld-example.cfg @@ -1094,8 +1094,8 @@ # Default is 100. # # back_off_milliseconds -# Number of milliseconds to wait between -# online_delete batches to allow other functions +# Number of milliseconds to wait between online_delete +# SQL deletion batches to allow other functions # to catch up. # Default is 100. # @@ -1109,10 +1109,22 @@ # The online delete process checks periodically # that xrpld is still in sync with the network, # and that the validated ledger is less than -# 'age_threshold_seconds' old. If not, then continue +# 'age_threshold_seconds' old, and that all +# recent ledgers are available. If not, then continue # sleeping for this number of seconds and # checking until healthy. -# Default is 5. +# Default is 2. +# +# max_waiting_ledgers +# The maximum number of ledgers that may be validated +# while online deletion is waiting for the node to get +# fully synced with the rest of the network. If more than +# this number of ledgers are validated while waiting, then +# online deletion gives up on the current ledger and tries +# again later. Note this only affects situations that cause +# rotation to wait, such as going out of sync, or missing +# ledgers. Forward progress is not penalized. Minimum is 64. +# Default is the online_delete value. # # Notes: # The 'node_db' entry configures the primary, persistent storage. diff --git a/include/xrpl/config/Constants.h b/include/xrpl/config/Constants.h index 85d9e3f147..c78643d6c3 100644 --- a/include/xrpl/config/Constants.h +++ b/include/xrpl/config/Constants.h @@ -125,6 +125,7 @@ struct Keys static constexpr auto kMaximumTxnInLedger = "maximum_txn_in_ledger"; static constexpr auto kMaximumTxnPerAccount = "maximum_txn_per_account"; static constexpr auto kMemoryLevel = "memory_level"; + static constexpr auto kMaxWaitingLedgers = "max_waiting_ledgers"; static constexpr auto kMinLedgersToComputeSizeLimit = "min_ledgers_to_compute_size_limit"; static constexpr auto kMinimumEscalationMultiplier = "minimum_escalation_multiplier"; static constexpr auto kMinimumLastLedgerBuffer = "minimum_last_ledger_buffer"; diff --git a/src/test/app/LedgerMaster_test.cpp b/src/test/app/LedgerMaster_test.cpp index 3cf9b3a9d9..ece25356fd 100644 --- a/src/test/app/LedgerMaster_test.cpp +++ b/src/test/app/LedgerMaster_test.cpp @@ -5,17 +5,21 @@ #include #include +#include #include +#include #include #include #include +#include #include #include #include #include #include +#include #include namespace xrpl::test { @@ -111,6 +115,71 @@ class LedgerMaster_test : public beast::unit_test::Suite } } + void + testCompleteLedgerRange(FeatureBitset features) + { + // Note that this test is intentionally very similar to + // SHAMapStore_test::testLedgerGaps, but has a different + // focus. + + testcase("Complete Ledger operations"); + + using namespace test::jtx; + + auto const deleteInterval = 8; + + Env env{*this, envconfig(onlineDelete, deleteInterval)}; + + auto const alice = Account("alice"); + env.fund(XRP(1000), alice); + env.close(); + + auto& lm = env.app().getLedgerMaster(); + LedgerIndex minSeq = 2; + LedgerIndex maxSeq = env.closed()->header().seq; + auto& store = env.app().getSHAMapStore(); + BEAST_EXPECT(store.rendezvous()); + LedgerIndex lastRotated = store.getLastRotated(); + BEAST_EXPECTS(maxSeq == 3, to_string(maxSeq)); + BEAST_EXPECTS(lm.getCompleteLedgers() == "2-3", lm.getCompleteLedgers()); + BEAST_EXPECTS(lastRotated == 3, to_string(lastRotated)); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0); + BEAST_EXPECT(minSeq + 1 > maxSeq - 1); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2); + + // Close enough ledgers to rotate a few times + for (int i = 0; i < 24; ++i) + { + for (int t = 0; t < 3; ++t) + { + env(noop(alice)); + } + env.close(); + BEAST_EXPECT(store.rendezvous()); + + ++maxSeq; + + if (maxSeq == lastRotated + deleteInterval) + { + minSeq = lastRotated; + lastRotated = maxSeq; + } + BEAST_EXPECTS( + env.closed()->header().seq == maxSeq, to_string(env.closed()->header().seq)); + BEAST_EXPECTS(store.getLastRotated() == lastRotated, to_string(store.getLastRotated())); + std::stringstream expectedRange; + expectedRange << minSeq << "-" << maxSeq; + BEAST_EXPECTS(lm.getCompleteLedgers() == expectedRange.str(), lm.getCompleteLedgers()); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 1, maxSeq - 1) == 0); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2); + } + } + public: void run() override @@ -124,6 +193,7 @@ public: testWithFeats(FeatureBitset features) { testTxnIdFromIndex(features); + testCompleteLedgerRange(features); } }; diff --git a/src/test/app/SHAMapStore_test.cpp b/src/test/app/SHAMapStore_test.cpp index 82019affba..0a8c51c56a 100644 --- a/src/test/app/SHAMapStore_test.cpp +++ b/src/test/app/SHAMapStore_test.cpp @@ -1,7 +1,9 @@ #include #include #include +#include +#include #include #include #include @@ -22,16 +24,21 @@ #include #include #include +#include #include +#include #include #include #include #include #include #include +#include #include +#include #include +#include namespace xrpl::test { @@ -42,9 +49,8 @@ class SHAMapStore_test : public beast::unit_test::Suite static auto onlineDelete(std::unique_ptr cfg) { - cfg->ledgerHistory = kDeleteInterval; - auto& section = cfg->section(Sections::kNodeDatabase); - section.set(Keys::kOnlineDelete, std::to_string(kDeleteInterval)); + cfg = jtx::onlineDelete(std::move(cfg), kDeleteInterval); + cfg->section(Sections::kNodeDatabase).set(Keys::kRecoveryWaitSeconds, "1"); return cfg; } @@ -143,11 +149,11 @@ class SHAMapStore_test : public beast::unit_test::Suite auto& store = env.app().getSHAMapStore(); int ledgerSeq = 3; - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(!store.getLastRotated()); env.close(); - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); auto ledger = env.rpc("ledger", "validated"); BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++))); @@ -227,7 +233,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(kDeleteInterval + 4))); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(store.getLastRotated() == kDeleteInterval + 3); lastRotated = store.getLastRotated(); @@ -254,7 +260,7 @@ public: !getHash(ledgers[i]).empty()); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(store.getLastRotated() == kDeleteInterval + lastRotated); @@ -292,7 +298,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); // The database will always have back to ledger 2, // regardless of lastRotated. @@ -307,7 +313,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - lastRotated, lastRotated); BEAST_EXPECT(lastRotated != store.getLastRotated()); @@ -323,7 +329,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, kDeleteInterval + 1, lastRotated); BEAST_EXPECT(lastRotated != store.getLastRotated()); @@ -362,7 +368,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - 2, 2); BEAST_EXPECT(lastRotated == store.getLastRotated()); @@ -372,7 +378,7 @@ public: BEAST_EXPECT(!rpc::containsError(canDelete[jss::result])); BEAST_EXPECT(canDelete[jss::result][jss::can_delete] == ledgerSeq + (kDeleteInterval / 2)); - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - 2, 2); BEAST_EXPECT(store.getLastRotated() == lastRotated); @@ -385,7 +391,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - lastRotated, lastRotated); @@ -401,7 +407,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(store.getLastRotated() == lastRotated); @@ -413,7 +419,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - firstBatch, firstBatch); @@ -435,7 +441,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(store.getLastRotated() == lastRotated); @@ -447,7 +453,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - lastRotated, lastRotated); @@ -468,7 +474,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(store.getLastRotated() == lastRotated); @@ -480,7 +486,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - lastRotated, lastRotated); @@ -603,6 +609,302 @@ public: BEAST_EXPECT(dbr->getName() == "3"); } + void + testLedgerGaps() + { + // Note that this test is intentionally very similar to + // LedgerMaster_test::testCompleteLedgerRange, but has a different + // focus. + + testcase("Wait for ledger gaps to fill in"); + + using namespace test::jtx; + + Env env{*this, envconfig(onlineDelete)}; + + auto failureMessage = [&](char const* label, auto expected, auto actual) { + std::stringstream ss; + ss << label << ": Expected: " << expected << ", Got: " << actual; + return ss.str(); + }; + + auto const alice = Account("alice"); + env.fund(XRP(1000), alice); + env.close(); + + auto& lm = env.app().getLedgerMaster(); + LedgerIndex minSeq = 2; + LedgerIndex maxSeq = env.closed()->header().seq; + auto& store = env.app().getSHAMapStore(); + LedgerIndex lastRotated = store.getLastRotated(); + auto& netOPs = env.app().getOPs(); + while (lastRotated != 3) + { + BEAST_EXPECT(store.rendezvous()); + lastRotated = store.getLastRotated(); + } + BEAST_EXPECTS(maxSeq == 3, std::to_string(maxSeq)); + BEAST_EXPECTS(lm.getCompleteLedgers() == "2-3", lm.getCompleteLedgers()); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0); + BEAST_EXPECT(minSeq + 1 > maxSeq - 1); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2); + + auto expectedRange = + [](LedgerIndex minSeq, std::vector const& deleteSeqs, LedgerIndex maxSeq) { + std::stringstream expectedRange; + expectedRange << minSeq; + auto lastDelete = minSeq - 1; + for (auto deleteSeq : deleteSeqs) + { + if (deleteSeq <= lastDelete) + continue; + expectedRange << "-" << (deleteSeq - 1); + if (deleteSeq + 1 <= maxSeq) + expectedRange << "," << (deleteSeq + 1); + lastDelete = deleteSeq; + } + if (lastDelete + 1 < maxSeq) + { + expectedRange << "-" << maxSeq; + } + return expectedRange.str(); + }; + + auto deleteLedgerSeq = + [&lm, &store, &netOPs, &minSeq, &lastRotated, &expectedRange, &failureMessage, this]( + Env& env, + LedgerIndex& maxSeq, + std::vector& deleteSeqs) -> LedgerIndex { + using namespace std::chrono_literals; + + // The next ledger will trigger a rotation. Delete the + // current ledger from LedgerMaster. + + netOPs.setMode(OperatingMode::CONNECTED); + + LedgerIndex const deleteSeq = maxSeq; + std::size_t iterations = 30; + while (!lm.haveLedger(deleteSeq) && --iterations > 0) + { + std::this_thread::sleep_for(10ms); + } + // Even the slowest machines should be able to finalize deleteSeq within 10 + // loops (100ms). If this test ever actually fails feel free to lower this + // cutoff. The intent of this test is to flag if the loop takes a very long + // time, but still allow the rest of this function to finish. + BEAST_EXPECTS(iterations > 20, std::to_string(iterations)); + if (!BEAST_EXPECT(lm.haveLedger(deleteSeq))) + return 0; + + // This test may be timing sensitive, because it's messing with server internals in ways + // that they can't be messed with normally. Sleep a little bit to give the server time + // to finish any internal work before we delete the ledger. + std::this_thread::sleep_for(250ms); + + lm.clearLedger(deleteSeq); + deleteSeqs.push_back(deleteSeq); + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return 0; + + BEAST_EXPECTS( + lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq), + failureMessage( + "Complete ledgers", + expectedRange(minSeq, deleteSeqs, maxSeq), + lm.getCompleteLedgers())); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == deleteSeqs.size()); + + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return 0; + // Close another ledger, which will trigger a rotation, but the + // rotation will be stuck until the missing ledger is filled in. + env.close(); + // Do not call rendezvous() here without a timeout; it will block until the missing + // ledger is backfilled. That will not happen automatically. It's a manual step that + // is done later in this test. + ++maxSeq; + + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return 0; + netOPs.setMode(OperatingMode::FULL); + + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return 0; + BEAST_EXPECT(!store.rendezvous(10ms)); + BEAST_EXPECT(netOPs.getOperatingMode() == OperatingMode::FULL); + + // Nothing has changed + BEAST_EXPECTS( + store.getLastRotated() == lastRotated, + failureMessage("lastRotated", lastRotated, store.getLastRotated())); + BEAST_EXPECTS( + lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq), + failureMessage( + "Complete ledgers", + expectedRange(minSeq, deleteSeqs, maxSeq), + lm.getCompleteLedgers())); + + return deleteSeq; + }; + + std::vector deleteSeqs; + + // Close enough ledgers to rotate a few times + while (maxSeq < 40) + { + for (int t = 0; t < 3; ++t) + { + env(noop(alice)); + } + env.close(); + BEAST_EXPECT(store.rendezvous()); + + ++maxSeq; + + if (maxSeq + 1 == lastRotated + kDeleteInterval) + { + using namespace std::chrono_literals; + + { + // Trigger the circuit breaker in SHAMapStoreImp::healthWait() to ensure it + // doesn't block forever. + LedgerIndex const deleteSeq = deleteLedgerSeq(env, maxSeq, deleteSeqs); + if (!BEAST_EXPECT(deleteSeq > 0)) + return; + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return; + + // Close 7 more ledgers, waiting a little bit in between to + // simulate the ledger making progress while online delete waits + // for the missing ledger to be filled in. + // After the 7th ledger, the circuit breaker will trigger and abort the attempt. + while (maxSeq < lastRotated + (kDeleteInterval * 2) - 2) + { + env.close(); + ++maxSeq; + // Nothing has changed + BEAST_EXPECTS( + store.getLastRotated() == lastRotated, + failureMessage("lastRotated", lastRotated, store.getLastRotated())); + BEAST_EXPECTS( + lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq), + failureMessage( + "Complete Ledgers", + expectedRange(minSeq, deleteSeqs, maxSeq), + lm.getCompleteLedgers())); + // The Store is "stuck" in healthWait() and won't finish the run() loop + // until it's backfilled + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return; + } + + // Close one more ledger, which will NOT trigger the circuit breaker. Wait for + // the full 1 second recovery wait timeout to ensure the circuit breaker is not + // triggered. + env.close(); + ++maxSeq; + // The Store is "stuck" in healthWait() and won't finish the run() loop + // until it's backfilled + BEAST_EXPECT(!store.rendezvous(1s)); + + // Close one more ledger, which will trigger the circuit breaker and abort the + // attempt to rotate. + env.close(); + ++maxSeq; + // Nothing has changed + BEAST_EXPECTS( + store.getLastRotated() == lastRotated, + failureMessage("lastRotated", lastRotated, store.getLastRotated())); + BEAST_EXPECTS( + lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq), + failureMessage( + "Complete Ledgers", + expectedRange(minSeq, deleteSeqs, maxSeq), + lm.getCompleteLedgers())); + + // The circuit breaker has been triggered. + BEAST_EXPECT(store.rendezvous()); + } + { + // Recover before the circuit breaker triggers, so the test can continue. + LedgerIndex const deleteSeq = deleteLedgerSeq(env, maxSeq, deleteSeqs); + if (!BEAST_EXPECT(deleteSeq > 0)) + return; + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return; + + // Close 5 more ledgers, waiting a little bit in between to + // simulate the ledger making progress while online delete waits + // for the missing ledger to be filled in. + // This ensures the healthWait check has time to run and + // detect the gap. + for (int l = 0; l < 5; ++l) + { + env.close(); + ++maxSeq; + // Nothing has changed + BEAST_EXPECTS( + store.getLastRotated() == lastRotated, + failureMessage("lastRotated", lastRotated, store.getLastRotated())); + BEAST_EXPECTS( + lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq), + failureMessage( + "Complete Ledgers", + expectedRange(minSeq, deleteSeqs, maxSeq), + lm.getCompleteLedgers())); + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return; + } + + // The Store is "stuck" in healthWait() and won't finish the run() loop + // until it's backfilled + // Wait for the full 1 second recovery wait timeout to ensure the circuit + // breaker is not triggered, and this isn't some other timing fluke. + BEAST_EXPECT(!store.rendezvous(1s)); + + // Put the missing ledger back in LedgerMaster + lm.setLedgerRangePresent(deleteSeq, deleteSeq); + BEAST_EXPECT(deleteSeqs.back() == deleteSeq); + deleteSeqs.pop_back(); + + // Wait for the rotation to finish + BEAST_EXPECT(store.rendezvous()); + + minSeq = lastRotated; + while (deleteSeqs.front() < minSeq) + { + deleteSeqs.erase(deleteSeqs.begin()); + } + lastRotated = deleteSeq + 1; + } + } + BEAST_EXPECT(maxSeq != lastRotated + kDeleteInterval); + BEAST_EXPECTS( + env.closed()->header().seq == maxSeq, + failureMessage("maxSeq", maxSeq, env.closed()->header().seq)); + BEAST_EXPECTS( + store.getLastRotated() == lastRotated, + failureMessage("lastRotated", lastRotated, store.getLastRotated())); + { + auto const expected = expectedRange(minSeq, deleteSeqs, maxSeq); + BEAST_EXPECTS( + lm.getCompleteLedgers() == expected, + failureMessage("CompleteLedgers", expected, lm.getCompleteLedgers())); + } + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == deleteSeqs.size()); + BEAST_EXPECT( + lm.missingFromCompleteLedgerRange(minSeq + 1, maxSeq - 1) == deleteSeqs.size()); + BEAST_EXPECT( + lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == deleteSeqs.size() + 2); + BEAST_EXPECT( + lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == deleteSeqs.size() + 2); + BEAST_EXPECT( + lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == deleteSeqs.size() + 2); + } + } + void run() override { @@ -610,6 +912,7 @@ public: testAutomatic(); testCanDelete(); testRotate(); + testLedgerGaps(); } }; diff --git a/src/test/jtx/envconfig.h b/src/test/jtx/envconfig.h index 1f920fca58..5ad24e25c4 100644 --- a/src/test/jtx/envconfig.h +++ b/src/test/jtx/envconfig.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -62,6 +63,19 @@ envconfig(F&& modfunc, Args&&... args) return modfunc(envconfig(), std::forward(args)...); } +/** + * @brief adjust config to enable online_delete + * + * @param cfg config instance to be modified + * + * @param deleteInterval how many new ledgers should be available before + * rotating. Defaults to 8, because the standalone minimum is 8. + * + * @return unique_ptr to Config instance + */ +std::unique_ptr +onlineDelete(std::unique_ptr cfg, std::uint32_t deleteInterval = 8); + /** * @brief adjust config so no admin ports are enabled * diff --git a/src/test/jtx/impl/envconfig.cpp b/src/test/jtx/impl/envconfig.cpp index bc65738b44..14690058ec 100644 --- a/src/test/jtx/impl/envconfig.cpp +++ b/src/test/jtx/impl/envconfig.cpp @@ -7,8 +7,10 @@ #include #include +#include #include #include +#include #include namespace xrpl::test { @@ -60,6 +62,15 @@ setupConfigForUnitTests(Config& cfg) namespace jtx { +std::unique_ptr +onlineDelete(std::unique_ptr cfg, std::uint32_t deleteInterval) +{ + cfg->ledgerHistory = deleteInterval; + auto& section = cfg->section(Sections::kNodeDatabase); + section.set(Keys::kOnlineDelete, std::to_string(deleteInterval)); + return cfg; +} + std::unique_ptr noAdmin(std::unique_ptr cfg) { diff --git a/src/xrpld/app/ledger/LedgerMaster.h b/src/xrpld/app/ledger/LedgerMaster.h index 32163fd57b..140b12fa59 100644 --- a/src/xrpld/app/ledger/LedgerMaster.h +++ b/src/xrpld/app/ledger/LedgerMaster.h @@ -123,7 +123,10 @@ public: failedSave(std::uint32_t seq, uint256 const& hash); std::string - getCompleteLedgers(); + getCompleteLedgers() const; + + std::size_t + missingFromCompleteLedgerRange(LedgerIndex first, LedgerIndex last) const; /** * Apply held transactions to the open ledger @@ -190,7 +193,7 @@ public: fixMismatch(ReadView const& ledger); bool - haveLedger(std::uint32_t seq); + haveLedger(std::uint32_t seq) const; void clearLedger(std::uint32_t seq); bool @@ -348,7 +351,7 @@ private: // A set of transactions to replay during the next close std::unique_ptr replayData_; - std::recursive_mutex completeLock_; + std::recursive_mutex mutable completeLock_; RangeSet completeLedgers_; // Publish thread is running. diff --git a/src/xrpld/app/ledger/detail/LedgerMaster.cpp b/src/xrpld/app/ledger/detail/LedgerMaster.cpp index 83d76bcd2a..878b257b69 100644 --- a/src/xrpld/app/ledger/detail/LedgerMaster.cpp +++ b/src/xrpld/app/ledger/detail/LedgerMaster.cpp @@ -57,6 +57,7 @@ #include #include +#include #include #include @@ -492,7 +493,7 @@ LedgerMaster::setBuildingLedger(LedgerIndex i) } bool -LedgerMaster::haveLedger(std::uint32_t seq) +LedgerMaster::haveLedger(std::uint32_t seq) const { std::scoped_lock const sl(completeLock_); return boost::icl::contains(completeLedgers_, seq); @@ -1576,12 +1577,36 @@ LedgerMaster::getPublishedLedger() } std::string -LedgerMaster::getCompleteLedgers() +LedgerMaster::getCompleteLedgers() const { std::scoped_lock const sl(completeLock_); return to_string(completeLedgers_); } +std::size_t +LedgerMaster::missingFromCompleteLedgerRange(LedgerIndex first, LedgerIndex last) const +{ + if (first > last) + { + // In expected usage, this will never happen because "first" is generally initialized to + // "last", "last" is guaranteed to grow monotonically, and "first" either doesn't change + // or grows more slowly. + // LCOV_EXCL_START + UNREACHABLE("xrpl::LedgerMaster::missingFromCompleteLedgerRange : invalid parameters"); + return 0; + // LCOV_EXCL_STOP + } + + RangeSet const target{range(first, last)}; + + auto const missing = [&target, this] { + std::scoped_lock const sl(completeLock_); + return target - completeLedgers_; + }(); + + return boost::icl::size(missing); +} + std::optional LedgerMaster::getCloseTimeBySeq(LedgerIndex ledgerIndex) { diff --git a/src/xrpld/app/misc/SHAMapStore.h b/src/xrpld/app/misc/SHAMapStore.h index eeb04df53d..9d50f988b5 100644 --- a/src/xrpld/app/misc/SHAMapStore.h +++ b/src/xrpld/app/misc/SHAMapStore.h @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -34,8 +35,8 @@ public: virtual void start() = 0; - virtual void - rendezvous() const = 0; + [[nodiscard]] virtual bool + rendezvous(std::optional const& timeout = {}) const = 0; virtual void stop() = 0; diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp index 9e3f1ac52b..e19df597a2 100644 --- a/src/xrpld/app/misc/SHAMapStoreImp.cpp +++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,8 @@ #include #include +#include +#include #include #include #include @@ -127,22 +130,6 @@ SHAMapStoreImp::SHAMapStoreImp( if (deleteInterval_ != 0u) { - // Configuration that affects the behavior of online delete - getIfExists(section, Keys::kDeleteBatch, deleteBatch_); - std::uint32_t temp = 0; - if (getIfExists(section, Keys::kBackOffMilliseconds, temp) || - // Included for backward compatibility with an undocumented setting - getIfExists(section, Keys::kBackOff, temp)) - { - backOff_ = std::chrono::milliseconds{temp}; - } - if (getIfExists(section, Keys::kAgeThresholdSeconds, temp)) - ageThreshold_ = std::chrono::seconds{temp}; - if (getIfExists(section, Keys::kRecoveryWaitSeconds, temp)) - recoveryWaitTime_ = std::chrono::seconds{temp}; - - getIfExists(section, Keys::kAdvisoryDelete, advisoryDelete_); - auto const minInterval = config.standalone() ? kMinimumDeletionIntervalSa : kMinimumDeletionInterval; if (deleteInterval_ < minInterval) @@ -159,6 +146,40 @@ SHAMapStoreImp::SHAMapStoreImp( std::to_string(config.ledgerHistory) + ")"); } + // Configuration that affects the behavior of online delete + getIfExists(section, Keys::kDeleteBatch, deleteBatch_); + std::uint32_t temp = 0; + if (getIfExists(section, Keys::kBackOffMilliseconds, temp) || + // Included for backward compatibility with an undocumented setting + getIfExists(section, Keys::kBackOff, temp)) + { + backOff_ = std::chrono::milliseconds{temp}; + } + if (getIfExists(section, Keys::kAgeThresholdSeconds, temp)) + ageThreshold_ = std::chrono::seconds{temp}; + if (getIfExists(section, Keys::kRecoveryWaitSeconds, temp)) + recoveryWaitTime_ = std::chrono::seconds{temp}; + if (recoveryWaitTime_ < std::chrono::seconds{1}) + Throw("recovery_wait_seconds must be at least 1 second"); + + getIfExists(section, Keys::kAdvisoryDelete, advisoryDelete_); + + if (getIfExists(section, Keys::kMaxWaitingLedgers, temp)) + { + maxWaitingLedgers_ = temp; + } + else + { + maxWaitingLedgers_ = deleteInterval_; + } + + auto const minWaiting = minInterval / 4; + if (maxWaitingLedgers_ < minWaiting) + { + Throw( + "max_waiting_ledgers must be at least " + std::to_string(minWaiting)); + } + stateDb_.init(config, dbName_); dbPaths(); } @@ -235,14 +256,22 @@ SHAMapStoreImp::onLedgerClosed(std::shared_ptr const& ledger) cond_.notify_one(); } -void -SHAMapStoreImp::rendezvous() const +[[nodiscard]] +bool +SHAMapStoreImp::rendezvous(std::optional const& timeout) const { if (!working_) - return; + return true; + + auto notWorking = [&] { return !working_; }; std::unique_lock lock(mutex_); - rendezvous_.wait(lock, [&] { return !working_; }); + if (timeout) + { + return rendezvous_.wait_for(lock, *timeout, notWorking); + } + rendezvous_.wait(lock, notWorking); + return true; } int @@ -275,7 +304,7 @@ SHAMapStoreImp::copyNode(std::uint64_t& nodeCount, SHAMapTreeNode const& node) } if ((++nodeCount % checkHealthInterval_) == 0u) { - if (healthWait() == HealthResult::Stopping) + if (healthWait() != HealthResult::KeepGoing) return false; } @@ -326,9 +355,35 @@ SHAMapStoreImp::run() stateDb_.setLastRotated(lastRotated); } + // We're starting a new cycle, so reset back to the default. + lastSuccessfulHealthCheck_ = 0; + bool const readyToRotate = validatedSeq >= lastRotated + deleteInterval_ && canDelete_ >= lastRotated - 1 && healthWait() == HealthResult::KeepGoing; + { + // Note that this is set after the healthWait() check, so that we + // don't start the rotation until the validated ledger is fully + // processed. It is not guaranteed to be done at this point. It also + // allows the testLedgerGaps unit test to work. + std::unique_lock lock(mutex_); + if (newLedger_) + { + // It is possible, though very unlikely outside of tests which manipulate internals, + // that healthWait() took so long that the validated ledger (newLedger_) has moved + // on from where we started. If that's the case, update lastGoodValidatedLedger_ + // to that ledger's sequence number. + lastGoodValidatedLedger_ = newLedger_->header().seq; + } + else + { + lastGoodValidatedLedger_ = validatedSeq; + } + auto const l = lastGoodValidatedLedger_; + lock.unlock(); + JLOG(journal_.trace()) << "run: Set lastGoodValidatedLedger_ to " << l; + } + // will delete up to (not including) lastRotated if (readyToRotate) { @@ -336,11 +391,19 @@ SHAMapStoreImp::run() << lastRotated << " deleteInterval " << deleteInterval_ << " canDelete_ " << canDelete_ << " state " << app_.getOPs().strOperatingMode(false) << " age " - << ledgerMaster_->getValidatedLedgerAge().count() << 's'; + << ledgerMaster_->getValidatedLedgerAge().count() + << "s. Complete ledgers: " << ledgerMaster_->getCompleteLedgers(); clearPrior(lastRotated); - if (healthWait() == HealthResult::Stopping) - return; + switch (healthWait()) + { + case HealthResult::Stopping: + return; + case HealthResult::Expired: + continue; + case HealthResult::KeepGoing: + break; + } JLOG(journal_.debug()) << "copying ledger " << validatedSeq; std::uint64_t nodeCount = 0; @@ -359,8 +422,15 @@ SHAMapStoreImp::run() continue; } - if (healthWait() == HealthResult::Stopping) - return; + switch (healthWait()) + { + case HealthResult::Stopping: + return; + case HealthResult::Expired: + continue; + case HealthResult::KeepGoing: + break; + } // Only log if we completed without a "health" abort JLOG(journal_.debug()) << "copied ledger " << validatedSeq << " nodecount " << nodeCount; @@ -384,8 +454,15 @@ SHAMapStoreImp::run() JLOG(journal_.debug()) << "freshening caches"; freshenCaches(); - if (healthWait() == HealthResult::Stopping) - return; + switch (healthWait()) + { + case HealthResult::Stopping: + return; + case HealthResult::Expired: + continue; + case HealthResult::KeepGoing: + break; + } // Only log if we completed without a "health" abort JLOG(journal_.debug()) << validatedSeq << " freshened caches"; @@ -394,8 +471,15 @@ SHAMapStoreImp::run() JLOG(journal_.debug()) << validatedSeq << " new backend " << newBackend->getName(); clearCaches(validatedSeq); - if (healthWait() == HealthResult::Stopping) - return; + switch (healthWait()) + { + case HealthResult::Stopping: + return; + case HealthResult::Expired: + continue; + case HealthResult::KeepGoing: + break; + } lastRotated = validatedSeq; @@ -411,7 +495,9 @@ SHAMapStoreImp::run() clearCaches(validatedSeq); }); - JLOG(journal_.warn()) << "finished rotation " << validatedSeq; + JLOG(journal_.warn()) << "finished rotation. validatedSeq: " << validatedSeq + << ", lastRotated: " << lastRotated + << ". Complete ledgers: " << ledgerMaster_->getCompleteLedgers(); } } } @@ -559,7 +645,7 @@ SHAMapStoreImp::clearSql( min = *m; } - if (min > lastRotated || healthWait() == HealthResult::Stopping) + if (min > lastRotated || healthWait() != HealthResult::KeepGoing) return; if (min == lastRotated) { @@ -572,18 +658,19 @@ SHAMapStoreImp::clearSql( << lastRotated; while (min < lastRotated) { + // The very first sleep is, arguably wasted, but clearSql is called multiple times for + // different tables, so the time is amortized among all the operations. This results in + // a backoff in between each set of tables, too. + std::this_thread::sleep_for(backOff_); + if (healthWait() != HealthResult::KeepGoing) + return; + min = std::min(lastRotated, min + deleteBatch_); JLOG(journal_.trace()) << "Begin: Delete up to " << deleteBatch_ << " rows with LedgerSeq < " << min << " from: " << tableName; deleteBeforeSeq(min); JLOG(journal_.trace()) << "End: Delete up to " << deleteBatch_ << " rows with LedgerSeq < " << min << " from: " << tableName; - if (healthWait() == HealthResult::Stopping) - return; - if (min < lastRotated) - std::this_thread::sleep_for(backOff_); - if (healthWait() == HealthResult::Stopping) - return; } JLOG(journal_.debug()) << "finished deleting from: " << tableName; } @@ -616,7 +703,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated) JLOG(journal_.trace()) << "Begin: Clear internal ledgers up to " << lastRotated; ledgerMaster_->clearPriorLedgers(lastRotated); JLOG(journal_.trace()) << "End: Clear internal ledgers up to " << lastRotated; - if (healthWait() == HealthResult::Stopping) + if (healthWait() != HealthResult::KeepGoing) return; auto& db = app_.getRelationalDatabase(); @@ -626,7 +713,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated) "Ledgers", [&db]() -> std::optional { return db.getMinLedgerSeq(); }, [&db](LedgerIndex min) -> void { db.deleteBeforeLedgerSeq(min); }); - if (healthWait() == HealthResult::Stopping) + if (healthWait() != HealthResult::KeepGoing) return; if (!app_.config().useTxTables()) @@ -637,7 +724,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated) "Transactions", [&db]() -> std::optional { return db.getTransactionsMinLedgerSeq(); }, [&db](LedgerIndex min) -> void { db.deleteTransactionsBeforeLedgerSeq(min); }); - if (healthWait() == HealthResult::Stopping) + if (healthWait() != HealthResult::KeepGoing) return; clearSql( @@ -645,30 +732,136 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated) "AccountTransactions", [&db]() -> std::optional { return db.getAccountTransactionsMinLedgerSeq(); }, [&db](LedgerIndex min) -> void { db.deleteAccountTransactionsBeforeLedgerSeq(min); }); - if (healthWait() == HealthResult::Stopping) + if (healthWait() != HealthResult::KeepGoing) return; } SHAMapStoreImp::HealthResult SHAMapStoreImp::healthWait() { - auto age = ledgerMaster_->getValidatedLedgerAge(); - OperatingMode mode = netOPs_->getOperatingMode(); - std::unique_lock lock(mutex_); - while (!stop_ && (mode != OperatingMode::FULL || age > ageThreshold_)) - { - lock.unlock(); - JLOG(journal_.warn()) << "Waiting " << recoveryWaitTime_.count() - << "s for node to stabilize. state: " - << app_.getOPs().strOperatingMode(mode, false) << ". age " - << age.count() << 's'; - std::this_thread::sleep_for(recoveryWaitTime_); + // Gets the current status of the server from ledgerMaster_ and netOPs_. Must be called + // while mutex_ is unlocked to avoid unlikely, but possible, deadlock with ledgerMaster_'s + // completeLock_. + // Releasing the lock may mean that status will be slightly out of date when the lock is + // reacquired, but it's close enough. In a normal rotation, healthWait() is called frequently, + // so a false positive will be detected on the next call, and a false negative will be detected + // in the next loop iteration. Database rotation is important, but not timely, so an extra + // delay is fine. + auto readServerStatus = [this]( + LedgerIndex& index, + bool& buildingIndex, + std::chrono::seconds& age, + OperatingMode& mode, + std::size_t& numMissing, + LedgerIndex const lowerBound, + ScopeUnlock const&) { + index = ledgerMaster_->getValidLedgerIndex(); + bool const haveIndex = ledgerMaster_->haveLedger(index); age = ledgerMaster_->getValidatedLedgerAge(); mode = netOPs_->getOperatingMode(); - lock.lock(); + + numMissing = + lowerBound == 0 ? 0 : ledgerMaster_->missingFromCompleteLedgerRange(lowerBound, index); + + buildingIndex = (numMissing == 1 && !haveIndex); + }; + + // Tracked server status properties + LedgerIndex index = 0; + bool buildingIndex = false; + std::chrono::seconds age; + OperatingMode mode = OperatingMode::DISCONNECTED; + std::size_t numMissing = 0; + + std::unique_lock lock(mutex_); + + auto const waitTime = recoveryWaitTime_; + auto const ageThreshold = ageThreshold_; + { + auto const lowerBound = lastGoodValidatedLedger_; + + ScopeUnlock const unlock(lock); + + readServerStatus(index, buildingIndex, age, mode, numMissing, lowerBound, unlock); + } + // If index gets past this point without the health check succeeding, return + // HealthWait::Expired. This depends on index being initialized, so it must be after + // readServerStatus(). + auto const lastSuccess = lastSuccessfulHealthCheck_ == 0 ? index : lastSuccessfulHealthCheck_; + auto const circuitBreaker = lastSuccess + maxWaitingLedgers_; + + auto healthy = [&] { + // Special case: If the server is disconnected, it's not doing any ledger I/O, because + // it's focused on trying to get peers. A disconnected state is should never be caused by + // the activity of the server. It's usually limited to hardware or connectivity issues. Take + // advantage of that to run as much rotation I/O as possible before it comes back online. + if (mode == OperatingMode::DISCONNECTED) + return true; + if (age > ageThreshold) + return false; + if (numMissing > 0) + return false; + if (mode != OperatingMode::FULL) + return false; + return true; + }; + + while (!stop_ && !healthy() && index < circuitBreaker) + { + // Future-proofing: this value shouldn't change while we are sleeping, but grab it while we + // have the lock in case it does. + auto const lowerBound = lastGoodValidatedLedger_; + + ScopeUnlock const unlock(lock); + + auto const [stream, waitMs] = std::invoke( + [mode, age, ageThreshold, buildingIndex, waitTime, index, lastSuccess, this] + -> std::pair { + if (mode != OperatingMode::FULL || age > ageThreshold || + (index - lastSuccess > maxWaitingLedgers_ / 4)) + return {journal_.warn(), waitTime}; + if (buildingIndex) + { + // We expect this ledger to be built soon, so log at a lower level, and don't + // wait as long. + return { + journal_.trace(), + std::chrono::duration_cast(waitTime) / 10}; + } + return {journal_.info(), waitTime}; + }); + JLOG(stream) << "Waiting " << waitMs.count() << "ms for node to stabilize. state: " + << app_.getOPs().strOperatingMode(mode, false) << ". age " << age.count() + << "s. Missing ledgers: " << numMissing << ". Expect: " << lowerBound << "-" + << index << ". Complete ledgers: " << ledgerMaster_->getCompleteLedgers(); + std::this_thread::sleep_for(waitMs); + + [[maybe_unused]] + LedgerIndex const lastLedger = index; + readServerStatus(index, buildingIndex, age, mode, numMissing, lowerBound, unlock); + SOMETIMES( + index > lastLedger, "SHAMapStoreImp::healthWait : validated ledger index changed"); } - return stop_ ? HealthResult::Stopping : HealthResult::KeepGoing; + auto const result = std::invoke([index, circuitBreaker, this]() -> HealthResult { + if (stop_) + return HealthResult::Stopping; + if (index < circuitBreaker) + return HealthResult::KeepGoing; + JLOG(journal_.error()) << "online_delete rotation has been unable to make progress for " + << maxWaitingLedgers_ << " ledgers. " + << "validated ledger index: " << index + << ", last successful health check index: " + << lastSuccessfulHealthCheck_ + << ", circuit breaker index: " << circuitBreaker; + return HealthResult::Expired; + }); + + XRPL_ASSERT(lock.owns_lock(), "SHAMapStoreImp::healthWait : lock held"); + if (result == HealthResult::KeepGoing) + lastSuccessfulHealthCheck_ = index; + + return result; } void diff --git a/src/xrpld/app/misc/SHAMapStoreImp.h b/src/xrpld/app/misc/SHAMapStoreImp.h index 8a1b7504b9..c1e9199665 100644 --- a/src/xrpld/app/misc/SHAMapStoreImp.h +++ b/src/xrpld/app/misc/SHAMapStoreImp.h @@ -88,6 +88,13 @@ private: std::thread thread_; bool stop_ = false; bool healthy_ = true; + // Used to prevent ledger gaps from forming during online deletion. Keeps + // track of the last validated ledger that was processed without gaps. There + // are no guarantees about gaps while online delete is not running. For + // that, use advisory_delete and check for gaps externally. + LedgerIndex lastGoodValidatedLedger_ = 0; + // Used to prevent the circuit breaker from tripping too quickly. + LedgerIndex lastSuccessfulHealthCheck_ = 0; mutable std::condition_variable cond_; mutable std::condition_variable rendezvous_; mutable std::mutex mutex_; @@ -102,12 +109,18 @@ private: std::chrono::milliseconds backOff_{100}; std::chrono::seconds ageThreshold_{60}; /** - * If the node is out of sync during an online_delete healthWait() - * call, sleep the thread for this time, and continue checking until - * recovery. + * If the node is out of sync, or any recent ledgers are not + * available during an online_delete healthWait() call, sleep + * the thread for this time, and continue checking until recovery. * See also: "recovery_wait_seconds" in xrpld-example.cfg */ - std::chrono::seconds recoveryWaitTime_{5}; + std::chrono::seconds recoveryWaitTime_{2}; + /** + * If the rotation stays "unhealthy" for a very long time, the process is aborted, and tried + * again later. This value represents the number of ledgers that must be validated without + * making rotation progress before the process is aborted. + */ + std::uint32_t maxWaitingLedgers_ = deleteBatch_; // these do not exist upon SHAMapStore creation, but do exist // as of run() or before @@ -163,8 +176,9 @@ public: void onLedgerClosed(std::shared_ptr const& ledger) override; - void - rendezvous() const override; + [[nodiscard]] + bool + rendezvous(std::optional const& timeout = {}) const override; int fdRequired() const override; @@ -192,7 +206,7 @@ private: for (auto const& key : cache.getKeys()) { dbRotating_->fetchNodeObject(key, 0, node_store::FetchType::Synchronous, true); - if (!(++check % checkHealthInterval_) && healthWait() == HealthResult::Stopping) + if (!(++check % checkHealthInterval_) && healthWait() != HealthResult::KeepGoing) return true; } @@ -220,11 +234,11 @@ private: /** * This is a health check for online deletion that waits until xrpld is * stable before returning. It returns an indication of whether the server - * is stopping. + * is stopping, or if this attempt should be abandoned. * * @return Whether the server is stopping. */ - enum class HealthResult { Stopping, KeepGoing }; + enum class HealthResult { Stopping, Expired, KeepGoing }; [[nodiscard]] HealthResult healthWait(); From 8bc6e81c5f0d2547a6944e8c91b54f87c3a20159 Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 24 Aug 2026 16:17:12 +0000 Subject: [PATCH 18/43] fix: Reject an inner node claimed at leaf depth in `verifyProofPath` (#7940) Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- src/libxrpl/shamap/SHAMapSync.cpp | 41 ++++++-- src/tests/libxrpl/shamap/SHAMap.cpp | 149 ++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 7 deletions(-) diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp index a12e524a5f..4319d0bcd4 100644 --- a/src/libxrpl/shamap/SHAMapSync.cpp +++ b/src/libxrpl/shamap/SHAMapSync.cpp @@ -143,6 +143,20 @@ SHAMap::visitDifferences( if (!function(*node)) return; + // Nibbles run out at kLeafDepth, so only a leaf belongs there. A well-formed map never + // holds an inner node at that depth: addKnownNode marks the map invalid rather than hooking + // one in, and fetch-pack data is hash-verified against a validated root, so reaching this + // means a defect or a corrupt store, not something a peer can provoke. Report the node + // anyway - the wire form carries no depth, and the recipient hooks blobs in by hash - but + // skip the children rather than letting getChildNodeID throw on them. + if (nodeID.getDepth() >= kLeafDepth) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::SHAMap::visitDifferences : inner node at leaf depth"); + continue; + // LCOV_EXCL_STOP + } + // 2) push non-matching child inner nodes for (auto i = 0u; i < kBranchFactor; ++i) { @@ -749,11 +763,9 @@ SHAMap::hasLeafNode(uint256 const& tag, SHAMapHash const& targetNodeHash) const do { - // An inner node is only reachable here at a depth below kLeafDepth in a well-formed map, - // where the loop always finds a leaf first. A malformed map could still have an inner - // node claiming kLeafDepth, and getChildNodeID below throws in that case: reject rather - // than let the throw escape uncaught. Not reachable through any public entry point, - // since addKnownNode already marks such a map invalid, so no test can cover this. + // Same kLeafDepth hazard as in visitDifferences above. That guard bounds the caller's own + // traversal, not the map queried here, and the loop below descends from this map's root + // independently, so this check is what keeps a malformed map from reaching getChildNodeID. if (nodeID.getDepth() >= kLeafDepth) { // LCOV_EXCL_START @@ -830,15 +842,30 @@ SHAMap::verifyProofPath(uint256 const& rootHash, uint256 const& key, std::vector if (node->getHash() != hash) return false; - auto const depth = std::distance(path.rbegin(), rit); + auto const depth = static_cast(std::distance(path.rbegin(), rit)); if (node->isInner()) { - auto nodeId = SHAMapNodeID::createID(static_cast(depth), key); + // Nibbles run out at kLeafDepth, so only the leaf terminating the path may sit + // there. These nodes come off the wire, so a peer can still claim an inner one; + // reject it rather than passing this depth to selectBranch. + SOMETIMES( + depth >= kLeafDepth, "xrpl::SHAMap::verifyProofPath : inner at leaf depth"); + if (depth >= kLeafDepth) + return false; + + auto nodeId = SHAMapNodeID::createID(depth, key); hash = safeDowncast(node.get()) ->getChildHash(selectBranch(nodeId, key)); } else { + // The hash chain up to rootHash only proves this leaf sits where the path claims, + // not that it is the leaf for `key`: a peer could substitute any other leaf whose + // subtree hashes to the same value at every level above it. Checking the terminal + // leaf's own key is what ties the proof to `key` specifically. + if (leafKey(*node) != key) + return false; + // should exhaust all the blobs now return depth + 1 == path.size(); } diff --git a/src/tests/libxrpl/shamap/SHAMap.cpp b/src/tests/libxrpl/shamap/SHAMap.cpp index c84cdf504f..7f7d6ffba2 100644 --- a/src/tests/libxrpl/shamap/SHAMap.cpp +++ b/src/tests/libxrpl/shamap/SHAMap.cpp @@ -3,19 +3,23 @@ #include #include #include +#include #include #include #include +#include #include #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -346,4 +350,149 @@ TEST_F(SHAMapPathProof, verify_proof_path) EXPECT_FALSE(map.verifyProofPath(rootHash, key, badPath)); } +// A legitimate proof path for two keys sharing all 63 leading nibbles is 65 elements: inner nodes +// at depths 0..63 plus the leaf at depth 64. This pins that the 65 bound is real, so the fix for +// the forged-path case below must not simply tighten the length limit. +TEST_F(SHAMapPathProof, legitimate_deep_path_is_sixty_five_elements) +{ + tests::TestNodeFamily f{j_}; + SHAMap map{SHAMapType::FREE, f}; + map.setUnbacked(); + + auto const kA = uint256{std::string_view{std::string(63, 'a') + "1"}}; + auto const kB = uint256{std::string_view{std::string(63, 'a') + "2"}}; + + for (auto const& k : {kA, kB}) + { + Buffer vuc{32}; + std::fill_n(vuc.data(), vuc.size(), std::uint8_t{1}); + ASSERT_TRUE(map.addItem(SHAMapNodeType::TnAccountState, makeShamapitem(k, std::move(vuc)))); + } + map.invariants(); + + auto const pathA = map.getProofPath(kA); + ASSERT_TRUE(pathA.has_value()); + // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above + EXPECT_EQ(pathA->size(), 65u); + EXPECT_TRUE(SHAMap::verifyProofPath(map.getHash().asUInt256(), kA, *pathA)); + // NOLINTEND(bugprone-unchecked-optional-access) + + auto const pathB = map.getProofPath(kB); + ASSERT_TRUE(pathB.has_value()); + // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above + EXPECT_EQ(pathB->size(), 65u); + EXPECT_TRUE(SHAMap::verifyProofPath(map.getHash().asUInt256(), kB, *pathB)); + // NOLINTEND(bugprone-unchecked-optional-access) +} + +// A forged path of 65 hash-chained inner nodes reaches depth kLeafDepth, where only the leaf +// terminating the path may sit. Such a path must be rejected. +TEST_F(SHAMapPathProof, all_inner_path_at_leaf_depth_is_rejected) +{ + // An arbitrary well-formed key; the test does not care about its specific value. + constexpr uint256 kTestKey("b92891fe4ef6cee585fdc6fda1e09eb4d386363158ec3321b8123e5a772c6ca8"); + + // Build upwards from the deepest node so each parent's selected branch carries its child's hash + // and the hash chain validates at every level. + std::vector path; + SHAMapHash childHash{uint256{1}}; + + for (auto depth = SHAMap::kLeafDepth + 1u; depth-- > 0;) + { + auto const id = SHAMapNodeID::createID(std::min(depth, SHAMap::kLeafDepth - 1u), kTestKey); + auto const branch = selectBranch(id, kTestKey); + + Serializer s; + for (auto i = 0u; i < SHAMap::kBranchFactor; ++i) + s.addBitString(i == branch ? childHash.asUInt256() : uint256{}); + s.add8(kWireTypeInner); + path.push_back(s.getData()); + + auto node = SHAMapTreeNode::makeFromWire(makeSlice(path.back())); + ASSERT_TRUE(node); + node->updateHash(); + childHash = node->getHash(); + } + + ASSERT_EQ(path.size(), 65u); + EXPECT_FALSE(SHAMap::verifyProofPath(childHash.asUInt256(), kTestKey, path)); +} + +/** + * Wrap a leaf blob in a forged root inner node whose branch for `key` carries that leaf's hash. + * + * The resulting two-element path hash-chains for `key` no matter which leaf sits at the bottom, + * which is exactly the substitution a peer could attempt. + * + * @param leafBlob the wire form of the leaf to place at the bottom of the path. + * @param key the key the forged path claims to prove. + * @return the path (deepest element first) and the forged root hash, or an empty path if the leaf + * blob does not parse. + */ +static std::pair, uint256> +forgeRootOverLeaf(Blob const& leafBlob, uint256 const& key) +{ + auto leaf = SHAMapTreeNode::makeFromWire(makeSlice(leafBlob)); + if (!leaf || !leaf->isLeaf()) + return {}; + leaf->updateHash(); + + auto const branch = selectBranch(SHAMapNodeID::createID(0, key), key); + Serializer s; + for (auto i = 0u; i < SHAMap::kBranchFactor; ++i) + s.addBitString(i == branch ? leaf->getHash().asUInt256() : uint256{}); + s.add8(kWireTypeInner); + + auto root = SHAMapTreeNode::makeFromWire(makeSlice(s.peekData())); + if (!root) + return {}; + root->updateHash(); + + return {std::vector{leafBlob, s.getData()}, root->getHash().asUInt256()}; +} + +// The hash chain above a leaf proves nothing about which key that leaf holds, so a peer can graft a +// genuine leaf from elsewhere in the map onto a path forged for another key. Comparing the terminal +// leaf's own key against the key being proved is what rejects it. +TEST_F(SHAMapPathProof, substituted_leaf_for_other_key_is_rejected) +{ + tests::TestNodeFamily f{j_}; + SHAMap map{SHAMapType::FREE, f}; + map.setUnbacked(); + + // Two arbitrary keys differing in their first nibble, so each leaf hangs off the root directly. + constexpr uint256 kKey("1c8cec8e5e9b0e5e0e0f5b3e2c9f7a1d6b4e8c2a0d7f3b9e5c1a8d4f2b6e0c93"); + constexpr uint256 kOtherKey("e3f1a7d5b9c2e8f406a1d3b5c7e9f2a4d6b8c0e2f4a6d8b0c2e4f6a8d0b2c4e6"); + + for (auto const& k : {kKey, kOtherKey}) + { + ASSERT_TRUE(map.addItem( + SHAMapNodeType::TnAccountState, makeShamapitem(k, Slice{k.data(), k.size()}))); + } + map.invariants(); + + auto const ownPath = map.getProofPath(kKey); + auto const otherPath = map.getProofPath(kOtherKey); + ASSERT_TRUE(ownPath.has_value()); + ASSERT_TRUE(otherPath.has_value()); + + // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above + // The genuine leaf blobs, deepest element first. + auto const& ownLeaf = ownPath->front(); + auto const& otherLeaf = otherPath->front(); + // NOLINTEND(bugprone-unchecked-optional-access) + + // Control: the forged root is accepted when the leaf below it really is kKey's leaf, so the + // rejection below can only come from the leaf key comparison. + auto const [goodPath, goodRoot] = forgeRootOverLeaf(ownLeaf, kKey); + ASSERT_EQ(goodPath.size(), 2u); + EXPECT_TRUE(SHAMap::verifyProofPath(goodRoot, kKey, goodPath)); + + // Same forged root, but kOtherKey's leaf substituted at the bottom: the hash chain still + // validates, yet the path does not prove anything about kKey. + auto const [badPath, badRoot] = forgeRootOverLeaf(otherLeaf, kKey); + ASSERT_EQ(badPath.size(), 2u); + EXPECT_FALSE(SHAMap::verifyProofPath(badRoot, kKey, badPath)); +} + } // namespace xrpl::tests From f137d7151059b223b0f2c4593b3f58d5fc44fcb0 Mon Sep 17 00:00:00 2001 From: Jingchen Date: Mon, 24 Aug 2026 16:17:33 +0000 Subject: [PATCH 19/43] test: Split Invariants_test.cpp into per-topic files (#8077) --- include/xrpl/protocol/STLedgerEntry.h | 6 +- src/test/app/Invariants_test.cpp | 7096 ----------------- src/test/app/NFTokenBurn_test.cpp | 49 +- .../app/invariants/InvariantsAMM_test.cpp | 249 + src/test/app/invariants/InvariantsBase.cpp | 200 + src/test/app/invariants/InvariantsBase.h | 122 + .../invariants/InvariantsEscrowNFT_test.cpp | 352 + .../app/invariants/InvariantsMPT_test.cpp | 1577 ++++ .../app/invariants/InvariantsMisc_test.cpp | 1333 ++++ .../InvariantsPermissioned_test.cpp | 957 +++ .../InvariantsPseudoAccount_test.cpp | 461 ++ .../invariants/InvariantsTrustLine_test.cpp | 237 + .../app/invariants/InvariantsVault_test.cpp | 2091 +++++ 13 files changed, 7604 insertions(+), 7126 deletions(-) delete mode 100644 src/test/app/Invariants_test.cpp create mode 100644 src/test/app/invariants/InvariantsAMM_test.cpp create mode 100644 src/test/app/invariants/InvariantsBase.cpp create mode 100644 src/test/app/invariants/InvariantsBase.h create mode 100644 src/test/app/invariants/InvariantsEscrowNFT_test.cpp create mode 100644 src/test/app/invariants/InvariantsMPT_test.cpp create mode 100644 src/test/app/invariants/InvariantsMisc_test.cpp create mode 100644 src/test/app/invariants/InvariantsPermissioned_test.cpp create mode 100644 src/test/app/invariants/InvariantsPseudoAccount_test.cpp create mode 100644 src/test/app/invariants/InvariantsTrustLine_test.cpp create mode 100644 src/test/app/invariants/InvariantsVault_test.cpp diff --git a/include/xrpl/protocol/STLedgerEntry.h b/include/xrpl/protocol/STLedgerEntry.h index 8731488adb..7bc369ea37 100644 --- a/include/xrpl/protocol/STLedgerEntry.h +++ b/include/xrpl/protocol/STLedgerEntry.h @@ -19,7 +19,7 @@ namespace xrpl { class Rules; namespace test { -class Invariants_test; +class InvariantsMisc_test; } // namespace test class STLedgerEntry final : public STObject, public CountedObject @@ -83,8 +83,8 @@ private: void setSLEType(); - friend test::Invariants_test; // this test wants access to the private - // type_ + friend test::InvariantsMisc_test; // this test wants access to the + // private type_ STBase* copy(std::size_t n, void* buf) const override; diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp deleted file mode 100644 index dcd22ffda6..0000000000 --- a/src/test/app/Invariants_test.cpp +++ /dev/null @@ -1,7096 +0,0 @@ -#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 -#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 -#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-only factory — not part of the public API. -// The returned Transactor holds a raw reference to ctx; the caller must ensure -// the ApplyContext outlives the Transactor. Implemented in applySteps.cpp -std::unique_ptr -makeTransactor(ApplyContext& ctx); - -} // namespace xrpl - -namespace xrpl::test { - -class Invariants_test : public beast::unit_test::Suite -{ - // The optional Preclose function is used to process additional transactions - // on the ledger after creating two accounts, but before closing it, and - // before the Precheck function. These should only be valid functions, and - // not direct manipulations. Preclose is not commonly used. - using Preclose = std::function< - bool(test::jtx::Account const& a, test::jtx::Account const& b, test::jtx::Env& env)>; - - // this is common setup/method for running a failing invariant check. The - // precheck function is used to manipulate the ApplyContext with view - // changes that will cause the check to fail. - using Precheck = std::function< - bool(test::jtx::Account const& a, test::jtx::Account const& b, ApplyContext& ac)>; - - static FeatureBitset - defaultAmendments() - { - return xrpl::test::jtx::testableAmendments() | fixCleanup3_1_3 | fixCleanup3_2_0; - } - - test::jtx::Env - makeEnv(FeatureBitset features) - { - return {*this, test::jtx::envconfig(), features, nullptr, beast::Severity::Disabled}; - } - - /** - * Run a specific test case to put the ledger into a state that will be - * detected by an invariant. Simulates the actions of a transaction that - * would violate an invariant. - * - * @param expect_logs One or more messages related to the failing invariant - * that should be in the log output - * @precheck See "Precheck" above - * @fee If provided, the fee amount paid by the simulated transaction. - * @tx A mock transaction that took the actions to trigger the invariant. In - * most cases, only the type matters. - * @ters The TER results expected on the two passes of the invariant - * checker. - * @preclose See "Preclose" above. Note that @preclose runs *before* - * @precheck, but is the last parameter for historical reasons - * @setTxAccount optionally set to add sfAccount to tx (either A1 or A2) - */ - enum class TxAccount : int { None = 0, A1, A2 }; - void - doInvariantCheck( - std::vector const& expectLogs, - Precheck const& precheck, - XRPAmount fee = XRPAmount{}, - STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, - std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - Preclose const& preclose = {}, - TxAccount setTxAccount = TxAccount::None, - std::source_location const& loc = std::source_location::current(), - // Result fed to the invariant checker on the first pass. Set it to a - // tec to exercise result-dependent invariants; the harness runs no - // transactor, so one never arises on its own. - TER initialResult = tesSUCCESS) - { - doInvariantCheck( - makeEnv(defaultAmendments()), - expectLogs, - precheck, - fee, - tx, - ters, - preclose, - setTxAccount, - loc, - initialResult); - } - - void - doInvariantCheck( - test::jtx::Env&& env, - std::vector const& expectLogs, - Precheck const& precheck, - XRPAmount fee = XRPAmount{}, - STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, - std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - Preclose const& preclose = {}, - TxAccount setTxAccount = TxAccount::None, - std::source_location const& loc = std::source_location::current(), - TER initialResult = tesSUCCESS) - { - using namespace test::jtx; - - Account const a1{"A1"}; - Account const a2{"A2"}; - env.fund(XRP(1000), a1, a2); - if (preclose) - BEAST_EXPECT(preclose(a1, a2, env)); - env.close(); - - if (setTxAccount != TxAccount::None) - tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id()); - - doInvariantCheck( - std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc, initialResult); - } - - void - doInvariantCheck( - // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) - test::jtx::Env&& env, - test::jtx::Account const& a1, - test::jtx::Account const& a2, - std::vector const& expectLogs, - Precheck const& precheck, - XRPAmount fee = XRPAmount{}, - STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, - std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - std::source_location const& loc = std::source_location::current(), - TER initialResult = tesSUCCESS) - { - using namespace test::jtx; - - OpenView ov{*env.current()}; - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; - - // Invariants normally run in the Transaction's "apply" (operator()) context, and can always - // access global Rules. - CurrentTransactionRulesGuard const rulesGuard(ov.rules()); - - BEAST_EXPECT(precheck(a1, a2, ac)); - - auto transactor = makeTransactor(ac); - if (!BEAST_EXPECT(transactor)) - return; - - // Invoke the check twice to cover the tec and tef cases. Both passes run - // against the same view -- production would discard it in between -- so - // the second sees the same violation and escalates tec -> tef. A - // {tec, tef} pair therefore means "enforced whatever the incoming - // result", not that the transaction ends in tef on ledger. - if (!BEAST_EXPECT(ters.size() == 2)) - return; - - TER terActual = initialResult; - for (TER const& terExpect : ters) - { - TER const terInput = terActual; - terActual = - transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full); - expect( - terExpect == terActual, - "expected: " + transToken(terExpect) + " got: " + transToken(terActual), - loc.file_name(), - loc.line()); - auto const messages = sink.messages().str(); - - // checkInvariants returns its input unchanged unless something - // fires, so a changed result means an invariant fired, and a firing - // invariant must log. - if (terActual != terInput) - { - expect( - messages.starts_with("Invariant failed:") || - messages.starts_with("Transaction caused an exception"), - messages, - loc.file_name(), - loc.line()); - } - - // std::cerr << messages << '\n'; - for (auto const& m : expectLogs) - { - expect(messages.contains(m), m, loc.file_name(), loc.line()); - } - } - } - - void - testXRPNotCreated() - { - using namespace test::jtx; - testcase << "XRP created"; - doInvariantCheck( - {{"XRP net change was positive: 500"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // put a single account in the view and "manufacture" some XRP - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto amt = sle->getFieldAmount(sfBalance); - sle->setFieldAmount(sfBalance, amt + STAmount{500}); - ac.view().update(sle); - return true; - }); - } - - void - testAccountRootsNotRemoved() - { - using namespace test::jtx; - testcase << "account root removed"; - - // An account was deleted, but not by an AccountDelete transaction. - doInvariantCheck( - {{"an account root was deleted"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // remove an account from the view - auto sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sle->at(sfBalance) = beast::kZero; - ac.view().erase(sle); - return true; - }); - - // Successful AccountDelete transaction that didn't delete an account. - // - // Note that this is a case where a second invocation of the invariant - // checker returns a tecINVARIANT_FAILED, not a tefINVARIANT_FAILED. - // After a discussion with the team, we believe that's okay. - doInvariantCheck( - {{"account deletion succeeded without deleting an account"}}, - [](Account const&, Account const&, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - - // Successful AccountDelete that deleted more than one account. - doInvariantCheck( - {{"account deletion succeeded but deleted multiple accounts"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - // remove two accounts from the view - auto sleA1 = ac.view().peek(keylet::account(a1.id())); - auto sleA2 = ac.view().peek(keylet::account(a2.id())); - if (!sleA1 || !sleA2) - return false; - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sleA1->at(sfBalance) = beast::kZero; - sleA2->at(sfBalance) = beast::kZero; - ac.view().erase(sleA1); - ac.view().erase(sleA2); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - } - - void - testAccountRootsDeletedClean() - { - using namespace test::jtx; - testcase << "account root deletion left artifact"; - - doInvariantCheck( - {{"account deletion left behind a non-zero balance"}}, - // NOLINTNEXTLINE(readability-identifier-naming) - [&](Account const& A1, Account const& A2, ApplyContext& ac) { - // A1 has a balance. Delete A1 - auto const a1 = A1.id(); - auto const sleA1 = ac.view().peek(keylet::account(a1)); - if (!sleA1) - return false; - if (!BEAST_EXPECT(*sleA1->at(sfBalance) != beast::kZero)) - return false; - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account deletion left behind a non-zero owner count"}}, - // NOLINTNEXTLINE(readability-identifier-naming) - [&](Account const& A1, Account const& A2, ApplyContext& ac) { - // Increment A1's owner count, then delete A1 - auto const a1 = A1.id(); - auto const sleA1 = ac.view().peek(keylet::account(a1)); - if (!sleA1) - return false; - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sleA1->at(sfBalance) = beast::kZero; - BEAST_EXPECT(sleA1->at(sfOwnerCount) == 0); - increaseOwnerCount(ac.view(), sleA1, {}, 1, ac.journal); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account deletion left behind a sponsorship field"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleA1 = ac.view().peek(keylet::account(a1.id())); - if (!sleA1) - return false; - sleA1->at(sfBalance) = beast::kZero; - sleA1->setFieldU32(sfSponsoredOwnerCount, 1); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account deletion left behind a sponsorship field"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleA1 = ac.view().peek(keylet::account(a1.id())); - if (!sleA1) - return false; - sleA1->at(sfBalance) = beast::kZero; - sleA1->setFieldU32(sfSponsoringOwnerCount, 1); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account deletion left behind a sponsorship field"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const a1Id = a1.id(); - auto const sleA1 = ac.view().peek(keylet::account(a1Id)); - if (!sleA1) - return false; - sleA1->at(sfBalance) = beast::kZero; - sleA1->setFieldU32(sfSponsoringAccountCount, 1); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account deletion left behind a sponsorship field"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleA1 = ac.view().peek(keylet::account(a1.id())); - if (!sleA1) - return false; - sleA1->at(sfBalance) = beast::kZero; - sleA1->setAccountID(sfSponsor, a2.id()); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - Env{*this, FeatureBitset{featureSponsor}}, - {{"account deletion left behind a sponsorship field"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleA1 = ac.view().peek(keylet::account(a1.id())); - if (!sleA1) - return false; - sleA1->at(sfBalance) = beast::kZero; - sleA1->setAccountID(sfSponsor, a2.id()); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - for (auto const& [keyletfunc, type, includeInTests] : kDirectAccountKeylets) - { - if (!includeInTests) - continue; - - using namespace std::string_literals; - - doInvariantCheck( - {{"account deletion left behind a "s + type.cStr() + " object"}}, - // NOLINTNEXTLINE(readability-identifier-naming) - [&](Account const& A1, Account const& A2, ApplyContext& ac) { - // Add an object to the ledger for account A1, then delete - // A1 - auto const a1 = A1.id(); - auto sleA1 = ac.view().peek(keylet::account(a1)); - if (!sleA1) - return false; - - auto const key = std::invoke(keyletfunc, a1); - auto const newSLE = std::make_shared(key); - ac.view().insert(newSLE); - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the - // desired check. - sleA1->at(sfBalance) = beast::kZero; - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - } - - // NFT special case - doInvariantCheck( - {{"account deletion left behind a NFTokenPage object"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - // remove an account from the view - auto sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sle->at(sfBalance) = beast::kZero; - sle->at(sfOwnerCount) = 0; - ac.view().erase(sle); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const&, Env& env) { - // Preclose callback to mint the NFT which will be deleted in - // the Precheck callback above. - env(token::mint(a1)); - - return true; - }); - - // AMM special cases - AccountID ammAcctID; - uint256 ammKey; - Issue ammIssue; - doInvariantCheck( - {{"account deletion left behind a DirectoryNode object"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // Delete the AMM account without cleaning up the directory or - // deleting the AMM object - auto sle = ac.view().peek(keylet::account(ammAcctID)); - if (!sle) - return false; - - BEAST_EXPECT(sle->at(~sfAMMID)); - BEAST_EXPECT(sle->at(~sfAMMID) == ammKey); - - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sle->at(sfBalance) = beast::kZero; - sle->at(sfOwnerCount) = 0; - ac.view().erase(sle); - - return true; - }, - XRPAmount{}, - STTx{ttAMM_WITHDRAW, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - // Preclose callback to create the AMM which will be partially - // deleted in the Precheck callback above. - AMM const amm(env, a1, XRP(100), a1["USD"](50)); - ammAcctID = amm.ammAccount(); - ammKey = amm.ammID(); - ammIssue = amm.lptIssue(); - return true; - }); - doInvariantCheck( - {{"account deletion left behind a AMM object"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // Delete all the AMM's trust lines, remove the AMM from the AMM - // account's directory (this deletes the directory), and delete - // the AMM account. Do not delete the AMM object. - auto sle = ac.view().peek(keylet::account(ammAcctID)); - if (!sle) - return false; - - BEAST_EXPECT(sle->at(~sfAMMID)); - BEAST_EXPECT(sle->at(~sfAMMID) == ammKey); - - for (auto const& trustKeylet : - {keylet::trustLine(ammAcctID, a1["USD"]), keylet::trustLine(a1, ammIssue)}) - { - auto const line = ac.view().peek(trustKeylet); - if (!line) - { - return false; - } - - STAmount const lowLimit = line->at(sfLowLimit); - STAmount const highLimit = line->at(sfHighLimit); - BEAST_EXPECT( - trustDelete( - ac.view(), - line, - lowLimit.getIssuer(), - highLimit.getIssuer(), - ac.journal) == tesSUCCESS); - } - - auto const ammSle = ac.view().peek(keylet::amm(ammKey)); - if (!BEAST_EXPECT(ammSle)) - return false; - auto const ownerDirKeylet = keylet::ownerDir(ammAcctID); - - BEAST_EXPECT( - ac.view().dirRemove(ownerDirKeylet, ammSle->at(sfOwnerNode), ammKey, false)); - BEAST_EXPECT( - !ac.view().exists(ownerDirKeylet) || ac.view().emptyDirDelete(ownerDirKeylet)); - - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sle->at(sfBalance) = beast::kZero; - sle->at(sfOwnerCount) = 0; - ac.view().erase(sle); - - return true; - }, - XRPAmount{}, - STTx{ttAMM_WITHDRAW, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - // Preclose callback to create the AMM which will be partially - // deleted in the Precheck callback above. - AMM const amm(env, a1, XRP(100), a1["USD"](50)); - ammAcctID = amm.ammAccount(); - ammKey = amm.ammID(); - ammIssue = amm.lptIssue(); - return true; - }); - } - - void - testTypesMatch() - { - using namespace test::jtx; - testcase << "ledger entry types don't match"; - doInvariantCheck( - {{"ledger entry type mismatch"}, {"XRP net change of -1000000000 doesn't match fee 0"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // replace an entry in the table with an SLE of a different type - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto const sleNew = std::make_shared(ltTICKET, sle->key()); - ac.rawView().rawReplace(sleNew); - return true; - }); - - doInvariantCheck( - {{"invalid ledger entry type added"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // add an entry in the table with an SLE of an invalid type - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - // make a dummy escrow ledger entry, then change the type to an - // unsupported value so that the valid type invariant check - // will fail. - auto const sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - - // We don't use ltNICKNAME directly since it's marked deprecated - // to prevent accidental use elsewhere. - sleNew->type_ = static_cast('n'); - ac.view().insert(sleNew); - return true; - }); - } - - void - testNoXRPTrustLine() - { - using namespace test::jtx; - testcase << "trust lines with XRP not allowed"; - doInvariantCheck( - {{"an XRP trust line was created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - // create simple trust SLE with xrp currency - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, xrpIssue().currency)); - ac.view().insert(sleNew); - return true; - }); - } - - void - testNoDeepFreezeTrustLinesWithoutFreeze() - { - using namespace test::jtx; - testcase << "trust lines with deep freeze flag without freeze " - "not allowed"; - doInvariantCheck( - {{"a trust line with deep freeze flag without normal freeze was " - "created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); - sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); - sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); - - std::uint32_t uFlags = 0u; - uFlags |= lsfLowDeepFreeze; - sleNew->setFieldU32(sfFlags, uFlags); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"a trust line with deep freeze flag without normal freeze was " - "created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); - sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); - sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); - std::uint32_t uFlags = 0u; - uFlags |= lsfHighDeepFreeze; - sleNew->setFieldU32(sfFlags, uFlags); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"a trust line with deep freeze flag without normal freeze was " - "created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); - sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); - sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); - std::uint32_t uFlags = 0u; - uFlags |= lsfLowDeepFreeze | lsfHighDeepFreeze; - sleNew->setFieldU32(sfFlags, uFlags); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"a trust line with deep freeze flag without normal freeze was " - "created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); - sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); - sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); - std::uint32_t uFlags = 0u; - uFlags |= lsfLowDeepFreeze | lsfHighFreeze; - sleNew->setFieldU32(sfFlags, uFlags); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"a trust line with deep freeze flag without normal freeze was " - "created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); - sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); - sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); - std::uint32_t uFlags = 0u; - uFlags |= lsfLowFreeze | lsfHighDeepFreeze; - sleNew->setFieldU32(sfFlags, uFlags); - ac.view().insert(sleNew); - return true; - }); - } - - void - testTransfersNotFrozen() - { - using namespace test::jtx; - testcase << "transfers when frozen"; - - Account const g1{"G1"}; - // Helper function to establish the trustlines - auto const createTrustlines = [&](Account const& a1, Account const& a2, Env& env) { - // Preclose callback to establish trust lines with gateway - env.fund(XRP(1000), g1); - - env.trust(g1["USD"](10000), a1); - env.trust(g1["USD"](10000), a2); - env.close(); - - env(pay(g1, a1, g1["USD"](1000))); - env(pay(g1, a2, g1["USD"](1000))); - env.close(); - - return true; - }; - - auto const a1FrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) { - createTrustlines(a1, a2, env); - env(trust(g1, a1["USD"](10000), tfSetFreeze)); - env.close(); - - return true; - }; - - auto const a1DeepFrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) { - a1FrozenByIssuer(a1, a2, env); - env(trust(g1, a1["USD"](10000), tfSetDeepFreeze)); - env.close(); - - return true; - }; - - auto const changeBalances = [&](Account const& a1, - Account const& a2, - ApplyContext& ac, - int a1Balance, - int a2Balance) { - auto const sleA1 = ac.view().peek(keylet::trustLine(a1, g1["USD"])); - auto const sleA2 = ac.view().peek(keylet::trustLine(a2, g1["USD"])); - - sleA1->setFieldAmount(sfBalance, g1["USD"](a1Balance)); - sleA2->setFieldAmount(sfBalance, g1["USD"](a2Balance)); - - ac.view().update(sleA1); - ac.view().update(sleA2); - }; - - // test: imitating frozen A1 making a payment to A2. - doInvariantCheck( - {{"Attempting to move frozen funds"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - changeBalances(a1, a2, ac, -900, -1100); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - a1FrozenByIssuer); - - // test: imitating deep frozen A1 making a payment to A2. - doInvariantCheck( - {{"Attempting to move frozen funds"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - changeBalances(a1, a2, ac, -900, -1100); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - a1DeepFrozenByIssuer); - - // test: imitating A2 making a payment to deep frozen A1. - doInvariantCheck( - {{"Attempting to move frozen funds"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - changeBalances(a1, a2, ac, -1100, -900); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - a1DeepFrozenByIssuer); - } - - void - testXRPBalanceCheck() - { - using namespace test::jtx; - testcase << "XRP balance checks"; - - doInvariantCheck( - {{"Cannot return non-native STAmount as XRPAmount"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - // non-native balance - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - STAmount const nonNative(a2["USD"](51)); - sle->setFieldAmount(sfBalance, nonNative); - ac.view().update(sle); - return true; - }); - - doInvariantCheck( - {{"incorrect account XRP balance"}, {"XRP net change was positive: 99999999000000001"}}, - [this](Account const& a1, Account const&, ApplyContext& ac) { - // balance exceeds genesis amount - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - // Use `drops(1)` to bypass a call to STAmount::canonicalize - // with an invalid value - sle->setFieldAmount(sfBalance, kInitialXrp + drops(1)); - BEAST_EXPECT(!sle->getFieldAmount(sfBalance).negative()); - ac.view().update(sle); - return true; - }); - - doInvariantCheck( - {{"incorrect account XRP balance"}, - {"XRP net change of -1000000001 doesn't match fee 0"}}, - [this](Account const& a1, Account const&, ApplyContext& ac) { - // balance is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setFieldAmount(sfBalance, STAmount{1, true}); - BEAST_EXPECT(sle->getFieldAmount(sfBalance).negative()); - ac.view().update(sle); - return true; - }); - } - - void - testTransactionFeeCheck() - { - using namespace test::jtx; - using namespace std::string_literals; - testcase << "Transaction fee checks"; - - doInvariantCheck( - {{"fee paid was negative: -1"}, {"XRP net change of 0 doesn't match fee -1"}}, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{-1}); - - doInvariantCheck( - {{"fee paid exceeds system limit: "s + to_string(kInitialXrp)}, - {"XRP net change of 0 doesn't match fee "s + to_string(kInitialXrp)}}, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{kInitialXrp}); - - doInvariantCheck( - {{"fee paid is 20 exceeds fee specified in transaction."}, - {"XRP net change of 0 doesn't match fee 20"}}, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{20}, - STTx{ttACCOUNT_SET, [](STObject& tx) { tx.setFieldAmount(sfFee, XRPAmount{10}); }}); - } - - void - testNoBadOffers() - { - using namespace test::jtx; - testcase << "no bad offers"; - - doInvariantCheck( - {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { - // offer with negative takerpays - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); - sleNew->setAccountID(sfAccount, a1.id()); - sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); - sleNew->setFieldAmount(sfTakerPays, XRP(-1)); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { - // offer with negative takergets - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); - sleNew->setAccountID(sfAccount, a1.id()); - sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); - sleNew->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleNew->setFieldAmount(sfTakerGets, XRP(-1)); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { - // offer XRP to XRP - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); - sleNew->setAccountID(sfAccount, a1.id()); - sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); - sleNew->setFieldAmount(sfTakerPays, XRP(10)); - sleNew->setFieldAmount(sfTakerGets, XRP(11)); - ac.view().insert(sleNew); - return true; - }); - } - - void - testNoZeroEscrow() - { - using namespace test::jtx; - testcase << "no zero escrow"; - - doInvariantCheck( - {{"XRP net change of -1000000 doesn't match fee 0"}, - {"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // escrow with negative amount - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - sleNew->setFieldAmount(sfAmount, XRP(-1)); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"XRP net change was positive: 100000000000000001"}, - {"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // escrow with too-large amount - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - // Use `drops(1)` to bypass a call to STAmount::canonicalize - // with an invalid value - sleNew->setFieldAmount(sfAmount, kInitialXrp + drops(1)); - ac.view().insert(sleNew); - return true; - }); - - // IOU < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // escrow with too-little iou - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - - Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; - STAmount const amt(usd, -1); - sleNew->setFieldAmount(sfAmount, amt); - ac.view().insert(sleNew); - return true; - }); - - // IOU bad currency - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // escrow with bad iou currency - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - - Issue const bad{badCurrency(), AccountID(0x4985601)}; - STAmount const amt(bad, 1); - sleNew->setFieldAmount(sfAmount, amt); - ac.view().insert(sleNew); - return true; - }); - - // MPT < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // escrow with too-little mpt - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - STAmount const amt(mpt, -1); - sleNew->setFieldAmount(sfAmount, amt); - ac.view().insert(sleNew); - return true; - }); - - // MPT OutstandingAmount < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptissuance outstanding is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldU64(sfOutstandingAmount, -1); - ac.view().insert(sleNew); - return true; - }); - - // MPT LockedAmount < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptissuance locked is less than locked - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldU64(sfLockedAmount, -1); - ac.view().insert(sleNew); - return true; - }); - - // MPT OutstandingAmount < LockedAmount - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptissuance outstanding is less than locked - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldU64(sfOutstandingAmount, 1); - sleNew->setFieldU64(sfLockedAmount, 10); - ac.view().insert(sleNew); - return true; - }); - - // MPT MPTAmount < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptoken amount is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1)); - sleNew->setFieldU64(sfMPTAmount, -1); - ac.view().insert(sleNew); - return true; - }); - - // MPT LockedAmount < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptoken locked amount is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1)); - sleNew->setFieldU64(sfLockedAmount, -1); - ac.view().insert(sleNew); - return true; - }); - } - - void - testValidNewAccountRoot() - { - using namespace test::jtx; - testcase << "valid new account root"; - - doInvariantCheck( - {{"account root created illegally"}}, - [](Account const&, Account const&, ApplyContext& ac) { - // Insert a new account root created by a non-payment into - // the view. - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"multiple accounts created in a single transaction"}}, - [](Account const&, Account const&, ApplyContext& ac) { - // Insert two new account roots into the view. - { - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleA3 = std::make_shared(acctKeylet); - ac.view().insert(sleA3); - } - { - Account const a4{"A4"}; - Keylet const acctKeylet = keylet::account(a4); - auto const sleA4 = std::make_shared(acctKeylet); - ac.view().insert(sleA4); - } - return true; - }); - - doInvariantCheck( - {{"account created with wrong starting sequence number"}}, - [](Account const&, Account const&, ApplyContext& ac) { - // Insert a new account root with the wrong starting sequence. - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - sleNew->setFieldU32(sfSequence, ac.view().seq() + 1); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}); - - doInvariantCheck( - {{"pseudo-account created by a wrong transaction type"}}, - [](Account const&, Account const&, ApplyContext& ac) { - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - sleNew->setFieldU32(sfSequence, 0); - sleNew->setFieldH256(sfAMMID, uint256(1)); - sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account created with wrong starting sequence number"}}, - [](Account const&, Account const&, ApplyContext& ac) { - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - sleNew->setFieldU32(sfSequence, ac.view().seq()); - sleNew->setFieldH256(sfAMMID, uint256(1)); - sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttAMM_CREATE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"pseudo-account created with wrong flags"}}, - [](Account const&, Account const&, ApplyContext& ac) { - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - sleNew->setFieldU32(sfSequence, 0); - sleNew->setFieldH256(sfAMMID, uint256(1)); - sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"pseudo-account created with wrong flags"}}, - [](Account const&, Account const&, ApplyContext& ac) { - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - sleNew->setFieldU32(sfSequence, 0); - sleNew->setFieldH256(sfAMMID, uint256(1)); - sleNew->setFieldU32( - sfFlags, - lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth | lsfRequireDestTag); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttAMM_CREATE, [](STObject& tx) {}}); - } - - void - testNFTokenPageInvariants() - { - using namespace test::jtx; - testcase << "NFTokenPage"; - - // lambda that returns an STArray of NFTokenIDs. - uint256 const firstNFTID( - "0000000000000000000000000000000000000001FFFFFFFFFFFFFFFF00000000"); - auto makeNFTokenIDs = [&firstNFTID](unsigned int nftCount) { - SOTemplate const* nfTokenTemplate = - InnerObjectFormats::getInstance().findSOTemplateBySField(sfNFToken); - - uint256 nftID(firstNFTID); - STArray ret; - for (int i = 0; i < nftCount; ++i) - { - STObject newNFToken(*nfTokenTemplate, sfNFToken, [&nftID](STObject& object) { - object.setFieldH256(sfNFTokenID, nftID); - }); - ret.pushBack(std::move(newNFToken)); - ++nftID; - } - return ret; - }; - - doInvariantCheck( - {{"NFT page has invalid size"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(0)); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT page has invalid size"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(33)); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFTs on page are not sorted"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - STArray nfTokens = makeNFTokenIDs(2); - std::iter_swap(nfTokens.begin(), nfTokens.begin() + 1); - - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, nfTokens); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT contains empty URI"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - STArray nfTokens = makeNFTokenIDs(1); - nfTokens[0].setFieldVL(sfURI, Blob{}); - - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, nfTokens); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT page is improperly linked"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); - nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMax(a1).key); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT page is improperly linked"}}, - [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) { - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); - nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMin(a2).key); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT page is improperly linked"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); - nftPage->setFieldH256(sfNextPageMin, nftPage->key()); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT page is improperly linked"}}, - [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) { - STArray nfTokens = makeNFTokenIDs(1); - auto nftPage = std::make_shared(keylet::nftokenPage( - keylet::nftokenPageMax(a1), ++(nfTokens[0].getFieldH256(sfNFTokenID)))); - nftPage->setFieldArray(sfNFTokens, nfTokens); - nftPage->setFieldH256(sfNextPageMin, keylet::nftokenPageMax(a2).key); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT found in incorrect page"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - STArray nfTokens = makeNFTokenIDs(2); - auto nftPage = std::make_shared(keylet::nftokenPage( - keylet::nftokenPageMax(a1), (nfTokens[1].getFieldH256(sfNFTokenID)))); - nftPage->setFieldArray(sfNFTokens, nfTokens); - - ac.view().insert(nftPage); - return true; - }); - } - - void - testAMMDeleteInvariants(FeatureBitset features) - { - using namespace test::jtx; - - bool const enforceAMMDelete = features[fixCleanup3_3_0]; - testcase << "AMM delete invariants" + std::string(enforceAMMDelete ? " fix" : ""); - - Env env(*this, features); - Account const issuer{"issuer"}; - Issue const lptIssue{Currency(0x4c50540000000000), issuer.id()}; - STAmount const zeroLP{lptIssue, 0}; - STAmount const nonZeroLP{lptIssue, 1}; - - auto const makeAMM = [](STAmount const& lptBalance) { - auto sleAMM = std::make_shared(keylet::amm(uint256(1))); - sleAMM->setFieldAmount(sfLPTokenBalance, lptBalance); - return sleAMM; - }; - - auto const checkInvariant = [&](TxType txType, - TER result, - std::optional const& deletedLPBalance, - bool expected, - std::string const& expectedLog) { - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - ValidAMM invariant; - - if (deletedLPBalance) - invariant.visitEntry(true, makeAMM(*deletedLPBalance), nullptr); - - bool const actual = invariant.finalize( - STTx{txType, [](STObject&) {}}, result, XRPAmount{}, *env.current(), jlog); - - BEAST_EXPECTS(actual == expected, "unexpected AMM delete invariant result"); - auto const messages = sink.messages().str(); - auto const expectedLogWhenEnforced = enforceAMMDelete ? expectedLog : ""; - if (!expectedLogWhenEnforced.empty()) - { - BEAST_EXPECTS(messages.contains(expectedLogWhenEnforced), expectedLogWhenEnforced); - } - else - { - BEAST_EXPECTS(messages.empty(), messages); - } - }; - - checkInvariant( - ttPAYMENT, - tesSUCCESS, - nonZeroLP, - !enforceAMMDelete, - "Invariant failed: AMM failed, unexpected AMM deletion by"); - checkInvariant( - ttAMM_DELETE, - tesSUCCESS, - std::nullopt, - !enforceAMMDelete, - "Invariant failed: AMMDelete failed, AMM object remained on tesSUCCESS"); - checkInvariant( - ttAMM_DELETE, - tesSUCCESS, - nonZeroLP, - !enforceAMMDelete, - "Invariant failed: AMMDelete failed, AMM object deleted with non-zero LP balance"); - checkInvariant( - ttAMM_DELETE, - tecINCOMPLETE, - zeroLP, - !enforceAMMDelete, - "Invariant failed: AMMDelete failed, AMM object deleted when result is not tesSUCCESS"); - - checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, nonZeroLP, true, ""); - checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, nonZeroLP, true, ""); - - checkInvariant(ttAMM_DELETE, tesSUCCESS, zeroLP, true, ""); - checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, zeroLP, true, ""); - checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, zeroLP, true, ""); - } - - static SLE::pointer - createPermissionedDomain( - ApplyContext& ac, - test::jtx::Account const& a1, - test::jtx::Account const& a2, - std::uint32_t numCreds = 2, - std::uint32_t seq = 10) - { - Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), SeqProxy::rawSequence(seq)); - auto sle = std::make_shared(pdKeylet); - - sle->setAccountID(sfOwner, a1); - sle->setFieldU32(sfSequence, seq); - - if (numCreds != 0u) - { - // This array is sorted naturally, but if you are going to change - // this behavior, don't forget to use credentials::makeSorted - STArray credentials(sfAcceptedCredentials, numCreds); - for (std::size_t n = 0; n < numCreds; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - auto credType = "cred_type" + std::to_string(n); - cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); - credentials.pushBack(std::move(cred)); - } - sle->setFieldArray(sfAcceptedCredentials, credentials); - } - - ac.view().insert(sle); - return sle; - }; - - void - testPermissionedDomainInvariants(FeatureBitset features) - { - using namespace test::jtx; - - bool const fixEnabled = features[fixCleanup3_1_3]; - std::initializer_list const badTers = {tecINVARIANT_FAILED, tecINVARIANT_FAILED}; - std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; - - testcase << "PermissionedDomain" + std::string(fixEnabled ? " fix" : ""); - - doInvariantCheck( - makeEnv(features), - {{"permissioned domain with no rules."}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - return createPermissionedDomain(ac, a1, a2, 0).get(); - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain 2"; - - static constexpr auto kTooBig = kMaxPermissionedDomainCredentialsArraySize + 1; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - return !!createPermissionedDomain(ac, a1, a2, kTooBig); - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain 3"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain credentials aren't sorted"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto slePd = createPermissionedDomain(ac, a1, a2, 0); - - STArray credentials(sfAcceptedCredentials, 2); - for (std::size_t n = 0; n < 2; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - auto credType = std::string("cred_type") + std::to_string(9 - n); - cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); - credentials.pushBack(std::move(cred)); - } - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain 4"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain credentials aren't unique"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto slePd = createPermissionedDomain(ac, a1, a2, 0); - - STArray credentials(sfAcceptedCredentials, 2); - for (std::size_t n = 0; n < 2; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - cred.setFieldVL(sfCredentialType, Slice("cred_type", 9)); - credentials.pushBack(std::move(cred)); - } - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain Set 1"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain with no rules."}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // create PD - auto slePd = createPermissionedDomain(ac, a1, a2); - - // update PD with empty rules - { - STArray const credentials(sfAcceptedCredentials, 2); - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - } - - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain Set 2"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // create PD - auto slePd = createPermissionedDomain(ac, a1, a2); - - // update PD - { - STArray credentials(sfAcceptedCredentials, kTooBig); - - for (std::size_t n = 0; n < kTooBig; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - auto credType = "cred_type2" + std::to_string(n); - cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); - credentials.pushBack(std::move(cred)); - } - - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - } - - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain Set 3"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain credentials aren't sorted"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // create PD - auto slePd = createPermissionedDomain(ac, a1, a2); - - // update PD - { - STArray credentials(sfAcceptedCredentials, 2); - for (std::size_t n = 0; n < 2; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - auto credType = std::string("cred_type2") + std::to_string(9 - n); - cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); - credentials.pushBack(std::move(cred)); - } - - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - } - - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain Set 4"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain credentials aren't unique"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // create PD - auto slePd = createPermissionedDomain(ac, a1, a2); - - // update PD - { - STArray credentials(sfAcceptedCredentials, 2); - for (std::size_t n = 0; n < 2; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - cred.setFieldVL(sfCredentialType, Slice("cred_type", 9)); - credentials.pushBack(std::move(cred)); - } - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - } - - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - std::initializer_list const goodTers = {tesSUCCESS, tesSUCCESS}; - - std::vector const badMoreThan1{ - {"transaction affected more than 1 permissioned domain entry."}}; - std::vector const emptyV; - std::vector const badNoDomains{{"no domain objects affected by"}}; - std::vector const badNotDeleted{ - {"domain object modified, but not deleted by "}}; - std::vector const badDeleted{{"domain object deleted by"}}; - std::vector const badTx{ - {"domain object(s) affected by an unauthorized transaction."}}; - - { - testcase << "PermissionedDomain set 2 domains "; - doInvariantCheck( - makeEnv(features), - fixEnabled ? badMoreThan1 : emptyV, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - createPermissionedDomain(ac, a1, a2); - createPermissionedDomain(ac, a1, a2, 2, 11); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : goodTers); - } - - { - testcase << "PermissionedDomain del 2 domains"; - - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - fixEnabled ? badMoreThan1 : emptyV, - [&pd1, &pd2](Account const&, Account const&, ApplyContext& ac) { - auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1}); - auto sle2 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd2}); - ac.view().erase(sle1); - ac.view().erase(sle2); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, - fixEnabled ? failTers : goodTers); - } - - { - testcase << "PermissionedDomain set 0 domains "; - doInvariantCheck( - makeEnv(features), - fixEnabled ? badNoDomains : emptyV, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? badTers : goodTers); - } - - { - testcase << "PermissionedDomain del 0 domains"; - - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - makeEnv(features), - a1, - a2, - fixEnabled ? badNoDomains : emptyV, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, - fixEnabled ? badTers : goodTers); - } - - { - testcase << "PermissionedDomain set, delete domain"; - - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - fixEnabled ? badDeleted : emptyV, - [&pd1](Account const&, Account const&, ApplyContext& ac) { - auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1}); - ac.view().erase(sle1); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : goodTers); - } - - { - testcase << "PermissionedDomain del, create domain "; - doInvariantCheck( - makeEnv(features), - fixEnabled ? badNotDeleted : emptyV, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - createPermissionedDomain(ac, a1, a2); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, - fixEnabled ? failTers : goodTers); - } - - { - testcase << "PermissionedDomain invalid tx"; - - doInvariantCheck( - fixEnabled ? badTx : emptyV, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - createPermissionedDomain(ac, a1, a2); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - failTers); - } - } - - void - testValidPseudoAccounts() - { - testcase << "valid pseudo accounts"; - - using namespace jtx; - - AccountID pseudoAccountID; - Preclose const createPseudo = [&, this](Account const& a, Account const& b, Env& env) { - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - // Create vault - Vault const vault{env}; - auto [tx, vKeylet] = vault.create({.owner = a, .asset = xrpAsset}); - env(tx); - env.close(); - if (auto const vSle = env.le(vKeylet); BEAST_EXPECT(vSle)) - { - pseudoAccountID = vSle->at(sfAccount); - } - - return BEAST_EXPECT(env.le(keylet::account(pseudoAccountID))); - }; - - /* Cases to check - "pseudo-account has 0 pseudo-account fields set" - "pseudo-account has 2 pseudo-account fields set" - "pseudo-account sequence changed" - "pseudo-account flags are not set" - "pseudo-account has a regular key" - "pseudo-account has a sponsorship field" - */ - struct Mod - { - std::string expectedFailure; - std::function func; - }; - auto const mods = std::to_array({ - { - .expectedFailure = "pseudo-account has 0 pseudo-account fields set", - .func = - [this](SLE::pointer& sle) { - BEAST_EXPECT(sle->at(~sfVaultID)); - sle->at(~sfVaultID) = std::nullopt; - }, - }, - { - .expectedFailure = "pseudo-account sequence changed", - .func = [](SLE::pointer& sle) { sle->at(sfSequence) = 12345; }, - }, - { - .expectedFailure = "pseudo-account flags are not set", - .func = [](SLE::pointer& sle) { sle->at(sfFlags) = lsfNoFreeze; }, - }, - { - .expectedFailure = "pseudo-account has a regular key", - .func = [](SLE::pointer& sle) { sle->at(sfRegularKey) = Account("regular").id(); }, - }, - { - .expectedFailure = "pseudo-account has a sponsorship field", - .func = [](SLE::pointer& sle) { sle->at(sfSponsoredOwnerCount) = 1; }, - }, - { - .expectedFailure = "pseudo-account has a sponsorship field", - .func = [](SLE::pointer& sle) { sle->at(sfSponsoringOwnerCount) = 1; }, - }, - { - .expectedFailure = "pseudo-account has a sponsorship field", - .func = [](SLE::pointer& sle) { sle->at(sfSponsoringAccountCount) = 1; }, - }, - { - .expectedFailure = "pseudo-account has a sponsorship field", - .func = [](SLE::pointer& sle) { sle->at(sfSponsor) = Account("sponsor").id(); }, - }, - }); - - for (auto const& mod : mods) - { - doInvariantCheck( - {{mod.expectedFailure}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(keylet::account(pseudoAccountID)); - if (!sle) - return false; - mod.func(sle); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createPseudo); - } - for (auto const pField : getPseudoAccountFields()) - { - // createPseudo creates a vault, so sfVaultID will be set, and - // setting it again will not cause an error - if (pField == &sfVaultID) - continue; - doInvariantCheck( - {{"pseudo-account has 2 pseudo-account fields set"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(keylet::account(pseudoAccountID)); - if (!sle) - return false; - - auto const vaultID = ~sle->at(~sfVaultID); - BEAST_EXPECT(vaultID && !sle->isFieldPresent(*pField)); - sle->setFieldH256(*pField, *vaultID); - - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createPseudo); - } - - // Take one of the regular accounts and set the sequence to 0, which - // will make it look like a pseudo-account - doInvariantCheck( - {{"pseudo-account has 0 pseudo-account fields set"}, - {"pseudo-account sequence changed"}, - {"pseudo-account flags are not set"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->at(sfSequence) = 0; - ac.view().update(sle); - return true; - }); - } - - static std::pair - createPermissionedDomainEnv( - test::jtx::Env& env, - test::jtx::Account const& a1, - test::jtx::Account const& a2, - std::uint32_t numCreds = 2) - { - using namespace test::jtx; - - pdomain::Credentials credentials; - - for (std::size_t n = 0; n < numCreds; ++n) - { - auto credType = "cred_type" + std::to_string(n); - credentials.push_back({.issuer = a2, .credType = credType}); - } - - std::uint32_t const seq = env.seq(a1); - env(pdomain::setTx(a1, credentials)); - uint256 const key = pdomain::getNewDomain(env.meta()); - - // std::cout << "PD, acc: " << A1.id() << ", seq: " << seq << ", k: " << - // key << std::endl; - return {seq, key}; - } - - void - testPermissionedDEX(FeatureBitset features) - { - using namespace test::jtx; - - bool const fixEnabled = features[fixCleanup3_1_3]; - - testcase << "PermissionedDEX" + std::string(fixEnabled ? " fix" : ""); - - doInvariantCheck( - makeEnv(features), - {{"domain doesn't exist"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a1.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a1); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ - ttOFFER_CREATE, - [](STObject& tx) { - tx.setFieldH256( - sfDomainID, - uint256{"F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E33" - "70F3649CE134E5"}); - Account const a1{"A1"}; - tx.setFieldAmount(sfTakerPays, a1["USD"](10)); - tx.setFieldAmount(sfTakerGets, XRP(1)); - }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - - // missing domain ID in offer object - doInvariantCheck( - makeEnv(features), - {{"hybrid offer is malformed"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFlag(lsfHybrid); - - STArray bookArr; - bookArr.pushBack(STObject::makeInnerObject(sfBook)); - sleOffer->setFieldArray(sfAdditionalBooks, bookArr); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ttOFFER_CREATE, [&](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - - // more than one entry in sfAdditionalBooks - { - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - {{"hybrid offer is malformed"}}, - [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFlag(lsfHybrid); - sleOffer->setFieldH256(sfDomainID, pd1); - - STArray bookArr; - bookArr.pushBack(STObject::makeInnerObject(sfBook)); - bookArr.pushBack(STObject::makeInnerObject(sfBook)); - sleOffer->setFieldArray(sfAdditionalBooks, bookArr); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ttOFFER_CREATE, [&](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - } - - // empty sfAdditionalBooks (size 0) - { - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - fixEnabled ? std::vector{{"hybrid offer is malformed"}} - : std::vector{}, - [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFlag(lsfHybrid); - sleOffer->setFieldH256(sfDomainID, pd1); - - STArray const bookArr; // empty array, size 0 - sleOffer->setFieldArray(sfAdditionalBooks, bookArr); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ttOFFER_CREATE, [&](STObject&) {}}, - fixEnabled ? std::initializer_list{tecINVARIANT_FAILED, tecINVARIANT_FAILED} - : std::initializer_list{tesSUCCESS, tesSUCCESS}); - } - - // hybrid offer missing sfAdditionalBooks - { - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - {{"hybrid offer is malformed"}}, - [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFlag(lsfHybrid); - sleOffer->setFieldH256(sfDomainID, pd1); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ttOFFER_CREATE, [&](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - } - - { - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - {{"transaction consumed wrong domains"}}, - [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFieldH256(sfDomainID, pd1); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ - ttOFFER_CREATE, - [&pd2, &a1](STObject& tx) { - tx.setFieldH256(sfDomainID, pd2); - tx.setFieldAmount(sfTakerPays, a1["USD"](10)); - tx.setFieldAmount(sfTakerGets, XRP(1)); - }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - } - - { - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - {{"domain transaction affected regular offers"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ - ttOFFER_CREATE, - [&](STObject& tx) { - Account const a1{"A1"}; - tx.setFieldH256(sfDomainID, pd1); - tx.setFieldAmount(sfTakerPays, a1["USD"](10)); - tx.setFieldAmount(sfTakerGets, XRP(1)); - }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - } - } - - void - testPermissionedDEXDeletedOfferFallback() - { - using namespace test::jtx; - - testcase << "PermissionedDEX null after"; - - // Tx is OfferCreate on pd2. Tracking pd1 fails the invariant iff that - // domain lands in the set finalize consults. after == null is never - // tracked (pre-340: after-only; post-340: early return) — same result, - // both sides are coverage/regression that we do not fall back to before. - auto const check = [this]( - FeatureBitset features, - bool const afterIsNull, - bool const isDelete, - bool const expectInvariantFailure) { - Env env(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env.fund(XRP(1000), a1, a2); - env.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env, a1, a2); - [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env, a1, a2); - env.close(); - - auto sleOffer = - std::make_shared(keylet::offer(a2.id(), SeqProxy::rawSequence(10))); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFieldH256(sfDomainID, pd1); - - CurrentTransactionRulesGuard const rulesGuard(env.current()->rules()); - - ValidPermissionedDEX invariant; - if (afterIsNull) - { - // Defensive path: after is null. Must not fall back to before. - invariant.visitEntry(isDelete, sleOffer, nullptr); - } - else - { - // Normal / real-erase path: after is the offer on pd1. - invariant.visitEntry(isDelete, nullptr, sleOffer); - } - - STTx const tx{ttOFFER_CREATE, [&pd2, &a1](STObject& tx) { - tx.setFieldH256(sfDomainID, pd2); - tx.setFieldAmount(sfTakerPays, a1["USD"](10)); - tx.setFieldAmount(sfTakerGets, XRP(1)); - }}; - - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - bool const passed = - invariant.finalize(tx, tesSUCCESS, XRPAmount{}, *env.current(), jlog); - BEAST_EXPECT(passed != expectInvariantFailure); - if (expectInvariantFailure) - { - BEAST_EXPECT(sink.messages().str().contains("transaction consumed wrong domains")); - } - else - { - BEAST_EXPECT(sink.messages().str().empty()); - } - }; - - auto const pre = defaultAmendments() - fixCleanup3_4_0; - auto const post = defaultAmendments() | fixCleanup3_4_0; - - // after == null: not tracked - check(pre, true, true, false); - check(post, true, true, false); - - // after == offer on pd1 - // pre-340: domainsOld_ (delete still inserted) → fail - check(pre, false, true, true); - // post-340: isDelete → only domainsOld_ → pass; !isDelete → domains_ → fail - check(post, false, true, false); - check(post, false, false, true); - } - - void - testBookDirectoryExchangeRate() - { - using namespace test::jtx; - testcase << "book directory exchange rate"; - - auto const getBookRootKey = [](Account const& account, std::uint64_t quality) { - Book const book{xrpIssue(), account["USD"], std::nullopt}; - return keylet::quality(keylet::book(book), quality); - }; - - // Root book-directory pages carry exchange-rate metadata that must - // match the quality encoded in the directory key. - auto const makeRootPage = [](Keylet const& dir, std::uint64_t exchangeRate) { - auto sleDir = std::make_shared(dir); - sleDir->setFieldH256(sfRootIndex, dir.key); - STVector256 indexes; - indexes.pushBack(uint256{1}); - sleDir->setFieldV256(sfIndexes, indexes); - sleDir->setFieldU64(sfExchangeRate, exchangeRate); - return sleDir; - }; - - // Child pages do not carry quality metadata; they only point back to - // the root directory. - auto const makeChildPage = [](Keylet const& rootDir) { - auto sleDir = std::make_shared(keylet::page(rootDir, 1)); - sleDir->setFieldH256(sfRootIndex, rootDir.key); - STVector256 indexes; - indexes.pushBack(uint256{2}); - sleDir->setFieldV256(sfIndexes, indexes); - return sleDir; - }; - - auto const makeOfferCreateTx = [] { - return STTx{ttOFFER_CREATE, [](STObject& tx) { - Account const account{"A1"}; - tx.setFieldAmount(sfTakerPays, XRP(1)); - tx.setFieldAmount(sfTakerGets, account["USD"](1)); - }}; - }; - std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; - - // Creating a root book directory with mismatched exchange-rate - // metadata violates the invariant. - doInvariantCheck( - {{"book directory exchange rate does not match directory quality"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto const directoryQuality = STAmount::kURateOne; - auto const dir = getBookRootKey(a1, directoryQuality); - ac.view().insert(makeRootPage(dir, directoryQuality + 1)); - return true; - }, - XRPAmount{}, - makeOfferCreateTx(), - failTers); - - // A new child page must point to an existing root page. - doInvariantCheck( - {{"book directory root missing"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto const directoryQuality = STAmount::kURateOne; - auto const rootDir = getBookRootKey(a1, directoryQuality); - // Insert only the child page. It points at rootDir, but the - // corresponding root page is intentionally missing. - ac.view().insert(makeChildPage(rootDir)); - return true; - }, - XRPAmount{}, - makeOfferCreateTx(), - failTers); - - // Legacy bad-root tolerance: - // - The view contains a pre-existing root page with bad sfExchangeRate - // metadata. - // - The simulated transaction only creates a child page pointing to - // that root. - // - The invariant must pass because this transaction did not create - // the bad root, only adding a child page. - { - Env env{*this, defaultAmendments()}; - Account const a1{"A1"}; - env.fund(XRP(1000), a1); - env.close(); - - OpenView view{*env.current()}; - auto const directoryQuality = STAmount::kURateOne; - auto const rootDir = getBookRootKey(a1, directoryQuality); - view.rawInsert(makeRootPage(rootDir, directoryQuality + 1)); - - ValidBookDirectory invariant; - invariant.visitEntry(false, nullptr, makeChildPage(rootDir)); - - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - BEAST_EXPECT( - invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); - } - - // A bad root is rejected when added, ignored when a legacy bad root is - // modified without changing sfRootIndex or deleted, and checked when a - // modified directory changes sfRootIndex. - { - Env env{*this, defaultAmendments()}; - Account const a1{"A1"}; - env.fund(XRP(1000), a1); - env.close(); - - OpenView view{*env.current()}; - auto const directoryQuality = STAmount::kURateOne; - auto const rootDir = getBookRootKey(a1, directoryQuality); - auto const missingRootDir = getBookRootKey(a1, directoryQuality + 1); - auto const badRoot = makeRootPage(rootDir, directoryQuality + 1); - view.rawInsert(badRoot); - - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - - { - // add - ValidBookDirectory invariant; - invariant.visitEntry(false, nullptr, badRoot); - - BEAST_EXPECT( - !invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); - } - { - // modify (without changing the sfRootIndex) - ValidBookDirectory invariant; - invariant.visitEntry(false, badRoot, badRoot); - - BEAST_EXPECT( - invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); - } - { - // modify (changing sfRootIndex to a missing root) - auto const childBefore = makeChildPage(rootDir); - auto const childAfter = std::make_shared(*childBefore, childBefore->key()); - childAfter->setFieldH256(sfRootIndex, missingRootDir.key); - - ValidBookDirectory invariant; - invariant.visitEntry(false, childBefore, childAfter); - - test::StreamSink missingRootSink{beast::Severity::Warning}; - beast::Journal const missingRootJlog{missingRootSink}; - BEAST_EXPECT(!invariant.finalize( - makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, missingRootJlog)); - BEAST_EXPECT( - missingRootSink.messages().str().contains("book directory root missing")); - } - { - // delete - view.rawErase(badRoot); - BEAST_EXPECT(!view.exists(rootDir)); - - ValidBookDirectory invariant; - invariant.visitEntry(true, badRoot, badRoot); - BEAST_EXPECT( - invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); - } - } - } - - Keylet - createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset) - { - using namespace jtx; - - // Create vault - uint256 vaultID; - Vault const vault{env}; - auto [tx, vKeylet] = vault.create({.owner = a, .asset = asset}); - env(tx); - BEAST_EXPECT(env.le(vKeylet)); - - vaultID = vKeylet.key; - - // Create Loan Broker - using namespace loan_broker; - - auto const loanBrokerKeylet = keylet::loanBroker(a.id(), SeqProxy::rawSequence(env.seq(a))); - // Create a Loan Broker with all default values. - env(set(a, vaultID), Fee(kIncrement)); - - return loanBrokerKeylet; - }; - - void - testNoModifiedUnmodifiableFields() - { - testcase("no modified unmodifiable fields"); - using namespace jtx; - - // Initialize with a placeholder value because there's no default ctor - Keylet loanBrokerKeylet = keylet::amendments(); - Preclose const createLoanBroker = [&, this](Account const& a, Account const& b, Env& env) { - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - loanBrokerKeylet = this->createLoanBroker(a, env, xrpAsset); - return BEAST_EXPECT(env.le(loanBrokerKeylet)); - }; - - { - auto const mods = std::to_array>({ - [](SLE::pointer& sle) { sle->at(sfSequence) += 1; }, - [](SLE::pointer& sle) { sle->at(sfOwnerNode) += 1; }, - [](SLE::pointer& sle) { sle->at(sfVaultNode) += 1; }, - [](SLE::pointer& sle) { sle->at(sfVaultID) = uint256(1u); }, - [](SLE::pointer& sle) { sle->at(sfAccount) = sle->at(sfOwner); }, - [](SLE::pointer& sle) { sle->at(sfOwner) = sle->at(sfAccount); }, - [](SLE::pointer& sle) { sle->at(sfManagementFeeRate) += 1; }, - [](SLE::pointer& sle) { sle->at(sfCoverRateMinimum) += 1; }, - [](SLE::pointer& sle) { sle->at(sfCoverRateLiquidation) += 1; }, - [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, - [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = sle->at(sfVaultID).value(); }, - }); - - for (auto const& mod : mods) - { - doInvariantCheck( - {{"changed an unchangeable field"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(loanBrokerKeylet); - if (!sle) - return false; - mod(sle); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - } - } - - // TODO: Loan Object - - // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation. - // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged. - Keylet closedEndedVaultKeylet = keylet::amendments(); - Preclose const createClosedEndedVault = [&, this]( - Account const& a, Account const&, Env& env) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + kMinInvestmentPeriod + 1'000'000; - Vault const vault{env}; - auto [tx, keylet] = vault.create( - {.owner = a, - .asset = xrpIssue(), - .vaultKind = std::to_underlying(VaultKind::ClosedEnded), - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - closedEndedVaultKeylet = keylet; - return BEAST_EXPECT(env.le(closedEndedVaultKeylet)); - }; - - { - // Each mutation must keep the vault otherwise valid so that only the immutability check - // fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind - // stays within the recognised range. - auto const mods = std::to_array>({ - [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; }, - [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; }, - [](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; }, - }); - - for (auto const& mod : mods) - { - doInvariantCheck( - {{"changed an unchangeable field"}}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(closedEndedVaultKeylet); - if (!sle) - return false; - mod(sle); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createClosedEndedVault); - } - } - - { - auto const mods = std::to_array>({ - [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, - [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = uint256(1u); }, - }); - - for (auto const& mod : mods) - { - doInvariantCheck( - {{"changed an unchangeable field"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - mod(sle); - ac.view().update(sle); - return true; - }); - } - } - } - - void - testValidLoanBroker() - { - testcase << "valid loan broker"; - - using namespace jtx; - - enum class Asset { XRP, IOU, MPT }; - auto const assetTypes = std::to_array({Asset::XRP, Asset::IOU, Asset::MPT}); - - for (auto const assetType : assetTypes) - { - // Initialize with a placeholder value because there's no default - // ctor - auto const setupAsset = - [&](Account const& alice, Account const& issuer, Env& env) -> PrettyAsset { - switch (assetType) - { - case Asset::IOU: { - PrettyAsset const iouAsset = issuer["IOU"]; - env(trust(alice, iouAsset(1000))); - env(pay(issuer, alice, iouAsset(1000))); - env.close(); - return iouAsset; - } - case Asset::MPT: { - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const mptAsset = mptt.issuanceID(); - mptt.authorize({.account = alice}); - env(pay(issuer, alice, mptAsset(1000))); - env.close(); - return mptAsset; - } - case Asset::XRP: - default: - return PrettyAsset{xrpIssue(), 1'000'000}; - } - }; - - Keylet loanBrokerKeylet = keylet::amendments(); - Preclose const createLoanBroker = - [&, this](Account const& alice, Account const& issuer, Env& env) { - auto const asset = setupAsset(alice, issuer, env); - loanBrokerKeylet = this->createLoanBroker(alice, env, asset); - return BEAST_EXPECT(env.le(loanBrokerKeylet)); - }; - - // Ensure the test scenarios are set up completely. The test cases - // will need to recompute any of these values it needs for itself - // rather than trying to return a bunch of items - auto setupTest = [&, this](Account const& a1, Account const&, ApplyContext& ac) - -> std::optional> { - if (loanBrokerKeylet.type != ltLOAN_BROKER) - return {}; - auto sleBroker = ac.view().peek(loanBrokerKeylet); - if (!sleBroker) - return {}; - if (!BEAST_EXPECT(sleBroker->at(sfOwnerCount) == 0)) - return {}; - // Need to touch sleBroker so that it is included in the - // modified entries for the invariant to find - ac.view().update(sleBroker); - - // The pseudo-account holds the directory, so get it - auto const pseudoAccountID = sleBroker->at(sfAccount); - auto const pseudoAccountKeylet = keylet::account(pseudoAccountID); - // Strictly speaking, we don't need to load the - // ACCOUNT_ROOT, but check anyway - auto slePseudo = ac.view().peek(pseudoAccountKeylet); - if (!BEAST_EXPECT(slePseudo)) - return {}; - // Make sure the directory doesn't already exist - auto const dirKeylet = keylet::ownerDir(pseudoAccountID); - auto sleDir = ac.view().peek(dirKeylet); - auto const describe = describeOwnerDir(pseudoAccountID); - if (!sleDir) - { - // Create the directory - BEAST_EXPECT( - ::xrpl::directory::createRoot( - ac.view(), dirKeylet, loanBrokerKeylet.key, describe) == 0); - - sleDir = ac.view().peek(dirKeylet); - } - - return std::make_pair(slePseudo, sleDir); - }; - - doInvariantCheck( - {{"Loan Broker with zero OwnerCount has multiple directory " - "pages"}}, - [&setupTest, this](Account const& a1, Account const& a2, ApplyContext& ac) { - auto test = setupTest(a1, a2, ac); - if (!test || !test->first || !test->second) - return false; - - auto slePseudo = test->first; - auto sleDir = test->second; - auto const describe = describeOwnerDir(slePseudo->at(sfAccount)); - - BEAST_EXPECT( - ::xrpl::directory::insertPage( - ac.view(), - 0, - sleDir, - 0, - sleDir, - slePseudo->key(), - keylet::page(sleDir->key(), 0), - describe) == 1); - - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - - doInvariantCheck( - {{"Loan Broker with zero OwnerCount has multiple indexes in " - "the Directory root"}}, - [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { - auto test = setupTest(a1, a2, ac); - if (!test || !test->first || !test->second) - return false; - - auto slePseudo = test->first; - auto sleDir = test->second; - auto indexes = sleDir->getFieldV256(sfIndexes); - - // Put some extra garbage into the directory - for (auto const& key : {slePseudo->key(), sleDir->key()}) - { - ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key); - } - - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - - doInvariantCheck( - {{"Loan Broker directory corrupt"}}, - [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { - auto test = setupTest(a1, a2, ac); - if (!test || !test->first || !test->second) - return false; - - auto slePseudo = test->first; - auto sleDir = test->second; - auto const describe = describeOwnerDir(slePseudo->at(sfAccount)); - // Empty vector will overwrite the existing entry for the - // holding, if any, avoiding the "has multiple indexes" - // failure. - STVector256 indexes; - - // Put one meaningless key into the directory - auto const key = keylet::account(Account("random").id()).key; - ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key); - - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - - doInvariantCheck( - {{"Loan Broker with zero OwnerCount has an unexpected entry in " - "the directory"}}, - [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { - auto test = setupTest(a1, a2, ac); - if (!test || !test->first || !test->second) - return false; - - auto slePseudo = test->first; - auto sleDir = test->second; - // Empty vector will overwrite the existing entry for the - // holding, if any, avoiding the "has multiple indexes" - // failure. - STVector256 indexes; - - ::xrpl::directory::insertKey( - ac.view(), sleDir, 0, false, indexes, slePseudo->key()); - - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - - doInvariantCheck( - {{"Loan Broker sequence number decreased"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - if (loanBrokerKeylet.type != ltLOAN_BROKER) - return false; - auto sleBroker = ac.view().peek(loanBrokerKeylet); - if (!sleBroker) - return false; - if (!BEAST_EXPECT(sleBroker->at(sfLoanSequence) > 0)) - return false; - // Need to touch sleBroker so that it is included in the - // modified entries for the invariant to find - ac.view().update(sleBroker); - - sleBroker->at(sfLoanSequence) -= 1; - - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - - // Test: cover available less than pseudo-account asset balance - { - 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)); - }; - - doInvariantCheck( - {{"Loan Broker cover available is less than pseudo-account asset balance"}}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(brokerKeylet); - if (!BEAST_EXPECT(sle)) - return false; - // Pseudo-account holds 10 units, set cover to 5 - sle->at(sfCoverAvailable) = Number(5); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createBrokerWithCover); - } - - // Test: cover available greater than pseudo-account asset balance - // (requires fixCleanup3_1_3) - doInvariantCheck( - {{"Loan Broker cover available is greater than pseudo-account asset balance"}}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(loanBrokerKeylet); - if (!BEAST_EXPECT(sle)) - return false; - // Pseudo-account has no cover deposited; set cover - // higher than any incidental balance - sle->at(sfCoverAvailable) = Number(1'000'000); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - } - } - - void - testVault() // NOLINT(readability-function-size) - { - using namespace test::jtx; - - struct AccountAmount - { - AccountID account; - int amount; - }; - struct Adjustments - { - // NOLINTBEGIN(readability-redundant-member-init) - std::optional assetsTotal = std::nullopt; - std::optional assetsAvailable = std::nullopt; - std::optional lossUnrealized = std::nullopt; - std::optional assetsMaximum = std::nullopt; - std::optional sharesTotal = std::nullopt; - std::optional vaultAssets = std::nullopt; - std::optional accountAssets = std::nullopt; - std::optional accountShares = std::nullopt; - // NOLINTEND(readability-redundant-member-init) - }; - constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) { - auto sleVault = ac.peek(keylet); - if (!sleVault) - return false; - - auto const mptIssuanceID = (*sleVault)[sfShareMPTID]; - auto sleShares = ac.peek(keylet::mptokenIssuance(mptIssuanceID)); - if (!sleShares) - return false; - - // These two fields are adjusted in absolute terms - if (args.lossUnrealized) - (*sleVault)[sfLossUnrealized] = *args.lossUnrealized; - if (args.assetsMaximum) - (*sleVault)[sfAssetsMaximum] = *args.assetsMaximum; - - // Remaining fields are adjusted in terms of difference - if (args.assetsTotal) - (*sleVault)[sfAssetsTotal] = *(*sleVault)[sfAssetsTotal] + *args.assetsTotal; - if (args.assetsAvailable) - { - (*sleVault)[sfAssetsAvailable] = - *(*sleVault)[sfAssetsAvailable] + *args.assetsAvailable; - } - ac.update(sleVault); - - if (args.sharesTotal) - { - (*sleShares)[sfOutstandingAmount] = - *(*sleShares)[sfOutstandingAmount] + *args.sharesTotal; - ac.update(sleShares); - } - - auto const assets = *(*sleVault)[sfAsset]; - auto const pseudoId = *(*sleVault)[sfAccount]; - if (args.vaultAssets) - { - if (assets.native()) - { - auto slePseudoAccount = ac.peek(keylet::account(pseudoId)); - if (!slePseudoAccount) - return false; - (*slePseudoAccount)[sfBalance] = - *(*slePseudoAccount)[sfBalance] + *args.vaultAssets; - ac.update(slePseudoAccount); - } - else if (assets.holds()) - { - auto const mptId = assets.get().getMptID(); - auto sleMPToken = ac.peek(keylet::mptoken(mptId, pseudoId)); - if (!sleMPToken) - return false; - (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + *args.vaultAssets; - ac.update(sleMPToken); - } - else - { - return false; // Not supporting testing with IOU - } - } - - if (args.accountAssets) - { - auto const& pair = *args.accountAssets; - if (assets.native()) - { - auto sleAccount = ac.peek(keylet::account(pair.account)); - if (!sleAccount) - return false; - (*sleAccount)[sfBalance] = *(*sleAccount)[sfBalance] + pair.amount; - ac.update(sleAccount); - } - else if (assets.holds()) - { - auto const mptID = assets.get().getMptID(); - auto sleMPToken = ac.peek(keylet::mptoken(mptID, pair.account)); - if (!sleMPToken) - return false; - (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + pair.amount; - ac.update(sleMPToken); - } - else - { - return false; // Not supporting testing with IOU - } - } - - if (args.accountShares) - { - auto const& pair = *args.accountShares; - auto sleMPToken = ac.peek(keylet::mptoken(mptIssuanceID, pair.account)); - if (!sleMPToken) - return false; - (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + pair.amount; - ac.update(sleMPToken); - } - return true; - }; - - static constexpr auto kArgs = [](AccountID id, int adjustment, auto fn) -> Adjustments { - Adjustments sample = { - .assetsTotal = adjustment, - .assetsAvailable = adjustment, - .lossUnrealized = 0, - .sharesTotal = adjustment, - .vaultAssets = adjustment, - .accountAssets = // - AccountAmount{.account = id, .amount = -adjustment}, - .accountShares = // - AccountAmount{.account = id, .amount = adjustment}}; - fn(sample); - return sample; - }; - - Account const a3{"A3"}; - Account const a4{"A4"}; - auto const precloseXrp = [&](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()}); - 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; - }; - - testcase << "Vault general checks"; - doInvariantCheck( - {"vault deletion succeeded without deleting a vault"}, - [&](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; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault updated by a wrong transaction type", - "deleted Vault without deleting its pseudo-account"}, - [&](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; - ac.view().erase(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault updated by a wrong transaction type"}, - [&](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; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault updated by a wrong transaction type"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - sleVault->setAccountID(sfAccount, a1.id()); - ac.view().insert(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - - doInvariantCheck( - {"vault deleted by a wrong transaction type", - "deleted Vault without deleting its pseudo-account"}, - [&](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; - ac.view().erase(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation updated more than single vault", - "deleted Vault without deleting its pseudo-account"}, - [&](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; - ac.view().erase(sleVault); - } - { - auto const keylet = - keylet::vault(a2.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - ac.view().erase(sleVault); - } - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - { - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - } - { - auto [tx, _] = vault.create({.owner = a2, .asset = xrpIssue()}); - env(tx); - } - return true; - }); - - doInvariantCheck( - {"vault operation updated more than single vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sequence = ac.view().seq(); - auto const insertVault = [&](Account const a) { - auto const vaultKeylet = keylet::vault(a.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(a.id()), sleVault->key(), describeOwnerDir(a.id())); - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - sleVault->setAccountID(sfAccount, a.id()); - ac.view().insert(sleVault); - }; - insertVault(a1); - insertVault(a2); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - - doInvariantCheck( - {"deleted vault must also delete shares", - "deleted Vault without deleting its pseudo-account"}, - [&](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; - ac.view().erase(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"deleted vault must have no shares outstanding", - "deleted vault must have no assets outstanding", - "deleted vault must have no assets available"}, - [&](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; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - ac.view().erase(sleVault); - ac.view().erase(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](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; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - // Note, such an "orphaned" update of MPT issuance attached to a - // vault is invalid; ttVAULT_SET must also update Vault object. - sleShares->setFieldH256(sfDomainID, uint256(13)); - ac.view().update(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttVAULT_CLAWBACK, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"updated vault must have shares"}, - [&](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)[sfAssetsMaximum] = 200; - ac.view().update(sleVault); - - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - ac.view().erase(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without updating shares", - "assets available must not be greater than assets outstanding"}, - [&](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)[sfAssetsTotal] = 9; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); - return true; - }); - - doInvariantCheck( - {"set must not change assets outstanding", - "set must not change assets available", - "set must not change shares outstanding", - "set must not change vault balance", - "assets available must not be negative", - "assets available must not be greater than assets outstanding", - "assets outstanding must not be negative"}, - [&](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; - auto slePseudoAccount = ac.view().peek(keylet::account(*(*sleVault)[sfAccount])); - if (!slePseudoAccount) - return false; - (*slePseudoAccount)[sfBalance] = *(*slePseudoAccount)[sfBalance] - 10; - ac.view().update(slePseudoAccount); - - // Move 10 drops to A4 to enforce total XRP balance - auto sleA4 = ac.view().peek(keylet::account(a4.id())); - if (!sleA4) - return false; - (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; - ac.view().update(sleA4); - - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { - sample.assetsAvailable = (kDropsPerXrp * -100).value(); - sample.assetsTotal = (kDropsPerXrp * -200).value(); - sample.sharesTotal = -1; - })); - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"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())); - 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, tecINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - {"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())); - 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, tecINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - {"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())); - 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, tecINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - {"vault transaction must not change loss unrealized", - "set must not change assets outstanding"}, - [&](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.lossUnrealized = 13; - sample.assetsTotal = 20; - })); - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"loss unrealized must not exceed the difference " - "between assets outstanding and available", - "vault transaction must not change loss unrealized"}, - [&](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(), 100, [&](Adjustments& sample) { - sample.lossUnrealized = 13; - })); - }, - XRPAmount{}, - STTx{ - ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - // A negative loss unrealized must trip the invariant. ttLOAN_MANAGE is - // allowed to change loss unrealized, so it isolates this check from the - // "must not change loss unrealized" invariant. Gated behind - // fixCleanup3_4_0 (see below). - 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, kArgs(a2.id(), 0, [&](Adjustments& sample) { - sample.lossUnrealized = -1; - })); - }, - XRPAmount{}, - STTx{ttLOAN_MANAGE, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - // Without fixCleanup3_4_0 the same state must NOT trip the invariant, - // preserving pre-amendment behavior (no fork risk). - doInvariantCheck( - makeEnv(defaultAmendments() - fixCleanup3_4_0), - {}, - [&](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.lossUnrealized = -1; - })); - }, - XRPAmount{}, - STTx{ttLOAN_MANAGE, [](STObject& tx) {}}, - {tesSUCCESS, tesSUCCESS}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"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.assetsMaximum = 1; - })); - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"assets maximum 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, kArgs(a2.id(), 0, [&](Adjustments& sample) { - sample.assetsMaximum = -1; - })); - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"set must not change shares outstanding", - "updated zero sized vault must have no assets outstanding", - "updated zero sized vault must have no assets available"}, - [&](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; - ac.view().update(sleVault); - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - (*sleShares)[sfOutstandingAmount] = 0; - ac.view().update(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"updated shares must not exceed maximum"}, - [&](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; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - (*sleShares)[sfMaximumAmount] = 10; - ac.view().update(sleShares); - - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {})); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"updated shares must not exceed maximum"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {})); - - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - (*sleShares)[sfOutstandingAmount] = kMaxMpTokenAmount + 1; - ac.view().update(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - testcase << "Vault create"; - doInvariantCheck( - { - "created vault must be empty", - "updated zero sized vault must have no assets outstanding", - "create operation must not have updated a vault", - }, - [&](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)[sfAssetsTotal] = 9; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - { - "created vault must be empty", - "updated zero sized vault must have no assets available", - "assets available must not be greater than assets outstanding", - "create operation must not have updated a vault", - }, - [&](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)[sfAssetsAvailable] = 9; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - { - "created vault must be empty", - "loss unrealized must not exceed the difference between assets " - "outstanding and available", - "vault transaction must not change loss unrealized", - "create operation must not have updated a vault", - }, - [&](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)[sfLossUnrealized] = 1; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - { - "created vault must be empty", - "create operation must not have updated a vault", - "invalid OutstandingAmount balance 0 9 0", - }, - [&](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; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - ac.view().update(sleVault); - (*sleShares)[sfOutstandingAmount] = 9; - ac.view().update(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - { - "assets maximum must not be negative", - "create operation must not have updated a vault", - }, - [&](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)[sfAssetsMaximum] = Number(-1); - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"create operation must not have updated a vault", - "shares issuer and vault pseudo-account must be the same", - "shares issuer must be a pseudo-account", - "shares issuer pseudo-account must point back to the vault"}, - [&](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; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - ac.view().update(sleVault); - (*sleShares)[sfIssuer] = a1.id(); - ac.view().update(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault created by a wrong transaction type", "account root created illegally"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // The code below will create a valid vault with (almost) all - // the invariants holding. Except one: it is created by the - // wrong transaction type. - auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - - auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); - // Create pseudo-account. - auto sleAccount = std::make_shared(keylet::account(pseudoId)); - sleAccount->setAccountID(sfAccount, pseudoId); - sleAccount->setFieldAmount(sfBalance, STAmount{}); - std::uint32_t const seqno = // - ac.view().rules().enabled(featureSingleAssetVault) // - ? 0 // - : sequence; - sleAccount->setFieldU32(sfSequence, seqno); - sleAccount->setFieldU32( - sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); - sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); - ac.view().insert(sleAccount); - - auto const sharesMptId = makeMptID(sequence, pseudoId); - auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); - auto sleShares = std::make_shared(sharesKeylet); - auto const sharesPage = ac.view().dirInsert( - keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); - sleShares->setFieldU64(sfOwnerNode, *sharesPage); - - sleShares->at(sfFlags) = 0; - sleShares->at(sfIssuer) = pseudoId; - sleShares->at(sfOutstandingAmount) = 0; - sleShares->at(sfSequence) = sequence; - - sleVault->at(sfAccount) = pseudoId; - sleVault->at(sfFlags) = 0; - sleVault->at(sfSequence) = sequence; - sleVault->at(sfOwner) = a1.id(); - sleVault->at(sfAssetsTotal) = Number(0); - sleVault->at(sfAssetsAvailable) = Number(0); - sleVault->at(sfLossUnrealized) = Number(0); - sleVault->at(sfShareMPTID) = sharesMptId; - sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; - - ac.view().insert(sleVault); - ac.view().insert(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - doInvariantCheck( - {"shares issuer and vault pseudo-account must be the same", - "shares issuer pseudo-account must point back to the vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - - auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); - // Create pseudo-account. - auto sleAccount = std::make_shared(keylet::account(pseudoId)); - sleAccount->setAccountID(sfAccount, pseudoId); - sleAccount->setFieldAmount(sfBalance, STAmount{}); - std::uint32_t const seqno = // - ac.view().rules().enabled(featureSingleAssetVault) // - ? 0 // - : sequence; - sleAccount->setFieldU32(sfSequence, seqno); - sleAccount->setFieldU32( - sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); - // sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); - // Setting wrong vault key - sleAccount->setFieldH256(sfVaultID, uint256(42)); - ac.view().insert(sleAccount); - - auto const sharesMptId = makeMptID(sequence, pseudoId); - auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); - auto sleShares = std::make_shared(sharesKeylet); - auto const sharesPage = ac.view().dirInsert( - keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); - sleShares->setFieldU64(sfOwnerNode, *sharesPage); - - sleShares->at(sfFlags) = 0; - sleShares->at(sfIssuer) = pseudoId; - sleShares->at(sfOutstandingAmount) = 0; - sleShares->at(sfSequence) = sequence; - - // sleVault->at(sfAccount) = pseudoId; - // Setting wrong pseudo account ID - sleVault->at(sfAccount) = a2.id(); - sleVault->at(sfFlags) = 0; - sleVault->at(sfSequence) = sequence; - sleVault->at(sfOwner) = a1.id(); - sleVault->at(sfAssetsTotal) = Number(0); - sleVault->at(sfAssetsAvailable) = Number(0); - sleVault->at(sfLossUnrealized) = Number(0); - sleVault->at(sfShareMPTID) = sharesMptId; - sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; - - ac.view().insert(sleVault); - ac.view().insert(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - doInvariantCheck( - {"shares issuer and vault pseudo-account must be the same", "shares issuer must exist"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - - auto const sharesMptId = makeMptID(sequence, a2.id()); - auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); - auto sleShares = std::make_shared(sharesKeylet); - auto const sharesPage = ac.view().dirInsert( - keylet::ownerDir(a2.id()), sharesKeylet, describeOwnerDir(a2.id())); - sleShares->setFieldU64(sfOwnerNode, *sharesPage); - - sleShares->at(sfFlags) = 0; - // Setting wrong pseudo account ID - sleShares->at(sfIssuer) = AccountID(42); - sleShares->at(sfOutstandingAmount) = 0; - sleShares->at(sfSequence) = sequence; - - sleVault->at(sfAccount) = a2.id(); - sleVault->at(sfFlags) = 0; - sleVault->at(sfSequence) = sequence; - sleVault->at(sfOwner) = a1.id(); - sleVault->at(sfAssetsTotal) = Number(0); - sleVault->at(sfAssetsAvailable) = Number(0); - sleVault->at(sfLossUnrealized) = Number(0); - sleVault->at(sfShareMPTID) = sharesMptId; - sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; - - ac.view().insert(sleVault); - ac.view().insert(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - testcase << "Vault deposit"; - doInvariantCheck( - {"deposit must change vault balance"}, - [&](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.vaultAssets.reset(); - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - {"deposit 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(), 200, [&](Adjustments& sample) { - sample.assetsMaximum = 1; - })); - }, - XRPAmount{}, - STTx{ - ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - // This really convoluted unit tests makes the zero balance on the - // depositor, by sending them the same amount as the transaction fee. - // The operation makes no sense, but the defensive check in - // ValidVault::finalize is otherwise impossible to trigger. - doInvariantCheck( - {"deposit must increase vault balance", "deposit must change depositor balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - - // Move 10 drops to A4 to enforce total XRP balance - auto sleA4 = ac.view().peek(keylet::account(a4.id())); - if (!sleA4) - return false; - (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; - ac.view().update(sleA4); - - return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) { - sample.accountAssets->amount = -100; - })); - }, - XRPAmount{100}, - STTx{ - ttVAULT_DEPOSIT, - [&](STObject& tx) { - tx[sfFee] = XRPAmount(100); - tx[sfAccount] = a3.id(); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - {"deposit must increase vault balance", - "deposit must decrease depositor balance", - "deposit must change vault and depositor balance by equal amount", - "deposit and assets outstanding must add up", - "deposit and assets available must add up"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - - // Move 10 drops from A2 to A3 to enforce total XRP balance - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - if (!sleA3) - return false; - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10; - ac.view().update(sleA3); - - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { - sample.vaultAssets = -20; - sample.accountAssets->amount = 10; - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit must change depositor balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - - // Move 10 drops from A3 to vault to enforce total XRP balance - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - if (!sleA3) - return false; - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 10; - ac.view().update(sleA3); - - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { - sample.accountAssets->amount = 0; - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit must change depositor shares"}, - [&](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(), 10, [&](Adjustments& sample) { - sample.accountShares.reset(); - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit must change vault shares"}, - [&](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(), 10, [](Adjustments& sample) { - sample.sharesTotal = 0; - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit must increase depositor shares", - "deposit must change depositor and vault shares by equal amount", - "deposit must not change vault balance by more than deposited " - "amount"}, - [&](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(), 10, [&](Adjustments& sample) { - sample.accountShares->amount = -5; - sample.sharesTotal = -10; - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(5); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit and assets outstanding must add up"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000; - ac.view().update(sleA3); - - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { - sample.assetsTotal = 11; - })); - }, - XRPAmount{2000}, - STTx{ - ttVAULT_DEPOSIT, - [&](STObject& tx) { - tx[sfAmount] = XRPAmount(10); - tx[sfDelegate] = a3.id(); - tx[sfFee] = XRPAmount(2000); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit and assets outstanding must add up", - "deposit and assets available must add up"}, - [&](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(), 10, [&](Adjustments& sample) { - sample.assetsTotal = 7; - sample.assetsAvailable = 7; - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - testcase << "Vault withdrawal"; - doInvariantCheck( - {"withdrawal must change vault balance"}, - [&](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.vaultAssets.reset(); - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp); - - // Almost identical to the really convoluted test for deposit, where the - // depositor spends only the transaction fee. In case of withdrawal, - // this test is almost the same as normal withdrawal where the - // sfDestination would have been A4, but has been omitted. - doInvariantCheck( - {"withdrawal must change one destination balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - - // Move 10 drops to A4 to enforce total XRP balance - auto sleA4 = ac.view().peek(keylet::account(a4.id())); - if (!sleA4) - return false; - (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; - ac.view().update(sleA4); - - return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) { - sample.accountAssets->amount = -100; - })); - }, - XRPAmount{100}, - STTx{ - ttVAULT_WITHDRAW, - [&](STObject& tx) { - tx[sfFee] = XRPAmount(100); - tx[sfAccount] = a3.id(); - // This commented out line causes the invariant violation. - // tx[sfDestination] = A4.id(); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - { - "withdrawal must change vault and destination balance by equal amount", - "withdrawal must decrease vault balance", - "withdrawal must increase destination balance", - "withdrawal and assets outstanding must add up", - "withdrawal and assets available must add up", - }, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - - // Move 10 drops from A2 to A3 to enforce total XRP balance - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - if (!sleA3) - return false; - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10; - ac.view().update(sleA3); - - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { - sample.vaultAssets = 10; - sample.accountAssets->amount = -20; - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal must change one destination balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - if (!kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { - *sample.vaultAssets -= 5; - }))) - return false; - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - if (!sleA3) - return false; - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 5; - ac.view().update(sleA3); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx.setAccountID(sfDestination, a3.id()); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal must change depositor shares"}, - [&](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(), -10, [&](Adjustments& sample) { - sample.accountShares.reset(); - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal must change vault shares"}, - [&](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(), -10, [](Adjustments& sample) { - sample.sharesTotal = 0; - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal must decrease depositor shares", - "withdrawal must change depositor and vault shares by equal " - "amount"}, - [&](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(), -10, [&](Adjustments& sample) { - sample.accountShares->amount = 5; - sample.sharesTotal = 10; - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal and assets outstanding must add up", - "withdrawal and assets available must add up"}, - [&](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(), -10, [&](Adjustments& sample) { - sample.assetsTotal = -15; - sample.assetsAvailable = -15; - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal and assets outstanding must add up"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000; - ac.view().update(sleA3); - - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { - sample.assetsTotal = -7; - })); - }, - XRPAmount{2000}, - STTx{ - ttVAULT_WITHDRAW, - [&](STObject& tx) { - tx[sfAmount] = XRPAmount(10); - tx[sfDelegate] = a3.id(); - tx[sfFee] = XRPAmount(2000); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - auto const precloseMpt = [&](Account const& a1, Account const& a2, Env& env) -> bool { - env.fund(XRP(1000), a3, a4); - - // Create MPT asset - { - json::Value jv; - jv[sfAccount] = a3.human(); - jv[sfTransactionType] = jss::MPTokenIssuanceCreate; - jv[sfFlags] = tfMPTCanTransfer; - env(jv); - env.close(); - } - - auto const mptID = makeMptID(env.seq(a3) - 1, a3); - Asset const asset = MPTIssue(mptID); - // Authorize A1 A2 A4 - { - json::Value jv; - jv[sfAccount] = a1.human(); - jv[sfTransactionType] = jss::MPTokenAuthorize; - jv[sfMPTokenIssuanceID] = to_string(mptID); - env(jv); - jv[sfAccount] = a2.human(); - env(jv); - jv[sfAccount] = a4.human(); - env(jv); - - env.close(); - } - // Send tokens to A1 A2 A4 - { - env(pay(a3, a1, asset(1000))); - env(pay(a3, a2, asset(1000))); - env(pay(a3, a4, asset(1000))); - env.close(); - } - - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); - env(tx); - env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = asset(10)})); - env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = asset(10)})); - env(vault.deposit({.depositor = a4, .id = keylet.key, .amount = asset(10)})); - return true; - }; - - doInvariantCheck( - {"withdrawal must decrease depositor shares", - "withdrawal must change depositor and vault shares by equal " - "amount"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { - sample.accountShares->amount = 5; - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseMpt, - TxAccount::A2); - - testcase << "Vault clawback"; - doInvariantCheck( - {"clawback must change vault balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -1, [&](Adjustments& sample) { - sample.vaultAssets.reset(); - })); - }, - XRPAmount{}, - STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseMpt); - - // Not the same as below check: attempt to clawback XRP - doInvariantCheck( - {"clawback may only be performed by the asset issuer"}, - [&](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) {})); - }, - XRPAmount{}, - STTx{ttVAULT_CLAWBACK, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp); - - // Not the same as above check: attempt to clawback MPT by bad account - doInvariantCheck( - {"clawback may only be performed by the asset issuer"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {})); - }, - XRPAmount{}, - STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a4.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseMpt); - - doInvariantCheck( - {"clawback must decrease vault balance", - "clawback must decrease holder shares", - "clawback must change vault shares"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a4.id(), 10, [&](Adjustments& sample) { - sample.sharesTotal = 0; - })); - }, - XRPAmount{}, - STTx{ - ttVAULT_CLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = a3.id(); - tx[sfHolder] = a4.id(); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseMpt); - - doInvariantCheck( - {"clawback must change holder shares"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) { - sample.accountShares.reset(); - })); - }, - XRPAmount{}, - STTx{ - ttVAULT_CLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = a3.id(); - tx[sfHolder] = a4.id(); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseMpt); - - doInvariantCheck( - {"clawback must change holder and vault shares by equal amount", - "clawback and assets outstanding must add up", - "clawback and assets available must add up"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) { - sample.accountShares->amount = -8; - sample.assetsTotal = -7; - sample.assetsAvailable = -7; - })); - }, - XRPAmount{}, - STTx{ - ttVAULT_CLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = a3.id(); - tx[sfHolder] = a4.id(); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseMpt); - - // ───────────────────────────────────────────────────────────── - // Closed-ended vault invariants added in ValidVault::finalize (create must supply both - // dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase, - // withdraw not in Investment, loan origination only in Investment. - - using d = NetClock::duration; - using tp = NetClock::time_point; - - auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); - - // Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it - // from ac.view().seq(), which depends on how many env.close() calls preclose issued. - Keylet closedEndedKeylet = keylet::amendments(); - - // Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with - // three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and - // optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub - // leaves the vault in Subscription. - auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) { - return [&, advanceBySub, doDeposit]( - Account const& a1, Account const& a2, Env& env) -> bool { - env.fund(XRP(1000), a3, a4); - auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + kMinInvestmentPeriod + 1'000'000; - Vault const vault{env}; - auto [tx, keylet] = vault.create( - {.owner = a1, - .asset = xrpIssue(), - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - closedEndedKeylet = keylet; - if (doDeposit) - { - 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)})); - } - if (advanceBySub >= 0) - env.close(tp{d{sub + advanceBySub}}); - return true; - }; - }; - - // Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance) - // directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE - // states no legitimate transactor would produce. - auto const insertBareClosedEndedVault = - [closedEnded]( - ApplyContext& ac, - Account const& owner, - std::optional subscriptionDate, - std::optional redemptionDate) -> bool { - auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id())); - if (!vaultPage) - return false; - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - - auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); - auto sleAccount = std::make_shared(keylet::account(pseudoId)); - sleAccount->setAccountID(sfAccount, pseudoId); - sleAccount->setFieldAmount(sfBalance, STAmount{}); - sleAccount->setFieldU32(sfSequence, 0); - sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); - sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); - ac.view().insert(sleAccount); - - auto const sharesMptId = makeMptID(sequence, pseudoId); - auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); - auto sleShares = std::make_shared(sharesKeylet); - auto const sharesPage = ac.view().dirInsert( - keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); - if (!sharesPage) - return false; - sleShares->setFieldU64(sfOwnerNode, *sharesPage); - sleShares->at(sfFlags) = 0; - sleShares->at(sfIssuer) = pseudoId; - sleShares->at(sfOutstandingAmount) = 0; - sleShares->at(sfSequence) = sequence; - - sleVault->at(sfAccount) = pseudoId; - sleVault->at(sfFlags) = 0; - sleVault->at(sfSequence) = sequence; - sleVault->at(sfOwner) = owner.id(); - sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}}); - sleVault->at(sfAssetsTotal) = Number(0); - sleVault->at(sfAssetsAvailable) = Number(0); - sleVault->at(sfLossUnrealized) = Number(0); - sleVault->at(sfShareMPTID) = sharesMptId; - sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; - sleVault->at(sfVaultKind) = closedEnded; - if (subscriptionDate) - sleVault->at(sfSubscriptionDate) = *subscriptionDate; - if (redemptionDate) - sleVault->at(sfRedemptionDate) = *redemptionDate; - - ac.view().insert(sleVault); - ac.view().insert(sleShares); - return true; - }; - - testcase << "Vault create closed-ended"; - - // A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate. - doInvariantCheck( - {"closed-ended vault must have SubscriptionDate and RedemptionDate"}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt); - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - // Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate; - // exercises the sub-minimum branch of the gap check. - doInvariantCheck( - {"closed-ended vault RedemptionDate - SubscriptionDate must be " - "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - std::uint32_t const sub = 1'000'000'000; - std::uint32_t const red = sub + kMinInvestmentPeriod - 1; - return insertBareClosedEndedVault(ac, a1, sub, red); - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - // RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and - // is caught by the sub-minimum branch of the gap check. - doInvariantCheck( - {"closed-ended vault RedemptionDate - SubscriptionDate must be " - "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - std::uint32_t const sub = 1'000'000'000; - std::uint32_t const red = sub - 1; - return insertBareClosedEndedVault(ac, a1, sub, red); - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - // Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right). - doInvariantCheck( - {"closed-ended vault RedemptionDate - SubscriptionDate must be " - "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - std::uint32_t const sub = 1'000'000'000; - std::uint32_t const red = sub + kMaxInvestmentPeriod; - return insertBareClosedEndedVault(ac, a1, sub, red); - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - testcase << "Vault deposit closed-ended"; - - // A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs - // simulates an otherwise valid deposit shape so only the phase invariant fires. - doInvariantCheck( - {"deposit only allowed in Subscription or NoPhase"}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - return kAdjust( - ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {})); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), - TxAccount::A2); - - testcase << "Vault withdrawal closed-ended"; - - // A withdrawal from a closed-ended vault in the Investment phase. - doInvariantCheck( - {"withdrawal not allowed during Investment phase"}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - return kAdjust( - ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {})); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), - TxAccount::A2); - - testcase << "Vault loan set"; - - // ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires - // on any vault mutation; touching the vault SLE with no field change is sufficient. - doInvariantCheck( - {"loan origination only allowed in Investment phase"}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto sleVault = ac.view().peek(closedEndedKeylet); - if (!sleVault) - return false; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttLOAN_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false)); - - testcase << "Vault loan set - closed-ended final payment past " - "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. - Keylet closedEndedBrokerKeylet = keylet::amendments(); - std::uint32_t closedEndedRed = 0; - doInvariantCheck( - {"closed-ended loan final payment must precede RedemptionDate"}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - // Touch the vault so ValidVault::finalizeLoanSet sees an - // entry in afterVault_; the vault is in Investment, so - // finalizeLoanSet itself passes. - auto sleVault = ac.view().peek(closedEndedKeylet); - if (!sleVault) - return false; - ac.view().update(sleVault); - - // Read the broker's next loan sequence to build the loan - // keylet the same way LoanSet::doApply would. - auto sleBroker = ac.view().peek(closedEndedBrokerKeylet); - if (!sleBroker) - 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. - auto sleLoan = std::make_shared( - keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq))); - sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; - sleLoan->at(sfLoanSequence) = loanSeq; - sleLoan->at(sfBorrower) = a1.id(); - sleLoan->at(sfStartDate) = closedEndedRed; - sleLoan->at(sfPaymentInterval) = 60; - sleLoan->at(sfPaymentRemaining) = 1; - sleLoan->at(sfTotalValueOutstanding) = Number(100); - sleLoan->at(sfPeriodicPayment) = Number(1); - ac.view().insert(sleLoan); - return true; - }, - XRPAmount{}, - STTx{ttLOAN_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const&, Env& env) -> bool { - auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + kMinInvestmentPeriod + 1'000'000; - closedEndedRed = red; - - Vault const vault{env}; - auto [tx, keylet] = vault.create( - {.owner = a1, - .asset = xrpIssue(), - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - closedEndedKeylet = keylet; - - // Create the loan broker; LoanBrokerSet has no phase gate. - closedEndedBrokerKeylet = - keylet::loanBroker(a1.id(), SeqProxy::rawSequence(env.seq(a1))); - env(loan_broker::set(a1, keylet.key)); - - // Advance parent close time into Investment so - // ValidVault::finalizeLoanSet is satisfied. - env.close(tp{d{sub + 1}}); - return true; - }); - } - - void - testMPT() - { - using namespace test::jtx; - testcase << "MPT"; - - MPTIssue const nonCanonicalMPTIssue{makeMptID(1, AccountID(0x4985601))}; - auto const nonCanonicalMPTAmount = [&](SField const& field) { - return STAmount{ - field, - nonCanonicalMPTIssue, - kMaxMpTokenAmount + std::uint64_t{1}, - 0, - false, - STAmount::Unchecked{}}; - }; - auto const negativeMPTAmount = [&](SField const& field) { - return STAmount{field, nonCanonicalMPTIssue, 2, 0, true, STAmount::Unchecked{}}; - }; - auto const nonCanonicalMPTPayment = [&]() { - return STTx{ttPAYMENT, [&](STObject& tx) { - tx.setFieldAmount(sfAmount, nonCanonicalMPTAmount(sfAmount)); - }}; - }; - - doInvariantCheck( - makeEnv(defaultAmendments() - fixCleanup3_2_0), - {}, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{}, - nonCanonicalMPTPayment(), - {tesSUCCESS, tesSUCCESS}); - - doInvariantCheck( - {{"ledger entry contains non-canonical MPT or XRP amount"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - auto sleNew = std::make_shared( - keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); - sleNew->setAccountID(sfAccount, a1.id()); - sleNew->setAccountID(sfDestination, a2.id()); - sleNew->setFieldAmount(sfSendMax, nonCanonicalMPTAmount(sfSendMax)); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"ledger entry contains non-canonical MPT or XRP amount"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - auto sleNew = std::make_shared( - keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); - sleNew->setAccountID(sfAccount, a1.id()); - sleNew->setAccountID(sfDestination, a2.id()); - sleNew->setFieldAmount(sfSendMax, negativeMPTAmount(sfSendMax)); - ac.view().insert(sleNew); - return true; - }); - - // MPT OutstandingAmount > MaximumAmount - doInvariantCheck( - {{"OutstandingAmount overflow"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptissuance outstanding is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldU64(sfOutstandingAmount, 110); - sleNew->setFieldU64(sfMaximumAmount, 100); - ac.view().insert(sleNew); - return true; - }); - - // MPTToken amount doesn't add up to OutstandingAmount - doInvariantCheck( - {{"invalid OutstandingAmount balance"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - // mptissuance outstanding is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldU64(sfOutstandingAmount, 100); - sleNew->setFieldU64(sfMaximumAmount, 100); - ac.view().insert(sleNew); - - sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2)); - sleNew->setFieldU64(sfMPTAmount, 90); - ac.view().insert(sleNew); - - return true; - }); - - // Overflow/Invalid balance on payment - auto testPayment = [&](std::string const& log, auto&& update) { - MPTID id; - doInvariantCheck( - {{log}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - return update(id, ac, a1); - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Account const gw("gw"); - env.fund(XRP(1'000), gw); - MPTTester const mpt( - {.env = env, .issuer = gw, .holders = {a1}, .pay = 100, .maxAmt = 100}); - id = mpt.issuanceID(); - return true; - }); - }; - testPayment( - "invalid OutstandingAmount balance", - [&](MPTID const& id, ApplyContext& ac, Account const& a1) { - auto sle = ac.view().peek(keylet::mptoken(id, a1)); - if (!sle) - return false; - sle->setFieldU64(sfMPTAmount, 101); - ac.view().update(sle); - return true; - }); - testPayment( - "OutstandingAmount overflow", [&](MPTID const& id, ApplyContext& ac, Account const&) { - auto sle = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sle) - return false; - sle->setFieldU64(sfOutstandingAmount, 101); - ac.view().update(sle); - return true; - }); - - // The on-failure MPT checks (OutstandingAmount balance / transfer) apply - // to every non-tesSUCCESS result, with no per-result exemption: on a tec - // the transactor discards the view and re-applies only offer, trust - // line, NFT offer and credential deletions, so an MPT change reaching - // the invariant is a bug whatever the code. Seeded via initialResult. - { - MPTID id; - // preclose: gw issues an MPT held by A1 and A2. - auto const setup = [&](Account const& a1, Account const& a2, Env& env) { - Account const gw("gw"); - env.fund(XRP(1'000), gw); - MPTTester const mpt( - {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 50, .maxAmt = 1'000}); - id = mpt.issuanceID(); - return true; - }; - - // Consistent mint: OutstandingAmount and A1's balance both grow by - // 10, so conservation holds and only the on-failure check fires. - Precheck const mint = [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); - auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); - if (!sleIss || !sleTok) - return false; - (*sleIss)[sfOutstandingAmount] = (*sleIss)[sfOutstandingAmount] + 10; - (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; - ac.view().update(sleIss); - ac.view().update(sleTok); - return true; - }; - - // Holder-to-holder transfer (A1 -> A2 by 10). OutstandingAmount is - // unchanged, and CanTransfer keeps the ordinary transfer check - // quiet, so only the on-failure check fires. - Precheck const transfer = [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); - auto sleA = ac.view().peek(keylet::mptoken(id, a1.id())); - auto sleB = ac.view().peek(keylet::mptoken(id, a2.id())); - if (!sleIss || !sleA || !sleB) - return false; - (*sleIss)[sfFlags] = (*sleIss)[sfFlags] | lsfMPTCanTransfer; - (*sleA)[sfMPTAmount] = (*sleA)[sfMPTAmount] - 10; - (*sleB)[sfMPTAmount] = (*sleB)[sfMPTAmount] + 10; - ac.view().update(sleIss); - ac.view().update(sleA); - ac.view().update(sleB); - return true; - }; - - STTx const payment{ttPAYMENT, [](STObject&) {}}; - - // Negative controls: nothing fires on tesSUCCESS. Without these, the - // cases below would still pass if the result guard were dropped. - doInvariantCheck({}, mint, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); - doInvariantCheck({}, transfer, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); - - // tecKILLED and tecINCOMPLETE are not special: an MPT change paired - // with either fires, as with any other failure. - doInvariantCheck( - {{"OutstandingAmount balance changed on failure"}}, - mint, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecKILLED); - doInvariantCheck( - {{"OutstandingAmount balance changed on failure"}}, - mint, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecINCOMPLETE); - doInvariantCheck( - {{"MPToken balance changed on failure"}}, - transfer, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecKILLED); - doInvariantCheck( - {{"MPToken balance changed on failure"}}, - transfer, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecINCOMPLETE); - // The same change under a third failure result: the check keys off - // "not tesSUCCESS", nothing finer. - doInvariantCheck( - {{"OutstandingAmount balance changed on failure"}}, - mint, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecEXPIRED); - doInvariantCheck( - {{"MPToken balance changed on failure"}}, - transfer, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecEXPIRED); - - // A lock moves value within one holder, so it is not a two-sided - // transfer and the `senders || receivers` form is what catches it. - // OutstandingAmount and the holder total are unchanged, so the - // balance check stays quiet. - Precheck const lock = [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); - if (!sleTok || (*sleTok)[sfMPTAmount] < 10) - return false; - // A fresh MPToken has no locked amount, so set it directly. - (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] - 10; - sleTok->setFieldU64(sfLockedAmount, 10); - ac.view().update(sleTok); - return true; - }; - // Negative control: a lock is legitimate on tesSUCCESS. - doInvariantCheck({}, lock, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); - doInvariantCheck( - {{"MPToken balance changed on failure"}}, - lock, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecKILLED); - // The lock is caught under any failure result. - doInvariantCheck( - {{"MPToken balance changed on failure"}}, - lock, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecEXPIRED); - - // A deleted MPToken has no amtAfter, so the sender/receiver counts - // skip it and only the deletedAuthorized_ term can catch it. That - // needs holders authorized but never paid, so the MPToken can be - // erased with a zero balance and OutstandingAmount untouched -- - // otherwise the holder would register as a sender instead. - MPTID emptyId; - auto const setupEmpty = [&](Account const& a1, Account const& a2, Env& env) { - Account const gw("gw"); - env.fund(XRP(1'000), gw); - MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}, .maxAmt = 100}); - emptyId = mpt.issuanceID(); - return true; - }; - Precheck const eraseToken = [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sleTok = ac.view().peek(keylet::mptoken(emptyId, a1.id())); - if (!sleTok || (*sleTok)[sfMPTAmount] != 0) - return false; - ac.view().erase(sleTok); - return true; - }; - // ValidMPTIssuance also reports the deletion, so assert on - // ValidMPTTransfer's message, which only the new check can produce. - doInvariantCheck( - {{"MPToken deleted on failure"}}, - eraseToken, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setupEmpty, - TxAccount::None, - std::source_location::current(), - tecEXPIRED); - } - - // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline clawback balance change is invalid"}}, - [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { - auto sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - if (!sle) - return false; - - STAmount balance{Issue{usd.currency, issuer.id()}, 80}; - if (holder.id() > issuer.id()) - balance.negate(); - sle->setFieldAmount(sfBalance, balance); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // Full IOU clawback may delete the trustline; missing after-SLE represents zero balance. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {}, - [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { - auto const sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - if (!sle) - return false; - - ac.view().erase(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 100}; - }}, - {tesSUCCESS, tesSUCCESS}); - } - - // Pre-MPTokensV2 invalid IOU clawback delta logs but remains non-enforcing. - { - Env env(*this, defaultAmendments() - featureMPTokensV2); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline clawback balance change is invalid"}}, - [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { - auto sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - if (!sle) - return false; - - STAmount balance{Issue{usd.currency, issuer.id()}, 80}; - if (holder.id() > issuer.id()) - balance.negate(); - sle->setFieldAmount(sfBalance, balance); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; - }}, - {tesSUCCESS, tesSUCCESS}); - } - - // Invalid MPT clawback delta must fail when raw MPToken debit mismatches sfAmount. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: MPT clawback balance change is invalid"}}, - [id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - - sleToken->setFieldU64(sfMPTAmount, 80); - sleIssuance->setFieldU64(sfOutstandingAmount, 80); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfHolder] = holder.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // A clawback that mutates both IOU and MPT entries must fail under MPTokensV2. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline and MPToken both changed"}}, - [issuer, usd, id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleLine = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder.id())); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleLine || !sleToken || !sleIssuance) - return false; - - STAmount balance{Issue{usd.currency, issuer.id()}, 90}; - if (holder.id() > issuer.id()) - balance.negate(); - sleLine->setFieldAmount(sfBalance, balance); - sleToken->setFieldU64(sfMPTAmount, 90); - sleIssuance->setFieldU64(sfOutstandingAmount, 90); - ac.view().update(sleLine); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfHolder] = holder.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // Clawback that modifies a trustline other than the one implied by the - // tx amount: clawbackTrustLineBalanceInHolderTerms returns nullopt for - // the mismatched line. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - auto const eur = issuer["EUR"]; - env.trust(eur(100), holder); - env(pay(issuer, holder, eur(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline clawback changed the wrong line"}}, - [issuer, eur](Account const& holder, Account const&, ApplyContext& ac) { - auto sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), eur.currency)); - if (!sle) - return false; - STAmount balance{Issue{eur.currency, issuer.id()}, 90}; - if (holder.id() > issuer.id()) - balance.negate(); - sle->setFieldAmount(sfBalance, balance); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // Clawback leaving the holder's balance negative. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline or MPT balance is negative"}}, - [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { - auto sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - if (!sle) - return false; - // Make the holder's balance negative from their perspective. - STAmount balance{Issue{usd.currency, issuer.id()}, 80}; - if (holder.id() < issuer.id()) - balance.negate(); - sle->setFieldAmount(sfBalance, balance); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // IOU-amount clawback while only an MPToken changed: no trustline was - // recorded, so iou_.before is empty. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline clawback changed the wrong line"}}, - [id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - sleToken->setFieldU64(sfMPTAmount, 90); - sleIssuance->setFieldU64(sfOutstandingAmount, 90); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // Valid trustline change but a zero clawback amount. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline clawback amount is invalid"}}, - [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { - auto sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - if (!sle) - return false; - STAmount balance{Issue{usd.currency, issuer.id()}, 90}; - if (holder.id() > issuer.id()) - balance.negate(); - sle->setFieldAmount(sfBalance, balance); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 0}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // MPT clawback tx missing the Holder field. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: MPT clawback missing holder"}}, - [id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - sleToken->setFieldU64(sfMPTAmount, 90); - sleIssuance->setFieldU64(sfOutstandingAmount, 90); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // MPT clawback where the holder's MPToken was deleted (after is empty). - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: MPT clawback token is missing"}}, - [id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - // Keep the issuance consistent after removing the token. - sleIssuance->setFieldU64(sfOutstandingAmount, 0); - ac.view().update(sleIssuance); - ac.view().erase(sleToken); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfHolder] = holder.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // MPT clawback that changed a different holder's MPToken. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - MPTTester const mpt( - {.env = env, - .issuer = issuer, - .holders = {holder, other}, - .pay = 100, - .maxAmt = 200}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: MPT clawback changed the wrong token"}}, - [id](Account const&, Account const& other, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, other)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - sleToken->setFieldU64(sfMPTAmount, 90); - sleIssuance->setFieldU64(sfOutstandingAmount, 190); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfHolder] = holder.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // Valid MPToken change but a zero MPT clawback amount. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: MPT clawback amount is invalid"}}, - [id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - sleToken->setFieldU64(sfMPTAmount, 90); - sleIssuance->setFieldU64(sfOutstandingAmount, 90); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfHolder] = holder.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 0}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // More MPTokens created than expected - std::array, 4> const tests = { - std::make_pair(ttAMM_WITHDRAW, 2), - std::make_pair(ttAMM_CLAWBACK, 2), - std::make_pair(ttAMM_CREATE, 3), - std::make_pair(ttCHECK_CASH, 2)}; - for (auto const& [tx, nTokens] : tests) - { - doInvariantCheck( - {{std::string("MPToken created for the MPT issuer")}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - auto seq = sle->getFieldU32(sfSequence); - for (int i = 0; i < nTokens; ++i) - { - MPTIssue const mpt{makeMptID(seq + i, a1)}; - auto sleNew = - std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - ac.view().insert(sleNew); - - sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2)); - ac.view().insert(sleNew); - } - - return true; - }, - XRPAmount{}, - STTx{tx, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // More MPTokens deleted than expected - for (auto const& tx : {ttAMM_WITHDRAW, ttAMM_CLAWBACK}) - { - MPTID id; - Account const a3("A3"); - doInvariantCheck( - {{"MPT authorize succeeded but created/deleted bad number of mptokens"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - for (auto const& a : {a1, a2, a3}) - { - auto sle = ac.view().peek(keylet::mptoken(id, a)); - if (!sle) - return false; - ac.view().erase(sle); - } - return true; - }, - XRPAmount{}, - STTx{tx, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Account const gw("gw"); - env.fund(XRP(1'000), gw, a3); - MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2, a3}}); - id = mpt.issuanceID(); - return true; - }); - } - - // sfReferenceHolding can only be set on creation by VaultCreate. A - // non-VaultCreate transaction that creates an MPTokenIssuance with - // sfReferenceHolding present must trip the invariant. - doInvariantCheck( - {{"sfReferenceHolding set on a new MPTokenIssuance by a " - "non-VaultCreate transaction"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - auto const sleAcct = ac.view().peek(keylet::account(a1.id())); - if (!sleAcct) - return false; - MPTIssue const mpt{makeMptID(sleAcct->getFieldU32(sfSequence), a1)}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldH256(sfReferenceHolding, uint256{1}); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}); - - // sfReferenceHolding is immutable: changing the field on an - // existing MPTokenIssuance must trip the invariant. Set up a real - // vault via preclose (so the share issuance carries - // sfReferenceHolding), then mutate it in precheck to produce a - // before/after pair. - { - uint256 vaultKey; - doInvariantCheck( - {{"sfReferenceHolding was modified on an existing " - "MPTokenIssuance"}}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto const sleVault = ac.view().peek(keylet::vault(vaultKey)); - if (!sleVault) - return false; - auto sleIssuance = - ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); - if (!sleIssuance) - return false; - sleIssuance->setFieldH256(sfReferenceHolding, uint256{2}); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const&, Env& env) { - Account const issuer{"issuer"}; - env.fund(XRP(10'000), issuer); - env.close(); - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const asset = mptt.issuanceID(); - mptt.authorize({.account = a1}); - env.close(); - - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); - env(tx); - env.close(); - vaultKey = keylet.key; - return true; - }); - } - - // A vault pseudo-account's MPToken cannot be deleted by anything - // other than a VaultDelete transaction. Set up a vault, then have - // an arbitrary tx erase the pseudo's MPToken in precheck. - { - uint256 vaultKey; - doInvariantCheck( - {{"vault pseudo-account holding deleted by a " - "non-VaultDelete transaction"}}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto const sleVault = ac.view().peek(keylet::vault(vaultKey)); - if (!sleVault) - return false; - auto const sleIssuance = - ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); - if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding)) - return false; - auto sleHolding = ac.view().peek( - keylet::unchecked(sleIssuance->getFieldH256(sfReferenceHolding))); - if (!sleHolding) - return false; - ac.view().erase(sleHolding); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const&, Env& env) { - Account const issuer{"issuer"}; - env.fund(XRP(10'000), issuer); - env.close(); - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const asset = mptt.issuanceID(); - mptt.authorize({.account = a1}); - env.close(); - - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); - env(tx); - env.close(); - vaultKey = keylet.key; - return true; - }); - } - - // Invalid transfer - std::array, 3> const invalidTransferTests = { - std::make_pair(ttAMM_WITHDRAW, false), - std::make_pair(ttPAYMENT, false), - std::make_pair(ttPAYMENT, true)}; - // The two amendments that gate enforcement, in all four combinations. - FeatureBitset const gatesEnabled{featureMPTokensV2, fixCleanup3_4_0}; - for (auto const gates : - {gatesEnabled, - gatesEnabled - featureMPTokensV2, - gatesEnabled - fixCleanup3_4_0, - FeatureBitset{}}) - { - for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests) - { - for (auto const flag : - {static_cast(lsfMPTLocked), - ~lsfMPTCanTransfer, - ~lsfMPTCanTrade, - 0u}) - { - MPTID id{}; - auto const isSuccess = !gates.any() || flag == 0 || - (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) || - (tx == ttAMM_WITHDRAW && - (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer)); - std::pair const error = isSuccess - ? std::make_pair(TER(tesSUCCESS), TER(tesSUCCESS)) - : std::make_pair(TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)); - doInvariantCheck( - {{isSuccess ? "" : "invalid MPToken transfer between holders"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto update = [&](AccountID const& a, std::uint64_t v) { - auto sle = ac.view().peek(keylet::mptoken(id, a)); - if (!sle) - return false; - sle->at(sfMPTAmount) = v; - ac.view().update(sle); - return true; - }; - auto issuanceSle = ac.view().peek(keylet::mptokenIssuance(id)); - if (!issuanceSle) - return false; - auto const flags = issuanceSle->at(sfFlags); - if (flag == lsfMPTLocked) - { - issuanceSle->at(sfFlags) = flags | lsfMPTLocked; - } - else if (flag != 0u) - { - issuanceSle->at(sfFlags) = flags & flag; - } - issuanceSle->at(sfOutstandingAmount) = 200; - ac.view().update(issuanceSle); - return update(a1, 101) && update(a2, 99); - }, - XRPAmount{}, - STTx{ - tx, - [&](STObject& tx) { - if (crossCurrencyPayment) - { - tx.setFieldAmount( - sfSendMax, STAmount(MPTAmount{100}, MPTIssue{id})); - } - }}, - {error.first, error.second}, - [&](Account const& a1, Account const& a2, Env& env) { - Account const gw("gw"); - env.fund(XRP(1'000), gw); - MPTTester const usd( - {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 100}); - id = usd.issuanceID(); - // Either gate enforces, so both must be off to stay - // advisory. Disable after setting up the MPT; the - // next env.close() is what makes it take effect. - if (!gates[featureMPTokensV2]) - env.disableFeature(featureMPTokensV2); - if (!gates[fixCleanup3_4_0]) - env.disableFeature(fixCleanup3_4_0); - return true; - }); - } - } - } - - // An orphan has a zero balance, so only deletion is legitimate (see - // "Skipping Deleted MPTs" in testConfidentialMPTTransfer). - { - MPTID orphanID; - auto const setupOrphan = [&](Account const& a1, Account const& a2, Env& env) { - MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); - mpt.create({.flags = tfMPTCanTransfer}); - orphanID = mpt.issuanceID(); - // A2 is authorized but never paid, so its balance is zero and - // the issuance can be destroyed while its MPToken lives on. - mpt.authorize({.account = a2}); - mpt.destroy(); - return true; - }; - // ValidMPTBalanceChanges also reports this, so assert on the - // orphan message, which only the missing-issuance branch produces. - doInvariantCheck( - {{"orphaned MPToken balance changed"}}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); - if (!sleTok || (*sleTok)[sfMPTAmount] != 0) - return false; - (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; - ac.view().update(sleTok); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setupOrphan); - // Negative control: erasing the orphan is how it gets cleaned up. - doInvariantCheck( - {}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); - if (!sleTok) - return false; - ac.view().erase(sleTok); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tesSUCCESS, tesSUCCESS}, - setupOrphan); - // The same erase on a failure. The orphan branch continues, so only - // the pre-loop deletion check can report this one. - doInvariantCheck( - {{"MPToken deleted on failure"}}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); - if (!sleTok) - return false; - ac.view().erase(sleTok); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setupOrphan, - TxAccount::None, - std::source_location::current(), - tecEXPIRED); - } - - // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends - // through sfReferenceHolding to test the vault's underlying asset for - // each changed holder. - { - Account const gw{"gw"}; - MPTID shareID{}; - - // Vault setup: a1 and a2 both deposit IOU and hold vault shares. - auto const setupVault = [&](Account const& a1, - Account const& a2, - Env& env) -> std::tuple { - env.fund(XRP(1'000), gw); - env.trust(gw["IOU"](10'000), a1); - env.trust(gw["IOU"](10'000), a2); - env.close(); - env(pay(gw, a1, gw["IOU"](500))); - env(pay(gw, a2, gw["IOU"](500))); - env.close(); - - Vault const vault{env}; - auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = gw["IOU"]}); - env(createTx); - env.close(); - env(vault.deposit( - {.depositor = a1, .id = vaultKeylet.key, .amount = gw["IOU"](100)})); - env(vault.deposit( - {.depositor = a2, .id = vaultKeylet.key, .amount = gw["IOU"](100)})); - env.close(); - - return {env.le(vaultKeylet)->at(sfShareMPTID), env.le(vaultKeylet)->at(sfAccount)}; - }; - - // Simulate a vault-share transfer: a1 sends 10 shares to a2. - auto const precheck = - [&](Account const& a1, Account const& a2, ApplyContext& ac) -> bool { - auto sle1 = ac.view().peek(keylet::mptoken(shareID, a1.id())); - auto sle2 = ac.view().peek(keylet::mptoken(shareID, a2.id())); - if (!sle1 || !sle2) - return false; - (*sle1)[sfMPTAmount] -= 10; - (*sle2)[sfMPTAmount] += 10; - ac.view().update(sle1); - ac.view().update(sle2); - return true; - }; - - // Case: vault pseudo-account's IOU trustline is frozen. - { - auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool { - auto [sid, vid] = setupVault(a1, a2, env); - shareID = sid; - env(trust(gw, gw["IOU"](0), Account{"vaultPseudo", vid}, tfSetFreeze)); - env.close(); - return true; - }; - - doInvariantCheck( - Env{*this, defaultAmendments()}, - {{"invalid MPToken transfer between holders"}}, - precheck, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - preclose); - } - - // Case: receiver's (a2's) IOU trustline is frozen. - { - auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool { - auto [sid, vid] = setupVault(a1, a2, env); - shareID = sid; - env(trust(gw, gw["IOU"](0), a2, tfSetFreeze)); - env.close(); - return true; - }; - - doInvariantCheck( - Env{*this, defaultAmendments()}, - {{"invalid MPToken transfer between holders"}}, - precheck, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - preclose); - } - } - } - - void - testAMM() - { - testcase << "AMM"; - using namespace jtx; - - MPTID mptID{}; - uint256 ammID{}; - AccountID ammAccountID{}; - Account const gw{"gw"}; - Issue lptIssue{}; - PrettyAsset poolAsset{xrpIssue()}; - - auto deleteAMMAccount = [&](ApplyContext& ac, bool) { - auto sle = ac.view().peek(keylet::account(ammAccountID)); - if (!sle) - return false; - ac.view().erase(sle); - return true; - }; - - auto updateLPTokensBalance = [&](ApplyContext& ac, std::int64_t amount) { - auto sle = ac.view().peek(keylet::amm(ammID)); - if (!sle) - return false; - sle->setFieldAmount(sfLPTokenBalance, STAmount{lptIssue, amount}); - ac.view().update(sle); - return true; - }; - auto updateLPTokensBadAmount = [&](ApplyContext& ac, bool) { - return updateLPTokensBalance(ac, -1); - }; - auto updateLPTokensBadBalance = [&](ApplyContext& ac, bool) { - return updateLPTokensBalance(ac, 200'000'000); - }; - auto updateAMM = [&](ApplyContext& ac, bool) { return updateLPTokensBalance(ac, 10); }; - - auto updateAMMPool = [&](ApplyContext& ac, bool isMPT) { - if (isMPT) - { - auto sle = ac.view().peek(keylet::mptoken(mptID, ammAccountID)); - if (!sle) - return false; - sle->setFieldU64(sfMPTAmount, 1); - ac.view().update(sle); - return true; - } - auto sle = ac.view().peek(keylet::account(ammAccountID)); - if (!sle) - return false; - sle->setFieldAmount(sfBalance, XRP(1)); - ac.view().update(sle); - return true; - }; - - auto test = [&](auto const txType, - auto&& update, - bool isMPT, - TER error = tecINVARIANT_FAILED) { - doInvariantCheck( - {{"AMM"}}, - [&](Account const&, Account const&, ApplyContext& ac) { return update(ac, isMPT); }, - XRPAmount{}, - STTx{txType, [&](STObject& tx) {}}, - {tecINVARIANT_FAILED, error}, - [&](Account const&, Account const&, Env& env) { - env.fund(XRP(1'000), gw); - poolAsset = [&]() -> PrettyAsset { - if (isMPT) - { - MPT const mpt = MPTTester({.env = env, .issuer = gw}); - mptID = mpt.issuanceID; - return mpt; - } - return gw["USD"]; - }(); - AMM const amm(env, gw, XRP(100), poolAsset(100)); - ammAccountID = amm.ammAccount(); - ammID = amm.ammID(); - lptIssue = amm.lptIssue(); - return true; - }); - }; - - for (bool const isMPT : {false, true}) - { - // Under fixCleanup3_4_0 the MPT balance invariants also fire on the - // second pass, so both IOU and MPT pools now escalate to tef. - auto const error = TER(tefINVARIANT_FAILED); - for (auto txType : {ttAMM_CREATE, ttAMM_DEPOSIT, ttAMM_CLAWBACK, ttAMM_WITHDRAW}) - { - test(txType, deleteAMMAccount, isMPT, tefINVARIANT_FAILED); - test(txType, updateLPTokensBadAmount, isMPT); - test(txType, updateLPTokensBadBalance, isMPT); - } - for (auto txType : {ttAMM_BID, ttAMM_VOTE}) - { - test(txType, updateAMMPool, isMPT, error); - test(txType, updateLPTokensBadAmount, isMPT); - test(txType, updateLPTokensBadBalance, isMPT); - } - for (auto txType : {ttAMM_DELETE, ttCHECK_CASH, ttOFFER_CREATE, ttPAYMENT}) - { - test(txType, updateAMM, isMPT); - } - } - } - - // Test the invariant overwrite fix for both pre- and post-amendment - // behavior. With the fix enabled, |= accumulates violations across - // entries so a later valid entry cannot clear an earlier violation. - // Without the fix, = assignment means the last-visited entry wins. - void - testInvariantOverwrite(FeatureBitset features) - { - using namespace test::jtx; - bool const fixEnabled = features[fixCleanup3_1_3]; - std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; - std::initializer_list const passTers = {tesSUCCESS, tesSUCCESS}; - - // Insert two trust line SLEs in hash-sorted order, with the "bad" - // entry at the lower-sorting key so it is visited first by - // ApplyStateTable::visit(). The configurer callables receive the - // SLE and the Issue corresponding to that side's keylet currency. - auto const insertOrderedTrustLinePair = [](ApplyContext& ac, - Account const& a1, - Account const& a2, - Account const& a3, - auto const& badConfig, - auto const& goodConfig) { - char const* const c1 = "USD"; - char const* const c2 = "EUR"; - auto const k1 = keylet::trustLine(a1, a2, a1[c1].currency); - auto const k2 = keylet::trustLine(a1, a3, a1[c2].currency); - - bool const k1First = k1.key < k2.key; - auto const& badKey = k1First ? k1 : k2; - auto const& goodKey = k1First ? k2 : k1; - Issue const badIss{k1First ? a1[c1].currency : a1[c2].currency, a1.id()}; - Issue const goodIss{k1First ? a1[c2].currency : a1[c1].currency, a1.id()}; - - auto const sleBad = std::make_shared(badKey); - badConfig(*sleBad, badIss); - ac.view().insert(sleBad); - - auto const sleGood = std::make_shared(goodKey); - goodConfig(*sleGood, goodIss); - ac.view().insert(sleGood); - }; - - // Regression: bad XRP trust line followed by a valid trust line. - // With the fix, the invariant catches the violation. Without it, - // the valid entry overwrites the flag to false. The keylet - // currencies are non-XRP (the invariant inspects sfLowLimit / - // sfHighLimit issue, not the keylet currency). - testcase << "overwrite: NoXRPTrustLines" + std::string(fixEnabled ? " fix" : ""); - doInvariantCheck( - makeEnv(features), - fixEnabled ? std::vector{{"an XRP trust line was created"}} - : std::vector{}, - [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) { - Account const a3{"A3"}; - insertOrderedTrustLinePair( - ac, - a1, - a2, - a3, - [](SLE& sle, Issue const& iss) { - // sfLowLimit has xrpIssue, making isXrp = true - sle.setFieldAmount(sfLowLimit, STAmount{xrpIssue(), 0}); - sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); - }, - [](SLE& sle, Issue const& iss) { - sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); - sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); - }); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - fixEnabled ? failTers : passTers); - - // Regression: bad deep-freeze trust line followed by a valid one. - testcase << "overwrite: NoDeepFreeze" + std::string(fixEnabled ? " fix" : ""); - doInvariantCheck( - makeEnv(features), - fixEnabled ? std::vector{{"a trust line with deep freeze flag without " - "normal freeze was created"}} - : std::vector{}, - [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) { - Account const a3{"A3"}; - insertOrderedTrustLinePair( - ac, - a1, - a2, - a3, - [](SLE& sle, Issue const& iss) { - sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); - sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); - sle.setFieldU32(sfFlags, lsfLowDeepFreeze); - }, - [](SLE& sle, Issue const& iss) { - sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); - sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); - sle.setFieldU32(sfFlags, 0u); - }); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - fixEnabled ? failTers : passTers); - - // Regression: MPT OutstandingAmount exceeds max, but locked <= - // outstanding. Plain assignment would overwrite bad_ = true. - // With the fix, NoZeroEscrow catches it. - // Without the fix, NoZeroEscrow passes but ValidMPTIssuance - // still fires ("a MPT issuance was created"). - testcase << "overwrite: NoZeroEscrow MPT" + std::string(fixEnabled ? " fix" : ""); - doInvariantCheck( - makeEnv(features), - fixEnabled ? std::vector{{"escrow specifies invalid amount"}} - : std::vector{{"a MPT issuance was created"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - // outstanding exceeds kMaxMpTokenAmount -> checkAmount sets bad_ - sleNew->setFieldU64(sfOutstandingAmount, kMaxMpTokenAmount + 1); - // locked is valid and <= outstanding -> must NOT clear bad_ - sleNew->setFieldU64(sfLockedAmount, 10); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - failTers); - } - - void - testVaultComputeCoarsestScale() - { - using namespace jtx; - - Account const issuer{"issuer"}; - PrettyAsset const vaultAsset = issuer["IOU"]; - - struct TestCase - { - std::string name; - std::int32_t expectedMinScale; - std::vector values; - }; - - for (auto const mantissaScale : MantissaRange::getAllScales()) - { - if (mantissaScale == MantissaRange::MantissaScale::Small) - continue; - NumberMantissaScaleGuard const g{mantissaScale}; - - auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo { - return {.delta = n, .scale = scale(n, vaultAsset.raw())}; - }; - - auto const testCases = std::vector{ - { - .name = "No values", - .expectedMinScale = 0, - .values = {}, - }, - { - .name = "Mixed integer and Number values", - .expectedMinScale = -15, - .values = {makeDelta(1), makeDelta(-1), makeDelta(Number{10, -1})}, - }, - { - .name = "Mixed scales", - .expectedMinScale = -17, - .values = - {makeDelta(Number{1, -2}), - makeDelta(Number{5, -3}), - makeDelta(Number{3, -2})}, - }, - { - .name = "Equal scales", - .expectedMinScale = -16, - .values = - {makeDelta(Number{1, -1}), - makeDelta(Number{5, -1}), - makeDelta(Number{1, -1})}, - }, - { - .name = "Mixed mantissa sizes", - .expectedMinScale = -12, - .values = - {makeDelta(Number{1}), - makeDelta(Number{1234, -3}), - makeDelta(Number{12345, -6}), - makeDelta(Number{123, 1})}, - }, - }; - - for (auto const& tc : testCases) - { - testcase("vault computeCoarsestScale: " + tc.name); - - auto const actualScale = ValidVault::computeCoarsestScale(tc.values); - - BEAST_EXPECTS( - actualScale == tc.expectedMinScale, - "expected: " + std::to_string(tc.expectedMinScale) + - ", actual: " + std::to_string(actualScale)); - for (auto const& num : tc.values) - { - // None of these scales are far enough apart that rounding the - // values would lose information, so check that the rounded - // value matches the original. - auto const actualRounded = roundToAsset(vaultAsset, num.delta, actualScale); - BEAST_EXPECTS( - actualRounded == num.delta, - "number " + to_string(num.delta) + " rounded to scale " + - std::to_string(actualScale) + " is " + to_string(actualRounded)); - } - } - - auto const testCases2 = std::vector{ - { - .name = "False equivalence", - .expectedMinScale = -15, - .values = - { - makeDelta(Number{1234567890123456789, -18}), - makeDelta(Number{12345, -4}), - makeDelta(Number{1}), - }, - }, - }; - - // Unlike the first set of test cases, the values in these test could - // look equivalent if using the wrong scale. - for (auto const& tc : testCases2) - { - testcase("vault computeCoarsestScale: " + tc.name); - - auto const actualScale = ValidVault::computeCoarsestScale(tc.values); - - BEAST_EXPECTS( - actualScale == tc.expectedMinScale, - "expected: " + std::to_string(tc.expectedMinScale) + - ", actual: " + std::to_string(actualScale)); - std::optional first; - Number firstRounded; - for (auto const& num : tc.values) - { - if (!first) - { - first = num.delta; - firstRounded = roundToAsset(vaultAsset, num.delta, actualScale); - continue; - } - auto const numRounded = roundToAsset(vaultAsset, num.delta, actualScale); - BEAST_EXPECTS( - numRounded != firstRounded, - "at a scale of " + std::to_string(actualScale) + " " + - to_string(num.delta) + " == " + to_string(*first)); - } - } - } - } - - void - testSponsorship() - { - using namespace test::jtx; - using namespace std::string_literals; - testcase("Sponsorship"); - { - auto const expectMessage = - "SponsoredOwnerCount does not equal SponsoringOwnerCount delta."; - - doInvariantCheck( - {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setFieldU32(sfSponsoredOwnerCount, 1); - ac.view().update(sle); - return true; - }); - - doInvariantCheck( - {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setFieldU32(sfSponsoringOwnerCount, 1); - ac.view().update(sle); - return true; - }); - } - - { - auto const expectMessage = - "OwnerCount must be greater than or equal to SponsoredOwnerCount."; - - doInvariantCheck( - {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setFieldU32(sfOwnerCount, 0); - sle->setFieldU32(sfSponsoredOwnerCount, 1); - ac.view().update(sle); - - auto const sle2 = ac.view().peek(keylet::account(a2.id())); - if (!sle2) - return false; - sle2->setFieldU32(sfSponsoringOwnerCount, 1); - ac.view().update(sle2); - return true; - }); - } - - { - auto const expectMessage = - "SponsoredObjectOwnerCount does not equal SponsoredOwnerCount delta."; - uint256 checkID; - - doInvariantCheck( - {{expectMessage}}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - auto const check = ac.view().peek(keylet::check(checkID)); - if (!check) - return false; - check->setAccountID(sfSponsor, a2.id()); - ac.view().update(check); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&checkID](Account const& a1, Account const& a2, Env& env) { - checkID = keylet::check(a1.id(), SeqProxy::rawSequence(env.seq(a1))).key; - env(check::create(a1, a2, XRP(1))); - return true; - }); - } - - { - auto const expectMessage = - "Invariant failed: Net delta of SponsoringAccountCount does " - "not match net delta of sfSponsor presence."; - - doInvariantCheck( - {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setFieldU32(sfSponsoringAccountCount, 1); - ac.view().update(sle); - return true; - }); - - doInvariantCheck( - {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setAccountID(sfSponsor, a2.id()); - ac.view().update(sle); - return true; - }); - } - } - - void - testObjectHasPseudoAccount() - { - testcase << "object has pseudo-account"; - using namespace jtx; - - auto const amendments = defaultAmendments() | fixCleanup3_3_0; - - // Vault: object deleted without its pseudo-account - { - Keylet vaultKeylet = keylet::amendments(); - doInvariantCheck( - Env{*this, amendments}, - {{"deleted Vault without deleting its pseudo-account"}}, - [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(vaultKeylet); - if (!sle) - return false; - ac.view().erase(sle); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&vaultKeylet](Account const& a1, Account const&, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - vaultKeylet = keylet; - return true; - }); - } - - // AMM: object deleted without its pseudo-account - { - uint256 ammID{}; - Account const gw{"gw"}; - doInvariantCheck( - Env{*this, amendments}, - {{"deleted AMM without deleting its pseudo-account"}}, - [&ammID](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(keylet::amm(ammID)); - if (!sle) - return false; - ac.view().erase(sle); - return true; - }, - XRPAmount{}, - STTx{ttAMM_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&ammID, &gw](Account const&, Account const&, Env& env) { - env.fund(XRP(1'000), gw); - AMM const amm(env, gw, XRP(100), gw["USD"](100)); - ammID = amm.ammID(); - return true; - }); - } - - // LoanBroker: object deleted without its pseudo-account - { - Keylet loanBrokerKeylet = keylet::amendments(); - doInvariantCheck( - Env{*this, amendments}, - {{"deleted LoanBroker without deleting its pseudo-account"}}, - [&loanBrokerKeylet](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(loanBrokerKeylet); - if (!sle) - return false; - ac.view().erase(sle); - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&loanBrokerKeylet, this](Account const& a1, Account const&, Env& env) { - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - loanBrokerKeylet = this->createLoanBroker(a1, env, xrpAsset); - return BEAST_EXPECT(env.le(loanBrokerKeylet)); - }); - } - - // Deleted object missing sfAccount field (defensive check). - // Manually construct the view to place a vault SLE without - // sfAccount into the base ledger, then erase it. - { - Env env{*this, amendments}; - Account const a1{"A1"}; - Account const a2{"A2"}; - env.fund(XRP(1000), a1, a2); - env.close(); - - OpenView ov{*env.current()}; - - auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ov.seq())); - auto sleVault = std::make_shared(vaultKeylet); - sleVault->makeFieldAbsent(sfAccount); - ov.rawInsert(sleVault); - - STTx const tx{ttVAULT_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 sle = ac.view().peek(vaultKeylet); - if (!BEAST_EXPECT(sle)) - return; - ac.view().erase(sle); - - 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("is missing pseudo-account field")); - } - } - - void - testTxCheckException() - { - testcase << "txCheck exception"; - using namespace jtx; - - // A TxInvariantCheck that throws from the requested hook, so we can - // exercise checkInvariantsHelper's catch block via the - // transaction-specific layer (as opposed to the protocol layer, - // which testObjectHasPseudoAccount's last case already covers via a - // real Transactor's finalizeInvariants). - enum class ThrowFrom { VisitEntry, Finalize }; - - struct ThrowingTxInvariantCheck : TxInvariantCheck - { - ThrowFrom const throwFrom; - - explicit ThrowingTxInvariantCheck(ThrowFrom throwFrom) : throwFrom(throwFrom) - { - } - - void - visitEntry(bool, SLE::const_ref, SLE::const_ref) override - { - if (throwFrom == ThrowFrom::VisitEntry) - throw std::runtime_error("test-injected visitEntry exception"); - } - - [[nodiscard]] bool - finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override - { - if (throwFrom == ThrowFrom::Finalize) - throw std::runtime_error("test-injected finalize exception"); - return true; - } - }; - - for (auto const throwFrom : {ThrowFrom::VisitEntry, ThrowFrom::Finalize}) - { - Env env{*this}; - Account const alice{"alice"}; - env.fund(XRP(1000), alice); - env.close(); - - OpenView ov{*env.current()}; - 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()); - - // visitEntry only runs for entries the transaction touched, so - // make a modification for the traversal to report. - auto sle = ac.view().peek(keylet::account(alice.id())); - if (!BEAST_EXPECT(sle)) - return; - sle->at(sfSequence) = sle->at(sfSequence) + 1; - ac.view().update(sle); - - ThrowingTxInvariantCheck throwing{throwFrom}; - TER terActual = tesSUCCESS; - for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)}) - { - terActual = checkInvariants(ac, terActual, XRPAmount{}, throwing); - BEAST_EXPECT(terExpect == terActual); - BEAST_EXPECT(sink.messages().str().contains( - "Transaction caused an exception during invariant checks")); - } - } - } - - void - testTxCheckFinalizeFalse() - { - testcase << "txCheck finalize returns false"; - using namespace jtx; - - // A TxInvariantCheck whose finalize returns false, so we can exercise - // the "Transaction has failed one or more transaction invariants" - // log path in checkInvariantsHelper independently of any real - // transactor. This is the transaction-layer analogue of the - // protocol-layer coverage in testObjectHasPseudoAccount / others. - struct FailingTxInvariantCheck : TxInvariantCheck - { - void - visitEntry(bool, SLE::const_ref, SLE::const_ref) override - { - } - - [[nodiscard]] bool - finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override - { - return false; - } - }; - - Env env{*this}; - Account const alice{"alice"}; - env.fund(XRP(1000), alice); - env.close(); - - OpenView ov{*env.current()}; - 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()); - - FailingTxInvariantCheck failing; - TER terActual = tesSUCCESS; - for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)}) - { - terActual = checkInvariants(ac, terActual, XRPAmount{}, failing); - BEAST_EXPECT(terExpect == terActual); - BEAST_EXPECT(sink.messages().str().contains( - "Transaction has failed one or more transaction invariants")); - // The protocol-layer log must not appear: only the tx-layer - // finalize failed here. - BEAST_EXPECT(!sink.messages().str().contains( - "Transaction has failed one or more global invariants")); - } - } - - void - testConfidentialMPTTransfer() - { - using namespace test::jtx; - testcase << "ValidConfidentialMPToken"; - - MPTID mptID; - - // Generate an MPT with privacy, issue 100 tokens to A2. - // Perform a confidential conversion to populate encrypted state. - auto const precloseConfidential = - [&mptID](Account const& a1, Account const& a2, Env& env) -> bool { - MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); - mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); - mptID = mpt.issuanceID(); - - mpt.authorize({.account = a2}); - mpt.pay(a1, a2, 100); - - mpt.generateKeyPair(a1); - mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)}); - - mpt.generateKeyPair(a2); - mpt.convert({ - .account = a2, - .amt = 100, - .holderPubKey = mpt.getPubKey(a2), - }); - return true; - }; - - // badDelete - doInvariantCheck( - {"MPToken deleted with encrypted fields while COA > 0"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - // Force an erase of the object while the COA remains 100 - ac.view().erase(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseConfidential); - - // badConsistency - doInvariantCheck( - {"MPToken encrypted field existence inconsistency"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - // Remove one of the required encrypted fields to create a mismatch - sleToken->makeFieldAbsent(sfIssuerEncryptedBalance); - ac.view().update(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - doInvariantCheck( - {"MPToken encrypted field existence inconsistency"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - sleToken->makeFieldAbsent(sfIssuerEncryptedBalance); - sleToken->makeFieldAbsent(sfConfidentialBalanceInbox); - sleToken->makeFieldAbsent(sfConfidentialBalanceSpending); - sleToken->setFieldVL(sfAuditorEncryptedBalance, Blob{0x00}); - ac.view().update(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - // requiresPrivacyFlag - auto const precloseNoPrivacy = [&mptID]( - Account const& a1, Account const& a2, Env& env) -> bool { - MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); - // completely omitted the tfMPTCanHoldConfidentialBalance flag here. - mpt.create({.flags = tfMPTCanTransfer}); - mptID = mpt.issuanceID(); - mpt.authorize({.account = a2}); - mpt.pay(a1, a2, 100); - return true; - }; - - doInvariantCheck( - {"MPToken has encrypted fields but Issuance does not have " - "lsfMPTCanHoldConfidentialBalance " - "set"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - // Inject all three encrypted fields consistently (inbox+spending+issuer must be - // in sync or badConsistency fires first and masks requiresPrivacyFlag). - sleToken->setFieldVL(sfConfidentialBalanceInbox, Blob{0x00}); - sleToken->setFieldVL(sfConfidentialBalanceSpending, Blob{0x00}); - sleToken->setFieldVL(sfIssuerEncryptedBalance, Blob{0x00}); - ac.view().update(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseNoPrivacy); - - // badCOA - doInvariantCheck( - {"Confidential outstanding amount exceeds total outstanding amount"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); - if (!sleIssuance) - return false; - // Total outstanding is natively 100; bloat the COA over 100 - sleIssuance->setFieldU64(sfConfidentialOutstandingAmount, 200); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_ISSUANCE_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - // Conservation Violation - doInvariantCheck( - {"Token conservation violation for MPT"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); - if (!sleIssuance) - return false; - - sleIssuance->setFieldU64( - sfConfidentialOutstandingAmount, - sleIssuance->getFieldU64(sfConfidentialOutstandingAmount) - 10); - ac.view().update(sleIssuance); - - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - // Send/MergeInbox must not change OutstandingAmount (coaDelta == 0) - doInvariantCheck( - {"Invariant failed: OutstandingAmount changed " - "by confidential transaction that should not " - "modify it for MPT"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); - if (!sleIssuance) - return false; - sleIssuance->setFieldU64( - sfOutstandingAmount, sleIssuance->getFieldU64(sfOutstandingAmount) + 1); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - // Send/MergeInbox and zero-COA-delta confidential transactions must not - // change public holder MPTAmount. - doInvariantCheck( - {"Invariant failed: MPTAmount changed by confidential " - "transaction that should not modify this field."}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - sleToken->setFieldU64(sfMPTAmount, sleToken->getFieldU64(sfMPTAmount) + 1); - ac.view().update(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}}, - // Second pass is tef: the bumped MPTAmount also trips - // ValidMPTTransfer's on-failure check, which escalates the tec. - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseConfidential); - - // badVersion - doInvariantCheck( - {"MPToken sfConfidentialBalanceVersion not updated when sfConfidentialBalanceSpending " - "changed"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - Blob const kChangedConfidentialSpending = {0xBA, 0xDD}; - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - sleToken->setFieldVL(sfConfidentialBalanceSpending, kChangedConfidentialSpending); - - // DO NOT update sfConfidentialBalanceVersion - ac.view().update(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - // Skipping Deleted MPTs (Issuance deleted) - auto const precloseOrphan = [&mptID]( - Account const& a1, Account const& a2, Env& env) -> bool { - MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); - mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); - mptID = mpt.issuanceID(); - mpt.authorize({.account = a2}); - - // Generate privacy keys and convert 0 amount so Bob has the encrypted fields - mpt.generateKeyPair(a1); - mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)}); - mpt.generateKeyPair(a2); - mpt.convert({ - .account = a2, - .amt = 0, - .holderPubKey = mpt.getPubKey(a2), - }); - - // Immediately destroy the issuance. A2's empty, encrypted token object lives on. - mpt.destroy(); - return true; - }; - - doInvariantCheck( - {}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - // Safely able to erase the deleted token. - ac.view().erase(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tesSUCCESS, tesSUCCESS}, - precloseOrphan); - } - -public: - void - run() override - { - testXRPNotCreated(); - testAccountRootsNotRemoved(); - testAccountRootsDeletedClean(); - testTypesMatch(); - testNoXRPTrustLine(); - testNoDeepFreezeTrustLinesWithoutFreeze(); - testTransfersNotFrozen(); - testXRPBalanceCheck(); - testTransactionFeeCheck(); - testNoBadOffers(); - testNoZeroEscrow(); - testValidNewAccountRoot(); - testNFTokenPageInvariants(); - testAMMDeleteInvariants(defaultAmendments()); - testAMMDeleteInvariants(defaultAmendments() - fixCleanup3_3_0); - testPermissionedDomainInvariants(defaultAmendments() | fixCleanup3_1_3); - testPermissionedDomainInvariants(defaultAmendments() - fixCleanup3_1_3); - testPermissionedDEX(defaultAmendments() | fixCleanup3_1_3); - testPermissionedDEX(defaultAmendments() - fixCleanup3_1_3); - testPermissionedDEXDeletedOfferFallback(); - testBookDirectoryExchangeRate(); - testNoModifiedUnmodifiableFields(); - testValidPseudoAccounts(); - testValidLoanBroker(); - testVault(); - testConfidentialMPTTransfer(); - testMPT(); - testInvariantOverwrite(defaultAmendments()); - testInvariantOverwrite(defaultAmendments() - fixCleanup3_1_3); - testVaultComputeCoarsestScale(); - testAMM(); - testObjectHasPseudoAccount(); - testSponsorship(); - testTxCheckException(); - testTxCheckFinalizeFalse(); - } -}; - -BEAST_DEFINE_TESTSUITE(Invariants, app, xrpl); - -} // namespace xrpl::test diff --git a/src/test/app/NFTokenBurn_test.cpp b/src/test/app/NFTokenBurn_test.cpp index ae1d557bb9..cc54c4feb5 100644 --- a/src/test/app/NFTokenBurn_test.cpp +++ b/src/test/app/NFTokenBurn_test.cpp @@ -117,33 +117,30 @@ class NFTokenBurn_test : public beast::unit_test::Suite std::cout << "Ledger state is not array!" << std::endl; return; } - for (json::UInt i = 0; i < state.size(); ++i) + for (auto& i : state) { - if (state[i].isMember(sfNFTokens.jsonName) && - state[i][sfNFTokens.jsonName].isArray()) + if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray()) { - std::uint32_t const tokenCount = state[i][sfNFTokens.jsonName].size(); - std::cout << tokenCount << " NFtokens in page " - << state[i][jss::index].asString() << std::endl; + std::uint32_t const tokenCount = i[sfNFTokens.jsonName].size(); + std::cout << tokenCount << " NFtokens in page " << i[jss::index].asString() + << std::endl; if (vol == Volume::Noisy) { - std::cout << state[i].toStyledString() << std::endl; + std::cout << i.toStyledString() << std::endl; } else { if (tokenCount > 0) { - std::cout - << "first: " << state[i][sfNFTokens.jsonName][0u].toStyledString() - << std::endl; + std::cout << "first: " << i[sfNFTokens.jsonName][0u].toStyledString() + << std::endl; } if (tokenCount > 1) { - std::cout - << "last: " - << state[i][sfNFTokens.jsonName][tokenCount - 1].toStyledString() - << std::endl; + std::cout << "last: " + << i[sfNFTokens.jsonName][tokenCount - 1].toStyledString() + << std::endl; } } } @@ -419,12 +416,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite json::Value& state = jrr[jss::result][jss::state]; int pageCount = 0; - for (json::UInt i = 0; i < state.size(); ++i) + for (auto& i : state) { - if (state[i].isMember(sfNFTokens.jsonName) && - state[i][sfNFTokens.jsonName].isArray()) + if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray()) { - BEAST_EXPECT(state[i][sfNFTokens.jsonName].size() == 32); + BEAST_EXPECT(i[sfNFTokens.jsonName].size() == 32); ++pageCount; } } @@ -459,11 +455,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite { json::Value jrr = env.rpc("json", "ledger_data", to_string(jvParams)); - json::Value& state = jrr[jss::result][jss::state]; + json::Value const& state = jrr[jss::result][jss::state]; - for (json::UInt i = 0; i < state.size(); ++i) + for (auto const& i : state) { - BEAST_EXPECT(!state[i].isMember(sfNFTokens.jsonName)); + BEAST_EXPECT(!i.isMember(sfNFTokens.jsonName)); } } }; @@ -757,8 +753,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite // We're going to fire an Invariant failure that is difficult to // cause. We do it here because the tools are here. // - // See Invariants_test.cpp for examples of other invariant tests - // that this one is modeled after. + // See InvariantsMisc_test.cpp for examples of other invariant + // tests that this one is modeled after. // Generate three closely packed NFTokenPages. std::vector nfts = genPackedTokens(); @@ -1076,12 +1072,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite json::Value& state = jrr[jss::result][jss::state]; int pageCount = 0; - for (json::UInt i = 0; i < state.size(); ++i) + for (auto& i : state) { - if (state[i].isMember(sfNFTokens.jsonName) && - state[i][sfNFTokens.jsonName].isArray()) + if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray()) { - BEAST_EXPECT(state[i][sfNFTokens.jsonName].size() == 32); + BEAST_EXPECT(i[sfNFTokens.jsonName].size() == 32); ++pageCount; } } diff --git a/src/test/app/invariants/InvariantsAMM_test.cpp b/src/test/app/invariants/InvariantsAMM_test.cpp new file mode 100644 index 0000000000..498c35c653 --- /dev/null +++ b/src/test/app/invariants/InvariantsAMM_test.cpp @@ -0,0 +1,249 @@ +#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 +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class InvariantsAMM_test : public InvariantsBase +{ + FeatureBitset const all_{test::jtx::testableAmendments()}; + + void + testAMMDeleteInvariants(FeatureBitset features) + { + using namespace test::jtx; + + bool const enforceAMMDelete = features[fixCleanup3_3_0]; + testcase << "AMM delete invariants" + std::string(enforceAMMDelete ? " fix" : ""); + + Env env(*this, features); + Account const issuer{"issuer"}; + Issue const lptIssue{Currency(0x4c50540000000000), issuer.id()}; + STAmount const zeroLP{lptIssue, 0}; + STAmount const nonZeroLP{lptIssue, 1}; + + auto const makeAMM = [](STAmount const& lptBalance) { + auto sleAMM = std::make_shared(keylet::amm(uint256(1))); + sleAMM->setFieldAmount(sfLPTokenBalance, lptBalance); + return sleAMM; + }; + + auto const checkInvariant = [&](TxType txType, + TER result, + std::optional const& deletedLPBalance, + bool expected, + std::string const& expectedLog) { + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ValidAMM invariant; + + if (deletedLPBalance) + invariant.visitEntry(true, makeAMM(*deletedLPBalance), nullptr); + + bool const actual = invariant.finalize( + STTx{txType, [](STObject&) {}}, result, XRPAmount{}, *env.current(), jlog); + + BEAST_EXPECTS(actual == expected, "unexpected AMM delete invariant result"); + auto const messages = sink.messages().str(); + auto const expectedLogWhenEnforced = enforceAMMDelete ? expectedLog : ""; + if (!expectedLogWhenEnforced.empty()) + { + BEAST_EXPECTS(messages.contains(expectedLogWhenEnforced), expectedLogWhenEnforced); + } + else + { + BEAST_EXPECTS(messages.empty(), messages); + } + }; + + checkInvariant( + ttPAYMENT, + tesSUCCESS, + nonZeroLP, + !enforceAMMDelete, + "Invariant failed: AMM failed, unexpected AMM deletion by"); + checkInvariant( + ttAMM_DELETE, + tesSUCCESS, + std::nullopt, + !enforceAMMDelete, + "Invariant failed: AMMDelete failed, AMM object remained on tesSUCCESS"); + checkInvariant( + ttAMM_DELETE, + tesSUCCESS, + nonZeroLP, + !enforceAMMDelete, + "Invariant failed: AMMDelete failed, AMM object deleted with non-zero LP balance"); + checkInvariant( + ttAMM_DELETE, + tecINCOMPLETE, + zeroLP, + !enforceAMMDelete, + "Invariant failed: AMMDelete failed, AMM object deleted when result is not tesSUCCESS"); + + checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, nonZeroLP, true, ""); + checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, nonZeroLP, true, ""); + + checkInvariant(ttAMM_DELETE, tesSUCCESS, zeroLP, true, ""); + checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, zeroLP, true, ""); + checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, zeroLP, true, ""); + } + + void + testAMM() + { + testcase << "AMM"; + using namespace jtx; + + MPTID mptID{}; + uint256 ammID{}; + AccountID ammAccountID{}; + Account const gw{"gw"}; + Issue lptIssue{}; + PrettyAsset poolAsset{xrpIssue()}; + + auto deleteAMMAccount = [&](ApplyContext& ac, bool) { + auto sle = ac.view().peek(keylet::account(ammAccountID)); + if (!sle) + return false; + ac.view().erase(sle); + return true; + }; + + auto updateLPTokensBalance = [&](ApplyContext& ac, std::int64_t amount) { + auto sle = ac.view().peek(keylet::amm(ammID)); + if (!sle) + return false; + sle->setFieldAmount(sfLPTokenBalance, STAmount{lptIssue, amount}); + ac.view().update(sle); + return true; + }; + auto updateLPTokensBadAmount = [&](ApplyContext& ac, bool) { + return updateLPTokensBalance(ac, -1); + }; + auto updateLPTokensBadBalance = [&](ApplyContext& ac, bool) { + return updateLPTokensBalance(ac, 200'000'000); + }; + auto updateAMM = [&](ApplyContext& ac, bool) { return updateLPTokensBalance(ac, 10); }; + + auto updateAMMPool = [&](ApplyContext& ac, bool isMPT) { + if (isMPT) + { + auto sle = ac.view().peek(keylet::mptoken(mptID, ammAccountID)); + if (!sle) + return false; + sle->setFieldU64(sfMPTAmount, 1); + ac.view().update(sle); + return true; + } + auto sle = ac.view().peek(keylet::account(ammAccountID)); + if (!sle) + return false; + sle->setFieldAmount(sfBalance, XRP(1)); + ac.view().update(sle); + return true; + }; + + auto test = [&](auto const txType, + auto&& update, + bool isMPT, + TER error = tecINVARIANT_FAILED) { + doInvariantCheck( + {{"AMM"}}, + [&](Account const&, Account const&, ApplyContext& ac) { return update(ac, isMPT); }, + XRPAmount{}, + STTx{txType, [&](STObject& tx) {}}, + {tecINVARIANT_FAILED, error}, + [&](Account const&, Account const&, Env& env) { + env.fund(XRP(1'000), gw); + poolAsset = [&]() -> PrettyAsset { + if (isMPT) + { + MPT const mpt = MPTTester({.env = env, .issuer = gw}); + mptID = mpt.issuanceID; + return mpt; + } + return gw["USD"]; + }(); + AMM const amm(env, gw, XRP(100), poolAsset(100)); + ammAccountID = amm.ammAccount(); + ammID = amm.ammID(); + lptIssue = amm.lptIssue(); + return true; + }); + }; + + for (bool const isMPT : {false, true}) + { + // Under fixCleanup3_4_0 the MPT balance invariants also fire on the + // second pass, so both IOU and MPT pools now escalate to tef. + auto const error = TER(tefINVARIANT_FAILED); + for (auto txType : {ttAMM_CREATE, ttAMM_DEPOSIT, ttAMM_CLAWBACK, ttAMM_WITHDRAW}) + { + test(txType, deleteAMMAccount, isMPT, tefINVARIANT_FAILED); + test(txType, updateLPTokensBadAmount, isMPT); + test(txType, updateLPTokensBadBalance, isMPT); + } + for (auto txType : {ttAMM_BID, ttAMM_VOTE}) + { + test(txType, updateAMMPool, isMPT, error); + test(txType, updateLPTokensBadAmount, isMPT); + test(txType, updateLPTokensBadBalance, isMPT); + } + for (auto txType : {ttAMM_DELETE, ttCHECK_CASH, ttOFFER_CREATE, ttPAYMENT}) + { + test(txType, updateAMM, isMPT); + } + } + } + + // Test the invariant overwrite fix for both pre- and post-amendment + // behavior. With the fix enabled, |= accumulates violations across + // entries so a later valid entry cannot clear an earlier violation. + // Without the fix, = assignment means the last-visited entry wins. + + void + run() override + { + testAMMDeleteInvariants(all_); + testAMMDeleteInvariants(all_ - fixCleanup3_3_0); + testAMM(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsAMM, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsBase.cpp b/src/test/app/invariants/InvariantsBase.cpp new file mode 100644 index 0000000000..92d75eca77 --- /dev/null +++ b/src/test/app/invariants/InvariantsBase.cpp @@ -0,0 +1,200 @@ +#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 +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +test::jtx::Env +InvariantsBase::makeEnv(FeatureBitset features) +{ + return {*this, test::jtx::envconfig(), features, nullptr, beast::Severity::Disabled}; +} + +void +InvariantsBase::doInvariantCheck( + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee, + STTx tx, + std::initializer_list ters, + Preclose const& preclose, + TxAccount setTxAccount, + std::source_location const& loc, + TER initialResult) +{ + doInvariantCheck( + makeEnv(test::jtx::testableAmendments()), + expectLogs, + precheck, + fee, + tx, + ters, + preclose, + setTxAccount, + loc, + initialResult); +} + +void +InvariantsBase::doInvariantCheck( + test::jtx::Env&& env, + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee, + STTx tx, + std::initializer_list ters, + Preclose const& preclose, + TxAccount setTxAccount, + std::source_location const& loc, + TER initialResult) +{ + using namespace test::jtx; + + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + if (preclose) + BEAST_EXPECT(preclose(a1, a2, env)); + env.close(); + + if (setTxAccount != TxAccount::None) + tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id()); + + doInvariantCheck( + std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc, initialResult); +} + +void +InvariantsBase::doInvariantCheck( + // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) + test::jtx::Env&& env, + test::jtx::Account const& a1, + test::jtx::Account const& a2, + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee, + STTx tx, + std::initializer_list ters, + std::source_location const& loc, + TER initialResult) +{ + using namespace test::jtx; + + OpenView ov{*env.current()}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + + // Invariants normally run in the Transaction's "apply" (operator()) context, and can always + // access global Rules. + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + BEAST_EXPECT(precheck(a1, a2, ac)); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + + // Invoke the check twice to cover the tec and tef cases. Both passes run + // against the same view -- production would discard it in between -- so + // the second sees the same violation and escalates tec -> tef. A + // {tec, tef} pair therefore means "enforced whatever the incoming + // result", not that the transaction ends in tef on ledger. + if (!BEAST_EXPECT(ters.size() == 2)) + return; + + TER terActual = initialResult; + for (TER const& terExpect : ters) + { + TER const terInput = terActual; + terActual = transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full); + expect( + terExpect == terActual, + "expected: " + transToken(terExpect) + " got: " + transToken(terActual), + loc.file_name(), + loc.line()); + auto const messages = sink.messages().str(); + + // checkInvariants returns its input unchanged unless something + // fires, so a changed result means an invariant fired, and a firing + // invariant must log. + if (terActual != terInput) + { + expect( + messages.starts_with("Invariant failed:") || + messages.starts_with("Transaction caused an exception"), + messages, + loc.file_name(), + loc.line()); + } + + // std::cerr << messages << '\n'; + for (auto const& m : expectLogs) + { + expect(messages.contains(m), m, loc.file_name(), loc.line()); + } + } +} + +Keylet +InvariantsBase::createLoanBroker( + jtx::Account const& a, + jtx::Env& env, + jtx::PrettyAsset const& asset) +{ + using namespace jtx; + + // Create vault + uint256 vaultID; + Vault const vault{env}; + auto [tx, vKeylet] = vault.create({.owner = a, .asset = asset}); + env(tx); + BEAST_EXPECT(env.le(vKeylet)); + + vaultID = vKeylet.key; + + // Create Loan Broker + using namespace loan_broker; + + auto const loanBrokerKeylet = keylet::loanBroker(a.id(), SeqProxy::rawSequence(env.seq(a))); + // Create a Loan Broker with all default values. + env(set(a, vaultID), Fee(kIncrement)); + + return loanBrokerKeylet; +} + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsBase.h b/src/test/app/invariants/InvariantsBase.h new file mode 100644 index 0000000000..73319d0ef8 --- /dev/null +++ b/src/test/app/invariants/InvariantsBase.h @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class Transactor; + +// Test-only factory — not part of the public API. +// The returned Transactor holds a raw reference to ctx; the caller must ensure +// the ApplyContext outlives the Transactor. Implemented in applySteps.cpp +std::unique_ptr +makeTransactor(ApplyContext& ctx); + +} // namespace xrpl + +namespace xrpl::test { + +class InvariantsBase : public beast::unit_test::Suite +{ +protected: + // The optional Preclose function is used to process additional transactions + // on the ledger after creating two accounts, but before closing it, and + // before the Precheck function. These should only be valid functions, and + // not direct manipulations. Preclose is not commonly used. + using Preclose = std::function< + bool(test::jtx::Account const& a, test::jtx::Account const& b, test::jtx::Env& env)>; + + // this is common setup/method for running a failing invariant check. The + // precheck function is used to manipulate the ApplyContext with view + // changes that will cause the check to fail. + using Precheck = std::function< + bool(test::jtx::Account const& a, test::jtx::Account const& b, ApplyContext& ac)>; + + enum class TxAccount : int { None = 0, A1, A2 }; + + test::jtx::Env + makeEnv(FeatureBitset features); + + /** + * Run a specific test case to put the ledger into a state that will be + * detected by an invariant. Simulates the actions of a transaction that + * would violate an invariant. + * + * @param expectLogs One or more messages related to the failing invariant + * that should be in the log output + * @param precheck See "Precheck" above + * @param fee If provided, the fee amount paid by the simulated transaction. + * @param tx A mock transaction that took the actions to trigger the + * invariant. In most cases, only the type matters. + * @param ters The TER results expected on the two passes of the invariant + * checker. + * @param preclose See "Preclose" above. Note that @preclose runs *before* + * @precheck, but is the last parameter for historical reasons + * @param setTxAccount optionally set to add sfAccount to tx (either A1 or A2) + */ + void + doInvariantCheck( + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee = XRPAmount{}, + STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, + std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + Preclose const& preclose = {}, + TxAccount setTxAccount = TxAccount::None, + std::source_location const& loc = std::source_location::current(), + // Result fed to the invariant checker on the first pass. Set it to a + // tec to exercise result-dependent invariants; the harness runs no + // transactor, so one never arises on its own. + TER initialResult = tesSUCCESS); + + void + doInvariantCheck( + test::jtx::Env&& env, + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee = XRPAmount{}, + STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, + std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + Preclose const& preclose = {}, + TxAccount setTxAccount = TxAccount::None, + std::source_location const& loc = std::source_location::current(), + TER initialResult = tesSUCCESS); + + void + doInvariantCheck( + // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) + test::jtx::Env&& env, + test::jtx::Account const& a1, + test::jtx::Account const& a2, + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee = XRPAmount{}, + STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, + std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + std::source_location const& loc = std::source_location::current(), + TER initialResult = tesSUCCESS); + + Keylet + createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset); +}; + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsEscrowNFT_test.cpp b/src/test/app/invariants/InvariantsEscrowNFT_test.cpp new file mode 100644 index 0000000000..f0afa2377c --- /dev/null +++ b/src/test/app/invariants/InvariantsEscrowNFT_test.cpp @@ -0,0 +1,352 @@ +#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 +#include +#include +#include + +namespace xrpl::test { + +class InvariantsEscrowNFT_test : public InvariantsBase +{ + void + testNoZeroEscrow() + { + using namespace test::jtx; + testcase << "no zero escrow"; + + doInvariantCheck( + {{"XRP net change of -1000000 doesn't match fee 0"}, + {"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // escrow with negative amount + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + sleNew->setFieldAmount(sfAmount, XRP(-1)); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"XRP net change was positive: 100000000000000001"}, + {"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // escrow with too-large amount + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + // Use `drops(1)` to bypass a call to STAmount::canonicalize + // with an invalid value + sleNew->setFieldAmount(sfAmount, kInitialXrp + drops(1)); + ac.view().insert(sleNew); + return true; + }); + + // IOU < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // escrow with too-little iou + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + STAmount const amt(usd, -1); + sleNew->setFieldAmount(sfAmount, amt); + ac.view().insert(sleNew); + return true; + }); + + // IOU bad currency + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // escrow with bad iou currency + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + + Issue const bad{badCurrency(), AccountID(0x4985601)}; + STAmount const amt(bad, 1); + sleNew->setFieldAmount(sfAmount, amt); + ac.view().insert(sleNew); + return true; + }); + + // MPT < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // escrow with too-little mpt + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + STAmount const amt(mpt, -1); + sleNew->setFieldAmount(sfAmount, amt); + ac.view().insert(sleNew); + return true; + }); + + // MPT OutstandingAmount < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptissuance outstanding is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldU64(sfOutstandingAmount, std::numeric_limits::max()); + ac.view().insert(sleNew); + return true; + }); + + // MPT LockedAmount < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptissuance locked is less than locked + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldU64(sfLockedAmount, std::numeric_limits::max()); + ac.view().insert(sleNew); + return true; + }); + + // MPT OutstandingAmount < LockedAmount + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptissuance outstanding is less than locked + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldU64(sfOutstandingAmount, 1); + sleNew->setFieldU64(sfLockedAmount, 10); + ac.view().insert(sleNew); + return true; + }); + + // MPT MPTAmount < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptoken amount is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1)); + sleNew->setFieldU64(sfMPTAmount, std::numeric_limits::max()); + ac.view().insert(sleNew); + return true; + }); + + // MPT LockedAmount < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptoken locked amount is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1)); + sleNew->setFieldU64(sfLockedAmount, std::numeric_limits::max()); + ac.view().insert(sleNew); + return true; + }); + } + + void + testNFTokenPageInvariants() + { + using namespace test::jtx; + testcase << "NFTokenPage"; + + // lambda that returns an STArray of NFTokenIDs. + uint256 const firstNFTID( + "0000000000000000000000000000000000000001FFFFFFFFFFFFFFFF00000000"); + auto makeNFTokenIDs = [&firstNFTID](unsigned int nftCount) { + SOTemplate const* nfTokenTemplate = + InnerObjectFormats::getInstance().findSOTemplateBySField(sfNFToken); + + uint256 nftID(firstNFTID); + STArray ret; + for (int i = 0; i < nftCount; ++i) + { + STObject newNFToken(*nfTokenTemplate, sfNFToken, [&nftID](STObject& object) { + object.setFieldH256(sfNFTokenID, nftID); + }); + ret.pushBack(std::move(newNFToken)); + ++nftID; + } + return ret; + }; + + doInvariantCheck( + {{"NFT page has invalid size"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(0)); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT page has invalid size"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(33)); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFTs on page are not sorted"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + STArray nfTokens = makeNFTokenIDs(2); + std::iter_swap(nfTokens.begin(), nfTokens.begin() + 1); + + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, nfTokens); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT contains empty URI"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + STArray nfTokens = makeNFTokenIDs(1); + nfTokens[0].setFieldVL(sfURI, Blob{}); + + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, nfTokens); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT page is improperly linked"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); + nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMax(a1).key); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT page is improperly linked"}}, + [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) { + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); + nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMin(a2).key); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT page is improperly linked"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); + nftPage->setFieldH256(sfNextPageMin, nftPage->key()); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT page is improperly linked"}}, + [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) { + STArray nfTokens = makeNFTokenIDs(1); + auto nftPage = std::make_shared(keylet::nftokenPage( + keylet::nftokenPageMax(a1), ++(nfTokens[0].getFieldH256(sfNFTokenID)))); + nftPage->setFieldArray(sfNFTokens, nfTokens); + nftPage->setFieldH256(sfNextPageMin, keylet::nftokenPageMax(a2).key); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT found in incorrect page"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + STArray nfTokens = makeNFTokenIDs(2); + auto nftPage = std::make_shared(keylet::nftokenPage( + keylet::nftokenPageMax(a1), (nfTokens[1].getFieldH256(sfNFTokenID)))); + nftPage->setFieldArray(sfNFTokens, nfTokens); + + ac.view().insert(nftPage); + return true; + }); + } + + void + run() override + { + testNoZeroEscrow(); + testNFTokenPageInvariants(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsEscrowNFT, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsMPT_test.cpp b/src/test/app/invariants/InvariantsMPT_test.cpp new file mode 100644 index 0000000000..4692463baa --- /dev/null +++ b/src/test/app/invariants/InvariantsMPT_test.cpp @@ -0,0 +1,1577 @@ +#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 +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class InvariantsMPT_test : public InvariantsBase +{ + FeatureBitset const all_{test::jtx::testableAmendments()}; + + void + testMPT() + { + using namespace test::jtx; + testcase << "MPT"; + + MPTIssue const nonCanonicalMPTIssue{makeMptID(1, AccountID(0x4985601))}; + auto const nonCanonicalMPTAmount = [&](SField const& field) { + return STAmount{ + field, + nonCanonicalMPTIssue, + kMaxMpTokenAmount + std::uint64_t{1}, + 0, + false, + STAmount::Unchecked{}}; + }; + auto const negativeMPTAmount = [&](SField const& field) { + return STAmount{field, nonCanonicalMPTIssue, 2, 0, true, STAmount::Unchecked{}}; + }; + auto const nonCanonicalMPTPayment = [&]() { + return STTx{ttPAYMENT, [&](STObject& tx) { + tx.setFieldAmount(sfAmount, nonCanonicalMPTAmount(sfAmount)); + }}; + }; + + doInvariantCheck( + makeEnv(all_ - fixCleanup3_2_0), + {}, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{}, + nonCanonicalMPTPayment(), + {tesSUCCESS, tesSUCCESS}); + + doInvariantCheck( + {{"ledger entry contains non-canonical MPT or XRP amount"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + auto sleNew = std::make_shared( + keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); + sleNew->setAccountID(sfAccount, a1.id()); + sleNew->setAccountID(sfDestination, a2.id()); + sleNew->setFieldAmount(sfSendMax, nonCanonicalMPTAmount(sfSendMax)); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"ledger entry contains non-canonical MPT or XRP amount"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + auto sleNew = std::make_shared( + keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); + sleNew->setAccountID(sfAccount, a1.id()); + sleNew->setAccountID(sfDestination, a2.id()); + sleNew->setFieldAmount(sfSendMax, negativeMPTAmount(sfSendMax)); + ac.view().insert(sleNew); + return true; + }); + + // MPT OutstandingAmount > MaximumAmount + doInvariantCheck( + {{"OutstandingAmount overflow"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptissuance outstanding is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldU64(sfOutstandingAmount, 110); + sleNew->setFieldU64(sfMaximumAmount, 100); + ac.view().insert(sleNew); + return true; + }); + + // MPTToken amount doesn't add up to OutstandingAmount + doInvariantCheck( + {{"invalid OutstandingAmount balance"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + // mptissuance outstanding is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldU64(sfOutstandingAmount, 100); + sleNew->setFieldU64(sfMaximumAmount, 100); + ac.view().insert(sleNew); + + sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2)); + sleNew->setFieldU64(sfMPTAmount, 90); + ac.view().insert(sleNew); + + return true; + }); + + // Overflow/Invalid balance on payment + auto testPayment = [&](std::string const& log, auto&& update) { + MPTID id; + doInvariantCheck( + {{log}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + return update(id, ac, a1); + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt( + {.env = env, .issuer = gw, .holders = {a1}, .pay = 100, .maxAmt = 100}); + id = mpt.issuanceID(); + return true; + }); + }; + testPayment( + "invalid OutstandingAmount balance", + [&](MPTID const& id, ApplyContext& ac, Account const& a1) { + auto sle = ac.view().peek(keylet::mptoken(id, a1)); + if (!sle) + return false; + sle->setFieldU64(sfMPTAmount, 101); + ac.view().update(sle); + return true; + }); + testPayment( + "OutstandingAmount overflow", [&](MPTID const& id, ApplyContext& ac, Account const&) { + auto sle = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sle) + return false; + sle->setFieldU64(sfOutstandingAmount, 101); + ac.view().update(sle); + return true; + }); + + // The on-failure MPT checks (OutstandingAmount balance / transfer) apply + // to every non-tesSUCCESS result, with no per-result exemption: on a tec + // the transactor discards the view and re-applies only offer, trust + // line, NFT offer and credential deletions, so an MPT change reaching + // the invariant is a bug whatever the code. Seeded via initialResult. + { + MPTID id; + // preclose: gw issues an MPT held by A1 and A2. + auto const setup = [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt( + {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 50, .maxAmt = 1'000}); + id = mpt.issuanceID(); + return true; + }; + + // Consistent mint: OutstandingAmount and A1's balance both grow by + // 10, so conservation holds and only the on-failure check fires. + Precheck const mint = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); + auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); + if (!sleIss || !sleTok) + return false; + (*sleIss)[sfOutstandingAmount] = (*sleIss)[sfOutstandingAmount] + 10; + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; + ac.view().update(sleIss); + ac.view().update(sleTok); + return true; + }; + + // Holder-to-holder transfer (A1 -> A2 by 10). OutstandingAmount is + // unchanged, and CanTransfer keeps the ordinary transfer check + // quiet, so only the on-failure check fires. + Precheck const transfer = [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); + auto sleA = ac.view().peek(keylet::mptoken(id, a1.id())); + auto sleB = ac.view().peek(keylet::mptoken(id, a2.id())); + if (!sleIss || !sleA || !sleB) + return false; + (*sleIss)[sfFlags] = (*sleIss)[sfFlags] | lsfMPTCanTransfer; + (*sleA)[sfMPTAmount] = (*sleA)[sfMPTAmount] - 10; + (*sleB)[sfMPTAmount] = (*sleB)[sfMPTAmount] + 10; + ac.view().update(sleIss); + ac.view().update(sleA); + ac.view().update(sleB); + return true; + }; + + STTx const payment{ttPAYMENT, [](STObject&) {}}; + + // Negative controls: nothing fires on tesSUCCESS. Without these, the + // cases below would still pass if the result guard were dropped. + doInvariantCheck({}, mint, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + doInvariantCheck({}, transfer, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + + // tecKILLED and tecINCOMPLETE are not special: an MPT change paired + // with either fires, as with any other failure. + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecINCOMPLETE); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecINCOMPLETE); + // The same change under a third failure result: the check keys off + // "not tesSUCCESS", nothing finer. + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + + // A lock moves value within one holder, so it is not a two-sided + // transfer and the `senders || receivers` form is what catches it. + // OutstandingAmount and the holder total are unchanged, so the + // balance check stays quiet. + Precheck const lock = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] < 10) + return false; + // A fresh MPToken has no locked amount, so set it directly. + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] - 10; + sleTok->setFieldU64(sfLockedAmount, 10); + ac.view().update(sleTok); + return true; + }; + // Negative control: a lock is legitimate on tesSUCCESS. + doInvariantCheck({}, lock, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + lock, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + // The lock is caught under any failure result. + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + lock, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + + // A deleted MPToken has no amtAfter, so the sender/receiver counts + // skip it and only the deletedAuthorized_ term can catch it. That + // needs holders authorized but never paid, so the MPToken can be + // erased with a zero balance and OutstandingAmount untouched -- + // otherwise the holder would register as a sender instead. + MPTID emptyId; + auto const setupEmpty = [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}, .maxAmt = 100}); + emptyId = mpt.issuanceID(); + return true; + }; + Precheck const eraseToken = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(emptyId, a1.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] != 0) + return false; + ac.view().erase(sleTok); + return true; + }; + // ValidMPTIssuance also reports the deletion, so assert on + // ValidMPTTransfer's message, which only the new check can produce. + doInvariantCheck( + {{"MPToken deleted on failure"}}, + eraseToken, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupEmpty, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + } + + // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback balance change is invalid"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + + STAmount balance{Issue{usd.currency, issuer.id()}, 80}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Full IOU clawback may delete the trustline; missing after-SLE represents zero balance. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto const sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 100}; + }}, + {tesSUCCESS, tesSUCCESS}); + } + + // Pre-MPTokensV2 invalid IOU clawback delta logs but remains non-enforcing. + { + Env env(*this, all_ - featureMPTokensV2); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback balance change is invalid"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + + STAmount balance{Issue{usd.currency, issuer.id()}, 80}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tesSUCCESS, tesSUCCESS}); + } + + // Invalid MPT clawback delta must fail when raw MPToken debit mismatches sfAmount. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback balance change is invalid"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + + sleToken->setFieldU64(sfMPTAmount, 80); + sleIssuance->setFieldU64(sfOutstandingAmount, 80); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // A clawback that mutates both IOU and MPT entries must fail under MPTokensV2. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline and MPToken both changed"}}, + [issuer, usd, id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleLine = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder.id())); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleLine || !sleToken || !sleIssuance) + return false; + + STAmount balance{Issue{usd.currency, issuer.id()}, 90}; + if (holder.id() > issuer.id()) + balance.negate(); + sleLine->setFieldAmount(sfBalance, balance); + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleLine); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Clawback that modifies a trustline other than the one implied by the + // tx amount: clawbackTrustLineBalanceInHolderTerms returns nullopt for + // the mismatched line. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + auto const eur = issuer["EUR"]; + env.trust(eur(100), holder); + env(pay(issuer, holder, eur(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback changed the wrong line"}}, + [issuer, eur](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), eur.currency)); + if (!sle) + return false; + STAmount balance{Issue{eur.currency, issuer.id()}, 90}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Clawback leaving the holder's balance negative. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline or MPT balance is negative"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + // Make the holder's balance negative from their perspective. + STAmount balance{Issue{usd.currency, issuer.id()}, 80}; + if (holder.id() < issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // IOU-amount clawback while only an MPToken changed: no trustline was + // recorded, so iou_.before is empty. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback changed the wrong line"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Valid trustline change but a zero clawback amount. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback amount is invalid"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + STAmount balance{Issue{usd.currency, issuer.id()}, 90}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 0}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // MPT clawback tx missing the Holder field. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback missing holder"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // MPT clawback where the holder's MPToken was deleted (after is empty). + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback token is missing"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + // Keep the issuance consistent after removing the token. + sleIssuance->setFieldU64(sfOutstandingAmount, 0); + ac.view().update(sleIssuance); + ac.view().erase(sleToken); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // MPT clawback that changed a different holder's MPToken. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, + .issuer = issuer, + .holders = {holder, other}, + .pay = 100, + .maxAmt = 200}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback changed the wrong token"}}, + [id](Account const&, Account const& other, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, other)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 190); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Valid MPToken change but a zero MPT clawback amount. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback amount is invalid"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 0}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // More MPTokens created than expected + std::array, 4> const tests = { + std::make_pair(ttAMM_WITHDRAW, 2), + std::make_pair(ttAMM_CLAWBACK, 2), + std::make_pair(ttAMM_CREATE, 3), + std::make_pair(ttCHECK_CASH, 2)}; + for (auto const& [tx, nTokens] : tests) + { + doInvariantCheck( + {{std::string("MPToken created for the MPT issuer")}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + auto seq = sle->getFieldU32(sfSequence); + for (int i = 0; i < nTokens; ++i) + { + MPTIssue const mpt{makeMptID(seq + i, a1)}; + auto sleNew = + std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + ac.view().insert(sleNew); + + sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2)); + ac.view().insert(sleNew); + } + + return true; + }, + XRPAmount{}, + STTx{tx, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // More MPTokens deleted than expected + for (auto const& tx : {ttAMM_WITHDRAW, ttAMM_CLAWBACK}) + { + MPTID id; + Account const a3("A3"); + doInvariantCheck( + {{"MPT authorize succeeded but created/deleted bad number of mptokens"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + for (auto const& a : {a1, a2, a3}) + { + auto sle = ac.view().peek(keylet::mptoken(id, a)); + if (!sle) + return false; + ac.view().erase(sle); + } + return true; + }, + XRPAmount{}, + STTx{tx, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw, a3); + MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2, a3}}); + id = mpt.issuanceID(); + return true; + }); + } + + // sfReferenceHolding can only be set on creation by VaultCreate. A + // non-VaultCreate transaction that creates an MPTokenIssuance with + // sfReferenceHolding present must trip the invariant. + doInvariantCheck( + {{"sfReferenceHolding set on a new MPTokenIssuance by a " + "non-VaultCreate transaction"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + auto const sleAcct = ac.view().peek(keylet::account(a1.id())); + if (!sleAcct) + return false; + MPTIssue const mpt{makeMptID(sleAcct->getFieldU32(sfSequence), a1)}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldH256(sfReferenceHolding, uint256{1}); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}); + + // sfReferenceHolding is immutable: changing the field on an + // existing MPTokenIssuance must trip the invariant. Set up a real + // vault via preclose (so the share issuance carries + // sfReferenceHolding), then mutate it in precheck to produce a + // before/after pair. + { + uint256 vaultKey; + doInvariantCheck( + {{"sfReferenceHolding was modified on an existing " + "MPTokenIssuance"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto const sleVault = ac.view().peek(keylet::vault(vaultKey)); + if (!sleVault) + return false; + auto sleIssuance = + ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); + if (!sleIssuance) + return false; + sleIssuance->setFieldH256(sfReferenceHolding, uint256{2}); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const&, Env& env) { + Account const issuer{"issuer"}; + env.fund(XRP(10'000), issuer); + env.close(); + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const asset = mptt.issuanceID(); + mptt.authorize({.account = a1}); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); + env(tx); + env.close(); + vaultKey = keylet.key; + return true; + }); + } + + // A vault pseudo-account's MPToken cannot be deleted by anything + // other than a VaultDelete transaction. Set up a vault, then have + // an arbitrary tx erase the pseudo's MPToken in precheck. + { + uint256 vaultKey; + doInvariantCheck( + {{"vault pseudo-account holding deleted by a " + "non-VaultDelete transaction"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto const sleVault = ac.view().peek(keylet::vault(vaultKey)); + if (!sleVault) + return false; + auto const sleIssuance = + ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); + if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding)) + return false; + auto sleHolding = ac.view().peek( + keylet::unchecked(sleIssuance->getFieldH256(sfReferenceHolding))); + if (!sleHolding) + return false; + ac.view().erase(sleHolding); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const&, Env& env) { + Account const issuer{"issuer"}; + env.fund(XRP(10'000), issuer); + env.close(); + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const asset = mptt.issuanceID(); + mptt.authorize({.account = a1}); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); + env(tx); + env.close(); + vaultKey = keylet.key; + return true; + }); + } + + // Invalid transfer + std::array, 3> const invalidTransferTests = { + std::make_pair(ttAMM_WITHDRAW, false), + std::make_pair(ttPAYMENT, false), + std::make_pair(ttPAYMENT, true)}; + // The two amendments that gate enforcement, in all four combinations. + FeatureBitset const gatesEnabled{featureMPTokensV2, fixCleanup3_4_0}; + for (auto const gates : + {gatesEnabled, + gatesEnabled - featureMPTokensV2, + gatesEnabled - fixCleanup3_4_0, + FeatureBitset{}}) + { + for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests) + { + for (auto const flag : + {static_cast(lsfMPTLocked), + ~lsfMPTCanTransfer, + ~lsfMPTCanTrade, + 0u}) + { + MPTID id{}; + auto const isSuccess = !gates.any() || flag == 0 || + (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) || + (tx == ttAMM_WITHDRAW && + (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer)); + std::pair const error = isSuccess + ? std::make_pair(TER(tesSUCCESS), TER(tesSUCCESS)) + : std::make_pair(TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)); + doInvariantCheck( + {{isSuccess ? "" : "invalid MPToken transfer between holders"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto update = [&](AccountID const& a, std::uint64_t v) { + auto sle = ac.view().peek(keylet::mptoken(id, a)); + if (!sle) + return false; + sle->at(sfMPTAmount) = v; + ac.view().update(sle); + return true; + }; + auto issuanceSle = ac.view().peek(keylet::mptokenIssuance(id)); + if (!issuanceSle) + return false; + auto const flags = issuanceSle->at(sfFlags); + if (flag == lsfMPTLocked) + { + issuanceSle->at(sfFlags) = flags | lsfMPTLocked; + } + else if (flag != 0u) + { + issuanceSle->at(sfFlags) = flags & flag; + } + issuanceSle->at(sfOutstandingAmount) = 200; + ac.view().update(issuanceSle); + return update(a1, 101) && update(a2, 99); + }, + XRPAmount{}, + STTx{ + tx, + [&](STObject& tx) { + if (crossCurrencyPayment) + { + tx.setFieldAmount( + sfSendMax, STAmount(MPTAmount{100}, MPTIssue{id})); + } + }}, + {error.first, error.second}, + [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const usd( + {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 100}); + id = usd.issuanceID(); + // Either gate enforces, so both must be off to stay + // advisory. Disable after setting up the MPT; the + // next env.close() is what makes it take effect. + if (!gates[featureMPTokensV2]) + env.disableFeature(featureMPTokensV2); + if (!gates[fixCleanup3_4_0]) + env.disableFeature(fixCleanup3_4_0); + return true; + }); + } + } + } + + // An orphan has a zero balance, so only deletion is legitimate (see + // "Skipping Deleted MPTs" in testConfidentialMPTTransfer). + { + MPTID orphanID; + auto const setupOrphan = [&](Account const& a1, Account const& a2, Env& env) { + MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer}); + orphanID = mpt.issuanceID(); + // A2 is authorized but never paid, so its balance is zero and + // the issuance can be destroyed while its MPToken lives on. + mpt.authorize({.account = a2}); + mpt.destroy(); + return true; + }; + // ValidMPTBalanceChanges also reports this, so assert on the + // orphan message, which only the missing-issuance branch produces. + doInvariantCheck( + {{"orphaned MPToken balance changed"}}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] != 0) + return false; + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; + ac.view().update(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupOrphan); + // Negative control: erasing the orphan is how it gets cleaned up. + doInvariantCheck( + {}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok) + return false; + ac.view().erase(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tesSUCCESS, tesSUCCESS}, + setupOrphan); + // The same erase on a failure. The orphan branch continues, so only + // the pre-loop deletion check can report this one. + doInvariantCheck( + {{"MPToken deleted on failure"}}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok) + return false; + ac.view().erase(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupOrphan, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + } + + // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends + // through sfReferenceHolding to test the vault's underlying asset for + // each changed holder. + { + Account const gw{"gw"}; + MPTID shareID{}; + + // Vault setup: a1 and a2 both deposit IOU and hold vault shares. + auto const setupVault = [&](Account const& a1, + Account const& a2, + Env& env) -> std::tuple { + env.fund(XRP(1'000), gw); + env.trust(gw["IOU"](10'000), a1); + env.trust(gw["IOU"](10'000), a2); + env.close(); + env(pay(gw, a1, gw["IOU"](500))); + env(pay(gw, a2, gw["IOU"](500))); + env.close(); + + Vault const vault{env}; + auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = gw["IOU"]}); + env(createTx); + env.close(); + env(vault.deposit( + {.depositor = a1, .id = vaultKeylet.key, .amount = gw["IOU"](100)})); + env(vault.deposit( + {.depositor = a2, .id = vaultKeylet.key, .amount = gw["IOU"](100)})); + env.close(); + + return {env.le(vaultKeylet)->at(sfShareMPTID), env.le(vaultKeylet)->at(sfAccount)}; + }; + + // Simulate a vault-share transfer: a1 sends 10 shares to a2. + auto const precheck = + [&](Account const& a1, Account const& a2, ApplyContext& ac) -> bool { + auto sle1 = ac.view().peek(keylet::mptoken(shareID, a1.id())); + auto sle2 = ac.view().peek(keylet::mptoken(shareID, a2.id())); + if (!sle1 || !sle2) + return false; + (*sle1)[sfMPTAmount] -= 10; + (*sle2)[sfMPTAmount] += 10; + ac.view().update(sle1); + ac.view().update(sle2); + return true; + }; + + // Case: vault pseudo-account's IOU trustline is frozen. + { + auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool { + auto [sid, vid] = setupVault(a1, a2, env); + shareID = sid; + env(trust(gw, gw["IOU"](0), Account{"vaultPseudo", vid}, tfSetFreeze)); + env.close(); + return true; + }; + + doInvariantCheck( + Env{*this, all_}, + {{"invalid MPToken transfer between holders"}}, + precheck, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + preclose); + } + + // Case: receiver's (a2's) IOU trustline is frozen. + { + auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool { + auto [sid, vid] = setupVault(a1, a2, env); + shareID = sid; + env(trust(gw, gw["IOU"](0), a2, tfSetFreeze)); + env.close(); + return true; + }; + + doInvariantCheck( + Env{*this, all_}, + {{"invalid MPToken transfer between holders"}}, + precheck, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + preclose); + } + } + } + + void + testConfidentialMPTTransfer() + { + using namespace test::jtx; + testcase << "ValidConfidentialMPToken"; + + MPTID mptID; + + // Generate an MPT with privacy, issue 100 tokens to A2. + // Perform a confidential conversion to populate encrypted state. + auto const precloseConfidential = + [&mptID](Account const& a1, Account const& a2, Env& env) -> bool { + MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptID = mpt.issuanceID(); + + mpt.authorize({.account = a2}); + mpt.pay(a1, a2, 100); + + mpt.generateKeyPair(a1); + mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)}); + + mpt.generateKeyPair(a2); + mpt.convert({ + .account = a2, + .amt = 100, + .holderPubKey = mpt.getPubKey(a2), + }); + return true; + }; + + // badDelete + doInvariantCheck( + {"MPToken deleted with encrypted fields while COA > 0"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + // Force an erase of the object while the COA remains 100 + ac.view().erase(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseConfidential); + + // badConsistency + doInvariantCheck( + {"MPToken encrypted field existence inconsistency"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + // Remove one of the required encrypted fields to create a mismatch + sleToken->makeFieldAbsent(sfIssuerEncryptedBalance); + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + doInvariantCheck( + {"MPToken encrypted field existence inconsistency"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + sleToken->makeFieldAbsent(sfIssuerEncryptedBalance); + sleToken->makeFieldAbsent(sfConfidentialBalanceInbox); + sleToken->makeFieldAbsent(sfConfidentialBalanceSpending); + sleToken->setFieldVL(sfAuditorEncryptedBalance, Blob{0x00}); + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // requiresPrivacyFlag + auto const precloseNoPrivacy = [&mptID]( + Account const& a1, Account const& a2, Env& env) -> bool { + MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); + // completely omitted the tfMPTCanHoldConfidentialBalance flag here. + mpt.create({.flags = tfMPTCanTransfer}); + mptID = mpt.issuanceID(); + mpt.authorize({.account = a2}); + mpt.pay(a1, a2, 100); + return true; + }; + + doInvariantCheck( + {"MPToken has encrypted fields but Issuance does not have " + "lsfMPTCanHoldConfidentialBalance " + "set"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + // Inject all three encrypted fields consistently (inbox+spending+issuer must be + // in sync or badConsistency fires first and masks requiresPrivacyFlag). + sleToken->setFieldVL(sfConfidentialBalanceInbox, Blob{0x00}); + sleToken->setFieldVL(sfConfidentialBalanceSpending, Blob{0x00}); + sleToken->setFieldVL(sfIssuerEncryptedBalance, Blob{0x00}); + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseNoPrivacy); + + // badCOA + doInvariantCheck( + {"Confidential outstanding amount exceeds total outstanding amount"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); + if (!sleIssuance) + return false; + // Total outstanding is natively 100; bloat the COA over 100 + sleIssuance->setFieldU64(sfConfidentialOutstandingAmount, 200); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_ISSUANCE_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // Conservation Violation + doInvariantCheck( + {"Token conservation violation for MPT"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); + if (!sleIssuance) + return false; + + sleIssuance->setFieldU64( + sfConfidentialOutstandingAmount, + sleIssuance->getFieldU64(sfConfidentialOutstandingAmount) - 10); + ac.view().update(sleIssuance); + + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // Send/MergeInbox must not change OutstandingAmount (coaDelta == 0) + doInvariantCheck( + {"Invariant failed: OutstandingAmount changed " + "by confidential transaction that should not " + "modify it for MPT"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); + if (!sleIssuance) + return false; + sleIssuance->setFieldU64( + sfOutstandingAmount, sleIssuance->getFieldU64(sfOutstandingAmount) + 1); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // Send/MergeInbox and zero-COA-delta confidential transactions must not + // change public holder MPTAmount. + doInvariantCheck( + {"Invariant failed: MPTAmount changed by confidential " + "transaction that should not modify this field."}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + sleToken->setFieldU64(sfMPTAmount, sleToken->getFieldU64(sfMPTAmount) + 1); + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}}, + // Second pass is tef: the bumped MPTAmount also trips + // ValidMPTTransfer's on-failure check, which escalates the tec. + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseConfidential); + + // badVersion + doInvariantCheck( + {"MPToken sfConfidentialBalanceVersion not updated when sfConfidentialBalanceSpending " + "changed"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + Blob const kChangedConfidentialSpending = {0xBA, 0xDD}; + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + sleToken->setFieldVL(sfConfidentialBalanceSpending, kChangedConfidentialSpending); + + // DO NOT update sfConfidentialBalanceVersion + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // Skipping Deleted MPTs (Issuance deleted) + auto const precloseOrphan = [&mptID]( + Account const& a1, Account const& a2, Env& env) -> bool { + MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptID = mpt.issuanceID(); + mpt.authorize({.account = a2}); + + // Generate privacy keys and convert 0 amount so Bob has the encrypted fields + mpt.generateKeyPair(a1); + mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)}); + mpt.generateKeyPair(a2); + mpt.convert({ + .account = a2, + .amt = 0, + .holderPubKey = mpt.getPubKey(a2), + }); + + // Immediately destroy the issuance. A2's empty, encrypted token object lives on. + mpt.destroy(); + return true; + }; + + doInvariantCheck( + {}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + // Safely able to erase the deleted token. + ac.view().erase(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tesSUCCESS, tesSUCCESS}, + precloseOrphan); + } + +public: + void + run() override + { + testConfidentialMPTTransfer(); + testMPT(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsMPT, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsMisc_test.cpp b/src/test/app/invariants/InvariantsMisc_test.cpp new file mode 100644 index 0000000000..b0b6c02f5c --- /dev/null +++ b/src/test/app/invariants/InvariantsMisc_test.cpp @@ -0,0 +1,1333 @@ +#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 +#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 InvariantsMisc_test : public InvariantsBase +{ + FeatureBitset const all_{test::jtx::testableAmendments()}; + + void + testXRPNotCreated() + { + using namespace test::jtx; + testcase << "XRP created"; + doInvariantCheck( + {{"XRP net change was positive: 500"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // put a single account in the view and "manufacture" some XRP + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto amt = sle->getFieldAmount(sfBalance); + sle->setFieldAmount(sfBalance, amt + STAmount{500}); + ac.view().update(sle); + return true; + }); + } + + void + testAccountRootsNotRemoved() + { + using namespace test::jtx; + testcase << "account root removed"; + + // An account was deleted, but not by an AccountDelete transaction. + doInvariantCheck( + {{"an account root was deleted"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // remove an account from the view + auto sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::kZero; + ac.view().erase(sle); + return true; + }); + + // Successful AccountDelete transaction that didn't delete an account. + // + // Note that this is a case where a second invocation of the invariant + // checker returns a tecINVARIANT_FAILED, not a tefINVARIANT_FAILED. + // After a discussion with the team, we believe that's okay. + doInvariantCheck( + {{"account deletion succeeded without deleting an account"}}, + [](Account const&, Account const&, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + // Successful AccountDelete that deleted more than one account. + doInvariantCheck( + {{"account deletion succeeded but deleted multiple accounts"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + // remove two accounts from the view + auto sleA1 = ac.view().peek(keylet::account(a1.id())); + auto sleA2 = ac.view().peek(keylet::account(a2.id())); + if (!sleA1 || !sleA2) + return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sleA1->at(sfBalance) = beast::kZero; + sleA2->at(sfBalance) = beast::kZero; + ac.view().erase(sleA1); + ac.view().erase(sleA2); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + } + + void + testAccountRootsDeletedClean() + { + using namespace test::jtx; + testcase << "account root deletion left artifact"; + + doInvariantCheck( + {{"account deletion left behind a non-zero balance"}}, + // NOLINTNEXTLINE(readability-identifier-naming) + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + // A1 has a balance. Delete A1 + auto const a1 = A1.id(); + auto const sleA1 = ac.view().peek(keylet::account(a1)); + if (!sleA1) + return false; + if (!BEAST_EXPECT(*sleA1->at(sfBalance) != beast::kZero)) + return false; + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a non-zero owner count"}}, + // NOLINTNEXTLINE(readability-identifier-naming) + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + // Increment A1's owner count, then delete A1 + auto const a1 = A1.id(); + auto const sleA1 = ac.view().peek(keylet::account(a1)); + if (!sleA1) + return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sleA1->at(sfBalance) = beast::kZero; + BEAST_EXPECT(sleA1->at(sfOwnerCount) == 0); + increaseOwnerCount(ac.view(), sleA1, {}, 1, ac.journal); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a sponsorship field"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleA1 = ac.view().peek(keylet::account(a1.id())); + if (!sleA1) + return false; + sleA1->at(sfBalance) = beast::kZero; + sleA1->setFieldU32(sfSponsoredOwnerCount, 1); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a sponsorship field"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleA1 = ac.view().peek(keylet::account(a1.id())); + if (!sleA1) + return false; + sleA1->at(sfBalance) = beast::kZero; + sleA1->setFieldU32(sfSponsoringOwnerCount, 1); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a sponsorship field"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const a1Id = a1.id(); + auto const sleA1 = ac.view().peek(keylet::account(a1Id)); + if (!sleA1) + return false; + sleA1->at(sfBalance) = beast::kZero; + sleA1->setFieldU32(sfSponsoringAccountCount, 1); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a sponsorship field"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleA1 = ac.view().peek(keylet::account(a1.id())); + if (!sleA1) + return false; + sleA1->at(sfBalance) = beast::kZero; + sleA1->setAccountID(sfSponsor, a2.id()); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + Env{*this, FeatureBitset{featureSponsor}}, + {{"account deletion left behind a sponsorship field"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleA1 = ac.view().peek(keylet::account(a1.id())); + if (!sleA1) + return false; + sleA1->at(sfBalance) = beast::kZero; + sleA1->setAccountID(sfSponsor, a2.id()); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + for (auto const& [keyletfunc, type, includeInTests] : kDirectAccountKeylets) + { + if (!includeInTests) + continue; + + using namespace std::string_literals; + + doInvariantCheck( + {{"account deletion left behind a "s + type.cStr() + " object"}}, + // NOLINTNEXTLINE(readability-identifier-naming) + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + // Add an object to the ledger for account A1, then delete + // A1 + auto const a1 = A1.id(); + auto sleA1 = ac.view().peek(keylet::account(a1)); + if (!sleA1) + return false; + + auto const key = std::invoke(keyletfunc, a1); + auto const newSLE = std::make_shared(key); + ac.view().insert(newSLE); + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the + // desired check. + sleA1->at(sfBalance) = beast::kZero; + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + } + + // NFT special case + doInvariantCheck( + {{"account deletion left behind a NFTokenPage object"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + // remove an account from the view + auto sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::kZero; + sle->at(sfOwnerCount) = 0; + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const&, Env& env) { + // Preclose callback to mint the NFT which will be deleted in + // the Precheck callback above. + env(token::mint(a1)); + + return true; + }); + + // AMM special cases + AccountID ammAcctID; + uint256 ammKey; + Issue ammIssue; + doInvariantCheck( + {{"account deletion left behind a DirectoryNode object"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // Delete the AMM account without cleaning up the directory or + // deleting the AMM object + auto sle = ac.view().peek(keylet::account(ammAcctID)); + if (!sle) + return false; + + BEAST_EXPECT(sle->at(~sfAMMID)); + BEAST_EXPECT(sle->at(~sfAMMID) == ammKey); + + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::kZero; + sle->at(sfOwnerCount) = 0; + ac.view().erase(sle); + + return true; + }, + XRPAmount{}, + STTx{ttAMM_WITHDRAW, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + // Preclose callback to create the AMM which will be partially + // deleted in the Precheck callback above. + AMM const amm(env, a1, XRP(100), a1["USD"](50)); + ammAcctID = amm.ammAccount(); + ammKey = amm.ammID(); + ammIssue = amm.lptIssue(); + return true; + }); + doInvariantCheck( + {{"account deletion left behind a AMM object"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // Delete all the AMM's trust lines, remove the AMM from the AMM + // account's directory (this deletes the directory), and delete + // the AMM account. Do not delete the AMM object. + auto sle = ac.view().peek(keylet::account(ammAcctID)); + if (!sle) + return false; + + BEAST_EXPECT(sle->at(~sfAMMID)); + BEAST_EXPECT(sle->at(~sfAMMID) == ammKey); + + for (auto const& trustKeylet : + {keylet::trustLine(ammAcctID, a1["USD"]), keylet::trustLine(a1, ammIssue)}) + { + auto const line = ac.view().peek(trustKeylet); + if (!line) + { + return false; + } + + STAmount const lowLimit = line->at(sfLowLimit); + STAmount const highLimit = line->at(sfHighLimit); + BEAST_EXPECT( + trustDelete( + ac.view(), + line, + lowLimit.getIssuer(), + highLimit.getIssuer(), + ac.journal) == tesSUCCESS); + } + + auto const ammSle = ac.view().peek(keylet::amm(ammKey)); + if (!BEAST_EXPECT(ammSle)) + return false; + auto const ownerDirKeylet = keylet::ownerDir(ammAcctID); + + BEAST_EXPECT( + ac.view().dirRemove(ownerDirKeylet, ammSle->at(sfOwnerNode), ammKey, false)); + BEAST_EXPECT( + !ac.view().exists(ownerDirKeylet) || ac.view().emptyDirDelete(ownerDirKeylet)); + + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::kZero; + sle->at(sfOwnerCount) = 0; + ac.view().erase(sle); + + return true; + }, + XRPAmount{}, + STTx{ttAMM_WITHDRAW, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + // Preclose callback to create the AMM which will be partially + // deleted in the Precheck callback above. + AMM const amm(env, a1, XRP(100), a1["USD"](50)); + ammAcctID = amm.ammAccount(); + ammKey = amm.ammID(); + ammIssue = amm.lptIssue(); + return true; + }); + } + + void + testTypesMatch() + { + using namespace test::jtx; + testcase << "ledger entry types don't match"; + doInvariantCheck( + {{"ledger entry type mismatch"}, {"XRP net change of -1000000000 doesn't match fee 0"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // replace an entry in the table with an SLE of a different type + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto const sleNew = std::make_shared(ltTICKET, sle->key()); + ac.rawView().rawReplace(sleNew); + return true; + }); + + doInvariantCheck( + {{"invalid ledger entry type added"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // add an entry in the table with an SLE of an invalid type + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + // make a dummy escrow ledger entry, then change the type to an + // unsupported value so that the valid type invariant check + // will fail. + auto const sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + + // We don't use ltNICKNAME directly since it's marked deprecated + // to prevent accidental use elsewhere. + sleNew->type_ = static_cast('n'); + ac.view().insert(sleNew); + return true; + }); + } + + void + testXRPBalanceCheck() + { + using namespace test::jtx; + testcase << "XRP balance checks"; + + doInvariantCheck( + {{"Cannot return non-native STAmount as XRPAmount"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + // non-native balance + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + STAmount const nonNative(a2["USD"](51)); + sle->setFieldAmount(sfBalance, nonNative); + ac.view().update(sle); + return true; + }); + + doInvariantCheck( + {{"incorrect account XRP balance"}, {"XRP net change was positive: 99999999000000001"}}, + [this](Account const& a1, Account const&, ApplyContext& ac) { + // balance exceeds genesis amount + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + // Use `drops(1)` to bypass a call to STAmount::canonicalize + // with an invalid value + sle->setFieldAmount(sfBalance, kInitialXrp + drops(1)); + BEAST_EXPECT(!sle->getFieldAmount(sfBalance).negative()); + ac.view().update(sle); + return true; + }); + + doInvariantCheck( + {{"incorrect account XRP balance"}, + {"XRP net change of -1000000001 doesn't match fee 0"}}, + [this](Account const& a1, Account const&, ApplyContext& ac) { + // balance is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setFieldAmount(sfBalance, STAmount{1, true}); + BEAST_EXPECT(sle->getFieldAmount(sfBalance).negative()); + ac.view().update(sle); + return true; + }); + } + + void + testTransactionFeeCheck() + { + using namespace test::jtx; + using namespace std::string_literals; + testcase << "Transaction fee checks"; + + doInvariantCheck( + {{"fee paid was negative: -1"}, {"XRP net change of 0 doesn't match fee -1"}}, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{-1}); + + doInvariantCheck( + {{"fee paid exceeds system limit: "s + to_string(kInitialXrp)}, + {"XRP net change of 0 doesn't match fee "s + to_string(kInitialXrp)}}, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{kInitialXrp}); + + doInvariantCheck( + {{"fee paid is 20 exceeds fee specified in transaction."}, + {"XRP net change of 0 doesn't match fee 20"}}, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{20}, + STTx{ttACCOUNT_SET, [](STObject& tx) { tx.setFieldAmount(sfFee, XRPAmount{10}); }}); + } + + void + testNoBadOffers() + { + using namespace test::jtx; + testcase << "no bad offers"; + + doInvariantCheck( + {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { + // offer with negative takerpays + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); + sleNew->setAccountID(sfAccount, a1.id()); + sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); + sleNew->setFieldAmount(sfTakerPays, XRP(-1)); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { + // offer with negative takergets + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); + sleNew->setAccountID(sfAccount, a1.id()); + sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); + sleNew->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleNew->setFieldAmount(sfTakerGets, XRP(-1)); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { + // offer XRP to XRP + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); + sleNew->setAccountID(sfAccount, a1.id()); + sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); + sleNew->setFieldAmount(sfTakerPays, XRP(10)); + sleNew->setFieldAmount(sfTakerGets, XRP(11)); + ac.view().insert(sleNew); + return true; + }); + } + + void + testValidNewAccountRoot() + { + using namespace test::jtx; + testcase << "valid new account root"; + + doInvariantCheck( + {{"account root created illegally"}}, + [](Account const&, Account const&, ApplyContext& ac) { + // Insert a new account root created by a non-payment into + // the view. + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"multiple accounts created in a single transaction"}}, + [](Account const&, Account const&, ApplyContext& ac) { + // Insert two new account roots into the view. + { + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleA3 = std::make_shared(acctKeylet); + ac.view().insert(sleA3); + } + { + Account const a4{"A4"}; + Keylet const acctKeylet = keylet::account(a4); + auto const sleA4 = std::make_shared(acctKeylet); + ac.view().insert(sleA4); + } + return true; + }); + + doInvariantCheck( + {{"account created with wrong starting sequence number"}}, + [](Account const&, Account const&, ApplyContext& ac) { + // Insert a new account root with the wrong starting sequence. + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + sleNew->setFieldU32(sfSequence, ac.view().seq() + 1); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}); + + doInvariantCheck( + {{"pseudo-account created by a wrong transaction type"}}, + [](Account const&, Account const&, ApplyContext& ac) { + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + sleNew->setFieldU32(sfSequence, 0); + sleNew->setFieldH256(sfAMMID, uint256(1)); + sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account created with wrong starting sequence number"}}, + [](Account const&, Account const&, ApplyContext& ac) { + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + sleNew->setFieldU32(sfSequence, ac.view().seq()); + sleNew->setFieldH256(sfAMMID, uint256(1)); + sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttAMM_CREATE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"pseudo-account created with wrong flags"}}, + [](Account const&, Account const&, ApplyContext& ac) { + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + sleNew->setFieldU32(sfSequence, 0); + sleNew->setFieldH256(sfAMMID, uint256(1)); + sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"pseudo-account created with wrong flags"}}, + [](Account const&, Account const&, ApplyContext& ac) { + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + sleNew->setFieldU32(sfSequence, 0); + sleNew->setFieldH256(sfAMMID, uint256(1)); + sleNew->setFieldU32( + sfFlags, + lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth | lsfRequireDestTag); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttAMM_CREATE, [](STObject& tx) {}}); + } + + void + testNoModifiedUnmodifiableFields() + { + testcase("no modified unmodifiable fields"); + using namespace jtx; + + // Initialize with a placeholder value because there's no default ctor + Keylet loanBrokerKeylet = keylet::amendments(); + Preclose const createLoanBroker = [&, this](Account const& a, Account const& b, Env& env) { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + loanBrokerKeylet = this->createLoanBroker(a, env, xrpAsset); + return BEAST_EXPECT(env.le(loanBrokerKeylet)); + }; + + { + auto const mods = std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfSequence) += 1; }, + [](SLE::pointer& sle) { sle->at(sfOwnerNode) += 1; }, + [](SLE::pointer& sle) { sle->at(sfVaultNode) += 1; }, + [](SLE::pointer& sle) { sle->at(sfVaultID) = uint256(1u); }, + [](SLE::pointer& sle) { sle->at(sfAccount) = sle->at(sfOwner); }, + [](SLE::pointer& sle) { sle->at(sfOwner) = sle->at(sfAccount); }, + [](SLE::pointer& sle) { sle->at(sfManagementFeeRate) += 1; }, + [](SLE::pointer& sle) { sle->at(sfCoverRateMinimum) += 1; }, + [](SLE::pointer& sle) { sle->at(sfCoverRateLiquidation) += 1; }, + [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, + [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = sle->at(sfVaultID).value(); }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangeable field"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanBrokerKeylet); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + } + } + + // TODO: Loan Object + + // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation. + // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged. + Keylet closedEndedVaultKeylet = keylet::amendments(); + Preclose const createClosedEndedVault = [&, this]( + Account const& a, Account const&, Env& env) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a, + .asset = xrpIssue(), + .vaultKind = std::to_underlying(VaultKind::ClosedEnded), + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedVaultKeylet = keylet; + return BEAST_EXPECT(env.le(closedEndedVaultKeylet)); + }; + + { + // Each mutation must keep the vault otherwise valid so that only the immutability check + // fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind + // stays within the recognised range. + auto const mods = std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; }, + [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; }, + [](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangeable field"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(closedEndedVaultKeylet); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createClosedEndedVault); + } + } + + { + auto const mods = std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, + [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = uint256(1u); }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangeable field"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }); + } + } + } + + void + testInvariantOverwrite(FeatureBitset features) + { + using namespace test::jtx; + bool const fixEnabled = features[fixCleanup3_1_3]; + std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; + std::initializer_list const passTers = {tesSUCCESS, tesSUCCESS}; + + // Insert two trust line SLEs in hash-sorted order, with the "bad" + // entry at the lower-sorting key so it is visited first by + // ApplyStateTable::visit(). The configurer callables receive the + // SLE and the Issue corresponding to that side's keylet currency. + auto const insertOrderedTrustLinePair = [](ApplyContext& ac, + Account const& a1, + Account const& a2, + Account const& a3, + auto const& badConfig, + auto const& goodConfig) { + char const* const c1 = "USD"; + char const* const c2 = "EUR"; + auto const k1 = keylet::trustLine(a1, a2, a1[c1].currency); + auto const k2 = keylet::trustLine(a1, a3, a1[c2].currency); + + bool const k1First = k1.key < k2.key; + auto const& badKey = k1First ? k1 : k2; + auto const& goodKey = k1First ? k2 : k1; + Issue const badIss{k1First ? a1[c1].currency : a1[c2].currency, a1.id()}; + Issue const goodIss{k1First ? a1[c2].currency : a1[c1].currency, a1.id()}; + + auto const sleBad = std::make_shared(badKey); + badConfig(*sleBad, badIss); + ac.view().insert(sleBad); + + auto const sleGood = std::make_shared(goodKey); + goodConfig(*sleGood, goodIss); + ac.view().insert(sleGood); + }; + + // Regression: bad XRP trust line followed by a valid trust line. + // With the fix, the invariant catches the violation. Without it, + // the valid entry overwrites the flag to false. The keylet + // currencies are non-XRP (the invariant inspects sfLowLimit / + // sfHighLimit issue, not the keylet currency). + testcase << "overwrite: NoXRPTrustLines" + std::string(fixEnabled ? " fix" : ""); + doInvariantCheck( + makeEnv(features), + fixEnabled ? std::vector{{"an XRP trust line was created"}} + : std::vector{}, + [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) { + Account const a3{"A3"}; + insertOrderedTrustLinePair( + ac, + a1, + a2, + a3, + [](SLE& sle, Issue const& iss) { + // sfLowLimit has xrpIssue, making isXrp = true + sle.setFieldAmount(sfLowLimit, STAmount{xrpIssue(), 0}); + sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); + }, + [](SLE& sle, Issue const& iss) { + sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); + sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); + }); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + fixEnabled ? failTers : passTers); + + // Regression: bad deep-freeze trust line followed by a valid one. + testcase << "overwrite: NoDeepFreeze" + std::string(fixEnabled ? " fix" : ""); + doInvariantCheck( + makeEnv(features), + fixEnabled ? std::vector{{"a trust line with deep freeze flag without " + "normal freeze was created"}} + : std::vector{}, + [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) { + Account const a3{"A3"}; + insertOrderedTrustLinePair( + ac, + a1, + a2, + a3, + [](SLE& sle, Issue const& iss) { + sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); + sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); + sle.setFieldU32(sfFlags, lsfLowDeepFreeze); + }, + [](SLE& sle, Issue const& iss) { + sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); + sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); + sle.setFieldU32(sfFlags, 0u); + }); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + fixEnabled ? failTers : passTers); + + // Regression: MPT OutstandingAmount exceeds max, but locked <= + // outstanding. Plain assignment would overwrite bad_ = true. + // With the fix, NoZeroEscrow catches it. + // Without the fix, NoZeroEscrow passes but ValidMPTIssuance + // still fires ("a MPT issuance was created"). + testcase << "overwrite: NoZeroEscrow MPT" + std::string(fixEnabled ? " fix" : ""); + doInvariantCheck( + makeEnv(features), + fixEnabled ? std::vector{{"escrow specifies invalid amount"}} + : std::vector{{"a MPT issuance was created"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + // outstanding exceeds kMaxMpTokenAmount -> checkAmount sets bad_ + sleNew->setFieldU64(sfOutstandingAmount, kMaxMpTokenAmount + 1); + // locked is valid and <= outstanding -> must NOT clear bad_ + sleNew->setFieldU64(sfLockedAmount, 10); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + failTers); + } + + void + testSponsorship() + { + using namespace test::jtx; + using namespace std::string_literals; + testcase("Sponsorship"); + { + auto const expectMessage = + "SponsoredOwnerCount does not equal SponsoringOwnerCount delta."; + + doInvariantCheck( + {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setFieldU32(sfSponsoredOwnerCount, 1); + ac.view().update(sle); + return true; + }); + + doInvariantCheck( + {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setFieldU32(sfSponsoringOwnerCount, 1); + ac.view().update(sle); + return true; + }); + } + + { + auto const expectMessage = + "OwnerCount must be greater than or equal to SponsoredOwnerCount."; + + doInvariantCheck( + {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setFieldU32(sfOwnerCount, 0); + sle->setFieldU32(sfSponsoredOwnerCount, 1); + ac.view().update(sle); + + auto const sle2 = ac.view().peek(keylet::account(a2.id())); + if (!sle2) + return false; + sle2->setFieldU32(sfSponsoringOwnerCount, 1); + ac.view().update(sle2); + return true; + }); + } + + { + auto const expectMessage = + "SponsoredObjectOwnerCount does not equal SponsoredOwnerCount delta."; + uint256 checkID; + + doInvariantCheck( + {{expectMessage}}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto const check = ac.view().peek(keylet::check(checkID)); + if (!check) + return false; + check->setAccountID(sfSponsor, a2.id()); + ac.view().update(check); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&checkID](Account const& a1, Account const& a2, Env& env) { + checkID = keylet::check(a1.id(), SeqProxy::rawSequence(env.seq(a1))).key; + env(check::create(a1, a2, XRP(1))); + return true; + }); + } + + { + auto const expectMessage = + "Invariant failed: Net delta of SponsoringAccountCount does " + "not match net delta of sfSponsor presence."; + + doInvariantCheck( + {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setFieldU32(sfSponsoringAccountCount, 1); + ac.view().update(sle); + return true; + }); + + doInvariantCheck( + {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setAccountID(sfSponsor, a2.id()); + ac.view().update(sle); + return true; + }); + } + } + + void + testObjectHasPseudoAccount() + { + testcase << "object has pseudo-account"; + using namespace jtx; + + auto const amendments = all_ | fixCleanup3_3_0; + + // Vault: object deleted without its pseudo-account + { + Keylet vaultKeylet = keylet::amendments(); + doInvariantCheck( + Env{*this, amendments}, + {{"deleted Vault without deleting its pseudo-account"}}, + [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(vaultKeylet); + if (!sle) + return false; + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&vaultKeylet](Account const& a1, Account const&, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + vaultKeylet = keylet; + return true; + }); + } + + // AMM: object deleted without its pseudo-account + { + uint256 ammID{}; + Account const gw{"gw"}; + doInvariantCheck( + Env{*this, amendments}, + {{"deleted AMM without deleting its pseudo-account"}}, + [&ammID](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::amm(ammID)); + if (!sle) + return false; + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ttAMM_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&ammID, &gw](Account const&, Account const&, Env& env) { + env.fund(XRP(1'000), gw); + AMM const amm(env, gw, XRP(100), gw["USD"](100)); + ammID = amm.ammID(); + return true; + }); + } + + // LoanBroker: object deleted without its pseudo-account + { + Keylet loanBrokerKeylet = keylet::amendments(); + doInvariantCheck( + Env{*this, amendments}, + {{"deleted LoanBroker without deleting its pseudo-account"}}, + [&loanBrokerKeylet](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanBrokerKeylet); + if (!sle) + return false; + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&loanBrokerKeylet, this](Account const& a1, Account const&, Env& env) { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + loanBrokerKeylet = this->createLoanBroker(a1, env, xrpAsset); + return BEAST_EXPECT(env.le(loanBrokerKeylet)); + }); + } + + // Deleted object missing sfAccount field (defensive check). + // Manually construct the view to place a vault SLE without + // sfAccount into the base ledger, then erase it. + { + Env env{*this, amendments}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + env.close(); + + OpenView ov{*env.current()}; + + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ov.seq())); + auto sleVault = std::make_shared(vaultKeylet); + sleVault->makeFieldAbsent(sfAccount); + ov.rawInsert(sleVault); + + STTx const tx{ttVAULT_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 sle = ac.view().peek(vaultKeylet); + if (!BEAST_EXPECT(sle)) + return; + ac.view().erase(sle); + + 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("is missing pseudo-account field")); + } + } + + void + testTxCheckException() + { + testcase << "txCheck exception"; + using namespace jtx; + + // A TxInvariantCheck that throws from the requested hook, so we can + // exercise checkInvariantsHelper's catch block via the + // transaction-specific layer (as opposed to the protocol layer, + // which testObjectHasPseudoAccount's last case already covers via a + // real Transactor's finalizeInvariants). + enum class ThrowFrom { VisitEntry, Finalize }; + + struct ThrowingTxInvariantCheck : TxInvariantCheck + { + ThrowFrom const throwFrom; + + explicit ThrowingTxInvariantCheck(ThrowFrom throwFrom) : throwFrom(throwFrom) + { + } + + void + visitEntry(bool, SLE::const_ref, SLE::const_ref) override + { + if (throwFrom == ThrowFrom::VisitEntry) + throw std::runtime_error("test-injected visitEntry exception"); + } + + [[nodiscard]] bool + finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override + { + if (throwFrom == ThrowFrom::Finalize) + throw std::runtime_error("test-injected finalize exception"); + return true; + } + }; + + for (auto const throwFrom : {ThrowFrom::VisitEntry, ThrowFrom::Finalize}) + { + Env env{*this}; + Account const alice{"alice"}; + env.fund(XRP(1000), alice); + env.close(); + + OpenView ov{*env.current()}; + 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()); + + // visitEntry only runs for entries the transaction touched, so + // make a modification for the traversal to report. + auto sle = ac.view().peek(keylet::account(alice.id())); + if (!BEAST_EXPECT(sle)) + return; + sle->at(sfSequence) = sle->at(sfSequence) + 1; + ac.view().update(sle); + + ThrowingTxInvariantCheck throwing{throwFrom}; + TER terActual = tesSUCCESS; + for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)}) + { + terActual = checkInvariants(ac, terActual, XRPAmount{}, throwing); + BEAST_EXPECT(terExpect == terActual); + BEAST_EXPECT(sink.messages().str().contains( + "Transaction caused an exception during invariant checks")); + } + } + } + + void + testTxCheckFinalizeFalse() + { + testcase << "txCheck finalize returns false"; + using namespace jtx; + + // A TxInvariantCheck whose finalize returns false, so we can exercise + // the "Transaction has failed one or more transaction invariants" + // log path in checkInvariantsHelper independently of any real + // transactor. This is the transaction-layer analogue of the + // protocol-layer coverage in testObjectHasPseudoAccount / others. + struct FailingTxInvariantCheck : TxInvariantCheck + { + void + visitEntry(bool, SLE::const_ref, SLE::const_ref) override + { + } + + [[nodiscard]] bool + finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override + { + return false; + } + }; + + Env env{*this}; + Account const alice{"alice"}; + env.fund(XRP(1000), alice); + env.close(); + + OpenView ov{*env.current()}; + 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()); + + FailingTxInvariantCheck failing; + TER terActual = tesSUCCESS; + for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)}) + { + terActual = checkInvariants(ac, terActual, XRPAmount{}, failing); + BEAST_EXPECT(terExpect == terActual); + BEAST_EXPECT(sink.messages().str().contains( + "Transaction has failed one or more transaction invariants")); + // The protocol-layer log must not appear: only the tx-layer + // finalize failed here. + BEAST_EXPECT(!sink.messages().str().contains( + "Transaction has failed one or more global invariants")); + } + } + + void + run() override + { + testXRPNotCreated(); + testAccountRootsNotRemoved(); + testAccountRootsDeletedClean(); + testTypesMatch(); + testXRPBalanceCheck(); + testTransactionFeeCheck(); + testNoBadOffers(); + testValidNewAccountRoot(); + testNoModifiedUnmodifiableFields(); + testInvariantOverwrite(all_); + testInvariantOverwrite(all_ - fixCleanup3_1_3); + testObjectHasPseudoAccount(); + testSponsorship(); + testTxCheckException(); + testTxCheckFinalizeFalse(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsMisc, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsPermissioned_test.cpp b/src/test/app/invariants/InvariantsPermissioned_test.cpp new file mode 100644 index 0000000000..87349fb9e1 --- /dev/null +++ b/src/test/app/invariants/InvariantsPermissioned_test.cpp @@ -0,0 +1,957 @@ +#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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class InvariantsPermissioned_test : public InvariantsBase +{ + FeatureBitset const all_{test::jtx::testableAmendments()}; + + void + testPermissionedDomainInvariants(FeatureBitset features) + { + using namespace test::jtx; + + bool const fixEnabled = features[fixCleanup3_1_3]; + std::initializer_list const badTers = {tecINVARIANT_FAILED, tecINVARIANT_FAILED}; + std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; + + testcase << "PermissionedDomain" + std::string(fixEnabled ? " fix" : ""); + + doInvariantCheck( + makeEnv(features), + {{"permissioned domain with no rules."}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + return createPermissionedDomain(ac, a1, a2, 0).get(); + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain 2"; + + static constexpr auto kTooBig = kMaxPermissionedDomainCredentialsArraySize + 1; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + return !!createPermissionedDomain(ac, a1, a2, kTooBig); + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain 3"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain credentials aren't sorted"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto slePd = createPermissionedDomain(ac, a1, a2, 0); + + STArray credentials(sfAcceptedCredentials, 2); + for (std::size_t n = 0; n < 2; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + auto credType = std::string("cred_type") + std::to_string(9 - n); + cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); + credentials.pushBack(std::move(cred)); + } + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain 4"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain credentials aren't unique"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto slePd = createPermissionedDomain(ac, a1, a2, 0); + + STArray credentials(sfAcceptedCredentials, 2); + for (std::size_t n = 0; n < 2; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + cred.setFieldVL(sfCredentialType, Slice("cred_type", 9)); + credentials.pushBack(std::move(cred)); + } + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain Set 1"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain with no rules."}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // create PD + auto slePd = createPermissionedDomain(ac, a1, a2); + + // update PD with empty rules + { + STArray const credentials(sfAcceptedCredentials, 2); + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + } + + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain Set 2"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // create PD + auto slePd = createPermissionedDomain(ac, a1, a2); + + // update PD + { + STArray credentials(sfAcceptedCredentials, kTooBig); + + for (std::size_t n = 0; n < kTooBig; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + auto credType = "cred_type2" + std::to_string(n); + cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); + credentials.pushBack(std::move(cred)); + } + + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + } + + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain Set 3"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain credentials aren't sorted"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // create PD + auto slePd = createPermissionedDomain(ac, a1, a2); + + // update PD + { + STArray credentials(sfAcceptedCredentials, 2); + for (std::size_t n = 0; n < 2; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + auto credType = std::string("cred_type2") + std::to_string(9 - n); + cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); + credentials.pushBack(std::move(cred)); + } + + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + } + + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain Set 4"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain credentials aren't unique"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // create PD + auto slePd = createPermissionedDomain(ac, a1, a2); + + // update PD + { + STArray credentials(sfAcceptedCredentials, 2); + for (std::size_t n = 0; n < 2; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + cred.setFieldVL(sfCredentialType, Slice("cred_type", 9)); + credentials.pushBack(std::move(cred)); + } + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + } + + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + std::initializer_list const goodTers = {tesSUCCESS, tesSUCCESS}; + + std::vector const badMoreThan1{ + {"transaction affected more than 1 permissioned domain entry."}}; + std::vector const emptyV; + std::vector const badNoDomains{{"no domain objects affected by"}}; + std::vector const badNotDeleted{ + {"domain object modified, but not deleted by "}}; + std::vector const badDeleted{{"domain object deleted by"}}; + std::vector const badTx{ + {"domain object(s) affected by an unauthorized transaction."}}; + + { + testcase << "PermissionedDomain set 2 domains "; + doInvariantCheck( + makeEnv(features), + fixEnabled ? badMoreThan1 : emptyV, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + createPermissionedDomain(ac, a1, a2); + createPermissionedDomain(ac, a1, a2, 2, 11); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : goodTers); + } + + { + testcase << "PermissionedDomain del 2 domains"; + + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + fixEnabled ? badMoreThan1 : emptyV, + [&pd1, &pd2](Account const&, Account const&, ApplyContext& ac) { + auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1}); + auto sle2 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd2}); + ac.view().erase(sle1); + ac.view().erase(sle2); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, + fixEnabled ? failTers : goodTers); + } + + { + testcase << "PermissionedDomain set 0 domains "; + doInvariantCheck( + makeEnv(features), + fixEnabled ? badNoDomains : emptyV, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? badTers : goodTers); + } + + { + testcase << "PermissionedDomain del 0 domains"; + + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + fixEnabled ? badNoDomains : emptyV, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, + fixEnabled ? badTers : goodTers); + } + + { + testcase << "PermissionedDomain set, delete domain"; + + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + fixEnabled ? badDeleted : emptyV, + [&pd1](Account const&, Account const&, ApplyContext& ac) { + auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1}); + ac.view().erase(sle1); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : goodTers); + } + + { + testcase << "PermissionedDomain del, create domain "; + doInvariantCheck( + makeEnv(features), + fixEnabled ? badNotDeleted : emptyV, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + createPermissionedDomain(ac, a1, a2); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, + fixEnabled ? failTers : goodTers); + } + + { + testcase << "PermissionedDomain invalid tx"; + + doInvariantCheck( + fixEnabled ? badTx : emptyV, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + createPermissionedDomain(ac, a1, a2); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + failTers); + } + } + + void + testPermissionedDEX(FeatureBitset features) + { + using namespace test::jtx; + + bool const fixEnabled = features[fixCleanup3_1_3]; + + testcase << "PermissionedDEX" + std::string(fixEnabled ? " fix" : ""); + + doInvariantCheck( + makeEnv(features), + {{"domain doesn't exist"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a1.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a1); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ + ttOFFER_CREATE, + [](STObject& tx) { + tx.setFieldH256( + sfDomainID, + uint256{"F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E33" + "70F3649CE134E5"}); + Account const a1{"A1"}; + tx.setFieldAmount(sfTakerPays, a1["USD"](10)); + tx.setFieldAmount(sfTakerGets, XRP(1)); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + // missing domain ID in offer object + doInvariantCheck( + makeEnv(features), + {{"hybrid offer is malformed"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFlag(lsfHybrid); + + STArray bookArr; + bookArr.pushBack(STObject::makeInnerObject(sfBook)); + sleOffer->setFieldArray(sfAdditionalBooks, bookArr); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ttOFFER_CREATE, [&](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + // more than one entry in sfAdditionalBooks + { + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + {{"hybrid offer is malformed"}}, + [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFlag(lsfHybrid); + sleOffer->setFieldH256(sfDomainID, pd1); + + STArray bookArr; + bookArr.pushBack(STObject::makeInnerObject(sfBook)); + bookArr.pushBack(STObject::makeInnerObject(sfBook)); + sleOffer->setFieldArray(sfAdditionalBooks, bookArr); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ttOFFER_CREATE, [&](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + } + + // empty sfAdditionalBooks (size 0) + { + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + fixEnabled ? std::vector{{"hybrid offer is malformed"}} + : std::vector{}, + [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFlag(lsfHybrid); + sleOffer->setFieldH256(sfDomainID, pd1); + + STArray const bookArr; // empty array, size 0 + sleOffer->setFieldArray(sfAdditionalBooks, bookArr); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ttOFFER_CREATE, [&](STObject&) {}}, + fixEnabled ? std::initializer_list{tecINVARIANT_FAILED, tecINVARIANT_FAILED} + : std::initializer_list{tesSUCCESS, tesSUCCESS}); + } + + // hybrid offer missing sfAdditionalBooks + { + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + {{"hybrid offer is malformed"}}, + [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFlag(lsfHybrid); + sleOffer->setFieldH256(sfDomainID, pd1); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ttOFFER_CREATE, [&](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + } + + { + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + {{"transaction consumed wrong domains"}}, + [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFieldH256(sfDomainID, pd1); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ + ttOFFER_CREATE, + [&pd2, &a1](STObject& tx) { + tx.setFieldH256(sfDomainID, pd2); + tx.setFieldAmount(sfTakerPays, a1["USD"](10)); + tx.setFieldAmount(sfTakerGets, XRP(1)); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + } + + { + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + {{"domain transaction affected regular offers"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ + ttOFFER_CREATE, + [&](STObject& tx) { + Account const a1{"A1"}; + tx.setFieldH256(sfDomainID, pd1); + tx.setFieldAmount(sfTakerPays, a1["USD"](10)); + tx.setFieldAmount(sfTakerGets, XRP(1)); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + } + } + + void + testPermissionedDEXDeletedOfferFallback() + { + using namespace test::jtx; + + testcase << "PermissionedDEX null after"; + + // Tx is OfferCreate on pd2. Tracking pd1 fails the invariant iff that + // domain lands in the set finalize consults. after == null is never + // tracked (pre-340: after-only; post-340: early return) — same result, + // both sides are coverage/regression that we do not fall back to before. + auto const check = [this]( + FeatureBitset features, + bool const afterIsNull, + bool const isDelete, + bool const expectInvariantFailure) { + Env env(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + env.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env, a1, a2); + [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env, a1, a2); + env.close(); + + auto sleOffer = + std::make_shared(keylet::offer(a2.id(), SeqProxy::rawSequence(10))); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFieldH256(sfDomainID, pd1); + + CurrentTransactionRulesGuard const rulesGuard(env.current()->rules()); + + ValidPermissionedDEX invariant; + if (afterIsNull) + { + // Defensive path: after is null. Must not fall back to before. + invariant.visitEntry(isDelete, sleOffer, nullptr); + } + else + { + // Normal / real-erase path: after is the offer on pd1. + invariant.visitEntry(isDelete, nullptr, sleOffer); + } + + STTx const tx{ttOFFER_CREATE, [&pd2, &a1](STObject& tx) { + tx.setFieldH256(sfDomainID, pd2); + tx.setFieldAmount(sfTakerPays, a1["USD"](10)); + tx.setFieldAmount(sfTakerGets, XRP(1)); + }}; + + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + bool const passed = + invariant.finalize(tx, tesSUCCESS, XRPAmount{}, *env.current(), jlog); + BEAST_EXPECT(passed != expectInvariantFailure); + if (expectInvariantFailure) + { + BEAST_EXPECT(sink.messages().str().contains("transaction consumed wrong domains")); + } + else + { + BEAST_EXPECT(sink.messages().str().empty()); + } + }; + + auto const pre = all_ - fixCleanup3_4_0; + auto const post = all_; + + // after == null: not tracked + check(pre, true, true, false); + check(post, true, true, false); + + // after == offer on pd1 + // pre-340: domainsOld_ (delete still inserted) → fail + check(pre, false, true, true); + // post-340: isDelete → only domainsOld_ → pass; !isDelete → domains_ → fail + check(post, false, true, false); + check(post, false, false, true); + } + + void + testBookDirectoryExchangeRate() + { + using namespace test::jtx; + testcase << "book directory exchange rate"; + + auto const getBookRootKey = [](Account const& account, std::uint64_t quality) { + Book const book{xrpIssue(), account["USD"], std::nullopt}; + return keylet::quality(keylet::book(book), quality); + }; + + // Root book-directory pages carry exchange-rate metadata that must + // match the quality encoded in the directory key. + auto const makeRootPage = [](Keylet const& dir, std::uint64_t exchangeRate) { + auto sleDir = std::make_shared(dir); + sleDir->setFieldH256(sfRootIndex, dir.key); + STVector256 indexes; + indexes.pushBack(uint256{1}); + sleDir->setFieldV256(sfIndexes, indexes); + sleDir->setFieldU64(sfExchangeRate, exchangeRate); + return sleDir; + }; + + // Child pages do not carry quality metadata; they only point back to + // the root directory. + auto const makeChildPage = [](Keylet const& rootDir) { + auto sleDir = std::make_shared(keylet::page(rootDir, 1)); + sleDir->setFieldH256(sfRootIndex, rootDir.key); + STVector256 indexes; + indexes.pushBack(uint256{2}); + sleDir->setFieldV256(sfIndexes, indexes); + return sleDir; + }; + + auto const makeOfferCreateTx = [] { + return STTx{ttOFFER_CREATE, [](STObject& tx) { + Account const account{"A1"}; + tx.setFieldAmount(sfTakerPays, XRP(1)); + tx.setFieldAmount(sfTakerGets, account["USD"](1)); + }}; + }; + std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; + + // Creating a root book directory with mismatched exchange-rate + // metadata violates the invariant. + doInvariantCheck( + {{"book directory exchange rate does not match directory quality"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto const directoryQuality = STAmount::kURateOne; + auto const dir = getBookRootKey(a1, directoryQuality); + ac.view().insert(makeRootPage(dir, directoryQuality + 1)); + return true; + }, + XRPAmount{}, + makeOfferCreateTx(), + failTers); + + // A new child page must point to an existing root page. + doInvariantCheck( + {{"book directory root missing"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto const directoryQuality = STAmount::kURateOne; + auto const rootDir = getBookRootKey(a1, directoryQuality); + // Insert only the child page. It points at rootDir, but the + // corresponding root page is intentionally missing. + ac.view().insert(makeChildPage(rootDir)); + return true; + }, + XRPAmount{}, + makeOfferCreateTx(), + failTers); + + // Legacy bad-root tolerance: + // - The view contains a pre-existing root page with bad sfExchangeRate + // metadata. + // - The simulated transaction only creates a child page pointing to + // that root. + // - The invariant must pass because this transaction did not create + // the bad root, only adding a child page. + { + Env env{*this, all_}; + Account const a1{"A1"}; + env.fund(XRP(1000), a1); + env.close(); + + OpenView view{*env.current()}; + auto const directoryQuality = STAmount::kURateOne; + auto const rootDir = getBookRootKey(a1, directoryQuality); + view.rawInsert(makeRootPage(rootDir, directoryQuality + 1)); + + ValidBookDirectory invariant; + invariant.visitEntry(false, nullptr, makeChildPage(rootDir)); + + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + BEAST_EXPECT( + invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); + } + + // A bad root is rejected when added, ignored when a legacy bad root is + // modified without changing sfRootIndex or deleted, and checked when a + // modified directory changes sfRootIndex. + { + Env env{*this, all_}; + Account const a1{"A1"}; + env.fund(XRP(1000), a1); + env.close(); + + OpenView view{*env.current()}; + auto const directoryQuality = STAmount::kURateOne; + auto const rootDir = getBookRootKey(a1, directoryQuality); + auto const missingRootDir = getBookRootKey(a1, directoryQuality + 1); + auto const badRoot = makeRootPage(rootDir, directoryQuality + 1); + view.rawInsert(badRoot); + + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + + { + // add + ValidBookDirectory invariant; + invariant.visitEntry(false, nullptr, badRoot); + + BEAST_EXPECT( + !invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); + } + { + // modify (without changing the sfRootIndex) + ValidBookDirectory invariant; + invariant.visitEntry(false, badRoot, badRoot); + + BEAST_EXPECT( + invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); + } + { + // modify (changing sfRootIndex to a missing root) + auto const childBefore = makeChildPage(rootDir); + auto const childAfter = std::make_shared(*childBefore, childBefore->key()); + childAfter->setFieldH256(sfRootIndex, missingRootDir.key); + + ValidBookDirectory invariant; + invariant.visitEntry(false, childBefore, childAfter); + + test::StreamSink missingRootSink{beast::Severity::Warning}; + beast::Journal const missingRootJlog{missingRootSink}; + BEAST_EXPECT(!invariant.finalize( + makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, missingRootJlog)); + BEAST_EXPECT( + missingRootSink.messages().str().contains("book directory root missing")); + } + { + // delete + view.rawErase(badRoot); + BEAST_EXPECT(!view.exists(rootDir)); + + ValidBookDirectory invariant; + invariant.visitEntry(true, badRoot, badRoot); + BEAST_EXPECT( + invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); + } + } + } + + static SLE::pointer + createPermissionedDomain( + ApplyContext& ac, + test::jtx::Account const& a1, + test::jtx::Account const& a2, + std::uint32_t numCreds = 2, + std::uint32_t seq = 10) + { + Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), SeqProxy::rawSequence(seq)); + auto sle = std::make_shared(pdKeylet); + + sle->setAccountID(sfOwner, a1); + sle->setFieldU32(sfSequence, seq); + + if (numCreds != 0u) + { + // This array is sorted naturally, but if you are going to change + // this behavior, don't forget to use credentials::makeSorted + STArray credentials(sfAcceptedCredentials, numCreds); + for (std::size_t n = 0; n < numCreds; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + auto credType = "cred_type" + std::to_string(n); + cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); + credentials.pushBack(std::move(cred)); + } + sle->setFieldArray(sfAcceptedCredentials, credentials); + } + + ac.view().insert(sle); + return sle; + } + + static std::pair + createPermissionedDomainEnv( + test::jtx::Env& env, + test::jtx::Account const& a1, + test::jtx::Account const& a2, + std::uint32_t numCreds = 2) + { + using namespace test::jtx; + + pdomain::Credentials credentials; + + for (std::size_t n = 0; n < numCreds; ++n) + { + auto credType = "cred_type" + std::to_string(n); + credentials.push_back({.issuer = a2, .credType = credType}); + } + + std::uint32_t const seq = env.seq(a1); + env(pdomain::setTx(a1, credentials)); + uint256 const key = pdomain::getNewDomain(env.meta()); + + return {seq, key}; + } + + void + run() override + { + testPermissionedDomainInvariants(all_); + testPermissionedDomainInvariants(all_ - fixCleanup3_1_3); + testPermissionedDEX(all_); + testPermissionedDEX(all_ - fixCleanup3_1_3); + testPermissionedDEXDeletedOfferFallback(); + testBookDirectoryExchangeRate(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsPermissioned, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsPseudoAccount_test.cpp b/src/test/app/invariants/InvariantsPseudoAccount_test.cpp new file mode 100644 index 0000000000..c43e73aca8 --- /dev/null +++ b/src/test/app/invariants/InvariantsPseudoAccount_test.cpp @@ -0,0 +1,461 @@ +#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 +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class InvariantsPseudoAccount_test : public InvariantsBase +{ + void + testValidPseudoAccounts() + { + testcase << "valid pseudo accounts"; + + using namespace jtx; + + AccountID pseudoAccountID; + Preclose const createPseudo = [&, this](Account const& a, Account const& b, Env& env) { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + // Create vault + Vault const vault{env}; + auto [tx, vKeylet] = vault.create({.owner = a, .asset = xrpAsset}); + env(tx); + env.close(); + if (auto const vSle = env.le(vKeylet); BEAST_EXPECT(vSle)) + { + pseudoAccountID = vSle->at(sfAccount); + } + + return BEAST_EXPECT(env.le(keylet::account(pseudoAccountID))); + }; + + /* Cases to check + "pseudo-account has 0 pseudo-account fields set" + "pseudo-account has 2 pseudo-account fields set" + "pseudo-account sequence changed" + "pseudo-account flags are not set" + "pseudo-account has a regular key" + "pseudo-account has a sponsorship field" + */ + struct Mod + { + std::string expectedFailure; + std::function func; + }; + auto const mods = std::to_array({ + { + .expectedFailure = "pseudo-account has 0 pseudo-account fields set", + .func = + [this](SLE::pointer& sle) { + BEAST_EXPECT(sle->at(~sfVaultID)); + sle->at(~sfVaultID) = std::nullopt; + }, + }, + { + .expectedFailure = "pseudo-account sequence changed", + .func = [](SLE::pointer& sle) { sle->at(sfSequence) = 12345; }, + }, + { + .expectedFailure = "pseudo-account flags are not set", + .func = [](SLE::pointer& sle) { sle->at(sfFlags) = lsfNoFreeze; }, + }, + { + .expectedFailure = "pseudo-account has a regular key", + .func = [](SLE::pointer& sle) { sle->at(sfRegularKey) = Account("regular").id(); }, + }, + { + .expectedFailure = "pseudo-account has a sponsorship field", + .func = [](SLE::pointer& sle) { sle->at(sfSponsoredOwnerCount) = 1; }, + }, + { + .expectedFailure = "pseudo-account has a sponsorship field", + .func = [](SLE::pointer& sle) { sle->at(sfSponsoringOwnerCount) = 1; }, + }, + { + .expectedFailure = "pseudo-account has a sponsorship field", + .func = [](SLE::pointer& sle) { sle->at(sfSponsoringAccountCount) = 1; }, + }, + { + .expectedFailure = "pseudo-account has a sponsorship field", + .func = [](SLE::pointer& sle) { sle->at(sfSponsor) = Account("sponsor").id(); }, + }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{mod.expectedFailure}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::account(pseudoAccountID)); + if (!sle) + return false; + mod.func(sle); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createPseudo); + } + for (auto const pField : getPseudoAccountFields()) + { + // createPseudo creates a vault, so sfVaultID will be set, and + // setting it again will not cause an error + if (pField == &sfVaultID) + continue; + doInvariantCheck( + {{"pseudo-account has 2 pseudo-account fields set"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::account(pseudoAccountID)); + if (!sle) + return false; + + auto const vaultID = ~sle->at(~sfVaultID); + BEAST_EXPECT(vaultID && !sle->isFieldPresent(*pField)); + sle->setFieldH256(*pField, *vaultID); + + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createPseudo); + } + + // Take one of the regular accounts and set the sequence to 0, which + // will make it look like a pseudo-account + doInvariantCheck( + {{"pseudo-account has 0 pseudo-account fields set"}, + {"pseudo-account sequence changed"}, + {"pseudo-account flags are not set"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->at(sfSequence) = 0; + ac.view().update(sle); + return true; + }); + } + + void + testValidLoanBroker() + { + testcase << "valid loan broker"; + + using namespace jtx; + + enum class Asset { XRP, IOU, MPT }; + auto const assetTypes = std::to_array({Asset::XRP, Asset::IOU, Asset::MPT}); + + for (auto const assetType : assetTypes) + { + // Initialize with a placeholder value because there's no default + // ctor + auto const setupAsset = + [&](Account const& alice, Account const& issuer, Env& env) -> PrettyAsset { + switch (assetType) + { + case Asset::IOU: { + PrettyAsset const iouAsset = issuer["IOU"]; + env(trust(alice, iouAsset(1000))); + env(pay(issuer, alice, iouAsset(1000))); + env.close(); + return iouAsset; + } + case Asset::MPT: { + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = alice}); + env(pay(issuer, alice, mptAsset(1000))); + env.close(); + return mptAsset; + } + case Asset::XRP: + default: + return PrettyAsset{xrpIssue(), 1'000'000}; + } + }; + + Keylet loanBrokerKeylet = keylet::amendments(); + Preclose const createLoanBroker = + [&, this](Account const& alice, Account const& issuer, Env& env) { + auto const asset = setupAsset(alice, issuer, env); + loanBrokerKeylet = this->createLoanBroker(alice, env, asset); + return BEAST_EXPECT(env.le(loanBrokerKeylet)); + }; + + // Ensure the test scenarios are set up completely. The test cases + // will need to recompute any of these values it needs for itself + // rather than trying to return a bunch of items + auto setupTest = [&, this](Account const& a1, Account const&, ApplyContext& ac) + -> std::optional> { + if (loanBrokerKeylet.type != ltLOAN_BROKER) + return {}; + auto sleBroker = ac.view().peek(loanBrokerKeylet); + if (!sleBroker) + return {}; + if (!BEAST_EXPECT(sleBroker->at(sfOwnerCount) == 0)) + return {}; + // Need to touch sleBroker so that it is included in the + // modified entries for the invariant to find + ac.view().update(sleBroker); + + // The pseudo-account holds the directory, so get it + auto const pseudoAccountID = sleBroker->at(sfAccount); + auto const pseudoAccountKeylet = keylet::account(pseudoAccountID); + // Strictly speaking, we don't need to load the + // ACCOUNT_ROOT, but check anyway + auto slePseudo = ac.view().peek(pseudoAccountKeylet); + if (!BEAST_EXPECT(slePseudo)) + return {}; + // Make sure the directory doesn't already exist + auto const dirKeylet = keylet::ownerDir(pseudoAccountID); + auto sleDir = ac.view().peek(dirKeylet); + auto const describe = describeOwnerDir(pseudoAccountID); + if (!sleDir) + { + // Create the directory + BEAST_EXPECT( + ::xrpl::directory::createRoot( + ac.view(), dirKeylet, loanBrokerKeylet.key, describe) == 0); + + sleDir = ac.view().peek(dirKeylet); + } + + return std::make_pair(slePseudo, sleDir); + }; + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has multiple directory " + "pages"}}, + [&setupTest, this](Account const& a1, Account const& a2, ApplyContext& ac) { + auto test = setupTest(a1, a2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto const describe = describeOwnerDir(slePseudo->at(sfAccount)); + + BEAST_EXPECT( + ::xrpl::directory::insertPage( + ac.view(), + 0, + sleDir, + 0, + sleDir, + slePseudo->key(), + keylet::page(sleDir->key(), 0), + describe) == 1); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has multiple indexes in " + "the Directory root"}}, + [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { + auto test = setupTest(a1, a2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto indexes = sleDir->getFieldV256(sfIndexes); + + // Put some extra garbage into the directory + for (auto const& key : {slePseudo->key(), sleDir->key()}) + { + ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key); + } + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker directory corrupt"}}, + [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { + auto test = setupTest(a1, a2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto const describe = describeOwnerDir(slePseudo->at(sfAccount)); + // Empty vector will overwrite the existing entry for the + // holding, if any, avoiding the "has multiple indexes" + // failure. + STVector256 indexes; + + // Put one meaningless key into the directory + auto const key = keylet::account(Account("random").id()).key; + ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has an unexpected entry in " + "the directory"}}, + [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { + auto test = setupTest(a1, a2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + // Empty vector will overwrite the existing entry for the + // holding, if any, avoiding the "has multiple indexes" + // failure. + STVector256 indexes; + + ::xrpl::directory::insertKey( + ac.view(), sleDir, 0, false, indexes, slePseudo->key()); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker sequence number decreased"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + if (loanBrokerKeylet.type != ltLOAN_BROKER) + return false; + auto sleBroker = ac.view().peek(loanBrokerKeylet); + if (!sleBroker) + return false; + if (!BEAST_EXPECT(sleBroker->at(sfLoanSequence) > 0)) + return false; + // Need to touch sleBroker so that it is included in the + // modified entries for the invariant to find + ac.view().update(sleBroker); + + sleBroker->at(sfLoanSequence) -= 1; + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + // Test: cover available less than pseudo-account asset balance + { + 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)); + }; + + doInvariantCheck( + {{"Loan Broker cover available is less than pseudo-account asset balance"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(brokerKeylet); + if (!BEAST_EXPECT(sle)) + return false; + // Pseudo-account holds 10 units, set cover to 5 + sle->at(sfCoverAvailable) = Number(5); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createBrokerWithCover); + } + + // Test: cover available greater than pseudo-account asset balance + // (requires fixCleanup3_1_3) + doInvariantCheck( + {{"Loan Broker cover available is greater than pseudo-account asset balance"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanBrokerKeylet); + if (!BEAST_EXPECT(sle)) + return false; + // Pseudo-account has no cover deposited; set cover + // higher than any incidental balance + sle->at(sfCoverAvailable) = Number(1'000'000); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + } + } + + void + run() override + { + testValidPseudoAccounts(); + testValidLoanBroker(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsPseudoAccount, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsTrustLine_test.cpp b/src/test/app/invariants/InvariantsTrustLine_test.cpp new file mode 100644 index 0000000000..e0995fc431 --- /dev/null +++ b/src/test/app/invariants/InvariantsTrustLine_test.cpp @@ -0,0 +1,237 @@ +#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 +#include +#include + +namespace xrpl::test { + +class InvariantsTrustLine_test : public InvariantsBase +{ + void + testNoXRPTrustLine() + { + using namespace test::jtx; + testcase << "trust lines with XRP not allowed"; + doInvariantCheck( + {{"an XRP trust line was created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + // create simple trust SLE with xrp currency + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, xrpIssue().currency)); + ac.view().insert(sleNew); + return true; + }); + } + + void + testNoDeepFreezeTrustLinesWithoutFreeze() + { + using namespace test::jtx; + testcase << "trust lines with deep freeze flag without freeze " + "not allowed"; + doInvariantCheck( + {{"a trust line with deep freeze flag without normal freeze was " + "created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); + sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); + sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); + + std::uint32_t uFlags = 0u; + uFlags |= lsfLowDeepFreeze; + sleNew->setFieldU32(sfFlags, uFlags); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"a trust line with deep freeze flag without normal freeze was " + "created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); + sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); + sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); + std::uint32_t uFlags = 0u; + uFlags |= lsfHighDeepFreeze; + sleNew->setFieldU32(sfFlags, uFlags); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"a trust line with deep freeze flag without normal freeze was " + "created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); + sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); + sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); + std::uint32_t uFlags = 0u; + uFlags |= lsfLowDeepFreeze | lsfHighDeepFreeze; + sleNew->setFieldU32(sfFlags, uFlags); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"a trust line with deep freeze flag without normal freeze was " + "created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); + sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); + sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); + std::uint32_t uFlags = 0u; + uFlags |= lsfLowDeepFreeze | lsfHighFreeze; + sleNew->setFieldU32(sfFlags, uFlags); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"a trust line with deep freeze flag without normal freeze was " + "created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); + sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); + sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); + std::uint32_t uFlags = 0u; + uFlags |= lsfLowFreeze | lsfHighDeepFreeze; + sleNew->setFieldU32(sfFlags, uFlags); + ac.view().insert(sleNew); + return true; + }); + } + + void + testTransfersNotFrozen() + { + using namespace test::jtx; + testcase << "transfers when frozen"; + + Account const g1{"G1"}; + // Helper function to establish the trustlines + auto const createTrustlines = [&](Account const& a1, Account const& a2, Env& env) { + // Preclose callback to establish trust lines with gateway + env.fund(XRP(1000), g1); + + env.trust(g1["USD"](10000), a1); + env.trust(g1["USD"](10000), a2); + env.close(); + + env(pay(g1, a1, g1["USD"](1000))); + env(pay(g1, a2, g1["USD"](1000))); + env.close(); + + return true; + }; + + auto const a1FrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) { + createTrustlines(a1, a2, env); + env(trust(g1, a1["USD"](10000), tfSetFreeze)); + env.close(); + + return true; + }; + + auto const a1DeepFrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) { + a1FrozenByIssuer(a1, a2, env); + env(trust(g1, a1["USD"](10000), tfSetDeepFreeze)); + env.close(); + + return true; + }; + + auto const changeBalances = [&](Account const& a1, + Account const& a2, + ApplyContext& ac, + int a1Balance, + int a2Balance) { + auto const sleA1 = ac.view().peek(keylet::trustLine(a1, g1["USD"])); + auto const sleA2 = ac.view().peek(keylet::trustLine(a2, g1["USD"])); + + sleA1->setFieldAmount(sfBalance, g1["USD"](a1Balance)); + sleA2->setFieldAmount(sfBalance, g1["USD"](a2Balance)); + + ac.view().update(sleA1); + ac.view().update(sleA2); + }; + + // test: imitating frozen A1 making a payment to A2. + doInvariantCheck( + {{"Attempting to move frozen funds"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + changeBalances(a1, a2, ac, -900, -1100); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + a1FrozenByIssuer); + + // test: imitating deep frozen A1 making a payment to A2. + doInvariantCheck( + {{"Attempting to move frozen funds"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + changeBalances(a1, a2, ac, -900, -1100); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + a1DeepFrozenByIssuer); + + // test: imitating A2 making a payment to deep frozen A1. + doInvariantCheck( + {{"Attempting to move frozen funds"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + changeBalances(a1, a2, ac, -1100, -900); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + a1DeepFrozenByIssuer); + } + + void + run() override + { + testNoXRPTrustLine(); + testNoDeepFreezeTrustLinesWithoutFreeze(); + testTransfersNotFrozen(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsTrustLine, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp new file mode 100644 index 0000000000..abcbe343f5 --- /dev/null +++ b/src/test/app/invariants/InvariantsVault_test.cpp @@ -0,0 +1,2091 @@ +#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 +#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 InvariantsVault_test : public InvariantsBase +{ + FeatureBitset const all_{test::jtx::testableAmendments()}; + + void + testVault() // NOLINT(readability-function-size) + { + using namespace test::jtx; + + struct AccountAmount + { + AccountID account; + int amount; + }; + struct Adjustments + { + // NOLINTBEGIN(readability-redundant-member-init) + std::optional assetsTotal = std::nullopt; + std::optional assetsAvailable = std::nullopt; + std::optional lossUnrealized = std::nullopt; + std::optional assetsMaximum = std::nullopt; + std::optional sharesTotal = std::nullopt; + std::optional vaultAssets = std::nullopt; + std::optional accountAssets = std::nullopt; + std::optional accountShares = std::nullopt; + // NOLINTEND(readability-redundant-member-init) + }; + constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) { + // Avoid uint64 + negative-int wrap (flagged by UBSan + // unsigned-integer-overflow) when adjusting UINT64 fields. + auto const addSigned = [](std::uint64_t current, int adj) -> std::uint64_t { + return adj >= 0 // + ? current + static_cast(adj) + : current - static_cast(-adj); + }; + auto sleVault = ac.peek(keylet); + if (!sleVault) + return false; + + auto const mptIssuanceID = (*sleVault)[sfShareMPTID]; + auto sleShares = ac.peek(keylet::mptokenIssuance(mptIssuanceID)); + if (!sleShares) + return false; + + // These two fields are adjusted in absolute terms + if (args.lossUnrealized) + (*sleVault)[sfLossUnrealized] = *args.lossUnrealized; + if (args.assetsMaximum) + (*sleVault)[sfAssetsMaximum] = *args.assetsMaximum; + + // Remaining fields are adjusted in terms of difference + if (args.assetsTotal) + (*sleVault)[sfAssetsTotal] = *(*sleVault)[sfAssetsTotal] + *args.assetsTotal; + if (args.assetsAvailable) + { + (*sleVault)[sfAssetsAvailable] = + *(*sleVault)[sfAssetsAvailable] + *args.assetsAvailable; + } + ac.update(sleVault); + + if (args.sharesTotal) + { + (*sleShares)[sfOutstandingAmount] = + addSigned(*(*sleShares)[sfOutstandingAmount], *args.sharesTotal); + ac.update(sleShares); + } + + auto const assets = *(*sleVault)[sfAsset]; + auto const pseudoId = *(*sleVault)[sfAccount]; + if (args.vaultAssets) + { + if (assets.native()) + { + auto slePseudoAccount = ac.peek(keylet::account(pseudoId)); + if (!slePseudoAccount) + return false; + (*slePseudoAccount)[sfBalance] = + *(*slePseudoAccount)[sfBalance] + *args.vaultAssets; + ac.update(slePseudoAccount); + } + else if (assets.holds()) + { + auto const mptId = assets.get().getMptID(); + auto sleMPToken = ac.peek(keylet::mptoken(mptId, pseudoId)); + if (!sleMPToken) + return false; + (*sleMPToken)[sfMPTAmount] = + addSigned(*(*sleMPToken)[sfMPTAmount], *args.vaultAssets); + ac.update(sleMPToken); + } + else + { + return false; // Not supporting testing with IOU + } + } + + if (args.accountAssets) + { + auto const& pair = *args.accountAssets; + if (assets.native()) + { + auto sleAccount = ac.peek(keylet::account(pair.account)); + if (!sleAccount) + return false; + (*sleAccount)[sfBalance] = *(*sleAccount)[sfBalance] + pair.amount; + ac.update(sleAccount); + } + else if (assets.holds()) + { + auto const mptID = assets.get().getMptID(); + auto sleMPToken = ac.peek(keylet::mptoken(mptID, pair.account)); + if (!sleMPToken) + return false; + (*sleMPToken)[sfMPTAmount] = + addSigned(*(*sleMPToken)[sfMPTAmount], pair.amount); + ac.update(sleMPToken); + } + else + { + return false; // Not supporting testing with IOU + } + } + + if (args.accountShares) + { + auto const& pair = *args.accountShares; + auto sleMPToken = ac.peek(keylet::mptoken(mptIssuanceID, pair.account)); + if (!sleMPToken) + return false; + (*sleMPToken)[sfMPTAmount] = addSigned(*(*sleMPToken)[sfMPTAmount], pair.amount); + ac.update(sleMPToken); + } + return true; + }; + + static constexpr auto kArgs = [](AccountID id, int adjustment, auto fn) -> Adjustments { + Adjustments sample = { + .assetsTotal = adjustment, + .assetsAvailable = adjustment, + .lossUnrealized = 0, + .sharesTotal = adjustment, + .vaultAssets = adjustment, + .accountAssets = // + AccountAmount{.account = id, .amount = -adjustment}, + .accountShares = // + AccountAmount{.account = id, .amount = adjustment}}; + fn(sample); + return sample; + }; + + Account const a3{"A3"}; + Account const a4{"A4"}; + auto const precloseXrp = [&](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()}); + 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; + }; + + testcase << "Vault general checks"; + doInvariantCheck( + {"vault deletion succeeded without deleting a vault"}, + [&](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; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault updated by a wrong transaction type", + "deleted Vault without deleting its pseudo-account"}, + [&](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; + ac.view().erase(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault updated by a wrong transaction type"}, + [&](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; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault updated by a wrong transaction type"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + sleVault->setAccountID(sfAccount, a1.id()); + ac.view().insert(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + doInvariantCheck( + {"vault deleted by a wrong transaction type", + "deleted Vault without deleting its pseudo-account"}, + [&](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; + ac.view().erase(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation updated more than single vault", + "deleted Vault without deleting its pseudo-account"}, + [&](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; + ac.view().erase(sleVault); + } + { + auto const keylet = + keylet::vault(a2.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + ac.view().erase(sleVault); + } + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + { + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + } + { + auto [tx, _] = vault.create({.owner = a2, .asset = xrpIssue()}); + env(tx); + } + return true; + }); + + doInvariantCheck( + {"vault operation updated more than single vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sequence = ac.view().seq(); + auto const insertVault = [&](Account const a) { + auto const vaultKeylet = keylet::vault(a.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(a.id()), sleVault->key(), describeOwnerDir(a.id())); + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + sleVault->setAccountID(sfAccount, a.id()); + ac.view().insert(sleVault); + }; + insertVault(a1); + insertVault(a2); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + doInvariantCheck( + {"deleted vault must also delete shares", + "deleted Vault without deleting its pseudo-account"}, + [&](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; + ac.view().erase(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"deleted vault must have no shares outstanding", + "deleted vault must have no assets outstanding", + "deleted vault must have no assets available"}, + [&](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; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + ac.view().erase(sleVault); + ac.view().erase(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](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; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + // Note, such an "orphaned" update of MPT issuance attached to a + // vault is invalid; ttVAULT_SET must also update Vault object. + sleShares->setFieldH256(sfDomainID, uint256(13)); + ac.view().update(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttVAULT_CLAWBACK, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"updated vault must have shares"}, + [&](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)[sfAssetsMaximum] = 200; + ac.view().update(sleVault); + + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + ac.view().erase(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without updating shares", + "assets available must not be greater than assets outstanding"}, + [&](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)[sfAssetsTotal] = 9; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); + return true; + }); + + doInvariantCheck( + {"set must not change assets outstanding", + "set must not change assets available", + "set must not change shares outstanding", + "set must not change vault balance", + "assets available must not be negative", + "assets available must not be greater than assets outstanding", + "assets outstanding must not be negative"}, + [&](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; + auto slePseudoAccount = ac.view().peek(keylet::account(*(*sleVault)[sfAccount])); + if (!slePseudoAccount) + return false; + (*slePseudoAccount)[sfBalance] = *(*slePseudoAccount)[sfBalance] - 10; + ac.view().update(slePseudoAccount); + + // Move 10 drops to A4 to enforce total XRP balance + auto sleA4 = ac.view().peek(keylet::account(a4.id())); + if (!sleA4) + return false; + (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; + ac.view().update(sleA4); + + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.assetsAvailable = (kDropsPerXrp * -100).value(); + sample.assetsTotal = (kDropsPerXrp * -200).value(); + sample.sharesTotal = -1; + })); + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"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())); + 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, tecINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"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())); + 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, tecINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"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())); + 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, tecINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"vault transaction must not change loss unrealized", + "set must not change assets outstanding"}, + [&](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.lossUnrealized = 13; + sample.assetsTotal = 20; + })); + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"loss unrealized must not exceed the difference " + "between assets outstanding and available", + "vault transaction must not change loss unrealized"}, + [&](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(), 100, [&](Adjustments& sample) { + sample.lossUnrealized = 13; + })); + }, + XRPAmount{}, + STTx{ + ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + // A negative loss unrealized must trip the invariant. ttLOAN_MANAGE is + // allowed to change loss unrealized, so it isolates this check from the + // "must not change loss unrealized" invariant. Gated behind + // fixCleanup3_4_0 (see below). + 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, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.lossUnrealized = -1; + })); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + // Without fixCleanup3_4_0 the same state must NOT trip the invariant, + // preserving pre-amendment behavior (no fork risk). + doInvariantCheck( + makeEnv(all_ - fixCleanup3_4_0), + {}, + [&](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.lossUnrealized = -1; + })); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) {}}, + {tesSUCCESS, tesSUCCESS}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"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.assetsMaximum = 1; + })); + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"assets maximum 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, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.assetsMaximum = -1; + })); + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"set must not change shares outstanding", + "updated zero sized vault must have no assets outstanding", + "updated zero sized vault must have no assets available"}, + [&](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; + ac.view().update(sleVault); + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + (*sleShares)[sfOutstandingAmount] = 0; + ac.view().update(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"updated shares must not exceed maximum"}, + [&](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; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + (*sleShares)[sfMaximumAmount] = 10; + ac.view().update(sleShares); + + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"updated shares must not exceed maximum"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {})); + + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + (*sleShares)[sfOutstandingAmount] = kMaxMpTokenAmount + 1; + ac.view().update(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + testcase << "Vault create"; + doInvariantCheck( + { + "created vault must be empty", + "updated zero sized vault must have no assets outstanding", + "create operation must not have updated a vault", + }, + [&](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)[sfAssetsTotal] = 9; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + { + "created vault must be empty", + "updated zero sized vault must have no assets available", + "assets available must not be greater than assets outstanding", + "create operation must not have updated a vault", + }, + [&](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)[sfAssetsAvailable] = 9; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + { + "created vault must be empty", + "loss unrealized must not exceed the difference between assets " + "outstanding and available", + "vault transaction must not change loss unrealized", + "create operation must not have updated a vault", + }, + [&](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)[sfLossUnrealized] = 1; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + { + "created vault must be empty", + "create operation must not have updated a vault", + "invalid OutstandingAmount balance 0 9 0", + }, + [&](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; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + ac.view().update(sleVault); + (*sleShares)[sfOutstandingAmount] = 9; + ac.view().update(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + { + "assets maximum must not be negative", + "create operation must not have updated a vault", + }, + [&](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)[sfAssetsMaximum] = Number(-1); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"create operation must not have updated a vault", + "shares issuer and vault pseudo-account must be the same", + "shares issuer must be a pseudo-account", + "shares issuer pseudo-account must point back to the vault"}, + [&](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; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + ac.view().update(sleVault); + (*sleShares)[sfIssuer] = a1.id(); + ac.view().update(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault created by a wrong transaction type", "account root created illegally"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // The code below will create a valid vault with (almost) all + // the invariants holding. Except one: it is created by the + // wrong transaction type. + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + + auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); + // Create pseudo-account. + auto sleAccount = std::make_shared(keylet::account(pseudoId)); + sleAccount->setAccountID(sfAccount, pseudoId); + sleAccount->setFieldAmount(sfBalance, STAmount{}); + std::uint32_t const seqno = // + ac.view().rules().enabled(featureSingleAssetVault) // + ? 0 // + : sequence; + sleAccount->setFieldU32(sfSequence, seqno); + sleAccount->setFieldU32( + sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); + ac.view().insert(sleAccount); + + auto const sharesMptId = makeMptID(sequence, pseudoId); + auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); + auto sleShares = std::make_shared(sharesKeylet); + auto const sharesPage = ac.view().dirInsert( + keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); + sleShares->setFieldU64(sfOwnerNode, *sharesPage); + + sleShares->at(sfFlags) = 0; + sleShares->at(sfIssuer) = pseudoId; + sleShares->at(sfOutstandingAmount) = 0; + sleShares->at(sfSequence) = sequence; + + sleVault->at(sfAccount) = pseudoId; + sleVault->at(sfFlags) = 0; + sleVault->at(sfSequence) = sequence; + sleVault->at(sfOwner) = a1.id(); + sleVault->at(sfAssetsTotal) = Number(0); + sleVault->at(sfAssetsAvailable) = Number(0); + sleVault->at(sfLossUnrealized) = Number(0); + sleVault->at(sfShareMPTID) = sharesMptId; + sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; + + ac.view().insert(sleVault); + ac.view().insert(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + doInvariantCheck( + {"shares issuer and vault pseudo-account must be the same", + "shares issuer pseudo-account must point back to the vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + + auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); + // Create pseudo-account. + auto sleAccount = std::make_shared(keylet::account(pseudoId)); + sleAccount->setAccountID(sfAccount, pseudoId); + sleAccount->setFieldAmount(sfBalance, STAmount{}); + std::uint32_t const seqno = // + ac.view().rules().enabled(featureSingleAssetVault) // + ? 0 // + : sequence; + sleAccount->setFieldU32(sfSequence, seqno); + sleAccount->setFieldU32( + sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + // sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); + // Setting wrong vault key + sleAccount->setFieldH256(sfVaultID, uint256(42)); + ac.view().insert(sleAccount); + + auto const sharesMptId = makeMptID(sequence, pseudoId); + auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); + auto sleShares = std::make_shared(sharesKeylet); + auto const sharesPage = ac.view().dirInsert( + keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); + sleShares->setFieldU64(sfOwnerNode, *sharesPage); + + sleShares->at(sfFlags) = 0; + sleShares->at(sfIssuer) = pseudoId; + sleShares->at(sfOutstandingAmount) = 0; + sleShares->at(sfSequence) = sequence; + + // sleVault->at(sfAccount) = pseudoId; + // Setting wrong pseudo account ID + sleVault->at(sfAccount) = a2.id(); + sleVault->at(sfFlags) = 0; + sleVault->at(sfSequence) = sequence; + sleVault->at(sfOwner) = a1.id(); + sleVault->at(sfAssetsTotal) = Number(0); + sleVault->at(sfAssetsAvailable) = Number(0); + sleVault->at(sfLossUnrealized) = Number(0); + sleVault->at(sfShareMPTID) = sharesMptId; + sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; + + ac.view().insert(sleVault); + ac.view().insert(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + doInvariantCheck( + {"shares issuer and vault pseudo-account must be the same", "shares issuer must exist"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + + auto const sharesMptId = makeMptID(sequence, a2.id()); + auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); + auto sleShares = std::make_shared(sharesKeylet); + auto const sharesPage = ac.view().dirInsert( + keylet::ownerDir(a2.id()), sharesKeylet, describeOwnerDir(a2.id())); + sleShares->setFieldU64(sfOwnerNode, *sharesPage); + + sleShares->at(sfFlags) = 0; + // Setting wrong pseudo account ID + sleShares->at(sfIssuer) = AccountID(42); + sleShares->at(sfOutstandingAmount) = 0; + sleShares->at(sfSequence) = sequence; + + sleVault->at(sfAccount) = a2.id(); + sleVault->at(sfFlags) = 0; + sleVault->at(sfSequence) = sequence; + sleVault->at(sfOwner) = a1.id(); + sleVault->at(sfAssetsTotal) = Number(0); + sleVault->at(sfAssetsAvailable) = Number(0); + sleVault->at(sfLossUnrealized) = Number(0); + sleVault->at(sfShareMPTID) = sharesMptId; + sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; + + ac.view().insert(sleVault); + ac.view().insert(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + testcase << "Vault deposit"; + doInvariantCheck( + {"deposit must change vault balance"}, + [&](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.vaultAssets.reset(); + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"deposit 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(), 200, [&](Adjustments& sample) { + sample.assetsMaximum = 1; + })); + }, + XRPAmount{}, + STTx{ + ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + // This really convoluted unit tests makes the zero balance on the + // depositor, by sending them the same amount as the transaction fee. + // The operation makes no sense, but the defensive check in + // ValidVault::finalize is otherwise impossible to trigger. + doInvariantCheck( + {"deposit must increase vault balance", "deposit must change depositor balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + + // Move 10 drops to A4 to enforce total XRP balance + auto sleA4 = ac.view().peek(keylet::account(a4.id())); + if (!sleA4) + return false; + (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; + ac.view().update(sleA4); + + return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) { + sample.accountAssets->amount = -100; + })); + }, + XRPAmount{100}, + STTx{ + ttVAULT_DEPOSIT, + [&](STObject& tx) { + tx[sfFee] = XRPAmount(100); + tx[sfAccount] = a3.id(); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"deposit must increase vault balance", + "deposit must decrease depositor balance", + "deposit must change vault and depositor balance by equal amount", + "deposit and assets outstanding must add up", + "deposit and assets available must add up"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + + // Move 10 drops from A2 to A3 to enforce total XRP balance + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + if (!sleA3) + return false; + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10; + ac.view().update(sleA3); + + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { + sample.vaultAssets = -20; + sample.accountAssets->amount = 10; + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit must change depositor balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + + // Move 10 drops from A3 to vault to enforce total XRP balance + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + if (!sleA3) + return false; + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 10; + ac.view().update(sleA3); + + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { + sample.accountAssets->amount = 0; + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit must change depositor shares"}, + [&](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(), 10, [&](Adjustments& sample) { + sample.accountShares.reset(); + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit must change vault shares"}, + [&](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(), 10, [](Adjustments& sample) { + sample.sharesTotal = 0; + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit must increase depositor shares", + "deposit must change depositor and vault shares by equal amount", + "deposit must not change vault balance by more than deposited " + "amount"}, + [&](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(), 10, [&](Adjustments& sample) { + sample.accountShares->amount = -5; + sample.sharesTotal = -10; + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(5); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit and assets outstanding must add up"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000; + ac.view().update(sleA3); + + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { + sample.assetsTotal = 11; + })); + }, + XRPAmount{2000}, + STTx{ + ttVAULT_DEPOSIT, + [&](STObject& tx) { + tx[sfAmount] = XRPAmount(10); + tx[sfDelegate] = a3.id(); + tx[sfFee] = XRPAmount(2000); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit and assets outstanding must add up", + "deposit and assets available must add up"}, + [&](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(), 10, [&](Adjustments& sample) { + sample.assetsTotal = 7; + sample.assetsAvailable = 7; + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + testcase << "Vault withdrawal"; + doInvariantCheck( + {"withdrawal must change vault balance"}, + [&](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.vaultAssets.reset(); + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // Almost identical to the really convoluted test for deposit, where the + // depositor spends only the transaction fee. In case of withdrawal, + // this test is almost the same as normal withdrawal where the + // sfDestination would have been A4, but has been omitted. + doInvariantCheck( + {"withdrawal must change one destination balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + + // Move 10 drops to A4 to enforce total XRP balance + auto sleA4 = ac.view().peek(keylet::account(a4.id())); + if (!sleA4) + return false; + (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; + ac.view().update(sleA4); + + return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) { + sample.accountAssets->amount = -100; + })); + }, + XRPAmount{100}, + STTx{ + ttVAULT_WITHDRAW, + [&](STObject& tx) { + tx[sfFee] = XRPAmount(100); + tx[sfAccount] = a3.id(); + // This commented out line causes the invariant violation. + // tx[sfDestination] = A4.id(); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + { + "withdrawal must change vault and destination balance by equal amount", + "withdrawal must decrease vault balance", + "withdrawal must increase destination balance", + "withdrawal and assets outstanding must add up", + "withdrawal and assets available must add up", + }, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + + // Move 10 drops from A2 to A3 to enforce total XRP balance + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + if (!sleA3) + return false; + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10; + ac.view().update(sleA3); + + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { + sample.vaultAssets = 10; + sample.accountAssets->amount = -20; + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal must change one destination balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + if (!kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { + *sample.vaultAssets -= 5; + }))) + return false; + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + if (!sleA3) + return false; + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 5; + ac.view().update(sleA3); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx.setAccountID(sfDestination, a3.id()); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal must change depositor shares"}, + [&](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(), -10, [&](Adjustments& sample) { + sample.accountShares.reset(); + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal must change vault shares"}, + [&](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(), -10, [](Adjustments& sample) { + sample.sharesTotal = 0; + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal must decrease depositor shares", + "withdrawal must change depositor and vault shares by equal " + "amount"}, + [&](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(), -10, [&](Adjustments& sample) { + sample.accountShares->amount = 5; + sample.sharesTotal = 10; + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal and assets outstanding must add up", + "withdrawal and assets available must add up"}, + [&](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(), -10, [&](Adjustments& sample) { + sample.assetsTotal = -15; + sample.assetsAvailable = -15; + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal and assets outstanding must add up"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000; + ac.view().update(sleA3); + + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { + sample.assetsTotal = -7; + })); + }, + XRPAmount{2000}, + STTx{ + ttVAULT_WITHDRAW, + [&](STObject& tx) { + tx[sfAmount] = XRPAmount(10); + tx[sfDelegate] = a3.id(); + tx[sfFee] = XRPAmount(2000); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + auto const precloseMpt = [&](Account const& a1, Account const& a2, Env& env) -> bool { + env.fund(XRP(1000), a3, a4); + + // Create MPT asset + { + json::Value jv; + jv[sfAccount] = a3.human(); + jv[sfTransactionType] = jss::MPTokenIssuanceCreate; + jv[sfFlags] = tfMPTCanTransfer; + env(jv); + env.close(); + } + + auto const mptID = makeMptID(env.seq(a3) - 1, a3); + Asset const asset = MPTIssue(mptID); + // Authorize A1 A2 A4 + { + json::Value jv; + jv[sfAccount] = a1.human(); + jv[sfTransactionType] = jss::MPTokenAuthorize; + jv[sfMPTokenIssuanceID] = to_string(mptID); + env(jv); + jv[sfAccount] = a2.human(); + env(jv); + jv[sfAccount] = a4.human(); + env(jv); + + env.close(); + } + // Send tokens to A1 A2 A4 + { + env(pay(a3, a1, asset(1000))); + env(pay(a3, a2, asset(1000))); + env(pay(a3, a4, asset(1000))); + env.close(); + } + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); + env(tx); + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = asset(10)})); + env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = asset(10)})); + env(vault.deposit({.depositor = a4, .id = keylet.key, .amount = asset(10)})); + return true; + }; + + doInvariantCheck( + {"withdrawal must decrease depositor shares", + "withdrawal must change depositor and vault shares by equal " + "amount"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { + sample.accountShares->amount = 5; + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseMpt, + TxAccount::A2); + + testcase << "Vault clawback"; + doInvariantCheck( + {"clawback must change vault balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -1, [&](Adjustments& sample) { + sample.vaultAssets.reset(); + })); + }, + XRPAmount{}, + STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseMpt); + + // Not the same as below check: attempt to clawback XRP + doInvariantCheck( + {"clawback may only be performed by the asset issuer"}, + [&](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) {})); + }, + XRPAmount{}, + STTx{ttVAULT_CLAWBACK, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // Not the same as above check: attempt to clawback MPT by bad account + doInvariantCheck( + {"clawback may only be performed by the asset issuer"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {})); + }, + XRPAmount{}, + STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a4.id(); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseMpt); + + doInvariantCheck( + {"clawback must decrease vault balance", + "clawback must decrease holder shares", + "clawback must change vault shares"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a4.id(), 10, [&](Adjustments& sample) { + sample.sharesTotal = 0; + })); + }, + XRPAmount{}, + STTx{ + ttVAULT_CLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = a3.id(); + tx[sfHolder] = a4.id(); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseMpt); + + doInvariantCheck( + {"clawback must change holder shares"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) { + sample.accountShares.reset(); + })); + }, + XRPAmount{}, + STTx{ + ttVAULT_CLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = a3.id(); + tx[sfHolder] = a4.id(); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseMpt); + + doInvariantCheck( + {"clawback must change holder and vault shares by equal amount", + "clawback and assets outstanding must add up", + "clawback and assets available must add up"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) { + sample.accountShares->amount = -8; + sample.assetsTotal = -7; + sample.assetsAvailable = -7; + })); + }, + XRPAmount{}, + STTx{ + ttVAULT_CLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = a3.id(); + tx[sfHolder] = a4.id(); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseMpt); + + // ───────────────────────────────────────────────────────────── + // Closed-ended vault invariants added in ValidVault::finalize (create must supply both + // dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase, + // withdraw not in Investment, loan origination only in Investment. + + using d = NetClock::duration; + using tp = NetClock::time_point; + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + + // Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it + // from ac.view().seq(), which depends on how many env.close() calls preclose issued. + Keylet closedEndedKeylet = keylet::amendments(); + + // Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with + // three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and + // optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub + // leaves the vault in Subscription. + auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) { + return [&, advanceBySub, doDeposit]( + Account const& a1, Account const& a2, Env& env) -> bool { + env.fund(XRP(1000), a3, a4); + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a1, + .asset = xrpIssue(), + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedKeylet = keylet; + if (doDeposit) + { + 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)})); + } + if (advanceBySub >= 0) + env.close(tp{d{sub + advanceBySub}}); + return true; + }; + }; + + // Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance) + // directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE + // states no legitimate transactor would produce. + auto const insertBareClosedEndedVault = + [closedEnded]( + ApplyContext& ac, + Account const& owner, + std::optional subscriptionDate, + std::optional redemptionDate) -> bool { + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id())); + if (!vaultPage) + return false; + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + + auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); + auto sleAccount = std::make_shared(keylet::account(pseudoId)); + sleAccount->setAccountID(sfAccount, pseudoId); + sleAccount->setFieldAmount(sfBalance, STAmount{}); + sleAccount->setFieldU32(sfSequence, 0); + sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); + ac.view().insert(sleAccount); + + auto const sharesMptId = makeMptID(sequence, pseudoId); + auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); + auto sleShares = std::make_shared(sharesKeylet); + auto const sharesPage = ac.view().dirInsert( + keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); + if (!sharesPage) + return false; + sleShares->setFieldU64(sfOwnerNode, *sharesPage); + sleShares->at(sfFlags) = 0; + sleShares->at(sfIssuer) = pseudoId; + sleShares->at(sfOutstandingAmount) = 0; + sleShares->at(sfSequence) = sequence; + + sleVault->at(sfAccount) = pseudoId; + sleVault->at(sfFlags) = 0; + sleVault->at(sfSequence) = sequence; + sleVault->at(sfOwner) = owner.id(); + sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}}); + sleVault->at(sfAssetsTotal) = Number(0); + sleVault->at(sfAssetsAvailable) = Number(0); + sleVault->at(sfLossUnrealized) = Number(0); + sleVault->at(sfShareMPTID) = sharesMptId; + sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; + sleVault->at(sfVaultKind) = closedEnded; + if (subscriptionDate) + sleVault->at(sfSubscriptionDate) = *subscriptionDate; + if (redemptionDate) + sleVault->at(sfRedemptionDate) = *redemptionDate; + + ac.view().insert(sleVault); + ac.view().insert(sleShares); + return true; + }; + + testcase << "Vault create closed-ended"; + + // A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate. + doInvariantCheck( + {"closed-ended vault must have SubscriptionDate and RedemptionDate"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate; + // exercises the sub-minimum branch of the gap check. + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub + kMinInvestmentPeriod - 1; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and + // is caught by the sub-minimum branch of the gap check. + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub - 1; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right). + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub + kMaxInvestmentPeriod; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + testcase << "Vault deposit closed-ended"; + + // A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs + // simulates an otherwise valid deposit shape so only the phase invariant fires. + doInvariantCheck( + {"deposit only allowed in Subscription or NoPhase"}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + return kAdjust( + ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), + TxAccount::A2); + + testcase << "Vault withdrawal closed-ended"; + + // A withdrawal from a closed-ended vault in the Investment phase. + doInvariantCheck( + {"withdrawal not allowed during Investment phase"}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + return kAdjust( + ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), + TxAccount::A2); + + testcase << "Vault loan set"; + + // ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires + // on any vault mutation; touching the vault SLE with no field change is sufficient. + doInvariantCheck( + {"loan origination only allowed in Investment phase"}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sleVault = ac.view().peek(closedEndedKeylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false)); + + testcase << "Vault loan set - closed-ended final payment past " + "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. + Keylet closedEndedBrokerKeylet = keylet::amendments(); + std::uint32_t closedEndedRed = 0; + doInvariantCheck( + {"closed-ended loan final payment must precede RedemptionDate"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + // Touch the vault so ValidVault::finalizeLoanSet sees an + // entry in afterVault_; the vault is in Investment, so + // finalizeLoanSet itself passes. + auto sleVault = ac.view().peek(closedEndedKeylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + + // Read the broker's next loan sequence to build the loan + // keylet the same way LoanSet::doApply would. + auto sleBroker = ac.view().peek(closedEndedBrokerKeylet); + if (!sleBroker) + 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. + auto sleLoan = std::make_shared( + keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq))); + sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; + sleLoan->at(sfLoanSequence) = loanSeq; + sleLoan->at(sfBorrower) = a1.id(); + sleLoan->at(sfStartDate) = closedEndedRed; + sleLoan->at(sfPaymentInterval) = 60; + sleLoan->at(sfPaymentRemaining) = 1; + sleLoan->at(sfTotalValueOutstanding) = Number(100); + sleLoan->at(sfPeriodicPayment) = Number(1); + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const&, Env& env) -> bool { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + closedEndedRed = red; + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a1, + .asset = xrpIssue(), + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedKeylet = keylet; + + // Create the loan broker; LoanBrokerSet has no phase gate. + closedEndedBrokerKeylet = + keylet::loanBroker(a1.id(), SeqProxy::rawSequence(env.seq(a1))); + env(loan_broker::set(a1, keylet.key)); + + // Advance parent close time into Investment so + // ValidVault::finalizeLoanSet is satisfied. + env.close(tp{d{sub + 1}}); + return true; + }); + } + + void + testVaultComputeCoarsestScale() + { + using namespace jtx; + + Account const issuer{"issuer"}; + PrettyAsset const vaultAsset = issuer["IOU"]; + + struct TestCase + { + std::string name; + std::int32_t expectedMinScale; + std::vector values; + }; + + for (auto const mantissaScale : MantissaRange::getAllScales()) + { + if (mantissaScale == MantissaRange::MantissaScale::Small) + continue; + NumberMantissaScaleGuard const g{mantissaScale}; + + auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo { + return {.delta = n, .scale = scale(n, vaultAsset.raw())}; + }; + + auto const testCases = std::vector{ + { + .name = "No values", + .expectedMinScale = 0, + .values = {}, + }, + { + .name = "Mixed integer and Number values", + .expectedMinScale = -15, + .values = {makeDelta(1), makeDelta(-1), makeDelta(Number{10, -1})}, + }, + { + .name = "Mixed scales", + .expectedMinScale = -17, + .values = + {makeDelta(Number{1, -2}), + makeDelta(Number{5, -3}), + makeDelta(Number{3, -2})}, + }, + { + .name = "Equal scales", + .expectedMinScale = -16, + .values = + {makeDelta(Number{1, -1}), + makeDelta(Number{5, -1}), + makeDelta(Number{1, -1})}, + }, + { + .name = "Mixed mantissa sizes", + .expectedMinScale = -12, + .values = + {makeDelta(Number{1}), + makeDelta(Number{1234, -3}), + makeDelta(Number{12345, -6}), + makeDelta(Number{123, 1})}, + }, + }; + + for (auto const& tc : testCases) + { + testcase("vault computeCoarsestScale: " + tc.name); + + auto const actualScale = ValidVault::computeCoarsestScale(tc.values); + + BEAST_EXPECTS( + actualScale == tc.expectedMinScale, + "expected: " + std::to_string(tc.expectedMinScale) + + ", actual: " + std::to_string(actualScale)); + for (auto const& num : tc.values) + { + // None of these scales are far enough apart that rounding the + // values would lose information, so check that the rounded + // value matches the original. + auto const actualRounded = roundToAsset(vaultAsset, num.delta, actualScale); + BEAST_EXPECTS( + actualRounded == num.delta, + "number " + to_string(num.delta) + " rounded to scale " + + std::to_string(actualScale) + " is " + to_string(actualRounded)); + } + } + + auto const testCases2 = std::vector{ + { + .name = "False equivalence", + .expectedMinScale = -15, + .values = + { + makeDelta(Number{1234567890123456789, -18}), + makeDelta(Number{12345, -4}), + makeDelta(Number{1}), + }, + }, + }; + + // Unlike the first set of test cases, the values in these test could + // look equivalent if using the wrong scale. + for (auto const& tc : testCases2) + { + testcase("vault computeCoarsestScale: " + tc.name); + + auto const actualScale = ValidVault::computeCoarsestScale(tc.values); + + BEAST_EXPECTS( + actualScale == tc.expectedMinScale, + "expected: " + std::to_string(tc.expectedMinScale) + + ", actual: " + std::to_string(actualScale)); + std::optional first; + Number firstRounded; + for (auto const& num : tc.values) + { + if (!first) + { + first = num.delta; + firstRounded = roundToAsset(vaultAsset, num.delta, actualScale); + continue; + } + auto const numRounded = roundToAsset(vaultAsset, num.delta, actualScale); + BEAST_EXPECTS( + numRounded != firstRounded, + "at a scale of " + std::to_string(actualScale) + " " + + to_string(num.delta) + " == " + to_string(*first)); + } + } + } + } + + void + run() override + { + testVault(); + testVaultComputeCoarsestScale(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsVault, app, xrpl); + +} // namespace xrpl::test From 9d41b1bd1c53a3146ad44ba1b03da0a265693782 Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:23:43 +0000 Subject: [PATCH 20/43] fix: Exempt vault and loan broker accounts from IOU authorization (#8013) Co-authored-by: Cursor --- .../ledger/helpers/RippleStateHelpers.cpp | 12 +- .../tx/transactors/lending/LoanPay.cpp | 14 +- src/test/app/AMMExtended_test.cpp | 74 +++++++ src/test/app/lending/LoanPay_test.cpp | 198 ++++++++++++++++++ 4 files changed, 292 insertions(+), 6 deletions(-) diff --git a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp index 868c9fb26d..706564db6f 100644 --- a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp +++ b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp @@ -584,9 +584,15 @@ requireAuth(ReadView const& view, Issue const& issue, AccountID const& account, { if (trustLine) { - return trustLine->isFlag((account > issue.account) ? lsfLowAuth : lsfHighAuth) - ? tesSUCCESS - : TER{tecNO_AUTH}; + if (trustLine->isFlag((account > issue.account) ? lsfLowAuth : lsfHighAuth)) + return tesSUCCESS; + + // A pseudo-account cannot submit transactions and only stores assets for the object + // that owns it, so it is implicitly authorized. + if (view.rules().enabled(fixCleanup3_4_0) && isPseudoAccount(view, account)) + return tesSUCCESS; + + return TER{tecNO_AUTH}; } return TER{tecNO_LINE}; } diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index c5bfd8e9ee..6e3487ec8e 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -620,7 +620,12 @@ LoanPay::doApply() ? STAmount{asset, 0} : conservationBalance(view, brokerPayee, asset, j_); - if (totalPaidToVaultRounded != beast::kZero) + // Only ledgers without the rule below reach these payee checks. Once it is in force + // requireAuth can no longer reject a pseudo-account, so the whole block goes away with the + // gate. + bool const skipPayeeAuth = view.rules().enabled(fixCleanup3_4_0); + + if (!skipPayeeAuth && totalPaidToVaultRounded != beast::kZero) { if (auto const ter = requireAuth(view, asset, vaultPseudoAccount, AuthType::StrongAuth)) return ter; @@ -644,8 +649,11 @@ LoanPay::doApply() return ter; } } - if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth)) - return ter; + if (!skipPayeeAuth) + { + if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth)) + return ter; + } } if (auto const ter = accountSendMulti( diff --git a/src/test/app/AMMExtended_test.cpp b/src/test/app/AMMExtended_test.cpp index 83c848b7c4..971a540ff7 100644 --- a/src/test/app/AMMExtended_test.cpp +++ b/src/test/app/AMMExtended_test.cpp @@ -1303,6 +1303,78 @@ private: BEAST_EXPECT(expectHolding(env, bob_, USD(0))); } + // Same shape as testRequireAuth, except the issuer never authorizes the AMM's own trust line. + // An AMM holds the asset for its liquidity providers and cannot sign a TrustSet for itself, so + // once pseudo-accounts are implicitly authorized the pool keeps trading. Before that the offer + // stream drops it and the taker's offer stays on the book. + void + testPseudoAccountRequireAuth(FeatureBitset features) + { + testcase("lsfRequireAuth, unauthorized AMM pseudo-account"); + + using namespace jtx; + + bool const pseudoExempt = features[fixCleanup3_4_0]; + + Env env{*this, features}; + + auto const aliceUSD = alice_["USD"]; + auto const bobUSD = bob_["USD"]; + + env.fund(XRP(400'000), gw_, alice_, bob_); + env.close(); + + env(fset(gw_, asfRequireAuth)); + env.close(); + + env(trust(gw_, bobUSD(100)), Txflags(tfSetfAuth)); + env(trust(bob_, USD(100))); + env(trust(gw_, aliceUSD(100)), Txflags(tfSetfAuth)); + env(trust(alice_, USD(2'000))); + env(pay(gw_, alice_, USD(1'000))); + env.close(); + + AMM const ammAlice(env, alice_, USD(1'000), XRP(1'050)); + + // The pool's own line stays unauthorized: AMMCreate opens it without the flag, and the + // pseudo-account has no key to ask for one. + auto const ammLineAuthorized = [&]() -> bool { + auto const line = + env.le(keylet::trustLine(ammAlice.ammAccount(), USD.issue().account, USD.currency)); + if (!BEAST_EXPECT(line)) + return false; + return line->isFlag( + ammAlice.ammAccount() > USD.issue().account ? lsfLowAuth : lsfHighAuth); + }; + BEAST_EXPECT(!ammLineAuthorized()); + + env(pay(gw_, bob_, USD(50))); + env.close(); + BEAST_EXPECT(expectHolding(env, bob_, USD(50))); + + // Bob sells USD into the pool, so the pool is the side that has to be authorized to hold + // the asset. + env(offer(bob_, XRP(50), USD(50))); + env.close(); + + if (pseudoExempt) + { + BEAST_EXPECT(ammAlice.expectBalances(USD(1'050), XRP(1'000), ammAlice.tokens())); + BEAST_EXPECT(expectOffers(env, bob_, 0)); + BEAST_EXPECT(expectHolding(env, bob_, USD(0))); + } + else + { + // The pool is skipped, so nothing crosses and the offer rests on the book. + BEAST_EXPECT(ammAlice.expectBalances(USD(1'000), XRP(1'050), ammAlice.tokens())); + BEAST_EXPECT(expectOffers(env, bob_, 1)); + BEAST_EXPECT(expectHolding(env, bob_, USD(50))); + } + + // Either way the exemption skips the check rather than setting the flag. + BEAST_EXPECT(!ammLineAuthorized()); + } + void testMissingAuth(FeatureBitset features) { @@ -1400,6 +1472,8 @@ private: testDirectToDirectPath(all_); testDirectToDirectPath(all_ - fixAMMv1_1 - fixAMMv1_3); testRequireAuth(all_); + testPseudoAccountRequireAuth(all_); + testPseudoAccountRequireAuth(all_ - fixCleanup3_4_0); testMissingAuth(all_); } diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp index 93d1671feb..ce08e71932 100644 --- a/src/test/app/lending/LoanPay_test.cpp +++ b/src/test/app/lending/LoanPay_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -15,9 +16,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -730,6 +733,200 @@ private: } } + // Which pseudo-account is left holding an unauthorized trust line when the + // repayment lands. + enum class UnauthorizedPayee { + // The vault's own line, as VaultCreate leaves it. + Vault, + // Same vault, but the issuer authorized the line by hand first. + VaultAuthorized, + // Vault line authorized, broker owner unable to take the fee, so the + // fee goes to the loan broker's pseudo-account instead. + Broker, + }; + + // A vault holding an IOU whose issuer requires authorization ends up with + // its own trust line unauthorized: VaultCreate opens the line without the + // auth flag, and the pseudo-account has no key to sign a TrustSet for + // itself. Neither deposits nor loan origination look at that line, so the + // vault appears to work right up to the first repayment, which is the only + // step that has to credit the vault back. + // + // The loan broker's pseudo-account has the same defect for the same reason, + // and LoanPay reaches it whenever the broker owner cannot take the fee. + // + // The issuer can still repair either line by hand, because TrustSet accepts + // a line that already exists even when its owner is a pseudo-account. + void + testRepayIntoUnauthorizedVault() + { + using namespace jtx; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + auto runTestCases = [&](FeatureBitset features, UnauthorizedPayee payee) { + bool const pseudoExempt = features[fixCleanup3_4_0]; + // With the vault's line repaired by the issuer, the only remaining + // unauthorized payee is the broker's pseudo-account. + bool const expectSuccess = pseudoExempt || payee == UnauthorizedPayee::VaultAuthorized; + + auto const payeeLabel = [payee]() -> char const* { + switch (payee) + { + case UnauthorizedPayee::Vault: + return "vault"; + case UnauthorizedPayee::VaultAuthorized: + return "vault authorized by the issuer"; + case UnauthorizedPayee::Broker: + return "loan broker"; + } + return ""; // LCOV_EXCL_LINE + }(); + + testcase << "LoanPay crediting an unauthorized " << payeeLabel << ": pseudo-account " + << (pseudoExempt ? "exempt" : "not exempt"); + + Env env{*this, features}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + env(fset(issuer, asfRequireAuth)); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency_]; + env(trust(lender, asset(100'000'000))); + env(trust(borrower, asset(100'000'000))); + env.close(); + + // Authorize the two participants. Nothing asks the issuer to also + // authorize the vault, which is the whole point of this test. + env(trust(issuer, asset(0), lender, tfSetfAuth)); + env(trust(issuer, asset(0), borrower, tfSetfAuth)); + env.close(); + + env(pay(issuer, lender, asset(10'000'000))); + env(pay(issuer, borrower, asset(10'000))); + env.close(); + + // Creating the vault and funding it with deposits succeeds even + // though the vault cannot be authorized to hold the asset. + BrokerInfo const broker{createVaultAndBroker(env, asset, lender)}; + + auto const vaultSle = env.le(broker.vaultKeylet()); + auto const brokerSle = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(vaultSle && brokerSle)) + return; + + Account const vaultPseudo{"vault pseudo-account", vaultSle->at(sfAccount)}; + Account const brokerPseudo{"broker pseudo-account", brokerSle->at(sfAccount)}; + + auto const lineIsAuthorized = [&](Account const& holder) -> bool { + auto const line = env.le(keylet::trustLine(holder, asset.raw().get())); + if (!BEAST_EXPECT(line)) + return false; + return line->isFlag(holder.id() > issuer.id() ? lsfLowAuth : lsfHighAuth); + }; + + BEAST_EXPECT(!lineIsAuthorized(vaultPseudo)); + BEAST_EXPECT(!lineIsAuthorized(brokerPseudo)); + + if (payee != UnauthorizedPayee::Vault) + { + env(trust(issuer, asset(0), vaultPseudo, tfSetfAuth)); + env.close(); + BEAST_EXPECT(lineIsAuthorized(vaultPseudo)); + } + + using namespace loan; + + // The service fee guarantees the broker is owed something on the + // first payment, so the broker leg of the transfer is exercised. + Number const serviceFee = asset(2).value(); + auto const loanKeylet = nextLoanKeylet(env, broker); + env(set(borrower, broker.brokerID, asset(1'000).value()), + Sig(sfCounterpartySignature, lender), + kLoanServiceFee(serviceFee), + kInterestRate(percentageToTenthBips(12)), + kPaymentTotal(12), + kPaymentInterval(600), + Fee(env.current()->fees().base * 2)); + env.close(); + + // Paying the principal out of the vault never needed authorization. + BEAST_EXPECT(env.le(loanKeylet)); + + if (payee == UnauthorizedPayee::Broker) + { + // A deep-frozen owner cannot take the fee, so LoanPay pays it + // into the broker's pseudo-account instead. + env(trust(issuer, asset(0), lender, tfSetFreeze | tfSetDeepFreeze)); + env.close(); + } + + auto const state = getCurrentState(env, broker, loanKeylet); + STAmount const payment{ + broker.asset, + roundPeriodicPayment( + broker.asset, state.periodicPayment + serviceFee, state.loanScale)}; + + // Repayment turns an outstanding loan back into cash the vault can + // lend again, so AssetsAvailable is what moves. AssetsTotal already + // counted the loan. + auto const assetsAvailable = [&]() -> Number { + auto const sle = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(sle)) + return Number{}; + return sle->at(sfAssetsAvailable); + }; + + auto const borrowerBefore = env.balance(borrower, asset).number(); + auto const vaultBefore = env.balance(vaultPseudo, asset).number(); + auto const brokerBefore = env.balance(brokerPseudo, asset).number(); + auto const assetsAvailableBefore = assetsAvailable(); + + env(pay(borrower, loanKeylet.key, payment), + Ter(expectSuccess ? TER{tesSUCCESS} : TER{tecNO_AUTH})); + env.close(); + + if (expectSuccess) + { + BEAST_EXPECT(env.balance(borrower, asset).number() < borrowerBefore); + BEAST_EXPECT(env.balance(vaultPseudo, asset).number() > vaultBefore); + BEAST_EXPECT(assetsAvailable() > assetsAvailableBefore); + // Confirms the broker variant really did route the fee to the + // pseudo-account rather than to the owner. + BEAST_EXPECT( + (env.balance(brokerPseudo, asset).number() > brokerBefore) == + (payee == UnauthorizedPayee::Broker)); + + // The payee is skipped by the check, not authorized by it: the line that just + // took the credit is still missing its auth flag. + if (payee == UnauthorizedPayee::Vault) + BEAST_EXPECT(!lineIsAuthorized(vaultPseudo)); + if (payee == UnauthorizedPayee::Broker) + BEAST_EXPECT(!lineIsAuthorized(brokerPseudo)); + } + else + { + // A rejected repayment must leave every balance untouched. + BEAST_EXPECT(env.balance(borrower, asset).number() == borrowerBefore); + BEAST_EXPECT(env.balance(vaultPseudo, asset).number() == vaultBefore); + BEAST_EXPECT(env.balance(brokerPseudo, asset).number() == brokerBefore); + BEAST_EXPECT(assetsAvailable() == assetsAvailableBefore); + } + }; + + for (auto const& features : {all_, all_ - fixCleanup3_4_0}) + { + runTestCases(features, UnauthorizedPayee::Vault); + runTestCases(features, UnauthorizedPayee::VaultAuthorized); + runTestCases(features, UnauthorizedPayee::Broker); + } + } + void testLoanPayFundsConservedPayeeBelowReserve(FeatureBitset features) { @@ -838,6 +1035,7 @@ private: runAmendmentIndependent() { testLoanSetNearZeroInterestRateSucceeds(); + testRepayIntoUnauthorizedVault(); } // Tests run under each entry in amendmentCombinations(). From 0fdaf69e2c2e92b447368e5cfc2872429dc4d934 Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 24 Aug 2026 20:41:58 +0000 Subject: [PATCH 21/43] chore: Bump version to 3.4.0-b1 (#8102) --- 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 ff4e5aa0ee..7f10630581 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-b0" +char const* const versionString = "3.4.0-b1" // clang-format on ; From 473fe44a85c89fd779d956ff5a1ae13eaceb7f5f Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Tue, 25 Aug 2026 13:48:26 +0000 Subject: [PATCH 22/43] chore: Upgrade rust toolchain to 1.97.1 (#8105) --- nix/check-tools/macos.txt | 20 ++++++++++---------- rust-toolchain.toml | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/nix/check-tools/macos.txt b/nix/check-tools/macos.txt index 8e99aa28e4..8edfeef311 100644 --- a/nix/check-tools/macos.txt +++ b/nix/check-tools/macos.txt @@ -114,8 +114,8 @@ Development tooling: Rust toolchain: ✅ cargo - cargo 1.95.0 (f2d3ce0bd 2026-03-21) - /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/cargo + cargo 1.97.1 (c980f4866 2026-06-30) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/cargo ✅ cargo-audit cargo-audit-audit 0.22.1 /nix/store/snwkga2f5gyf404h7mmp9wriwxb8v65f-cargo-audit-0.22.1/bin/cargo-audit @@ -126,17 +126,17 @@ Rust toolchain: cargo-nextest 0.9.137 /nix/store/ylz7m947mhkgsp6i7611id3s3gcd58nq-cargo-nextest-0.9.137/bin/cargo-nextest ✅ clippy-driver - clippy 0.1.95 (59807616e1 2026-04-14) - /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/clippy-driver + clippy 0.1.97 (8bab26f4f6 2026-07-14) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/clippy-driver ✅ rust-analyzer - rust-analyzer 1.95.0 (59807616 2026-04-14) - /nix/store/jqvjap2727r9cjpr25fkw5glv2kbxrdx-rust-analyzer-preview-1.95.0-aarch64-apple-darwin/bin/rust-analyzer + rust-analyzer 1.97.1 (8bab26f4 2026-07-14) + /nix/store/j6apc5pmd0giy15da9p650r8zklslmvi-rust-analyzer-preview-1.97.1-aarch64-apple-darwin/bin/rust-analyzer ✅ rustc - rustc 1.95.0 (59807616e 2026-04-14) - /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/rustc + rustc 1.97.1 (8bab26f4f 2026-07-14) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/rustc ✅ rustfmt - rustfmt 1.9.0-stable (59807616e1 2026-04-14) - /nix/store/03x750yj6fakl7shbhicpnkxiwqxjrrs-rustfmt-preview-1.95.0-aarch64-apple-darwin/bin/rustfmt + rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14) + /nix/store/5ymwgr9jqjz7zzbmj0j5vqbwcd3kp0vm-rustfmt-preview-1.97.1-aarch64-apple-darwin/bin/rustfmt Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). diff --git a/rust-toolchain.toml b/rust-toolchain.toml index a82b4734d8..dd5e1fe438 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.95" -components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview"] +channel = "1.97.1" +components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview", "rust-src"] profile = "minimal" From c5dc4085969f85c01d4deb155c5ecb68e24cc23f Mon Sep 17 00:00:00 2001 From: Jingchen Date: Tue, 25 Aug 2026 14:13:02 +0000 Subject: [PATCH 23/43] fix: Remove `explicit` from std/boost hash specialisation default constructors (#8100) --- include/xrpl/beast/net/IPAddress.h | 2 +- include/xrpl/protocol/Book.h | 8 ++++---- include/xrpl/protocol/MPTIssue.h | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/include/xrpl/beast/net/IPAddress.h b/include/xrpl/beast/net/IPAddress.h index 7422778ea2..e636a69ce7 100644 --- a/include/xrpl/beast/net/IPAddress.h +++ b/include/xrpl/beast/net/IPAddress.h @@ -103,7 +103,7 @@ namespace boost { template <> struct hash<::beast::ip::Address> { - explicit hash() = default; + hash() = default; std::size_t operator()(::beast::ip::Address const& addr) const diff --git a/include/xrpl/protocol/Book.h b/include/xrpl/protocol/Book.h index a83eb41b24..e6ed3729dd 100644 --- a/include/xrpl/protocol/Book.h +++ b/include/xrpl/protocol/Book.h @@ -133,7 +133,7 @@ private: using id_hash_type = boost::base_from_member, 0>; public: - explicit hash() = default; + hash() = default; using value_type = std::size_t; using argument_type = xrpl::MPTIssue; @@ -160,7 +160,7 @@ private: mptissue_hasher mMptissueHasher_; public: - explicit hash() = default; + hash() = default; value_type operator()(argument_type const& asset) const @@ -227,7 +227,7 @@ struct hash : std::hash template <> struct hash : std::hash { - explicit hash() = default; + hash() = default; using Base = std::hash; }; @@ -235,7 +235,7 @@ struct hash : std::hash template <> struct hash : std::hash { - explicit hash() = default; + hash() = default; using Base = std::hash; }; diff --git a/include/xrpl/protocol/MPTIssue.h b/include/xrpl/protocol/MPTIssue.h index 7f473da6a2..49c1fd63dc 100644 --- a/include/xrpl/protocol/MPTIssue.h +++ b/include/xrpl/protocol/MPTIssue.h @@ -151,7 +151,7 @@ namespace std { template <> struct hash : xrpl::MPTID::hasher { - explicit hash() = default; + hash() = default; }; } // namespace std From 45e4b8899df3b2e5d6e0bafb54dbf6e4300b38c4 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Tue, 25 Aug 2026 14:34:41 +0000 Subject: [PATCH 24/43] build: Update packaging images; add Python (#8106) --- .github/workflows/build-packaging-images.yml | 8 +++++--- package/install-packaging-tools.sh | 18 +++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-packaging-images.yml b/.github/workflows/build-packaging-images.yml index c927942fca..fd04eae995 100644 --- a/.github/workflows/build-packaging-images.yml +++ b/.github/workflows/build-packaging-images.yml @@ -33,12 +33,14 @@ jobs: strategy: fail-fast: false matrix: + # Newest of each distro: these images only wrap pre-built binaries, so + # they set no floor for consumers. build_pkg.py pins the RPM dist tag. distro: - name: debian - base_image: debian:bookworm - # AlmaLinux rather than UBI9, which does not ship rpm-sign. + base_image: debian:trixie + # AlmaLinux rather than UBI, which does not ship rpm-sign. - name: rhel - base_image: almalinux:9 + base_image: almalinux:10 uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/packaging-${{ matrix.distro.name }} diff --git a/package/install-packaging-tools.sh b/package/install-packaging-tools.sh index 2326d8f2ac..36557364ae 100755 --- a/package/install-packaging-tools.sh +++ b/package/install-packaging-tools.sh @@ -28,31 +28,31 @@ esac # - debhelper and dpkg-dev build the DEB # - 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 signs the built RPM -# - git gives build_pkg.sh a real history to read SOURCE_DATE_EPOCH from; -# without one the timestamp falls back to the wall clock -# - curl uploads the finished packages in publish_pkg.sh -# - ca-certificates lets curl and git verify TLS +# - rpm-sign and gnupg2 sign the built RPM +# - python3 runs the packaging scripts +# - git gives build_pkg.py the commit timestamp it stamps files with +# - ca-certificates lets git and the packaging scripts verify TLS function install() { case "${ID}" in debian | ubuntu) apt-get update -y apt-get install -y --no-install-recommends \ ca-certificates \ - curl \ debhelper \ debhelper-compat \ dpkg-dev \ - git + git \ + python3 ;; rhel | centos | rocky | almalinux) dnf install -y --setopt=install_weak_deps=False \ - curl-minimal \ git \ + gnupg2 \ + python3 \ + redhat-rpm-config \ rpm-build \ rpm-sign \ - redhat-rpm-config \ systemd-rpm-macros ;; esac From ec042fefeecadfc59305f150eea81cb867e59b9b Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:07:58 +0000 Subject: [PATCH 25/43] fix: Absorb Vault invariant rounding noise (#8055) --- src/libxrpl/tx/invariants/VaultInvariant.cpp | 128 ++++- .../tx/transactors/vault/VaultClawback.cpp | 5 - .../tx/transactors/vault/VaultWithdraw.cpp | 3 - .../app/invariants/InvariantsVault_test.cpp | 179 +++++++ .../vault/VaultInvariantPrecision_test.cpp | 458 ++++++++++++++++++ src/test/app/vault/VaultPrecisionFixture.h | 242 +++++++++ 6 files changed, 990 insertions(+), 25 deletions(-) create mode 100644 src/test/app/vault/VaultInvariantPrecision_test.cpp create mode 100644 src/test/app/vault/VaultPrecisionFixture.h diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 7ba42383ad..1bfb9d3d43 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -305,6 +306,45 @@ ValidVault::finalizeLoanSet(ReadView const& view, beast::Journal const& j) const return true; } +namespace { + +// sfAssetsTotal, sfAssetsAvailable and sfLossUnrealized are STNumber fields +// with kSmdNeedsAsset, so IOU writes go through associateAsset -> roundToAsset +// -> STAmount quantization. Since assetsTotal is the largest number, it lands +// on the coarsest decimal grid, and strict equality on the deltas can fire on +// a single unit of quantization noise even when the underlying flow is +// correct. Absorb one unit at the coarsest scale. +// +// XRP and MPT are integer-domain assets (Asset::integral() is true) with no +// sub-ULP quantization; treating a whole drop / MPT unit as "noise" would +// hide real accounting bugs. Keep the strict comparison there. Note that +// gating on the sign of `scale` would be wrong: IOU amounts >= 1e15 have a +// non-negative STAmount exponent but still quantize. +[[nodiscard]] bool +agreesWithinOneUnit(Number const& lhs, Number const& rhs, Asset const& asset, std::int32_t scale) +{ + if (asset.integral()) + return lhs == rhs; + auto const diff = lhs - rhs; + Number const tolerance{1, scale}; + return (diff < beast::kZero ? -diff : diff) <= tolerance; +} + +// L, T and A are each independently quantized; the strict L <= T - A check +// can fire on residual noise even when the true relationship holds. Tolerate +// one unit at scale(assetsTotal) - the coarsest of the three grids. As with +// the delta check above, the tolerance is meaningful only for IOU +// (Asset::integral() is false); XRP and MPT keep the strict comparison. +[[nodiscard]] bool +lessOrEqualPlusOneUnit(Number const& lhs, Number const& rhs, Asset const& asset, std::int32_t scale) +{ + if (asset.integral()) + return lhs <= rhs; + return lhs <= rhs + Number{1, scale}; +} + +} // namespace + std::int32_t ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const { @@ -340,6 +380,7 @@ ValidVault::finalize( beast::Journal const& j) { bool const enforce = view.rules().enabled(featureSingleAssetVault); + bool const fixEnabled = view.rules().enabled(fixCleanup3_4_0); if (!isTesSuccess(ret)) return true; // Do not perform checks @@ -527,15 +568,32 @@ ValidVault::finalize( "not be greater than assets outstanding"; result = false; } - else if (afterVault.lossUnrealized > afterVault.assetsTotal - afterVault.assetsAvailable) + else { - JLOG(j.fatal()) // - << "Invariant failed: loss unrealized must not exceed " - "the difference between assets outstanding and available"; - result = false; + bool const gapExceeded = [&] { + if (!fixEnabled) + { + return afterVault.lossUnrealized > + afterVault.assetsTotal - afterVault.assetsAvailable; + } + + auto const s = scale(afterVault.assetsTotal, afterVault.asset); + return !lessOrEqualPlusOneUnit( + afterVault.lossUnrealized, + afterVault.assetsTotal - afterVault.assetsAvailable, + afterVault.asset, + s); + }(); + if (gapExceeded) + { + JLOG(j.fatal()) // + << "Invariant failed: loss unrealized must not exceed " + "the difference between assets outstanding and available"; + result = false; + } } - if (view.rules().enabled(fixCleanup3_4_0) && afterVault.lossUnrealized < kZero) + if (fixEnabled && afterVault.lossUnrealized < kZero) { JLOG(j.fatal()) << "Invariant failed: loss unrealized must not be negative"; result = false; @@ -821,7 +879,14 @@ ValidVault::finalize( result = false; } - if (localVaultDeltaAssets * -1 != accountDeltaAssets) + bool const acctVaultAddsUp = fixEnabled + ? agreesWithinOneUnit( + localVaultDeltaAssets * -1, + accountDeltaAssets, + vaultAsset, + localMinScale) + : localVaultDeltaAssets * -1 == accountDeltaAssets; + if (!acctVaultAddsUp) { JLOG(j.fatal()) << "Invariant failed: " << // "deposit must change vault and depositor balance by equal amount"; @@ -869,7 +934,10 @@ ValidVault::finalize( auto const assetTotalDelta = roundToAsset( vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); - if (assetTotalDelta != vaultDeltaAssets) + bool const totalAddsUp = fixEnabled + ? agreesWithinOneUnit(assetTotalDelta, vaultDeltaAssets, vaultAsset, minScale) + : assetTotalDelta == vaultDeltaAssets; + if (!totalAddsUp) { JLOG(j.fatal()) << "Invariant failed: deposit and assets outstanding must add up"; @@ -878,7 +946,11 @@ ValidVault::finalize( auto const assetAvailableDelta = roundToAsset( vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); - if (assetAvailableDelta != vaultDeltaAssets) + bool const availableAddsUp = fixEnabled + ? agreesWithinOneUnit( + assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale) + : assetAvailableDelta == vaultDeltaAssets; + if (!availableAddsUp) { JLOG(j.fatal()) << "Invariant failed: deposit and assets available must add up"; result = false; @@ -920,8 +992,8 @@ 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 = view.rules().enabled(fixCleanup3_4_0) && - !maybeVaultDeltaAssets && beforeVault.assetsTotal == beforeVault.lossUnrealized; + bool const zeroDeltaIsLegitimate = fixEnabled && !maybeVaultDeltaAssets && + beforeVault.assetsTotal == beforeVault.lossUnrealized; if (!maybeVaultDeltaAssets && !zeroDeltaIsLegitimate) { @@ -1027,8 +1099,14 @@ ValidVault::finalize( vaultDeltaAssets.delta * -1 - destinationDelta.delta, destinationScale, Number::RoundingMode::Downward) == kZero; - if (!destroyedIsSubUlp && - localPseudoDeltaAssets * -1 != roundedDestinationDelta) + bool const withdrawAddsUp = fixEnabled + ? agreesWithinOneUnit( + localPseudoDeltaAssets * -1, + roundedDestinationDelta, + vaultAsset, + localMinScale) + : localPseudoDeltaAssets * -1 == roundedDestinationDelta; + if (!destroyedIsSubUlp && !withdrawAddsUp) { JLOG(j.fatal()) << "Invariant failed: " << // "withdrawal must change vault and destination balance by equal " @@ -1071,7 +1149,11 @@ ValidVault::finalize( auto const assetTotalDelta = roundToAsset( vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); // Note, vaultBalance is negative (see check above) - if (assetTotalDelta != vaultPseudoDeltaAssets) + bool const totalAddsUp = fixEnabled + ? agreesWithinOneUnit( + assetTotalDelta, vaultPseudoDeltaAssets, vaultAsset, minScale) + : assetTotalDelta == vaultPseudoDeltaAssets; + if (!totalAddsUp) { JLOG(j.fatal()) << "Invariant failed: withdrawal and assets outstanding must add up"; @@ -1081,7 +1163,11 @@ ValidVault::finalize( auto const assetAvailableDelta = roundToAsset( vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); - if (assetAvailableDelta != vaultPseudoDeltaAssets) + bool const availableAddsUp = fixEnabled + ? agreesWithinOneUnit( + assetAvailableDelta, vaultPseudoDeltaAssets, vaultAsset, minScale) + : assetAvailableDelta == vaultPseudoDeltaAssets; + if (!availableAddsUp) { JLOG(j.fatal()) << "Invariant failed: withdrawal and assets available must add up"; @@ -1126,7 +1212,11 @@ ValidVault::finalize( auto const assetsTotalDelta = roundToAsset( vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); - if (assetsTotalDelta != vaultDeltaAssets) + bool const totalAddsUp = fixEnabled + ? agreesWithinOneUnit( + assetsTotalDelta, vaultDeltaAssets, vaultAsset, minScale) + : assetsTotalDelta == vaultDeltaAssets; + if (!totalAddsUp) { JLOG(j.fatal()) << // "Invariant failed: clawback and assets outstanding must add up"; @@ -1137,7 +1227,11 @@ ValidVault::finalize( vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); - if (assetAvailableDelta != vaultDeltaAssets) + bool const availableAddsUp = fixEnabled + ? agreesWithinOneUnit( + assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale) + : assetAvailableDelta == vaultDeltaAssets; + if (!availableAddsUp) { JLOG(j.fatal()) << // "Invariant failed: clawback and assets available must add up"; diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp index d0eeaed071..b6dc9377c2 100644 --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp @@ -353,11 +353,6 @@ VaultClawback::doApply() auto assetsAvailable = vault->at(sfAssetsAvailable); auto assetsTotal = vault->at(sfAssetsTotal); - [[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized); - XRPL_ASSERT( - lossUnrealized <= (assetsTotal - assetsAvailable), - "xrpl::VaultClawback::doApply : loss and assets do balance"); - AccountID const holder = tx[sfHolder]; STAmount sharesDestroyed = {share}; STAmount assetsRecovered = {vault->at(sfAsset)}; diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index ffefa51d05..cee03f3999 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -357,9 +357,6 @@ VaultWithdraw::doApply() auto assetsAvailable = vault->at(sfAssetsAvailable); auto assetsTotal = vault->at(sfAssetsTotal); auto const lossUnrealized = vault->at(sfLossUnrealized); - XRPL_ASSERT( - lossUnrealized <= (assetsTotal - assetsAvailable), - "xrpl::VaultWithdraw::doApply : loss and assets do balance"); if (view().rules().enabled(fixCleanup3_4_0) && !isFinalWithdrawal) { diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp index abcbe343f5..56ccaa46fc 100644 --- a/src/test/app/invariants/InvariantsVault_test.cpp +++ b/src/test/app/invariants/InvariantsVault_test.cpp @@ -3,7 +3,10 @@ #include #include #include +#include #include +#include +#include #include #include @@ -1947,6 +1950,181 @@ class InvariantsVault_test : public InvariantsBase }); } + // Minimal impaired-loan setup for testVaultLossExceedsGap. Kept + // inline here so this file has no dependency on LoanTestBase. + Keylet + makeImpairedVault( + test::jtx::Account const& owner, + test::jtx::Account const& borrower, + test::jtx::Account const& issuer, + test::jtx::Env& env) + { + using namespace test::jtx; + + env.fund(XRP(1'000'000), issuer, borrower); + env.close(); + + PrettyAsset const usd = issuer["USD"]; + STAmount const trustLimit{usd.raw(), Number{9'999'999'999'999'999LL}}; + env(trust(owner, trustLimit)); + env(trust(borrower, trustLimit)); + env.close(); + + env(pay(issuer, owner, usd(100'000))); + env(pay(issuer, borrower, usd(1'000))); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd}); + env(vaultTx); + env.close(); + + env(vault.deposit( + {.depositor = owner, .id = vaultKeylet.key, .amount = usd(1'000).value()})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + + { + using namespace loan_broker; + env(set(owner, vaultKeylet.key), + kCoverRateMinimum(percentageToTenthBips(1)), + kCoverRateLiquidation(xrpl::lending::kMaxCoverRate), + Fee(env.current()->fees().base * 2)); + env.close(); + + env(coverDeposit(owner, brokerKeylet.key, usd(10'000).value()), + Fee(env.current()->fees().base * 2)); + env.close(); + } + + auto const brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return vaultKeylet; + + auto const loanKeylet = + keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))); + + { + using namespace loan; + env(set(borrower, brokerKeylet.key, usd(100).value()), + kCounterparty(owner), + kInterestRate(TenthBips32{1000}), + kPaymentTotal(120), + kPaymentInterval(86400u * 30u), + kGracePeriod(86400u * 30u), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 200)); + env.close(); + + env(manage(owner, loanKeylet.key, tfLoanImpair)); + env.close(); + } + + return vaultKeylet; + } + + // Regression test for the loss-vs-gap invariant relaxation introduced + // by fixCleanup3_4_0. Even with the one-unit tolerance, a loss value + // exceeding (T - A) by more than one ULP must still fire. Two + // mutations exercise this: + // 1. L = (T - A) * 2 — fires under both amendment settings. + // 2. L = (T - A) + 2 * oneUnit — fires post-amendment, catching + // any accidental widening of the tolerance beyond one unit. + void + testVaultLossExceedsGap() + { + testcase("vault loss exceeds gap (fixCleanup3_4_0 tolerance)"); + using namespace test::jtx; + + auto const kExpectedLog = std::vector{ + "loss unrealized must not exceed the difference between assets " + "outstanding and available"}; + + for (auto const withFix : {false, true}) + { + FeatureBitset amendments = all_; + if (!withFix) + amendments = amendments - fixCleanup3_4_0; + + // Variant 1: L = (T - A) * 2. Fires under both settings. + { + Keylet vaultKeylet = keylet::vault(uint256{}); + Account const issuer{"issuer_loss_gap"}; + Account const borrower{"borrower_loss_gap"}; + + auto preclose = [&, this](Account const& owner, Account const&, Env& env) -> bool { + vaultKeylet = this->makeImpairedVault(owner, borrower, issuer, env); + return BEAST_EXPECT(env.le(vaultKeylet)); + }; + + doInvariantCheck( + makeEnv(amendments), + kExpectedLog, + [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) -> bool { + auto sle = ac.view().peek(vaultKeylet); + if (!sle) + return false; + Number const total = sle->at(sfAssetsTotal); + Number const available = sle->at(sfAssetsAvailable); + (*sle)[sfLossUnrealized] = (total - available) * 2; + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttVAULT_DEPOSIT, + [&vaultKeylet](STObject& tx) { + tx.setFieldH256(sfVaultID, vaultKeylet.key); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + preclose, + TxAccount::A1); + } + + // Variant 2: L = (T - A) + 2 * oneUnit at scale(T). Must fire + // post-fix because the tolerance is exactly one unit. A + // regression that widened it to two units would silently accept + // this state. + { + Keylet vaultKeylet = keylet::vault(uint256{}); + Account const issuer{"issuer_loss_gap2"}; + Account const borrower{"borrower_loss_gap2"}; + + auto preclose = [&, this](Account const& owner, Account const&, Env& env) -> bool { + vaultKeylet = this->makeImpairedVault(owner, borrower, issuer, env); + return BEAST_EXPECT(env.le(vaultKeylet)); + }; + + doInvariantCheck( + makeEnv(amendments), + kExpectedLog, + [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) -> bool { + auto sle = ac.view().peek(vaultKeylet); + if (!sle) + return false; + Number const total = sle->at(sfAssetsTotal); + Number const available = sle->at(sfAssetsAvailable); + Asset const asset = sle->at(sfAsset); + Number const oneUnit{1, scale(total, asset)}; + (*sle)[sfLossUnrealized] = (total - available) + oneUnit * 2; + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttVAULT_DEPOSIT, + [&vaultKeylet](STObject& tx) { + tx.setFieldH256(sfVaultID, vaultKeylet.key); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + preclose, + TxAccount::A1); + } + } + } + void testVaultComputeCoarsestScale() { @@ -2082,6 +2260,7 @@ class InvariantsVault_test : public InvariantsBase run() override { testVault(); + testVaultLossExceedsGap(); testVaultComputeCoarsestScale(); } }; diff --git a/src/test/app/vault/VaultInvariantPrecision_test.cpp b/src/test/app/vault/VaultInvariantPrecision_test.cpp new file mode 100644 index 0000000000..a7eeda34ae --- /dev/null +++ b/src/test/app/vault/VaultInvariantPrecision_test.cpp @@ -0,0 +1,458 @@ +#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 disabled the six delta invariants and the +// lossUnrealized > (assetsTotal - assetsAvailable) gap invariant spuriously +// fire on legitimate flows; with the amendment enabled the one-unit +// tolerance absorbs the sub-ULP drift and every one of these transactions +// must succeed. Exactness (assetsTotal delta == assetsAvailable delta +// exactly) is covered by VaultTransactorPrecision_test. +class VaultInvariantPrecision_test : public VaultPrecisionFixture +{ + // Deposit small integer amounts into an A-1 vault. Pre-amendment, + // deposits of 1, 7, and 10'000'000 land on assetsTotal/assetsAvailable + // grids that disagree by one ULP and the invariant fires. Post- + // amendment the tolerance-widened check accepts the same states. + void + testDepositBoundaryInvariant(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-1 deposit boundary invariant") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kAmounts{1, 7, 10'000'000}; + + for (auto const amount : kAmounts) + { + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + continue; + } + auto const& asset = *f.asset; + + 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(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual == tesSUCCESS, + "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " + + transToken(actual)); + + auto const after = read(env, f); + Number const tDelta = after.assetsTotal - before.assetsTotal; + Number const aDelta = after.assetsAvailable - before.assetsAvailable; + Number const requested = asset(amount).number(); + + BEAST_EXPECT(tDelta <= requested); + + Number const gap = tDelta > aDelta ? tDelta - aDelta : aDelta - tDelta; + BEAST_EXPECT(gap <= oneUnit(asset, after.assetsTotal)); + } + else + { + BEAST_EXPECTS( + actual == tecINVARIANT_FAILED, + "amount=" + std::to_string(amount) + " expected tecINVARIANT_FAILED, got " + + transToken(actual)); + } + } + } + + // Withdraw long-mantissa share counts from an A-1 vault. Pre-fix + // some counts trip the withdraw delta invariants; post-fix none does. + void + testWithdrawBoundaryInvariant(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-1 withdraw boundary invariant") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kShareCounts{ + 99'999u, 100'001u, 333'333u, 1'234'567u, 142'857'142u, 333'333'333u}; + + // Fill the vault with enough shares that every count below is + // available to the depositor. + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + return; + } + auto const& asset = *f.asset; + + Vault const v{env}; + // Deposit a large amount so we can afford every withdrawal below. + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(1'000'000).value()}), + Ter(std::ignore)); + env.close(); + + 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)}}; + env(v.withdraw( + {.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = shareAmount}), + Ter(std::ignore)); + env.close(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual != tecINVARIANT_FAILED, + "shares=" + std::to_string(count) + " unexpected invariant failure"); + + if (actual == tesSUCCESS) + { + auto const after = read(env, f); + Number const tDelta = before.assetsTotal - after.assetsTotal; + Number const pDelta = before.pseudo - after.pseudo; + Number const gap = tDelta > pDelta ? tDelta - pDelta : pDelta - tDelta; + // VaultTransactorPrecision_test tightens this to strict + // equality. + BEAST_EXPECT(gap <= oneUnit(asset, before.assetsTotal)); + } + } + // Pre-fix behaviour is fixture-dependent: some share counts may + // succeed even without the amendment. The important property is + // that post-fix no legitimate withdrawal is rejected by the + // widened invariant. + } + } + + // Clawback of small IOU amounts against a live-loan vault. Pre-fix + // some amounts trip the clawback delta invariants; post-fix none does. + // Also assert the owner force-burn path returns tecNO_PERMISSION + // under both amendment states (it never enters assetsToClawback). + void + testClawbackBoundaryInvariant(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-1 clawback boundary invariant") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kAmounts{1, 7, 99, 333, 993, 2000}; + + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false, /*allowClawback=*/true); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + return; + } + auto const& asset = *f.asset; + + Vault const v{env}; + + // Give the depositor a stake so that the issuer has something to + // claw back. + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(2'000).value()}), + Ter(std::ignore)); + env.close(); + + for (auto const amount : kAmounts) + { + auto const before = read(env, f); + if (before.sharesTotal == 0) + continue; + + env(v.clawback( + {.issuer = f.issuer, + .id = f.vaultKeylet.key, + .holder = f.depositor, + .amount = asset(amount).value()}), + Ter(std::ignore)); + env.close(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual != tecINVARIANT_FAILED, + "amount=" + std::to_string(amount) + " unexpected invariant failure"); + } + // Pre-fix behaviour is fixture-dependent: some clawback amounts + // may succeed even without the amendment. The important + // property is that post-fix no legitimate clawback is rejected + // by the widened invariant. + } + + // Owner force-burn only succeeds against an EMPTY vault (see + // VaultClawback::preclaim). Our fixture keeps a live loan, so + // this must return tecNO_PERMISSION regardless of the amendment. + env(v.clawback({.issuer = f.lender, .id = f.vaultKeylet.key, .holder = f.depositor}), + Ter(tecNO_PERMISSION)); + env.close(); + } + + // Deposit into an A-3 vault where the impaired-loan gap plus the + // interest earned from the sibling repayment lands L > (T - A) by + // sub-ULP. Pre-fix the loss invariant fires; post-fix it does not. + void + testLossInvariantA3(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-3 loss invariant sweep") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kAmounts{1, 7, 10'000'000}; + + for (auto const amount : kAmounts) + { + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + continue; + } + auto const& asset = *f.asset; + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(amount).value()}), + Ter(std::ignore)); + env.close(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual == tesSUCCESS, + "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " + + transToken(actual)); + + auto const after = read(env, f); + BEAST_EXPECT( + after.lossUnrealized <= (after.assetsTotal - after.assetsAvailable) + + oneUnit(asset, after.assetsTotal)); + } + else + { + BEAST_EXPECTS( + actual == tecINVARIANT_FAILED, + "amount=" + std::to_string(amount) + " expected tecINVARIANT_FAILED, got " + + transToken(actual)); + } + } + } + + // Full 17-magnitude A-1 deposit sweep. Pre-fix {1, 7, 10'000'000} + // are the boundary amounts that fail; post-fix every amount succeeds. + void + testA1DepositMagnitudes(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-1 deposit magnitude sweep") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kAmounts{ + 1, + 2, + 5, + 7, + 10, + 50, + 100, + 500, + 1'000, + 5'000, + 10'000, + 50'000, + 100'000, + 500'000, + 1'000'000, + 5'000'000, + 10'000'000}; + std::array const kPreFixFailures{1, 7, 10'000'000}; + + for (auto const amount : kAmounts) + { + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + continue; + } + auto const& asset = *f.asset; + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(amount).value()}), + Ter(std::ignore)); + env.close(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual == tesSUCCESS, + "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " + + transToken(actual)); + } + else + { + bool const shouldFail = + std::ranges::find(kPreFixFailures, amount) != kPreFixFailures.end(); + if (shouldFail) + { + BEAST_EXPECTS( + actual == tecINVARIANT_FAILED, + "pre-fix amount=" + std::to_string(amount) + + " expected tecINVARIANT_FAILED, got " + transToken(actual)); + } + // For other amounts pre-fix, we accept any outcome; the + // interesting property is only asserted for the known-failing + // ones. + } + } + } + + // A-3 deposit sweep. Pre-fix {1, 7, 10'000, 10'000'000} fail; post-fix + // every amount succeeds. 99'999 (delta tolerance) and 10'000'000 + // (loss tolerance) are the two boundary cases that motivate this PR. + void + testA3DepositMagnitudes(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-3 deposit magnitude sweep") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kAmounts{ + 1, 7, 100, 1'000, 10'000, 100'000, 1'000'000, 10'000'000, 99'999}; + + std::array const kPreFixFailures{1, 7, 10'000, 10'000'000}; + + for (auto const amount : kAmounts) + { + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + continue; + } + auto const& asset = *f.asset; + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(amount).value()}), + Ter(std::ignore)); + env.close(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual == tesSUCCESS, + "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " + + transToken(actual)); + } + else + { + bool const shouldFail = + std::ranges::find(kPreFixFailures, amount) != kPreFixFailures.end(); + if (shouldFail) + { + BEAST_EXPECTS( + actual == tecINVARIANT_FAILED, + "pre-fix amount=" + std::to_string(amount) + + " expected tecINVARIANT_FAILED, got " + transToken(actual)); + } + } + } + } + +public: + void + run() override + { + for (auto const& features : {all_ - fixCleanup3_4_0, all_}) + { + testDepositBoundaryInvariant(features); + testWithdrawBoundaryInvariant(features); + testClawbackBoundaryInvariant(features); + testLossInvariantA3(features); + testA1DepositMagnitudes(features); + testA3DepositMagnitudes(features); + } + } +}; + +BEAST_DEFINE_TESTSUITE(VaultInvariantPrecision, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/vault/VaultPrecisionFixture.h b/src/test/app/vault/VaultPrecisionFixture.h new file mode 100644 index 0000000000..c324161347 --- /dev/null +++ b/src/test/app/vault/VaultPrecisionFixture.h @@ -0,0 +1,242 @@ +#pragma once + +#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 { + +// 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. +class VaultPrecisionFixture : public LoanTestBase +{ +protected: + static constexpr std::uint32_t kFixturePaymentInterval = 86400u * 30u; + static constexpr std::uint32_t kFixtureGracePeriod = 86400u * 30u; + static constexpr std::uint32_t kFixturePaymentTotal = 120u; + // 10% APR, expressed in tenth-bips (1000 = 10.00 %). + static constexpr std::uint32_t kFixtureInterestTenthBips = 1000u; + + struct Fixture + { + // Every account is initialised with a placeholder name because + // jtx::Account has no default constructor; setupSingleLoanVault + // overwrites them. + jtx::Account issuer{"vp_issuer_placeholder"}; + jtx::Account lender{"vp_lender_placeholder"}; + jtx::Account borrower{"vp_borrower_placeholder"}; + // Distinct account used to deposit into the vault. Keeps share + // ownership independent of the initial vault seeding. + jtx::Account depositor{"vp_depositor_placeholder"}; + // Optional so callers can BEAST_EXPECT(f.asset && f.broker) + // after setup; both are populated in the happy path. + std::optional asset; + std::optional broker; + // Keylet has no default constructor. Fill with an obviously + // meaningless placeholder; setupSingleLoanVault overwrites the + // fields that matter. + Keylet vaultKeylet{ltACCOUNT_ROOT, uint256{}}; + Keylet loan1Keylet{ltACCOUNT_ROOT, uint256{}}; + // Only meaningful when impairAndPaySibling == true. + Keylet loan2Keylet{ltACCOUNT_ROOT, uint256{}}; + jtx::Account vaultAccount{"vp_vault_pseudo_placeholder"}; + MPTID share; + }; + + // Read-only snapshot of the vault + share issuance at a point in time. + // Uses Number for exact arithmetic (no re-quantization). + struct Numbers + { + Asset asset; + MPTIssue share; + 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 + }; + + static Numbers + read(jtx::Env const& env, Fixture const& f) + { + Numbers n{.asset = f.asset ? f.asset->raw() : Asset{}, .share = MPTIssue{f.share}}; + if (auto const vaultSle = env.le(f.vaultKeylet)) + { + n.assetsTotal = vaultSle->at(sfAssetsTotal); + n.assetsAvailable = vaultSle->at(sfAssetsAvailable); + n.lossUnrealized = vaultSle->at(sfLossUnrealized); + } + if (auto const issuanceSle = env.le(keylet::mptokenIssuance(f.share))) + { + n.sharesTotal = issuanceSle->at(sfOutstandingAmount); + } + if (f.asset) + n.pseudo = env.balance(f.vaultAccount, *f.asset).number(); + return n; + } + + // One unit at the STAmount scale of `assetsTotalAfter`. Used as the + // tolerance in one-unit-band assertions. + static Number + oneUnit(Asset const& asset, Number const& assetsTotalAfter) + { + return Number{1, scale(assetsTotalAfter, asset)}; + } + + // Build the shared vault + loan(s) layout. The caller constructs + // `env` with whatever FeatureBitset they want to exercise; this helper + // just uses it. If `allowClawback` is true, the issuer's + // asfAllowTrustLineClawback flag is set BEFORE any trust line is + // established for that issuer. A separate env.close() runs so the + // flag lands in the ledger before the trust lines are set up. + static Fixture + setupSingleLoanVault(jtx::Env& env, bool impairAndPaySibling, bool allowClawback = false) + { + using namespace jtx; + using namespace jtx::loan; + using namespace jtx::loan_broker; + + Fixture f; + f.issuer = Account{"vp_issuer"}; + f.lender = Account{"vp_lender"}; + f.borrower = Account{"vp_borrower"}; + f.depositor = Account{"vp_depositor"}; + + env.fund(XRP(1'000'000), f.issuer, f.lender, f.borrower, f.depositor); + env.close(); + + // Must be set BEFORE any trust line to `issuer` is created. + if (allowClawback) + { + env(fset(f.issuer, asfAllowTrustLineClawback)); + env.close(); + } + + PrettyAsset const asset = f.issuer["USD"]; + f.asset = asset; + + env.trust(asset(1'000'000'000), f.lender); + env.trust(asset(1'000'000'000), f.borrower); + env.trust(asset(1'000'000'000), f.depositor); + env(pay(f.issuer, f.lender, asset(100'000'000))); + env(pay(f.issuer, f.borrower, asset(100'000'000))); + env(pay(f.issuer, f.depositor, asset(100'000'000))); + env.close(); + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000, + .debtMax = 0, + .coverRateMin = percentageToTenthBips(1), + .coverDeposit = 10'000, + .managementFeeRate = TenthBips16{100}, + .coverRateLiquidation = xrpl::lending::kMaxCoverRate}; + + // Build the vault + broker manually (rather than calling + // createVaultAndBroker) so we can seed only the lender/depositor + // trust lines we set up above, and skip the LoanTestBase auto + // funding that assumes an XRP asset. + Vault const vault{env}; + auto [createTx, vaultKeylet] = vault.create({.owner = f.lender, .asset = asset}); + env(createTx); + env.close(); + f.vaultKeylet = vaultKeylet; + + env(vault.deposit( + {.depositor = f.lender, + .id = vaultKeylet.key, + .amount = asset(brokerParams.vaultDeposit)})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender))); + + env(set(f.lender, vaultKeylet.key, brokerParams.flags), + kManagementFeeRate(brokerParams.managementFeeRate), + kDebtMaximum(asset(brokerParams.debtMax).value()), + kCoverRateMinimum(brokerParams.coverRateMin), + kCoverRateLiquidation(TenthBips32(brokerParams.coverRateLiquidation))); + env(coverDeposit(f.lender, brokerKeylet.key, asset(brokerParams.coverDeposit).value())); + env.close(); + + f.broker = BrokerInfo{asset, brokerKeylet, vaultKeylet, brokerParams}; + + auto const vaultSle = env.le(vaultKeylet); + f.vaultAccount = Account{"vp_vault_pseudo", vaultSle->at(sfAccount)}; + f.share = vaultSle->at(sfShareMPTID); + + Fee const bigFee{env.current()->fees().base * 200}; + + auto const setLoan = [&](Number const& principal) -> Keylet { + auto const brokerSle = env.le(brokerKeylet); + auto const loanKeylet = keylet::loan( + brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))); + env(loan::set(f.borrower, brokerKeylet.key, asset(principal).number()), + Sig(sfCounterpartySignature, f.lender), + jtx::loan::kInterestRate(TenthBips32{kFixtureInterestTenthBips}), + jtx::loan::kPaymentTotal(kFixturePaymentTotal), + jtx::loan::kPaymentInterval(kFixturePaymentInterval), + jtx::loan::kGracePeriod(kFixtureGracePeriod), + bigFee); + env.close(); + return loanKeylet; + }; + + // Loan 1: principal 7, the one ordinary loan in both fixtures. + // With vault deposit 1000, this leaves A ≈ 993 (see plan). + f.loan1Keylet = setLoan(Number{7}); + + if (!impairAndPaySibling) + return f; + + // Loan 2: sibling loan of principal 11. + f.loan2Keylet = setLoan(Number{11}); + + // Impair loan 1 → drives sfLossUnrealized to loan 1's value. + env(jtx::loan::manage(f.lender, f.loan1Keylet.key, tfLoanImpair), bigFee); + env.close(); + + // Pay off loan 2 in full so its total value flows into the vault + // and pushes T-A upward, meeting the residual loss. Generous + // upper bound; the transactor takes only what is due. + auto const payoff = asset(Number{50}).value(); + env(pay(f.borrower, f.loan2Keylet.key, payoff, tfLoanFullPayment), bigFee); + env.close(); + + return f; + } +}; + +} // namespace xrpl::test From 9e2aaf6f60aaf43f70a660606b4404876474ce95 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Tue, 25 Aug 2026 17:09:03 +0000 Subject: [PATCH 26/43] build: Add rust-toolchain.toml to .envrc (#8107) --- .envrc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.envrc b/.envrc index ec38b75f5c..a3f6be96ea 100644 --- a/.envrc +++ b/.envrc @@ -1,5 +1,8 @@ watch_file nix/*.nix +# Pinned Rust toolchain, read by nix/packages.nix via fromRustupToolchainFile. +watch_file rust-toolchain.toml + # The dev shell derivation includes all of conan/ (see nix/devshell.nix), so any # change in there has to invalidate direnv's cached environment. watch_dir conan From 5e3d20b3ed7bc4086ff44298abd290a4fedce3af Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:59:37 +0000 Subject: [PATCH 27/43] fix: Prevent vault clawback and withdraw overrun (#8075) --- .../tx/transactors/vault/VaultClawback.cpp | 9 +- .../tx/transactors/vault/VaultWithdraw.cpp | 13 +- src/test/app/vault/VaultBugs_test.cpp | 240 ++++++++++++++++++ src/test/app/vault/VaultScale_test.cpp | 105 ++++---- 4 files changed, 310 insertions(+), 57 deletions(-) diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp index b6dc9377c2..7348e1734b 100644 --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp @@ -271,8 +271,15 @@ VaultClawback::assetsToClawback( } else { + // Pre-fixCleanup3_4_0: shares were rounded to nearest, so the + // round-trip back to assets could exceed clawbackAmount. + // 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); + assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount, truncate); if (!maybeShares) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE sharesDestroyed = *maybeShares; diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index cee03f3999..9e066304ed 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -305,9 +305,20 @@ VaultWithdraw::doApply() if (amount.asset() == vaultAsset) { // Fixed assets, variable shares. + // + // Pre-fixCleanup3_4_0: shares were rounded to nearest, so the + // round-trip back to assets could exceed the requested amount. + // That over-delivers to the depositor and can bypass the + // preclaim canWithdraw check on the destination, which was + // validated against the requested amount only. + // Post-amendment: truncate shares so assetsWithdrawn <= + // requested amount by construction. If truncation yields zero + // shares, the tecPRECISION_LOSS guard below fires. + auto const truncate = + view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes : TruncateShares::No; { auto const maybeShares = assetsToSharesWithdraw( - vault, sleIssuance, amount, TruncateShares::No, waiveUnrealizedLoss); + vault, sleIssuance, amount, truncate, waiveUnrealizedLoss); if (!maybeShares) return tecINTERNAL; // LCOV_EXCL_LINE sharesRedeemed = *maybeShares; diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index 0771d4a450..ad6fdcc8b8 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -974,6 +976,242 @@ private: } } + // Shared setup for testBugClawbackRoundTripOvershoot and + // testBugWithdrawRoundTripOvershoot, which both need a vault at + // assetsTotal=7, sharesTotal=5 and differ only in what they do once + // that state is reached. + // + // The (7, 5) state is reached through ordinary transactions: a 5 USD + // deposit mints 5 shares 1:1, then a loan broker on the vault issues a + // single-payment bullet loan for the full 5 USD at 40% interest. When + // the borrower repays a year later, LoanPay books the 2 USD of accrued + // interest into sfAssetsTotal without minting shares, leaving + // assetsTotal=7 against sharesTotal=5 (see + // testBugDepositShareTruncationSubUlp for the same technique in more + // detail). + struct RoundTripOvershootVault + { + test::jtx::Account issuer; + test::jtx::Account holder; + PrettyAsset usd; + test::jtx::Vault vault; + Keylet vaultKeylet; + Number initialAssetsTotal; + Number initialAssetsAvailable; + }; + + std::optional + makeRoundTripOvershootVault(test::jtx::Env& env) + { + 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 borrower{"borrower"}; + + env.fund(XRP(10'000), issuer, owner, holder, borrower); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const usd = issuer["USD"]; + env.trust(usd(1'000), owner); + env.trust(usd(1'000), holder); + env.trust(usd(1'000), borrower); + env.close(); + + env(pay(issuer, holder, usd(100))); + env(pay(issuer, borrower, usd(100))); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd}); + vaultTx[sfScale] = 0; + env(vaultTx); + env.close(); + + // Holder deposits 5 USD, minting 5 shares 1:1. + env(vault.deposit({.depositor = holder, .id = vaultKeylet.key, .amount = usd(5)})); + env.close(); + + // A loan broker on the vault, then a single bullet loan for the + // entire deposit at 40% interest, one payment, one year out. + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + env(set(borrower, brokerKeylet.key, usd(5).value()), + loan::kInterestRate(percentageToTenthBips(40)), + kGracePeriod(60), + kPaymentInterval(365 * 24 * 60 * 60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // Advance to just before the single payment falls due and let the + // borrower repay principal plus interest. Share supply stays at 5, + // so assetsTotal/sharesTotal becomes 7/5. + env.close(std::chrono::seconds{(365 * 24 * 60 * 60) - 3600}); + env(pay(borrower, loanKeylet.key, usd(10).value()), Ter(tesSUCCESS)); + env.close(); + + auto const vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return std::nullopt; + auto const mptIssuanceID = vaultSle->at(sfShareMPTID); + + Number const initialAssetsTotal = vaultSle->at(sfAssetsTotal); + Number const initialAssetsAvailable = vaultSle->at(sfAssetsAvailable); + BEAST_EXPECT(initialAssetsTotal == usd(7).number()); + BEAST_EXPECT(initialAssetsAvailable == usd(7).number()); + { + auto const sleIssuance = env.le(keylet::mptokenIssuance(mptIssuanceID)); + if (!BEAST_EXPECT(sleIssuance)) + return std::nullopt; + BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == 5); + } + + return RoundTripOvershootVault{ + .issuer = issuer, + .holder = holder, + .usd = usd, + .vault = vault, + .vaultKeylet = vaultKeylet, + .initialAssetsTotal = initialAssetsTotal, + .initialAssetsAvailable = initialAssetsAvailable}; + } + + // VaultClawback::assetsToClawback converts clawbackAmount to shares + // with round-to-nearest, then round-trips back to assets. When shares + // round up, assetsRecovered can exceed clawbackAmount. + // + // Repro: assetsTotal=7, sharesTotal=5, request 4: + // shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4. + // + // Post-fixCleanup3_4_0: truncate shares so assetsRecovered <= + // clawbackAmount by construction. + void + testBugClawbackRoundTripOvershoot() + { + using namespace test::jtx; + + auto runScenario = [this](FeatureBitset features, bool withFix) { + Env env{*this, features}; + + auto const setup = makeRoundTripOvershootVault(env); + if (!BEAST_EXPECT(setup)) + return; + + auto const clawbackAmount = setup->usd(4); + env(setup->vault.clawback( + {.issuer = setup->issuer, + .id = setup->vaultKeylet.key, + .holder = setup->holder, + .amount = clawbackAmount.value()})); + + auto const vaultSleAfter = env.current()->read(setup->vaultKeylet); + if (!BEAST_EXPECT(vaultSleAfter)) + return; + Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal); + Number const assetsRecovered = setup->initialAssetsTotal - finalAssetsTotal; + Number const clawbackNum = clawbackAmount.number(); + + Number const expectedPost{28LL, -1}; + Number const expectedPre{42LL, -1}; + if (withFix) + { + BEAST_EXPECT(assetsRecovered <= clawbackNum); + BEAST_EXPECT(assetsRecovered == expectedPost); + } + else + { + BEAST_EXPECT(assetsRecovered > clawbackNum); + BEAST_EXPECT(assetsRecovered == expectedPre); + } + }; + + { + testcase( + "bug: VaultClawback round-trip overshoot lets issuer recover " + "more than requested (pre-fixCleanup3_4_0)"); + runScenario(testableAmendments() - fixCleanup3_4_0, false); + } + { + testcase( + "bug: VaultClawback round-trip overshoot is clamped so " + "assetsRecovered <= clawbackAmount (post-fixCleanup3_4_0)"); + runScenario(testableAmendments(), true); + } + } + + // Same root cause as testBugClawbackRoundTripOvershoot on the + // withdraw path. Also bypasses the preclaim canWithdraw check, which + // validates destination limits against the requested amount only. + // + // Repro: assetsTotal=7, sharesTotal=5, request 4: + // pre-fix : shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4. + // post-fix: shares = floor(20/7) = 2, assets = 7*2/5 = 2.8 <= 4. + void + testBugWithdrawRoundTripOvershoot() + { + using namespace test::jtx; + + auto runScenario = [this](FeatureBitset features, bool withFix) { + Env env{*this, features}; + + auto const setup = makeRoundTripOvershootVault(env); + if (!BEAST_EXPECT(setup)) + return; + + auto const requested = setup->usd(4); + env(setup->vault.withdraw( + {.depositor = setup->holder, + .id = setup->vaultKeylet.key, + .amount = requested.value()})); + + auto const vaultSleAfter = env.current()->read(setup->vaultKeylet); + if (!BEAST_EXPECT(vaultSleAfter)) + return; + Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal); + Number const assetsWithdrawn = setup->initialAssetsTotal - finalAssetsTotal; + Number const requestedNum = requested.number(); + + Number const expectedPost{28LL, -1}; + Number const expectedPre{42LL, -1}; + if (withFix) + { + BEAST_EXPECT(assetsWithdrawn <= requestedNum); + BEAST_EXPECT(assetsWithdrawn == expectedPost); + } + else + { + BEAST_EXPECT(assetsWithdrawn > requestedNum); + BEAST_EXPECT(assetsWithdrawn == expectedPre); + } + }; + + { + testcase( + "bug: VaultWithdraw round-trip overshoot delivers more than " + "requested (pre-fixCleanup3_4_0)"); + runScenario(testableAmendments() - fixCleanup3_4_0, false); + } + { + testcase( + "bug: VaultWithdraw round-trip overshoot is clamped so " + "assetsWithdrawn <= requested (post-fixCleanup3_4_0)"); + runScenario(testableAmendments(), true); + } + } + void testCredentialPinsPseudoAccount() { @@ -1101,6 +1339,8 @@ public: testCredentialPinsPseudoAccount(); testCredentialPinOverflow(); testBug6LimitBypassWithShares(); + testBugClawbackRoundTripOvershoot(); + testBugWithdrawRoundTripOvershoot(); } }; diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp index 94c594f674..28c9729d78 100644 --- a/src/test/app/vault/VaultScale_test.cpp +++ b/src/test/app/vault/VaultScale_test.cpp @@ -546,13 +546,13 @@ private: } { - testcase("Scale withdraw with rounding shares up"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8 + testcase("Scale withdraw with rounding shares up (truncated post-fixCleanup3_4_0)"); + // Pre-fixCleanup3_4_0: + // shares = round(875 * 3.75 / 87.5) = 38 + // assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested. + // Post-fixCleanup3_4_0: + // shares = floor(37.5) = 37 + // assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.withdraw( @@ -561,26 +561,23 @@ private: .amount = STAmount(d.asset, Number(375, -2))}); env(tx); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 37)); BEAST_EXPECT( env.balance(d.depositor, d.assets) == - STAmount(d.asset, start + Number(38, -1))); + STAmount(d.asset, start + Number(37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(875 - 38, -1))); + STAmount(d.asset, Number(875 - 37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(875 - 38, 0))); + STAmount(d.share, -Number(875 - 37, 0))); } { testcase("Scale withdraw with rounding shares down"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7 + // Chained state: 838 shares outstanding, 83.8 assets. + // shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37 + // assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.withdraw( @@ -589,37 +586,37 @@ private: .amount = STAmount(d.asset, Number(372, -2))}); env(tx); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(838 - 37)); BEAST_EXPECT( env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(837 - 37, -1))); + STAmount(d.asset, Number(838 - 37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(837 - 37, 0))); + STAmount(d.share, -Number(838 - 37, 0))); } { - testcase("Scale withdraw tiny amount"); + testcase("Scale withdraw tiny amount rejected post-fixCleanup3_4_0"); + // Chained state: 801 shares outstanding, 80.1 assets. + // shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0 + // Zero shares => tecPRECISION_LOSS. State is unchanged. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.withdraw( {.depositor = d.depositor, .id = d.keylet.key, .amount = STAmount(d.asset, Number(9, -2))}); - env(tx); + env(tx, Ter{tecPRECISION_LOSS}); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801)); + BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); BEAST_EXPECT( - env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -1))); + env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1))); BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(800 - 1, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(800 - 1, 0))); + env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 0))); } { @@ -738,13 +735,13 @@ private: } { - testcase("Scale clawback with rounding shares up"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8 + testcase("Scale clawback with rounding shares up (truncated post-fixCleanup3_4_0)"); + // Pre-fixCleanup3_4_0: + // shares = round(875 * 3.75 / 87.5) = 38 + // assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested. + // Post-fixCleanup3_4_0: + // shares = floor(37.5) = 37 + // assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.clawback( @@ -754,24 +751,21 @@ private: .amount = STAmount(d.asset, Number(375, -2))}); env(tx); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 37)); BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); BEAST_EXPECT( env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(875 - 38, -1))); + STAmount(d.asset, Number(875 - 37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(875 - 38, 0))); + STAmount(d.share, -Number(875 - 37, 0))); } { testcase("Scale clawback with rounding shares down"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7 + // Chained state: 838 shares outstanding, 83.8 assets. + // shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37 + // assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.clawback( @@ -781,18 +775,21 @@ private: .amount = STAmount(d.asset, Number(372, -2))}); env(tx); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(838 - 37)); BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); BEAST_EXPECT( env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(837 - 37, -1))); + STAmount(d.asset, Number(838 - 37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(837 - 37, 0))); + STAmount(d.share, -Number(838 - 37, 0))); } { - testcase("Scale clawback tiny amount"); + testcase("Scale clawback tiny amount rejected post-fixCleanup3_4_0"); + // Chained state: 801 shares outstanding, 80.1 assets. + // shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0 + // Zero shares => tecPRECISION_LOSS. State is unchanged. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.clawback( @@ -800,16 +797,14 @@ private: .id = d.keylet.key, .holder = d.depositor, .amount = STAmount(d.asset, Number(9, -2))}); - env(tx); + env(tx, Ter{tecPRECISION_LOSS}); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801)); BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(800 - 1, -1))); + env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1))); BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(800 - 1, 0))); + env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 0))); } { From fee4bfc22e9a4bf0423b4aab02d665c2fc82c46f Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Tue, 25 Aug 2026 19:32:03 +0000 Subject: [PATCH 28/43] build: Implement packaging in Python (#8109) --- .github/scripts/strategy-matrix/generate.py | 8 +- .github/scripts/strategy-matrix/linux.json | 6 +- .github/workflows/reusable-package.yml | 24 +- .pre-commit-config.yaml | 16 ++ cmake/XrplPackaging.cmake | 18 +- package/README.md | 87 +++---- package/build_pkg.py | 263 ++++++++++++++++++++ package/build_pkg.sh | 252 ------------------- package/publish_pkg.py | 153 ++++++++++++ package/publish_pkg.sh | 109 -------- package/sign_rpm.py | 128 ++++++++++ package/sign_rpm.sh | 67 ----- 12 files changed, 640 insertions(+), 491 deletions(-) create mode 100755 package/build_pkg.py delete mode 100755 package/build_pkg.sh create mode 100755 package/publish_pkg.py delete mode 100755 package/publish_pkg.sh create mode 100755 package/sign_rpm.py delete mode 100755 package/sign_rpm.sh diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 7fef6643ff..7a3b7a8cf5 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -57,7 +57,9 @@ class LinuxConfig: sanitizers: list[str] = dataclasses.field(default_factory=list) suffix: str = "" extra_cmake_args: str = "" - image: str = "" # only used by package_configs entries + # The two below are only used by package_configs entries. + image: str = "" + package_type: str = "" # "deb" or "rpm"; has to match what image provides @dataclasses.dataclass @@ -156,7 +158,7 @@ class PackagingEntry: xrpld_artifact_name: str validator_keys_artifact_name: str image: str - distro: str # e.g. "debian" or "rhel"; drives package-format-specific steps + package_type: str # "deb" or "rpm"; drives the format-specific steps # --------------------------------------------------------------------------- @@ -243,7 +245,7 @@ def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: xrpld_artifact_name=f"xrpld-{config_name}", validator_keys_artifact_name=f"validator-keys-{config_name}", image=cfg.image, - distro=distro, + package_type=cfg.package_type, ) ) diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index e739a42d5a..8450c3079e 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -92,7 +92,8 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-a6983f8" + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-45e4b88", + "package_type": "deb" } ], @@ -102,7 +103,8 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-a6983f8" + "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-45e4b88", + "package_type": "rpm" } ] } diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index cfae706ee1..4d1968b93c 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -1,9 +1,9 @@ # Build Linux packages from the pre-built xrpld and validator-keys artifacts: # # - one job per distro, taken from "package_configs" in linux.json -# - each job runs in that distro's container, which is what decides DEB or RPM +# - each entry names its container image and the format it builds there # - with 'publish: true' a job also uploads what it built -# (see package/publish_pkg.sh) +# (see package/publish_pkg.py) # # Only linux/amd64 is supported; the runner is hardcoded in the job below. name: Package @@ -97,17 +97,23 @@ jobs: - name: Build package env: + PACKAGE_TYPE: ${{ matrix.package_type }} PKG_RELEASE: ${{ steps.release_info.outputs.pkg_release }} - PKG_CHANNEL: ${{ steps.release_info.outputs.channel }} - run: ./package/build_pkg.sh + CHANNEL: ${{ steps.release_info.outputs.channel }} + run: | + ./package/build_pkg.py \ + --package-type "${PACKAGE_TYPE}" \ + --build-dir "${BUILD_DIR}" \ + --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. - name: Sign RPM - if: ${{ inputs.publish && matrix.distro == 'rhel' }} + if: ${{ inputs.publish && matrix.package_type == 'rpm' }} env: PKG_SIGNING_KEY: ${{ secrets.signing_key }} - run: ./package/sign_rpm.sh "${BUILD_DIR}" + run: ./package/sign_rpm.py --package-dir "${BUILD_DIR}" - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -126,4 +132,8 @@ jobs: NEXUS_URL: ${{ inputs.nexus_url }} NEXUS_USERNAME: ${{ secrets.remote_username }} NEXUS_PASSWORD: ${{ secrets.remote_password }} - run: ./package/publish_pkg.sh "${CHANNEL}" "${BUILD_DIR}" + run: | + ./package/publish_pkg.py \ + --channel "${CHANNEL}" \ + --package-dir "${BUILD_DIR}" \ + --nexus-url "${NEXUS_URL}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5e69759fd..f223ab1684 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -82,11 +82,27 @@ repos: - id: prettier args: [--end-of-line=auto] + # Scoped to package/: the rest of the repo's Python has pre-existing findings, + # so widening these is its own change. + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 7c55798a78262d14b2074abf623d8a992ebb70d4 # frozen: v0.16.2 + hooks: + - id: ruff-check + args: [--fix] + files: ^package/.*\.py$ + - repo: https://github.com/psf/black-pre-commit-mirror rev: 4160603246a6b365d4a2af661c6d71b0a0f50478 # frozen: 26.5.1 hooks: - id: black + - repo: https://github.com/pre-commit/mirrors-mypy + rev: 41e691678310dfd3833f7ab4e180ddb014310356 # frozen: v2.3.0 + hooks: + - id: mypy + args: [--strict] + files: ^package/.*\.py$ + - repo: https://github.com/scop/pre-commit-shfmt rev: 05c1426671b9237fb5e1444dd63aa5731bec0dfb # frozen: v3.13.1-1 hooks: diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake index bee7b15791..c454f487dc 100644 --- a/cmake/XrplPackaging.cmake +++ b/cmake/XrplPackaging.cmake @@ -1,7 +1,7 @@ #[===================================================================[ Linux packaging support: 'package' target. - The packaging script (package/build_pkg.sh) installs to FHS-standard + The packaging script (package/build_pkg.py) installs to FHS-standard paths (/usr/bin, /etc/xrpld, etc.) regardless of CMAKE_INSTALL_PREFIX, so no prefix guard is needed here. #]===================================================================] @@ -38,19 +38,19 @@ if(NOT TARGET validator-keys) return() endif() -set(package_env - SRC_DIR=${CMAKE_SOURCE_DIR} - BUILD_DIR=${CMAKE_BINARY_DIR} - PKG_RELEASE=${pkg_release} -) +if(DPKG_BUILDPACKAGE_EXECUTABLE) + set(pkg_type deb) +else() + set(pkg_type rpm) +endif() add_custom_target( package COMMAND - ${CMAKE_COMMAND} -E env ${package_env} - ${CMAKE_SOURCE_DIR}/package/build_pkg.sh + ${CMAKE_SOURCE_DIR}/package/build_pkg.py --package-type ${pkg_type} + --build-dir ${CMAKE_BINARY_DIR} --pkg-release ${pkg_release} WORKING_DIRECTORY ${CMAKE_BINARY_DIR} DEPENDS xrpld validator-keys - COMMENT "Building Linux package (deb/rpm inferred from host tooling)" + COMMENT "Building Linux ${pkg_type} package" VERBATIM ) diff --git a/package/README.md b/package/README.md index 54b1e57204..04f4db2ea5 100644 --- a/package/README.md +++ b/package/README.md @@ -8,9 +8,9 @@ a build configured with `-Dvalidator_keys=ON`. ``` package/ - build_pkg.sh Staging and build script (called by the CMake `package` target and CI) - sign_rpm.sh Signs the built RPMs (called by CI when publishing) - publish_pkg.sh Uploads built packages to the XRPLF Nexus repositories (called by CI) + 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) rpm/ xrpld.spec RPM spec debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format) @@ -28,8 +28,9 @@ Packaging targets and their container images are declared in 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 -package format (deb or rpm) is inferred at build time from the container's -package manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). +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. | Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required | | ------------ | ---------------------------------------------------------- | --------------------------------------------------- | @@ -51,10 +52,10 @@ 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, so the package format -follows from the container's package manager. 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. +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 binaries come from the `debian` and `rhel` build configurations in `linux.json`'s `configs` section, which pass `-Dvalidator_keys=ON` so that the @@ -75,9 +76,8 @@ 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; the package format is inferred from the -# container's package manager. Example for the rpm-producing image (use -# .package_configs.debian[0].image for the deb image): +# 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) PKG_RELEASE=1 @@ -86,7 +86,7 @@ docker run --rm \ -v "$(pwd):/src" \ -w /src \ "${IMAGE}" \ - ./package/build_pkg.sh --pkg-release "${PKG_RELEASE}" + ./package/build_pkg.py --package-type rpm --pkg-release "${PKG_RELEASE}" # Output: # build/debbuild/*.deb (DEB + dbgsym; Debian names both .deb) @@ -113,12 +113,12 @@ cmake --build . --target package # deb on Debian/Ubuntu, rpm on RHEL The `cmake/XrplPackaging.cmake` module defines the `package` target only if at least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and `validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target -builds both binaries before packaging. `build_pkg.sh` then infers the package -format from the host's package manager. The packaging script installs to +builds both binaries before packaging, passing `--package-type deb` when +`dpkg-buildpackage` is present and `rpm` otherwise. The packaging script installs to FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of `CMAKE_INSTALL_PREFIX`. -The package version is not a CMake input on this path: `build_pkg.sh` derives it +The package version is not a CMake input on this path: `build_pkg.py` derives it from the just-built `xrpld` binary's `xrpld --version` output. The package release defaults to 1 and is overridable with `-Dpkg_release=N`. @@ -126,7 +126,7 @@ release defaults to 1 and is overridable with `-Dpkg_release=N`. Packages are published to the XRPLF repositories on Sonatype Nexus at `https://packages.xrplf.org`. The `release-info` action decides the channel from -the event, and `publish_pkg.sh` maps that channel to its repositories: +the event, and `publish_pkg.py` maps that channel to its repositories: | Event | Version | Channel | DEB repository | RPM upload repository | | ------------------------ | ----------------- | -------------- | ------------------ | ------------------------- | @@ -162,7 +162,7 @@ Nexus owns the repository metadata; nothing here indexes anything. Worth knowing repository sits behind a `rpm-` yum group repository whose metadata Nexus signs. Uploads go to the hosted repository; clients point at the group and verify the metadata with `repo_gpgcheck=1`. Nexus never signs the RPMs - themselves, so `sign_rpm.sh` signs them before they are uploaded, and clients + themselves, so `sign_rpm.py` signs them before they are uploaded, and clients verify them with `gpgcheck=1`. - yum metadata is rebuilt asynchronously, so a successful publish is not immediately installable. @@ -172,20 +172,20 @@ 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. -## How `build_pkg.sh` works +## How `build_pkg.py` works -`build_pkg.sh` derives the `xrpld` software version from +`build_pkg.py` derives the `xrpld` software version from `${BUILD_DIR}/xrpld --version` in both package formats. The binary's version is already SemVer-validated by `BuildInfo`. -`build_pkg.sh` converts pre-release versions such as `3.2.0-b1` or +`build_pkg.py` converts pre-release versions such as `3.2.0-b1` or `3.2.0-rc1` from `-` to `~` for package metadata so pre-releases sort before 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. `pkg_version` is the normalized package metadata version derived inside -`build_pkg.sh` from the binary-reported `xrpld` version (`-` pre-release +`build_pkg.py` from the binary-reported `xrpld` version (`-` pre-release separator converted to `~`). It is not a separate user input. `PKG_RELEASE` is a different value: the package release iteration for that @@ -203,35 +203,39 @@ With `PKG_RELEASE=1`, the package metadata becomes: | `3.2.0-b1` | `3.2.0~b1-1%{?dist}` | `3.2.0~b1-1` | | `3.2.0-rc1` | `3.2.0~rc1-1%{?dist}` | `3.2.0~rc1-1` | -The Debian changelog entry carries the channel passed as `--channel` -(`PKG_CHANNEL`), defaulting to `unstable`. An unsupported pre-release, and build -metadata on a final release such as `3.2.0+abc123`, are both rejected. +`build_pkg.py` defines `dist` as `.el9` rather than letting rpmbuild take it +from the build host, so the RHEL image can track a newer release without +changing what the packages claim to target. + +The Debian changelog entry carries the channel passed as `--channel`, +defaulting to `unstable`. An unsupported pre-release, and build metadata on a +final release such as `3.2.0+abc123`, are both rejected. The RPM path intentionally uses `~` in `Version`, matching the Debian pre-release ordering convention, so RPM filenames/NVRs begin with forms like `xrpld-3.2.0~b1-...` and `xrpld-3.2.0~rc1-...` instead of encoding pre-releases with an older `0..` RPM `Release` value. -The package format (`deb` or `rpm`) is inferred from the host's package -manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). Hosts without one of those -fail early. +The package format is `--package-type`, either `deb` or `rpm`. It is required, +so a job never silently builds the wrong format for the image it runs in; the +matching build tool still has to be on PATH. -Flags are for explicit invocation; environment variables are intended for -CMake/CI integration. The CI workflow and the CMake `package` target both invoke -`build_pkg.sh` with no flags; CMake supplies `SRC_DIR`, `BUILD_DIR`, and -`PKG_RELEASE` via env, while CI supplies `BUILD_DIR`, `PKG_RELEASE` and -`PKG_CHANNEL` via env and lets the script use defaults for the rest. +Every input is a named argument. CMake passes `--package-type`, `--build-dir` +and `--pkg-release`; CI adds `--channel`. The repository root is not an argument +at all: the script reads it from its own location. Only secrets stay in the +environment, so they never reach the process list -- `PKG_SIGNING_KEY` for +`sign_rpm.py`, and `NEXUS_USERNAME` / `NEXUS_PASSWORD` for `publish_pkg.py`. -Signing is not part of this script. `sign_rpm.sh` does it in a separate CI step +Signing is not part of this script. `sign_rpm.py` does it in a separate CI step that only runs when publishing, so a published RPM is always signed and a local build never needs a key. -It resolves `SRC_DIR` and `BUILD_DIR` to absolute paths, then calls +It resolves the build directory to an absolute path, then calls `stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files, and shared support files into the staging area, and invokes the platform build -tool. Both binaries must be present in `BUILD_DIR` and must run in the packaging -environment; a missing or non-runnable one fails early. That runtime check is -what catches a binary still linked against the Nix store's ELF loader (see +tool. Both binaries must be present in the build directory and must run in the +packaging environment; a missing or non-runnable one fails early. That runtime +check is what catches a binary still linked against the Nix store's ELF loader (see `patch_nix_binary` in `cmake/PatchNixBinary.cmake`). ### RPM @@ -277,10 +281,9 @@ lintian -I debbuild/*.deb ## Reproducibility -`build_pkg.sh` already defaults `SOURCE_DATE_EPOCH` to the latest git commit -time, or the current time outside a git tree, and exports it (override with -`--source-date-epoch` / `SOURCE_DATE_EPOCH`); the RPM spec clamps file -modification times to it via `%build_mtime_policy`. The remaining variables +`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: diff --git a/package/build_pkg.py b/package/build_pkg.py new file mode 100755 index 0000000000..28835d1ccd --- /dev/null +++ b/package/build_pkg.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Build an RPM or Debian package from the pre-built xrpld and validator-keys binaries. + +The build tool for the chosen format has to be on PATH, so this runs in the +vanilla distro image that matches it. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import textwrap +from datetime import datetime, timezone +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]+)(\+.*)?$") + +# Files both packaging systems consume, staged under the same names. +STAGED_FROM_BUILD = ("xrpld", "validator-keys", "validator-keys-LICENSE") +STAGED_FROM_SRC = { + "cfg/xrpld-example.cfg": "xrpld.cfg", + "cfg/validators-example.txt": "validators.txt", + "LICENSE.md": "LICENSE.md", + "README.md": "README.md", +} +STAGED_UNITS = ("xrpld.service", "xrpld.sysusers", "xrpld.tmpfiles", "xrpld.logrotate") + + +def run(*command: object, cwd: Path | None = None) -> None: + """Echo a command and run it.""" + argv = [str(part) for part in command] + print("+ " + " ".join(argv), flush=True) + subprocess.run(argv, check=True, cwd=cwd) + + +def capture(*command: object) -> str: + """Run a command and return its stdout, stripped.""" + argv = [str(part) for part in command] + # stderr is left alone so a failing command explains itself. + return subprocess.run( + argv, stdout=subprocess.PIPE, text=True, check=True + ).stdout.strip() + + +def package_version(reported: str) -> str: + """Normalise a reported version into one the package formats accept. + + A pre-release switches to '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before + the final 3.2.0; a no-op for a final release. + """ + base, _, pre_release = reported.partition("-") + version = f"{base}~{pre_release}" if pre_release else base + + # BuildInfo already SemVer-validates the version. Packaging adds one narrower + # constraint: after normalisation the version must not contain '-', because + # RPM forbids it in Version and Debian reads it as the revision separator. + assert "-" not in version, ( + f"unsupported version {reported!r}: {version!r} cannot contain '-'. " + "Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2." + ) + assert pre_release or "+" not in reported, ( + f"unsupported version {reported!r}: " + "build metadata is only supported on bN/rcN pre-releases." + ) + assert not pre_release or PRE_RELEASE.match(pre_release), ( + f"unsupported pre-release {pre_release!r}: use bN or rcN, " + "e.g. 3.2.0-b1 or 3.2.0-rc2." + ) + return version + + +def read_version(xrpld: Path) -> str: + """Read the version from the binary that is about to be packaged.""" + fields = capture(xrpld, "--version").partition("\n")[0].split() + assert len(fields) >= 3, f"cannot read a version from {xrpld} --version" + return fields[2] + + +def check_binaries(build_dir: Path) -> None: + """Fail unless the binaries and their notices are present and runnable.""" + missing = [ + name + for name in ("xrpld", "validator-keys") + if not os.access(build_dir / name, os.X_OK) + ] + assert not missing, ( + f"missing or not executable in {build_dir}: {' '.join(missing)}. " + "Both binaries come from a single CMake build directory configured with " + "-Dxrpld=ON -Dvalidator_keys=ON." + ) + + # No package goes out without the attribution. + notice = build_dir / "validator-keys-LICENSE" + assert notice.is_file(), ( + f"missing {notice}. cmake/XrplValidatorKeys.cmake copies it out of the " + "fetched validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." + ) + + # Catches a binary still pointing at the Nix store's ELF loader, since + # packaging runs in a vanilla distro container. + capture(build_dir / "validator-keys", "--version") + + +def source_date_epoch() -> int: + """The last commit's timestamp.""" + # git refuses to read a checkout owned by another user, which is what a CI + # container or a bind mount hands it. + return int( + capture( + "git", + "-c", + f"safe.directory={SRC_DIR}", + "-C", + SRC_DIR, + "log", + "-1", + "--format=%ct", + ) + ) + + +def stage_common(build_dir: Path, dest: Path) -> None: + """Copy everything both packaging systems consume into dest.""" + dest.mkdir(parents=True, exist_ok=True) + + for name in STAGED_FROM_BUILD: + shutil.copy2(build_dir / name, dest / name) + for source, name in STAGED_FROM_SRC.items(): + shutil.copy2(SRC_DIR / source, dest / name) + for name in STAGED_UNITS: + shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / name) + + +def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: + """Stage the spec and its sources, then build the binary RPMs.""" + topdir = build_dir / "rpmbuild" + for name in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"): + (topdir / name).mkdir(parents=True, exist_ok=True) + + spec = topdir / "SPECS" / "xrpld.spec" + shutil.copy2(SRC_DIR / "package" / "rpm" / "xrpld.spec", spec) + stage_common(build_dir, topdir / "SOURCES") + + run( + "rpmbuild", + "-bb", + "--define", + f"_topdir {topdir}", + "--define", + f"pkg_version {version}", + "--define", + f"pkg_release {pkg_release}", + # The image tracks the newest distro, but the packages target el9. + "--define", + "dist .el9", + spec, + ) + + +def build_deb( + build_dir: Path, + *, + version: str, + reported: str, + pkg_release: str, + channel: str, + epoch: int, +) -> None: + """Stage the debian directory and its sources, then build the binary DEBs.""" + staging = build_dir / "debbuild" / "source" + stage_common(build_dir, staging) + 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) + + date = datetime.fromtimestamp(epoch, timezone.utc).strftime( + "%a, %d %b %Y %H:%M:%S %z" + ) + # The leading spaces are significant to dpkg. + changelog = textwrap.dedent(f"""\ + xrpld ({version}-{pkg_release}) {channel}; urgency=medium + * Release {reported}. + + -- XRPL Foundation {date} + """) + (staging / "debian" / "changelog").write_text(changelog) + + (staging / "debian" / "rules").chmod(0o755) + + run("dpkg-buildpackage", "-b", "--no-sign", "-d", cwd=staging) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--package-type", + required=True, + choices=("deb", "rpm"), + help="the package format to build", + ) + parser.add_argument( + "--build-dir", + type=Path, + default=Path("build"), + help="directory holding the xrpld and validator-keys binaries (default: %(default)s)", + ) + parser.add_argument( + "--pkg-release", + default="1", + help="package release iteration (default: %(default)s)", + ) + parser.add_argument( + "--channel", + default="unstable", + help="release channel, written to debian/changelog (default: %(default)s)", + ) + args = parser.parse_args() + package_type: str = args.package_type + build_dir: Path = args.build_dir.resolve() + pkg_release: str = args.pkg_release + channel: str = args.channel + + assert build_dir.is_dir(), ( + f"build directory not found: {build_dir}. Build the binaries before " + "packaging, or point --build-dir at the directory holding them." + ) + + check_binaries(build_dir) + reported = read_version(build_dir / "xrpld") + version = package_version(reported) + epoch = source_date_epoch() + + # rpmbuild and dpkg-buildpackage both honour this for file timestamps. + os.environ["SOURCE_DATE_EPOCH"] = str(epoch) + + # Remove both build trees, because a package left from an earlier build would + # otherwise be picked up and published alongside this one. + for tree in ("debbuild", "rpmbuild"): + shutil.rmtree(build_dir / tree, ignore_errors=True) + + if package_type == "deb": + build_deb( + build_dir, + version=version, + reported=reported, + pkg_release=pkg_release, + channel=channel, + epoch=epoch, + ) + else: + build_rpm(build_dir, version=version, pkg_release=pkg_release) + + +if __name__ == "__main__": + main() diff --git a/package/build_pkg.sh b/package/build_pkg.sh deleted file mode 100755 index cca3be7248..0000000000 --- a/package/build_pkg.sh +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Build an RPM or Debian package from the pre-built xrpld and validator-keys -# binaries. -# -# Flags override env vars; env vars override defaults. - -usage() { - cat <<'EOF' -Usage: build_pkg.sh [options] - -Options (each can also be set via the env var shown): - --src-dir DIR repo root [SRC_DIR; default: ${PWD}] - --build-dir DIR directory holding the - xrpld and validator-keys - binaries [BUILD_DIR; default: ${PWD}/build] - --pkg-release N package release iteration [PKG_RELEASE; default: 1] - --channel NAME release channel, written - to debian/changelog [PKG_CHANNEL; default: unstable] - --source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; latest git ctime; fallback: current time] - -h, --help show this help and exit -EOF -} - -need_arg() { - if [[ $# -lt 2 || "$2" == --* ]]; then - echo "Missing value for $1" >&2 - exit 2 - fi -} - -# Seed from env. CLI parsing below overrides these directly. -SRC_DIR="${SRC_DIR:-}" -BUILD_DIR="${BUILD_DIR:-}" -PKG_RELEASE="${PKG_RELEASE:-1}" -PKG_CHANNEL="${PKG_CHANNEL:-unstable}" -SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-}" - -while [[ $# -gt 0 ]]; do - case "$1" in - --src-dir) - need_arg "$@" - SRC_DIR="$2" - shift 2 - ;; - --build-dir) - need_arg "$@" - BUILD_DIR="$2" - shift 2 - ;; - --pkg-release) - need_arg "$@" - PKG_RELEASE="$2" - shift 2 - ;; - --channel) - need_arg "$@" - PKG_CHANNEL="$2" - shift 2 - ;; - --source-date-epoch) - need_arg "$@" - SOURCE_DATE_EPOCH="$2" - shift 2 - ;; - -h | --help) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -SRC_DIR="$(cd "${SRC_DIR:-${PWD}}" && pwd)" -BUILD_DIR="${BUILD_DIR:-${PWD}/build}" -if [[ ! -d "${BUILD_DIR}" ]]; then - echo "build_pkg.sh: build directory not found: ${BUILD_DIR}" >&2 - echo "Build the binaries before packaging, or set BUILD_DIR to the directory containing them." >&2 - exit 1 -fi -BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)" - -xrpld_binary="${BUILD_DIR}/xrpld" -validator_keys_binary="${BUILD_DIR}/validator-keys" - -# Report both binaries at once: they share a single BUILD_DIR, so telling the -# reader to point it at one of them in isolation is advice they cannot follow. -missing=() -[[ -x "${xrpld_binary}" ]] || missing+=(xrpld) -[[ -x "${validator_keys_binary}" ]] || missing+=(validator-keys) - -if [[ ${#missing[@]} -gt 0 ]]; then - echo "build_pkg.sh: missing or not executable in ${BUILD_DIR}: ${missing[*]}" >&2 - echo "Both binaries come from a single CMake build directory configured with" >&2 - echo "-Dxrpld=ON -Dvalidator_keys=ON. Build them, then point BUILD_DIR at that" >&2 - echo "directory." >&2 - exit 1 -fi - -# Shipping validator-keys means shipping its notice, so treat it as required -# rather than letting a package go out without the attribution. -validator_keys_license="${BUILD_DIR}/validator-keys-LICENSE" -if [[ ! -f "${validator_keys_license}" ]]; then - echo "build_pkg.sh: missing ${validator_keys_license}." >&2 - echo "cmake/XrplValidatorKeys.cmake copies it out of the fetched" >&2 - echo "validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." >&2 - exit 1 -fi - -# The binary must also *run* here. Packaging happens in a vanilla distro -# container, so this is what catches a binary still pointing at the Nix store's -# ELF loader (see patch_nix_binary in cmake/PatchNixBinary.cmake); xrpld is -# covered implicitly by the version query below. -if ! "${validator_keys_binary}" --version >/dev/null; then - echo "build_pkg.sh: ${validator_keys_binary} exists but does not run here." >&2 - exit 1 -fi - -xrpld_version="$("${xrpld_binary}" --version | awk 'NR == 1 { print $3 }')" - -if [[ -z "${xrpld_version}" ]]; then - echo "build_pkg.sh: unable to derive xrpld version from ${xrpld_binary} --version." >&2 - exit 1 -fi - -# The version as the package formats consume it: identical to xrpld_version -# except a pre-release uses '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before -# the final 3.2.0; a no-op for a final release. Lowercase = derived internally, -# not an input (cf. pkg_type). -pkg_version="${xrpld_version}" -pre_release="" -if [[ "${xrpld_version}" == *-* ]]; then - pre_release="${xrpld_version#*-}" - pkg_version="${xrpld_version%%-*}~${pre_release}" -fi - -# BuildInfo already SemVer-validates the binary's version. Packaging adds one -# narrower constraint: after pre-release normalization, the package version must -# not contain '-' because RPM forbids it in Version and Debian uses it as the -# upstream/revision separator. -if [[ "${pkg_version}" == *-* ]]; then - echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2 - echo "Package version '${pkg_version}' cannot contain '-'." >&2 - echo "Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 -fi - -if [[ -z "${pre_release}" && "${xrpld_version}" == *+* ]]; then - echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2 - echo "Build metadata is only supported on bN/rcN pre-releases." >&2 - exit 1 -fi - -if [[ -n "${pre_release}" && ! "${pre_release}" =~ ^(b0|b[1-9][0-9]*|rc[0-9]+)(\+.*)?$ ]]; then - echo "build_pkg.sh: unsupported xrpld pre-release '${pre_release}'." >&2 - echo "Use bN or rcN, e.g. 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 -fi - -if command -v apt-get >/dev/null 2>&1; then - pkg_type=deb -elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then - pkg_type=rpm -else - echo "Cannot infer pkg_type: no apt-get, dnf, or yum on PATH." >&2 - exit 1 -fi - -if [[ -z "${SOURCE_DATE_EPOCH}" ]]; then - if git -C "${SRC_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - SOURCE_DATE_EPOCH="$(git -C "${SRC_DIR}" log -1 --format=%ct)" - else - SOURCE_DATE_EPOCH="$(date +%s)" - fi -fi - -export SOURCE_DATE_EPOCH -CHANGELOG_DATE="$(date -u -R -d "@${SOURCE_DATE_EPOCH}")" - -SHARED="${SRC_DIR}/package/shared" -DEBIAN_DIR="${SRC_DIR}/package/debian" - -# Stage files that both packaging systems consume using the same filenames. -stage_common() { - local dest="$1" - mkdir -p "${dest}" - - cp "${xrpld_binary}" "${dest}/xrpld" - cp "${validator_keys_binary}" "${dest}/validator-keys" - cp "${validator_keys_license}" "${dest}/validator-keys-LICENSE" - cp "${SRC_DIR}/cfg/xrpld-example.cfg" "${dest}/xrpld.cfg" - cp "${SRC_DIR}/cfg/validators-example.txt" "${dest}/validators.txt" - cp "${SRC_DIR}/LICENSE.md" "${dest}/LICENSE.md" - cp "${SRC_DIR}/README.md" "${dest}/README.md" - - cp "${SHARED}/xrpld.service" "${dest}/xrpld.service" - cp "${SHARED}/xrpld.sysusers" "${dest}/xrpld.sysusers" - cp "${SHARED}/xrpld.tmpfiles" "${dest}/xrpld.tmpfiles" - cp "${SHARED}/xrpld.logrotate" "${dest}/xrpld.logrotate" -} - -build_rpm() { - local topdir="${BUILD_DIR}/rpmbuild" - mkdir -p "${topdir}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} - - cp "${SRC_DIR}/package/rpm/xrpld.spec" "${topdir}/SPECS/xrpld.spec" - stage_common "${topdir}/SOURCES" - - set -x - rpmbuild -bb \ - --define "_topdir ${topdir}" \ - --define "pkg_version ${pkg_version}" \ - --define "pkg_release ${PKG_RELEASE}" \ - "${topdir}/SPECS/xrpld.spec" -} - -build_deb() { - local staging="${BUILD_DIR}/debbuild/source" - mkdir -p "${staging}" - - stage_common "${staging}" - cp -r "${DEBIAN_DIR}" "${staging}/debian" - - cp "${staging}/xrpld.service" "${staging}/debian/xrpld.service" - cp "${staging}/xrpld.sysusers" "${staging}/debian/xrpld.sysusers" - cp "${staging}/xrpld.tmpfiles" "${staging}/debian/xrpld.tmpfiles" - cp "${staging}/xrpld.logrotate" "${staging}/debian/xrpld.logrotate" - - # Debian version is [~
]-.
-    cat >"${staging}/debian/changelog" <  ${CHANGELOG_DATE}
-EOF
-
-    chmod +x "${staging}/debian/rules"
-
-    set -x
-    (cd "${staging}" && dpkg-buildpackage -b --no-sign -d)
-}
-
-# Remove both build directories, because a package left from an earlier build
-# would otherwise be picked up and published alongside this one.
-rm -rf "${BUILD_DIR}/debbuild" "${BUILD_DIR}/rpmbuild"
-
-"build_${pkg_type}"
diff --git a/package/publish_pkg.py b/package/publish_pkg.py
new file mode 100755
index 0000000000..2c320a595a
--- /dev/null
+++ b/package/publish_pkg.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+"""Publish the packages built by build_pkg.py to the XRPLF repositories on Nexus.
+
+RPMs are uploaded to the hosted repository, but yum clients install from the
+'rpm-' group repository in front of it, which serves signed metadata.
+
+NEXUS_USERNAME and NEXUS_PASSWORD are read from the environment, so the
+credentials never reach the process list.
+"""
+
+import argparse
+import base64
+import os
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+SUFFIXES = (".deb", ".ddeb", ".rpm")
+
+# No progress for this long ends an attempt. urlopen applies the timeout per
+# socket operation, so a stalled transfer fails while a merely slow one carries
+# on -- the debuginfo package is large enough for that distinction to matter.
+STALL_TIMEOUT = 300
+
+ATTEMPTS = 4
+RETRY_DELAY = 5
+
+
+def build_opener() -> urllib.request.OpenerDirector:
+    """An opener with no redirect handler, so a 3xx raises instead of being followed.
+
+    A redirected upload is silently downgraded to a GET, turning it into a no-op
+    that still answers 200.
+    """
+    opener = urllib.request.OpenerDirector()
+    opener.add_handler(urllib.request.HTTPHandler())
+    opener.add_handler(urllib.request.HTTPSHandler())
+    opener.add_handler(urllib.request.HTTPErrorProcessor())
+    opener.add_handler(urllib.request.HTTPDefaultErrorHandler())
+    return opener
+
+
+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.
+    """
+    opener = build_opener()
+
+    for attempt in range(1, ATTEMPTS + 1):
+        try:
+            with package.open("rb") as body:
+                request = urllib.request.Request(
+                    url,
+                    data=body,
+                    method=method,
+                    headers={**headers, "Content-Length": str(package.stat().st_size)},
+                )
+                opener.open(request, timeout=STALL_TIMEOUT)
+            return
+        except urllib.error.HTTPError as error:
+            detail = error.read().decode(errors="replace").strip()
+            reason = f"HTTP {error.code}: {detail}"
+            retryable = error.code >= 500
+        except (urllib.error.URLError, OSError) as error:
+            reason = str(error)
+            retryable = True
+
+        assert (
+            retryable and attempt < ATTEMPTS
+        ), f"upload of {package.name} failed: {reason}"
+        print(f"    attempt {attempt} failed ({reason}), retrying")
+        time.sleep(RETRY_DELAY)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--channel",
+        required=True,
+        help="release channel, selecting the deb- and rpm--hosted repositories",
+    )
+    parser.add_argument(
+        "--package-dir",
+        type=Path,
+        default=Path("build"),
+        help=f"searched recursively for {', '.join(SUFFIXES)} (default: %(default)s)",
+    )
+    parser.add_argument(
+        "--nexus-url",
+        default="https://packages.xrplf.org",
+        help="the Nexus instance to publish to (default: %(default)s)",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="list the uploads without performing them",
+    )
+    args = parser.parse_args()
+    channel: str = args.channel
+    package_dir: Path = args.package_dir
+    nexus_url: str = args.nexus_url
+    dry_run: bool = args.dry_run
+
+    nexus = nexus_url.rstrip("/")
+    deb_repo = f"deb-{channel}"
+    rpm_repo = f"rpm-{channel}-hosted"
+
+    auth: dict[str, str] = {}
+    if not dry_run:
+        username = os.environ.get("NEXUS_USERNAME")
+        password = os.environ.get("NEXUS_PASSWORD")
+        assert username and password, "NEXUS_USERNAME and NEXUS_PASSWORD are required"
+        token = base64.b64encode(f"{username}:{password}".encode()).decode()
+        auth = {"Authorization": f"Basic {token}"}
+
+    packages = sorted(
+        path
+        for path in package_dir.rglob("*")
+        if path.is_file() and path.suffix in SUFFIXES
+    )
+    # Uploading nothing would otherwise look like a successful publish.
+    assert packages, f"no packages found in {package_dir}"
+
+    print(f"Publishing {package_dir} to {deb_repo} and {rpm_repo} on {nexus}:")
+    for package in packages:
+        if package.suffix == ".rpm":
+            # yum repositories are addressed by path, and the arch comes from
+            # the name, e.g. xrpld-3.4.0-1.el9.x86_64.rpm.
+            destination = f"{rpm_repo}/{package.stem.rsplit('.', 1)[-1]}"
+            url = f"{nexus}/repository/{destination}/{package.name}"
+            method, content_type = "PUT", "application/octet-stream"
+        else:
+            # A raw body with a multipart Content-Type, POSTed to the repository
+            # root, is the documented upload for a hosted apt repository:
+            # https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
+            destination = deb_repo
+            url = f"{nexus}/repository/{destination}/"
+            method, content_type = "POST", "multipart/form-data"
+
+        print(f"  {package.name} -> {destination}")
+        if not dry_run:
+            upload(url, method, {"Content-Type": content_type, **auth}, package)
+
+    verb = "would be published" if dry_run else "published"
+    print(f"{len(packages)} package(s) {verb}.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/package/publish_pkg.sh b/package/publish_pkg.sh
deleted file mode 100755
index 8ea9b189f4..0000000000
--- a/package/publish_pkg.sh
+++ /dev/null
@@ -1,109 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Publish the DEB and RPM packages built by build_pkg.sh to the XRPLF package
-# repositories on Sonatype Nexus.
-#
-# Usage: publish_pkg.sh  [package-dir]
-#
-#   channel      release channel, selecting the 'deb-' and
-#                'rpm--hosted' repositories
-#   package-dir  searched recursively for *.deb, *.ddeb and *.rpm ('build' by
-#                default)
-#
-# RPMs are uploaded to the hosted repository, but yum clients install from the
-# 'rpm-' group repository in front of it, which serves signed metadata.
-#
-# NEXUS_USERNAME and NEXUS_PASSWORD are required. NEXUS_URL overrides the target
-# instance, and DRY_RUN=1 lists the uploads without performing them.
-
-channel="${1:-}"
-pkg_dir="${2:-build}"
-nexus_url="${NEXUS_URL:-https://packages.xrplf.org}"
-
-if [[ -z "${channel}" ]]; then
-    echo "usage: publish_pkg.sh  [package-dir]" >&2
-    exit 2
-fi
-
-deb_repo="deb-${channel}"
-rpm_repo="rpm-${channel}-hosted"
-
-if [[ -z "${DRY_RUN:-}" ]]; then
-    : "${NEXUS_USERNAME:?is required}" "${NEXUS_PASSWORD:?is required}"
-fi
-
-# Deliberate curl choices:
-#
-#   - no --fail, which would hide the response body where Nexus explains what it
-#     rejected
-#   - no --location, since curl downgrades a redirected POST to GET and turns an
-#     upload into a no-op that still answers 200
-#   - credentials on stdin, to keep them out of the process list
-upload() {
-    local url="$1"
-    shift
-    [[ -z "${DRY_RUN:-}" ]] || return 0
-
-    local body code status=0
-    body="$(mktemp)"
-    code="$(
-        printf 'user = %s:%s\n' "${NEXUS_USERNAME}" "${NEXUS_PASSWORD}" |
-            curl \
-                --config - \
-                --silent \
-                --show-error \
-                --retry 3 \
-                --retry-delay 5 \
-                --retry-all-errors \
-                --output "${body}" \
-                --write-out '%{http_code}' \
-                "$@" \
-                "${url}"
-    )" || status=$?
-
-    if [[ ${status} -ne 0 || ! "${code}" =~ ^2[0-9][0-9]$ ]]; then
-        echo "publish_pkg.sh: upload failed (curl ${status}, HTTP ${code}): ${url}" >&2
-        cat "${body}" >&2
-        echo >&2
-        rm -f "${body}"
-        exit 1
-    fi
-
-    rm -f "${body}"
-}
-
-echo "Publishing ${pkg_dir} to ${deb_repo} and ${rpm_repo} on ${nexus_url}:"
-
-count=0
-while IFS= read -r -d '' file; do
-    name="${file##*/}"
-    case "${name}" in
-        # A raw body with a multipart Content-Type, POSTed to the repository root,
-        # is the documented upload for a hosted apt repository:
-        # https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
-        *.deb | *.ddeb)
-            echo "  ${name} -> ${deb_repo}"
-            upload "${nexus_url}/repository/${deb_repo}/" \
-                --header 'Content-Type: multipart/form-data' \
-                --data-binary "@${file}"
-            ;;
-        # yum repositories are addressed by path; the arch comes from the name.
-        *.rpm)
-            arch="${name%.rpm}"
-            arch="${arch##*.}"
-            echo "  ${name} -> ${rpm_repo}/${arch}"
-            upload "${nexus_url}/repository/${rpm_repo}/${arch}/${name}" \
-                --upload-file "${file}"
-            ;;
-    esac
-    count=$((count + 1))
-done < <(find "${pkg_dir}" -type f \( -name '*.deb' -o -name '*.ddeb' -o -name '*.rpm' \) -print0)
-
-# Uploading nothing would otherwise look like a successful publish.
-if [[ ${count} -eq 0 ]]; then
-    echo "publish_pkg.sh: no packages found in ${pkg_dir}." >&2
-    exit 1
-fi
-
-echo "${count} package(s) ${DRY_RUN:+would be }published."
diff --git a/package/sign_rpm.py b/package/sign_rpm.py
new file mode 100755
index 0000000000..05c719b710
--- /dev/null
+++ b/package/sign_rpm.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""Sign the RPMs built by build_pkg.py.
+
+Nexus signs the yum repository metadata (via the 'rpm-' group
+repository), but never the packages themselves, so they carry their own
+signature. Clients verify the packages with gpgcheck=1 and the metadata with
+repo_gpgcheck=1.
+
+The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
+but apt does not verify them by default and trusts the repository metadata,
+which Nexus signs, instead.
+
+PKG_SIGNING_KEY is read from the environment, so the key never reaches the
+process list.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+# An RSA signature lands in the RSAHEADER tag, a DSA or EdDSA one in DSAHEADER,
+# so both are queried; checking only the first would reject a signed package.
+SIGNATURE_QUERY = "%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}"
+UNSIGNED = "(none)(none)"
+
+
+def gpg(gnupghome: Path, *args: str, stdin: str | None = None) -> str:
+    """Run gpg against a throwaway keyring and return its stdout."""
+    return subprocess.run(
+        ["gpg", "--batch", "--quiet", *args],
+        input=stdin,
+        # stderr is left alone so a failing gpg explains itself.
+        stdout=subprocess.PIPE,
+        text=True,
+        check=True,
+        env={**os.environ, "GNUPGHOME": str(gnupghome)},
+    ).stdout
+
+
+def import_key(gnupghome: Path, key: str) -> str:
+    """Import the armoured private key and return its fingerprint."""
+    gpg(gnupghome, "--import", stdin=key)
+
+    records = [
+        line.split(":")
+        for line in gpg(gnupghome, "--list-secret-keys", "--with-colons").splitlines()
+    ]
+    # Exactly one, so the fingerprint picked below is not a guess.
+    secrets = [record for record in records if record[0] == "sec"]
+    assert (
+        len(secrets) == 1
+    ), f"PKG_SIGNING_KEY must hold exactly one secret key, found {len(secrets)}"
+
+    # The first fingerprint belongs to the primary key; subkeys follow.
+    fingerprints = [record[9] for record in records if record[0] == "fpr"]
+    assert fingerprints, "PKG_SIGNING_KEY holds a secret key with no fingerprint"
+    return fingerprints[0]
+
+
+def sign(gnupghome: Path, rpms: list[Path], fingerprint: str) -> None:
+    """Attach a signature to every RPM in one rpmsign invocation."""
+    subprocess.run(
+        [
+            "rpmsign",
+            "--define",
+            f"_gpg_name {fingerprint}",
+            # Loopback pinentry: the key is unattended, so there is no tty to
+            # prompt on.
+            "--define",
+            "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes",
+            "--addsign",
+            *(str(rpm) for rpm in rpms),
+        ],
+        check=True,
+        env={**os.environ, "GNUPGHOME": str(gnupghome)},
+    )
+
+
+def verify(rpms: list[Path]) -> None:
+    """Fail unless every RPM now carries a signature.
+
+    rpmsign can exit 0 having attached nothing, and an unsigned package is only
+    rejected later, on the installing machine.
+    """
+    for rpm in rpms:
+        signature = subprocess.run(
+            ["rpm", "--query", "--queryformat", SIGNATURE_QUERY, "--package", str(rpm)],
+            stdout=subprocess.PIPE,
+            text=True,
+            check=True,
+        ).stdout.strip()
+        assert signature != UNSIGNED, f"{rpm} is unsigned after rpmsign"
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--package-dir",
+        type=Path,
+        default=Path("build"),
+        help="searched recursively for *.rpm (default: %(default)s)",
+    )
+    args = parser.parse_args()
+    package_dir: Path = args.package_dir
+
+    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}"
+
+    key = os.environ.get("PKG_SIGNING_KEY")
+    assert key, "PKG_SIGNING_KEY is required"
+
+    # The keyring holds an unencrypted private key, so it goes even if signing
+    # fails.
+    with tempfile.TemporaryDirectory() as tmp:
+        gnupghome = Path(tmp)
+        fingerprint = import_key(gnupghome, key)
+        print(f"Signing {len(rpms)} RPM(s) with {fingerprint}.")
+        sign(gnupghome, rpms, fingerprint)
+        verify(rpms)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/package/sign_rpm.sh b/package/sign_rpm.sh
deleted file mode 100755
index 250e806dd7..0000000000
--- a/package/sign_rpm.sh
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Sign the RPMs built by build_pkg.sh. Nexus signs the yum repository metadata
-# (via the 'rpm-' group repository), but never the packages themselves,
-# so they carry their own signature. Clients verify the packages with gpgcheck=1
-# and the metadata with repo_gpgcheck=1.
-#
-# Usage: sign_rpm.sh [package-dir]
-#
-#   package-dir  searched recursively for *.rpm ('build' by default)
-#
-# PKG_SIGNING_KEY must hold an armoured PGP private key. It has no flag, to keep
-# the key out of the process list.
-#
-# The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
-# but apt does not verify them by default and trusts the repository metadata,
-# which Nexus signs, instead.
-
-pkg_dir="${1:-build}"
-
-mapfile -d '' rpms < <(find "${pkg_dir}" -type f -name '*.rpm' -print0)
-
-# Signing nothing would otherwise look like a successful signing.
-if [[ ${#rpms[@]} -eq 0 ]]; then
-    echo "sign_rpm.sh: no RPMs found in ${pkg_dir}." >&2
-    exit 1
-fi
-
-: "${PKG_SIGNING_KEY:?is required}"
-
-# Global, and expanded by the trap when it fires: the keyring holds an
-# unencrypted private key, so it must go even if signing fails.
-signing_home="$(mktemp -d)"
-trap 'rm -rf "${signing_home}"' EXIT
-export GNUPGHOME="${signing_home}"
-
-printf '%s' "${PKG_SIGNING_KEY}" | gpg --batch --quiet --import
-
-# Exactly one secret key, so that picking the first below is not a guess between
-# several.
-secrets="$(gpg --list-secret-keys --with-colons | grep -c '^sec:' || true)"
-if [[ "${secrets}" -ne 1 ]]; then
-    echo "sign_rpm.sh: PKG_SIGNING_KEY must hold exactly one secret key, found ${secrets}." >&2
-    exit 1
-fi
-
-key="$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ { print $10; exit }')"
-echo "Signing ${#rpms[@]} RPM(s) with ${key}."
-
-# Loopback pinentry: the key is unattended, so there is no tty to prompt on.
-rpmsign \
-    --define "_gpg_name ${key}" \
-    --define "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes" \
-    --addsign "${rpms[@]}"
-
-# rpmsign can exit 0 having attached nothing, and an unsigned package is only
-# rejected later, on the installing machine. Both header tags are checked
-# because an RSA signature lands in RSAHEADER and a DSA or EdDSA one in
-# DSAHEADER.
-for pkg in "${rpms[@]}"; do
-    signature="$(rpm --query --queryformat '%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}' --package "${pkg}")"
-    if [[ "${signature}" == "(none)(none)" ]]; then
-        echo "sign_rpm.sh: ${pkg} is unsigned after rpmsign." >&2
-        exit 1
-    fi
-done

From 421af6db796631da4ff8b78f5d3bafae0fd3ca32 Mon Sep 17 00:00:00 2001
From: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
Date: Wed, 26 Aug 2026 00:08:25 +0000
Subject: [PATCH 29/43] fix: Reject open-ended vaults at LoanBrokerSet (#8076)

Co-authored-by: Kenny Lei <3003853+kennyzlei@users.noreply.github.com>
---
 .../tx/transactors/lending/LoanBrokerSet.cpp  | 16 ++++++
 src/test/app/Batch_test.cpp                   | 10 +++-
 src/test/app/Sponsor_test.cpp                 |  8 ++-
 src/test/app/invariants/InvariantsBase.cpp    | 13 ++++-
 .../app/invariants/InvariantsVault_test.cpp   | 13 ++++-
 src/test/app/lending/LendingHelpers_test.cpp  |  9 ++-
 src/test/app/lending/LoanBroker_test.cpp      | 36 +++++++-----
 src/test/app/lending/LoanLifecycle_test.cpp   |  8 ++-
 src/test/app/lending/LoanSecurity_test.cpp    | 10 +++-
 src/test/app/lending/LoanTestBase.h           | 24 +++++++-
 src/test/app/lending/LoanValidation_test.cpp  | 57 +++++++++++++++++++
 src/test/app/vault/VaultBugs_test.cpp         | 31 ++++++----
 src/test/app/vault/VaultClawback_test.cpp     | 26 ++++++++-
 src/test/app/vault/VaultScale_test.cpp        |  8 ++-
 .../app/vault/VaultSoleShareholder_test.cpp   | 28 ++++++---
 src/test/jtx/impl/vault.cpp                   | 26 +++++++++
 src/test/jtx/vault.h                          | 34 +++++++++++
 17 files changed, 310 insertions(+), 47 deletions(-)

diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
index d6cda9c326..1ab4eb2ce0 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
@@ -8,7 +8,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -144,6 +146,20 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx)
     }
     else
     {
+        // LP V1.1: only closed-ended vaults may host a loan broker. The
+        // lending protocol relies on the closed-ended Subscription /
+        // Investment / Redemption phase structure; attaching a broker to
+        // an open-ended vault has no well-defined lifecycle. VaultCreate
+        // stays unrestricted so existing open-ended flows keep working;
+        // the constraint is enforced here, at the point where the vault
+        // is first bound to the lending protocol.
+        if (ctx.view.rules().enabled(featureLendingProtocolV1_1) &&
+            getVaultKind(sleVault) != VaultKind::ClosedEnded)
+        {
+            JLOG(ctx.j.warn()) << "LoanBroker requires a closed-ended Vault.";
+            return tecNO_PERMISSION;
+        }
+
         if (auto const ter = canAddHolding(ctx.view, asset))
             return ter;
 
diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp
index c332b26a5b..7e6ecfb8ca 100644
--- a/src/test/app/Batch_test.cpp
+++ b/src/test/app/Batch_test.cpp
@@ -3169,7 +3169,12 @@ class Batch_test : public beast::unit_test::Suite
         auto const debtMaximumValue = asset(25'000).value();
         auto const coverDepositValue = asset(1000).value();
 
-        auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one with a subscription
+        // window that lets the lender deposit now, then advance the clock
+        // past SubscriptionDate before creating loans.
+        auto [tx, vaultKeylet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = asset});
         env(tx);
         env.close();
         BEAST_EXPECT(env.le(vaultKeylet));
@@ -3177,6 +3182,9 @@ class Batch_test : public beast::unit_test::Suite
         env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit}));
         env.close();
 
+        // Move into the Investment phase before creating loans.
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerKeylet =
             keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
 
diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp
index a1a9f80a11..e58d8c9f8f 100644
--- a/src/test/app/Sponsor_test.cpp
+++ b/src/test/app/Sponsor_test.cpp
@@ -1877,7 +1877,11 @@ public:
 
             PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
             Vault const vault{env};
-            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = xrpAsset});
+            // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+            // accepts closed-ended vaults; build one and advance past
+            // SubscriptionDate before creating a loan.
+            auto [vaultTx, vaultKeylet, subscriptionDate] =
+                vault.createClosedEnded({.owner = alice, .asset = xrpAsset});
             env(vaultTx);
             env.close();
 
@@ -1885,6 +1889,8 @@ public:
                 {.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(1000)}));
             env.close();
 
+            vault.closePastSubscription(subscriptionDate);
+
             auto const brokerKeylet =
                 keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
             env(loan_broker::set(alice, vaultKeylet.key),
diff --git a/src/test/app/invariants/InvariantsBase.cpp b/src/test/app/invariants/InvariantsBase.cpp
index 92d75eca77..a573cc45ea 100644
--- a/src/test/app/invariants/InvariantsBase.cpp
+++ b/src/test/app/invariants/InvariantsBase.cpp
@@ -18,6 +18,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -29,6 +30,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -178,10 +180,17 @@ InvariantsBase::createLoanBroker(
 {
     using namespace jtx;
 
-    // Create vault
+    // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+    // accepts closed-ended vaults. Build one with a comfortable
+    // subscription window; LoanBrokerSet itself is not phase-gated,
+    // so leaving the vault in the Subscription phase is fine here.
     uint256 vaultID;
     Vault const vault{env};
-    auto [tx, vKeylet] = vault.create({.owner = a, .asset = asset});
+    auto [tx, vKeylet, _] = vault.createClosedEnded(
+        {.owner = a,
+         .asset = asset,
+         .subscriptionOffset = std::chrono::seconds{60},
+         .investmentWindow = std::chrono::seconds{kMinInvestmentPeriod + 1'000'000u}});
     env(tx);
     BEAST_EXPECT(env.le(vKeylet));
 
diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp
index 56ccaa46fc..5b2511626e 100644
--- a/src/test/app/invariants/InvariantsVault_test.cpp
+++ b/src/test/app/invariants/InvariantsVault_test.cpp
@@ -1974,8 +1974,16 @@ class InvariantsVault_test : public InvariantsBase
         env(pay(issuer, borrower, usd(1'000)));
         env.close();
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults. The 10-year investment window
+        // covers this helper's 120 monthly payments so LoanSet's
+        // RedemptionDate bound is satisfied.
         Vault const vault{env};
-        auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
+        auto [vaultTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+            {.owner = owner,
+             .asset = usd,
+             .subscriptionOffset = std::chrono::seconds{60},
+             .investmentWindow = std::chrono::seconds{10ull * 365ull * 24ull * 60ull * 60ull}});
         env(vaultTx);
         env.close();
 
@@ -1999,6 +2007,9 @@ class InvariantsVault_test : public InvariantsBase
             env.close();
         }
 
+        // LoanSet is gated on Investment; advance out of Subscription.
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerSle = env.le(brokerKeylet);
         if (!BEAST_EXPECT(brokerSle))
             return vaultKeylet;
diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp
index 32c49feb02..96adfd5254 100644
--- a/src/test/app/lending/LendingHelpers_test.cpp
+++ b/src/test/app/lending/LendingHelpers_test.cpp
@@ -1901,12 +1901,19 @@ public:
         env.fund(XRP(10'000), lender, borrower);
         env.close();
 
-        auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()});
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one with a near-future
+        // SubscriptionDate, deposit while still in the Subscription phase,
+        // and advance past SubscriptionDate before creating the broker.
+        auto [vaultTx, vaultKeylet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = xrpIssue()});
         env(vaultTx);
         env.close();
         env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = XRP(1'000)}));
         env.close();
 
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerKeylet =
             keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
         env(loan_broker::set(lender, vaultKeylet.key));
diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp
index 437a0cea99..d75b359868 100644
--- a/src/test/app/lending/LoanBroker_test.cpp
+++ b/src/test/app/lending/LoanBroker_test.cpp
@@ -72,7 +72,13 @@ class LoanBroker_test : public beast::unit_test::Suite
 {
     // Ensure that all the features needed for Lending Protocol are included,
     // even if they are set to unsupported.
-    FeatureBitset const all_{jtx::testableAmendments()};
+    //
+    // featureLendingProtocolV1_1 is excluded from the default set: it adds
+    // the closed-ended vault gate on LoanBrokerSet::preclaim (see
+    // LoanBrokerSet.cpp), but this suite exercises loan-broker mechanics on
+    // plain open-ended vaults. Tests that specifically exercise the
+    // amendment opt it back in explicitly and use closed-ended vaults.
+    FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1};
 
     void
     testDisabled()
@@ -872,7 +878,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         using namespace loan_broker;
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1108,7 +1114,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             Account const alice{"alice"};
             Account const issuer{"issuer"};
             auto const usd = alice["USD"];
-            Env env(*this);
+            Env env(*this, all_);
             env.fund(XRP(100'000), alice);
             env.close();
 
@@ -1211,7 +1217,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // This test is lifted directly from
         // https://bugs.immunefi.com/dashboard/submission/57808
         using namespace jtx;
-        Env env(*this);
+        Env env(*this, all_);
 
         Account const alice{"alice"};
         env.fund(XRP(10000), alice);
@@ -1269,7 +1275,7 @@ class LoanBroker_test : public beast::unit_test::Suite
 
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1377,7 +1383,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         using namespace loan_broker;
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1543,7 +1549,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         Account const& broker = issuer;
 
         auto test = [&](auto&& getToken) {
-            Env env(*this);
+            Env env(*this, all_);
 
             env.fund(XRP(1'000), issuer, holder);
             env.close();
@@ -1616,7 +1622,7 @@ class LoanBroker_test : public beast::unit_test::Suite
     {
         testcase << "RIPD-4466 - LoanBrokerSet disallows frozen vaults";
         using namespace jtx;
-        Env env(*this);
+        Env env(*this, all_);
 
         Account const issuer{"issuer"}, lender{"lender"}, borrower{"borrower"};
         env.fund(XRP(20'000), issuer, lender, borrower);
@@ -1855,7 +1861,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === IOU ===
         {
             testcase("LoanBrokerCoverDeposit IOU freeze checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -1922,7 +1928,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === MPT ===
         {
             testcase("LoanBrokerCoverDeposit MPT lock checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2005,7 +2011,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         Account const issuer{"issuer"};
         Account const alice{"alice"};
         Account const dest{"dest"};
-        Env env{*this};
+        Env env{*this, all_};
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice, dest);
@@ -2071,7 +2077,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === IOU ===
         {
             testcase("LoanBrokerCoverWithdraw IOU freeze checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2183,7 +2189,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === MPT ===
         {
             testcase("LoanBrokerCoverWithdraw MPT lock checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2304,7 +2310,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         };
 
         auto test = [&](TrustState trustState) {
-            Env env(*this);
+            Env env(*this, all_);
 
             testcase << "RIPD-4274 IOU with state: " << static_cast(trustState);
 
@@ -2429,7 +2435,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         };
 
         auto test = [&](MPTState mptState) {
-            Env env(*this);
+            Env env(*this, all_);
 
             testcase << "RIPD-4274 MPT with state: " << static_cast(mptState);
 
diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp
index 6cced5c97a..dae6f6ce16 100644
--- a/src/test/app/lending/LoanLifecycle_test.cpp
+++ b/src/test/app/lending/LoanLifecycle_test.cpp
@@ -347,7 +347,11 @@ private:
             auto const& asset = debtMaximumRequest.asset();
             auto const initialVault = asset(debtMaximumRequest * 100);
 
-            auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset});
+            // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim
+            // only accepts closed-ended vaults, so build one and advance
+            // past SubscriptionDate before creating broker/loan.
+            auto [tx, vaultKeylet, subscriptionDate] =
+                vault.createClosedEnded({.owner = broker, .asset = asset});
             env(tx, txFee);
             env.close();
 
@@ -356,6 +360,8 @@ private:
                 txFee);
             env.close();
 
+            vault.closePastSubscription(subscriptionDate);
+
             auto const brokerKeylet =
                 keylet::loanBroker(broker.id(), SeqProxy::rawSequence(env.seq(broker)));
 
diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp
index 21772d0617..b878547a70 100644
--- a/src/test/app/lending/LoanSecurity_test.cpp
+++ b/src/test/app/lending/LoanSecurity_test.cpp
@@ -411,13 +411,17 @@ private:
         Account const depositor{"depositor"};
         auto const txFee = Fee(XRP(100));
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one and advance past
+        // SubscriptionDate before creating the broker and the loan.
         Env env(*this);
         Vault const vault(env);
 
         env.fund(XRP(10'000), lender, issuer, borrower, depositor);
         env.close();
 
-        auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()});
+        auto [tx, vaultKeyLet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = xrpIssue()});
         env(tx, txFee);
         env.close();
 
@@ -425,6 +429,10 @@ private:
             txFee);
         env.close();
 
+        // Move into the Investment phase before creating the broker and
+        // the loan.
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerKeyLet =
             keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
 
diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h
index b3669742fe..99b387938e 100644
--- a/src/test/app/lending/LoanTestBase.h
+++ b/src/test/app/lending/LoanTestBase.h
@@ -496,9 +496,27 @@ protected:
 
         auto const coverRateMinValue = params.coverRateMin;
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+        // brokers attached to open-ended vaults. Many callers of this
+        // helper leave vaultKind at the OpenEnded default and don't care
+        // about the vault kind per se — they just need a broker on a
+        // vault. When LP V1.1 is enabled, transparently promote to
+        // ClosedEnded so those tests keep working without threading
+        // vaultKind through every call site. Callers that explicitly
+        // asked for ClosedEnded are left untouched. Tests that want to
+        // exercise the open-ended rejection under LP V1.1 build their own
+        // vault directly instead of going through this helper, since it
+        // always promotes OpenEnded once the amendment is enabled.
+        auto effectiveVaultKind = params.vaultKind;
+        if (env.current()->rules().enabled(featureLendingProtocolV1_1) &&
+            effectiveVaultKind == VaultKind::OpenEnded)
+        {
+            effectiveVaultKind = VaultKind::ClosedEnded;
+        }
+
         std::optional subscriptionDate;
         std::optional redemptionDate;
-        if (params.vaultKind == VaultKind::ClosedEnded)
+        if (effectiveVaultKind == VaultKind::ClosedEnded)
         {
             auto const nowSec = env.now().time_since_epoch().count();
             subscriptionDate = nowSec + params.subscriptionOffset;
@@ -508,9 +526,9 @@ protected:
         auto [tx, vaultKeylet] = vault.create(
             {.owner = lender,
              .asset = asset,
-             .vaultKind = params.vaultKind == VaultKind::OpenEnded
+             .vaultKind = effectiveVaultKind == VaultKind::OpenEnded
                  ? std::optional{}
-                 : std::optional{std::to_underlying(params.vaultKind)},
+                 : std::optional{std::to_underlying(effectiveVaultKind)},
              .subscriptionDate = subscriptionDate,
              .redemptionDate = redemptionDate});
         if (params.vaultScale)
diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp
index c6ff22bbb3..ebbef70f40 100644
--- a/src/test/app/lending/LoanValidation_test.cpp
+++ b/src/test/app/lending/LoanValidation_test.cpp
@@ -13,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -530,6 +531,61 @@ private:
         env.close();
     }
 
+    // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+    // attaching a broker to an open-ended vault. VaultCreate itself is
+    // not gated by the amendment, so the same open-ended vault can be
+    // built under either feature set; only the broker create is
+    // amendment-sensitive. Cover both branches: LP V1.1 disabled lets
+    // the broker create succeed, LP V1.1 enabled rejects it. The gate
+    // only fires on the create path; existing brokers keep working.
+    void
+    testLoanBrokerRequiresClosedEndedVault()
+    {
+        testcase("LoanBrokerSet requires closed-ended vault under LP V1.1");
+        using namespace jtx;
+
+        Account const owner{"lp11_owner"};
+
+        auto const build = [&](FeatureBitset features,
+                               TER expected,
+                               std::optional updateExpected = std::nullopt) {
+            Env env(*this, features);
+            env.fund(XRP(1'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+            env(tx);
+            env.close();
+            env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = XRP(100)}));
+            env.close();
+
+            auto const brokerKeylet =
+                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            env(loan_broker::set(owner, vaultKeylet.key), Ter(expected));
+            env.close();
+
+            // The create-path gate is the only new check; updates to an
+            // existing broker on the same open-ended vault are not
+            // affected. Only exercise the update path when the create
+            // succeeded (so there is a broker to update).
+            if (updateExpected && expected == tesSUCCESS)
+            {
+                env(loan_broker::set(owner, vaultKeylet.key),
+                    loan_broker::kLoanBrokerId(brokerKeylet.key),
+                    loan_broker::kDebtMaximum(XRP(1'000).value()),
+                    Ter(*updateExpected));
+                env.close();
+            }
+        };
+
+        // Baseline: LP V1.1 disabled -> open-ended vault + broker succeeds.
+        build(all_, tesSUCCESS, tesSUCCESS);
+
+        // LP V1.1 enabled -> open-ended vault + broker rejected on create.
+        build(all_ | featureLendingProtocolV1_1, tecNO_PERMISSION);
+    }
+
     void
     runAmendmentIndependent()
     {
@@ -541,6 +597,7 @@ private:
         testInvalidLoanPay();
         testRequireAuth();
         testLimitExceeded();
+        testLoanBrokerRequiresClosedEndedVault();
     }
 
     // Tests run under each entry in amendmentCombinations().
diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp
index ad6fdcc8b8..e3e0c40bc2 100644
--- a/src/test/app/vault/VaultBugs_test.cpp
+++ b/src/test/app/vault/VaultBugs_test.cpp
@@ -564,44 +564,49 @@ private:
             env.close();
         };
 
+        // Strip featureLendingProtocolV1_1: this scenario runs an
+        // open-ended vault through deposit/broker/loan/repay/deposit,
+        // which spans both Subscription and post-loan lifetime — a phase
+        // pattern that only makes sense on open-ended vaults. The gate
+        // added by LP V1.1 is unrelated to the truncation bug asserted
+        // here.
+        auto const legacy = testableAmendments() - featureLendingProtocolV1_1;
         {
             testcase(
                 "bug: VaultDeposit share truncation lets depositor debit "
                 "round away to zero (pre-fixCleanup3_4_0)");
-            runScenario(testableAmendments() - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED);
+            runScenario(legacy - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED);
         }
         {
             testcase(
                 "bug: VaultDeposit share truncation lets depositor debit "
                 "round away to zero (pre-fixCleanup3_2_0 and pre-fixCleanup3_4_0)");
             runScenario(
-                testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0,
-                Line::Holding,
-                tecINVARIANT_FAILED);
+                legacy - fixCleanup3_2_0 - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED);
         }
         {
             testcase(
                 "bug: VaultDeposit share truncation rejected with "
                 "tecPRECISION_LOSS (post-fixCleanup3_4_0)");
-            runScenario(testableAmendments(), Line::Holding, tecPRECISION_LOSS);
+            runScenario(legacy, Line::Holding, tecPRECISION_LOSS);
         }
         {
             testcase(
                 "bug: VaultDeposit share truncation rejected with "
                 "tecPRECISION_LOSS (post-fixCleanup3_4_0, pre-fixCleanup3_2_0)");
-            runScenario(testableAmendments() - fixCleanup3_2_0, Line::Holding, tecPRECISION_LOSS);
+            runScenario(legacy - fixCleanup3_2_0, Line::Holding, tecPRECISION_LOSS);
         }
         {
             testcase(
                 "bug: VaultDeposit share truncation against a debt balance "
                 "round away to zero (pre-fixCleanup3_4_0)");
-            runScenario(testableAmendments() - fixCleanup3_4_0, Line::InDebt, tecINVARIANT_FAILED);
+            runScenario(legacy - fixCleanup3_4_0, Line::InDebt, tecINVARIANT_FAILED);
         }
         {
             testcase(
                 "bug: VaultDeposit share truncation against a debt balance rejected with "
                 "tecPRECISION_LOSS (post-fixCleanup3_4_0)");
-            runScenario(testableAmendments(), Line::InDebt, tecPRECISION_LOSS);
+            runScenario(legacy, Line::InDebt, tecPRECISION_LOSS);
         }
     }
 
@@ -1104,7 +1109,10 @@ private:
         using namespace test::jtx;
 
         auto runScenario = [this](FeatureBitset features, bool withFix) {
-            Env env{*this, features};
+            // This regression requires the open-ended vault lifecycle: deposit,
+            // originate and repay a loan, then claw back shares. LP V1.1
+            // independently rejects attaching a broker to an open-ended vault.
+            Env env{*this, features - featureLendingProtocolV1_1};
 
             auto const setup = makeRoundTripOvershootVault(env);
             if (!BEAST_EXPECT(setup))
@@ -1165,7 +1173,10 @@ private:
         using namespace test::jtx;
 
         auto runScenario = [this](FeatureBitset features, bool withFix) {
-            Env env{*this, features};
+            // This regression requires the open-ended vault lifecycle: deposit,
+            // originate and repay a loan, then withdraw shares. LP V1.1
+            // independently rejects attaching a broker to an open-ended vault.
+            Env env{*this, features - featureLendingProtocolV1_1};
 
             auto const setup = makeRoundTripOvershootVault(env);
             if (!BEAST_EXPECT(setup))
diff --git a/src/test/app/vault/VaultClawback_test.cpp b/src/test/app/vault/VaultClawback_test.cpp
index 2a9fe42b1c..6ce847f9fd 100644
--- a/src/test/app/vault/VaultClawback_test.cpp
+++ b/src/test/app/vault/VaultClawback_test.cpp
@@ -68,12 +68,20 @@ private:
             return sleIssuance->at(sfOutstandingAmount);
         };
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build vaults in this suite as
+        // closed-ended and advance past SubscriptionDate before creating
+        // brokers/loans. VaultClawback itself is not phase-gated. The
+        // subscription offset must be large enough that the deposit
+        // ledger close does not accidentally push us past SubscriptionDate
+        // (which would land the deposit in Investment phase and fail).
         auto const setupVault = [&](PrettyAsset const& asset,
                                     Account const& owner,
                                     Account const& depositor) -> std::pair {
             Vault const vault{env};
 
-            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            auto const& [tx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = owner, .asset = asset, .subscriptionOffset = std::chrono::seconds{60}});
             env(tx, Ter(tesSUCCESS));
             env.close();
 
@@ -87,6 +95,10 @@ private:
                 Ter(tesSUCCESS));
             env.close();
 
+            // Move past SubscriptionDate so LoanBrokerSet/LoanSet run in
+            // the Investment phase.
+            vault.closePastSubscription(subscriptionDate);
+
             auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet);
             BEAST_EXPECT(availablePreDefault == totalPreDefault);
             BEAST_EXPECT(availablePreDefault == asset(100).value());
@@ -313,13 +325,21 @@ private:
         Env env(*this);
         env.enableFeature(fixCleanup3_1_3);
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults; some tests using this helper later
+        // attach loan brokers to the vault. Build it as closed-ended and
+        // advance past SubscriptionDate so subsequent broker/loan setup
+        // runs in the Investment phase. VaultClawback itself is not
+        // phase-gated. See the other setupVault (share tests) for why the
+        // subscription offset must be generous.
         auto const setupVault = [&](PrettyAsset const& asset,
                                     Account const& owner,
                                     Account const& depositor,
                                     Account const& issuer) -> std::pair {
             Vault const vault{env};
 
-            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            auto const& [tx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = owner, .asset = asset, .subscriptionOffset = std::chrono::seconds{60}});
             env(tx, Ter(tesSUCCESS));
             env.close();
 
@@ -331,6 +351,8 @@ private:
                 Ter(tesSUCCESS));
             env.close();
 
+            vault.closePastSubscription(subscriptionDate);
+
             return std::make_pair(vault, vaultKeylet);
         };
 
diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp
index 28c9729d78..b2ce4a5abf 100644
--- a/src/test/app/vault/VaultScale_test.cpp
+++ b/src/test/app/vault/VaultScale_test.cpp
@@ -22,6 +22,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -71,7 +72,12 @@ private:
 
         auto testCase = [&, this](
                             std::uint8_t scale, std::function test) {
-            Env env{*this, testableAmendments()};
+            // These scale-focused tests build an open-ended vault and
+            // exercise deposit/withdraw/clawback (with one test also
+            // attaching a loan broker). featureLendingProtocolV1_1 adds a
+            // closed-ended vault gate on LoanBrokerSet::preclaim and is
+            // orthogonal to what this suite asserts, so strip it here.
+            Env env{*this, testableAmendments() - featureLendingProtocolV1_1};
             Account const owner{"owner"};
             Account const issuer{"issuer"};
             Account const depositor{"depositor"};
diff --git a/src/test/app/vault/VaultSoleShareholder_test.cpp b/src/test/app/vault/VaultSoleShareholder_test.cpp
index ffaad07112..62cbc28bcd 100644
--- a/src/test/app/vault/VaultSoleShareholder_test.cpp
+++ b/src/test/app/vault/VaultSoleShareholder_test.cpp
@@ -464,7 +464,10 @@ private:
             "Vault withdraw: sole-shareholder partial fixed-shares uses "
             "full-price rate (fixCleanup3_2_0)");
 
-        Env env(*this, all_ | fixCleanup3_2_0);
+        // Strip featureLendingProtocolV1_1: setupStuckDepositor builds an
+        // open-ended vault and this test asserts amendment-independent
+        // withdrawal invariants (see the note on run()).
+        Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0);
         auto const f = setupStuckDepositor(env);
         if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
         {
@@ -551,7 +554,8 @@ private:
             "Vault withdraw: sole shareholder fully exits after impaired "
             "loan is repaid (fixCleanup3_2_0)");
 
-        Env env(*this, all_ | fixCleanup3_2_0);
+        // Strip featureLendingProtocolV1_1 as above.
+        Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0);
         auto const f = setupStuckDepositor(env);
         if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
         {
@@ -639,12 +643,20 @@ public:
     void
     run() override
     {
-        testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderFixedAssetExit(all_);
-        testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderFullSharesRejected(all_);
-        testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderCleanVaultUnaffected(all_);
+        // These sole-shareholder exit scenarios build an open-ended vault
+        // and drive it through deposits, a loan broker, an impaired loan
+        // and finally a withdrawal by the last shareholder. Under
+        // featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+        // brokers attached to open-ended vaults, so this suite runs with
+        // the amendment stripped; the invariants asserted here are
+        // amendment-independent.
+        auto const legacy = all_ - featureLendingProtocolV1_1;
+        testWithdrawSoleShareholderFixedAssetExit(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFixedAssetExit(legacy);
+        testWithdrawSoleShareholderFullSharesRejected(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFullSharesRejected(legacy);
+        testWithdrawSoleShareholderCleanVaultUnaffected(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderCleanVaultUnaffected(legacy);
         testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
         testWithdrawSoleShareholderLoanRepaymentExit();
     }
diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp
index 978c3864d6..4688e8c4a6 100644
--- a/src/test/jtx/impl/vault.cpp
+++ b/src/test/jtx/impl/vault.cpp
@@ -3,17 +3,22 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 
+#include 
+#include 
 #include 
 #include 
+#include 
 
 namespace xrpl::test::jtx {
 
@@ -37,6 +42,27 @@ Vault::create(CreateArgs const& args) const
     return {jv, keylet};
 }
 
+std::tuple
+Vault::createClosedEnded(CreateClosedEndedArgs const& args) const
+{
+    auto const sub = env.now() + args.subscriptionOffset;
+    auto const red = sub + args.investmentWindow;
+    auto [jv, keylet] = create(
+        {.owner = args.owner,
+         .asset = args.asset,
+         .flags = args.flags,
+         .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
+         .subscriptionDate = static_cast(sub.time_since_epoch().count()),
+         .redemptionDate = static_cast(red.time_since_epoch().count())});
+    return {jv, keylet, sub};
+}
+
+void
+Vault::closePastSubscription(NetClock::time_point subscriptionDate) const
+{
+    env.close(subscriptionDate + std::chrono::seconds{1});
+}
+
 json::Value
 Vault::set(SetArgs const& args)
 {
diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h
index 992051b61f..6b2ffddfb3 100644
--- a/src/test/jtx/vault.h
+++ b/src/test/jtx/vault.h
@@ -3,10 +3,12 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -39,6 +41,38 @@ struct Vault
     [[nodiscard]] std::tuple
     create(CreateArgs const& args) const;
 
+    struct CreateClosedEndedArgs
+    {
+        Account owner;
+        Asset asset;
+        std::optional flags =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
+        NetClock::duration subscriptionOffset = std::chrono::seconds{10};
+        NetClock::duration investmentWindow = std::chrono::seconds{1'000'000};
+    };
+
+    /**
+     * Return a VaultCreate transaction for a closed-ended vault, its
+     * expected keylet, and the vault's SubscriptionDate.
+     *
+     * Under featureLendingProtocolV1_1, LoanBrokerSet::preclaim only
+     * accepts closed-ended vaults, so tests that attach a loan broker
+     * need one. SubscriptionDate is set to now() + subscriptionOffset,
+     * giving callers a window to deposit while still in the Subscription
+     * phase; pass the returned date to closePastSubscription() afterwards
+     * to advance into the Investment phase.
+     */
+    [[nodiscard]] std::tuple
+    createClosedEnded(CreateClosedEndedArgs const& args) const;
+
+    /**
+     * Advance env's clock to just past subscriptionDate, moving a
+     * closed-ended vault from the Subscription phase into the Investment
+     * phase.
+     */
+    void
+    closePastSubscription(NetClock::time_point subscriptionDate) const;
+
     struct SetArgs
     {
         Account owner;

From 50527485d3c365dc34ef61e9fc681ee1dd134166 Mon Sep 17 00:00:00 2001
From: Ayaz Salikhov 
Date: Wed, 26 Aug 2026 13:13:59 +0000
Subject: [PATCH 30/43] build: Rename release channels: unstable->rc,
 experimental->beta (#8116)

---
 .github/actions/release-info/action.yml |  4 +--
 cmake/XrplPackaging.cmake               |  5 ++--
 docs/install.md                         |  4 +--
 package/README.md                       | 37 ++++++++++++++-----------
 package/build_pkg.py                    |  5 ++--
 package/publish_pkg.py                  |  1 +
 6 files changed, 32 insertions(+), 24 deletions(-)

diff --git a/.github/actions/release-info/action.yml b/.github/actions/release-info/action.yml
index 7f1061df93..ab69a35f68 100644
--- a/.github/actions/release-info/action.yml
+++ b/.github/actions/release-info/action.yml
@@ -61,9 +61,9 @@ runs:
         elif [[ -z "${pre_release}" ]]; then
             channel=stable
         elif [[ "${pre_release}" =~ ^rc[0-9]+(\+.*)?$ ]]; then
-            channel=unstable
+            channel=rc
         elif [[ "${pre_release}" =~ ^b(0|[1-9][0-9]*)(\+.*)?$ ]]; then
-            channel=experimental
+            channel=beta
         else
             echo "Unsupported pre-release in tag '${REF_NAME}'. Use bN or rcN." >&2
             exit 1
diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake
index c454f487dc..e2f7029ad2 100644
--- a/cmake/XrplPackaging.cmake
+++ b/cmake/XrplPackaging.cmake
@@ -47,8 +47,9 @@ endif()
 add_custom_target(
     package
     COMMAND
-        ${CMAKE_SOURCE_DIR}/package/build_pkg.py --package-type ${pkg_type}
-        --build-dir ${CMAKE_BINARY_DIR} --pkg-release ${pkg_release}
+        ${CMAKE_SOURCE_DIR}/package/build_pkg.py --package-type=${pkg_type}
+        --build-dir=${CMAKE_BINARY_DIR} --pkg-release=${pkg_release}
+        --channel=UNRELEASED
     WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
     DEPENDS xrpld validator-keys
     COMMENT "Building Linux ${pkg_type} package"
diff --git a/docs/install.md b/docs/install.md
index ee9c31868b..01cfc144a1 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -14,8 +14,8 @@ To build from source instead, see [BUILD.md](../BUILD.md).
 Packages are published to four channels:
 
 - `stable` - the latest production release
-- `unstable` - release candidates
-- `experimental` - beta builds
+- `rc` - release candidates
+- `beta` - beta builds
 - `develop` - every push to the [`develop` branch](https://github.com/XRPLF/rippled/tree/develop)
 
 See [Publishing packages](../package/README.md#publishing-packages) for how channels are produced.
diff --git a/package/README.md b/package/README.md
index 04f4db2ea5..bacd79efe5 100644
--- a/package/README.md
+++ b/package/README.md
@@ -86,7 +86,10 @@ docker run --rm \
     -v "$(pwd):/src" \
     -w /src \
     "${IMAGE}" \
-    ./package/build_pkg.py --package-type rpm --pkg-release "${PKG_RELEASE}"
+    ./package/build_pkg.py \
+    --package-type rpm \
+    --pkg-release "${PKG_RELEASE}" \
+    --channel UNRELEASED
 
 # Output:
 #   build/debbuild/*.deb         (DEB + dbgsym; Debian names both .deb)
@@ -114,9 +117,9 @@ The `cmake/XrplPackaging.cmake` module defines the `package` target only if at
 least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and
 `validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target
 builds both binaries before packaging, passing `--package-type deb` when
-`dpkg-buildpackage` is present and `rpm` otherwise. The packaging script installs to
-FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of
-`CMAKE_INSTALL_PREFIX`.
+`dpkg-buildpackage` is present and `rpm` otherwise, and `--channel UNRELEASED`.
+The packaging script installs to FHS-standard paths (`/usr/bin`, `/etc/xrpld`,
+etc.) regardless of `CMAKE_INSTALL_PREFIX`.
 
 The package version is not a CMake input on this path: `build_pkg.py` derives it
 from the just-built `xrpld` binary's `xrpld --version` output. The package
@@ -128,13 +131,13 @@ Packages are published to the XRPLF repositories on Sonatype Nexus at
 `https://packages.xrplf.org`. The `release-info` action decides the channel from
 the event, and `publish_pkg.py` maps that channel to its repositories:
 
-| Event                    | Version           | Channel        | DEB repository     | RPM upload repository     |
-| ------------------------ | ----------------- | -------------- | ------------------ | ------------------------- |
-| tag                      | `X.Y.Z`           | `stable`       | `deb-stable`       | `rpm-stable-hosted`       |
-| tag                      | `X.Y.Z-rcN`       | `unstable`     | `deb-unstable`     | `rpm-unstable-hosted`     |
-| tag                      | `X.Y.Z-bN`        | `experimental` | `deb-experimental` | `rpm-experimental-hosted` |
-| push to `develop`        | `xrpld --version` | `develop`      | `deb-develop`      | `rpm-develop-hosted`      |
-| tag, non-public codebase | _any_             | `private`      | `deb-private`      | `rpm-private-hosted`      |
+| Event                    | Version           | Channel   | DEB repository | RPM upload repository |
+| ------------------------ | ----------------- | --------- | -------------- | --------------------- |
+| tag                      | `X.Y.Z`           | `stable`  | `deb-stable`   | `rpm-stable-hosted`   |
+| tag                      | `X.Y.Z-rcN`       | `rc`      | `deb-rc`       | `rpm-rc-hosted`       |
+| tag                      | `X.Y.Z-bN`        | `beta`    | `deb-beta`     | `rpm-beta-hosted`     |
+| push to `develop`        | `xrpld --version` | `develop` | `deb-develop`  | `rpm-develop-hosted`  |
+| tag, non-public codebase | _any_             | `private` | `deb-private`  | `rpm-private-hosted`  |
 
 Only a tag names a channel — do not extend that to `develop`, where
 `BuildInfo.cpp`'s `versionString` moves through `-bN`, `-rcN` and even the final
@@ -207,9 +210,11 @@ With `PKG_RELEASE=1`, the package metadata becomes:
 from the build host, so the RHEL image can track a newer release without
 changing what the packages claim to target.
 
-The Debian changelog entry carries the channel passed as `--channel`,
-defaulting to `unstable`. An unsupported pre-release, and build metadata on a
-final release such as `3.2.0+abc123`, are both rejected.
+The Debian changelog entry carries the channel passed as `--channel`, which
+only accepts the channels in the table above plus `UNRELEASED`, the Debian
+convention for a build that targets no channel at all — what local and CMake
+builds pass, since nothing publishes them. An unsupported pre-release, and
+build metadata on a final release such as `3.2.0+abc123`, are both rejected.
 
 The RPM path intentionally uses `~` in `Version`, matching the Debian
 pre-release ordering convention, so RPM filenames/NVRs begin with forms like
@@ -220,8 +225,8 @@ The package format is `--package-type`, either `deb` or `rpm`. It is required,
 so a job never silently builds the wrong format for the image it runs in; the
 matching build tool still has to be on PATH.
 
-Every input is a named argument. CMake passes `--package-type`, `--build-dir`
-and `--pkg-release`; CI adds `--channel`. The repository root is not an argument
+Every input is a named argument, and every argument but `--build-dir` and
+`--pkg-release` is required. The repository root is not an argument
 at all: the script reads it from its own location. Only secrets stay in the
 environment, so they never reach the process list -- `PKG_SIGNING_KEY` for
 `sign_rpm.py`, and `NEXUS_USERNAME` / `NEXUS_PASSWORD` for `publish_pkg.py`.
diff --git a/package/build_pkg.py b/package/build_pkg.py
index 28835d1ccd..2518d8c1db 100755
--- a/package/build_pkg.py
+++ b/package/build_pkg.py
@@ -219,8 +219,9 @@ def main() -> None:
     )
     parser.add_argument(
         "--channel",
-        default="unstable",
-        help="release channel, written to debian/changelog (default: %(default)s)",
+        required=True,
+        choices=("stable", "rc", "beta", "develop", "private", "UNRELEASED"),
+        help="release channel, written to debian/changelog",
     )
     args = parser.parse_args()
     package_type: str = args.package_type
diff --git a/package/publish_pkg.py b/package/publish_pkg.py
index 2c320a595a..0bd39d0845 100755
--- a/package/publish_pkg.py
+++ b/package/publish_pkg.py
@@ -81,6 +81,7 @@ def main() -> None:
     parser.add_argument(
         "--channel",
         required=True,
+        choices=("stable", "rc", "beta", "develop", "private"),
         help="release channel, selecting the deb- and rpm--hosted repositories",
     )
     parser.add_argument(

From f7ea645bf4d4886149166aad9ed97c72b6484b69 Mon Sep 17 00:00:00 2001
From: Peter Chen <34582813+PeterChen13579@users.noreply.github.com>
Date: Wed, 26 Aug 2026 13:14:53 +0000
Subject: [PATCH 31/43] fix: AMMClawback exact LP token boundary (#7373)

---
 .../tx/transactors/dex/AMMClawback.cpp        | 11 ++--
 src/test/app/AMMClawback_test.cpp             | 64 +++++++++++++++++++
 2 files changed, 70 insertions(+), 5 deletions(-)

diff --git a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
index e690cd7693..b25c90069c 100644
--- a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
@@ -324,11 +324,13 @@ AMMClawback::equalWithdrawMatchingOneAmount(
     auto amount2Withdraw = amount2Balance * frac;
 
     auto const lpTokensWithdraw = toSTAmount(lptAMMBalance.asset(), lptAMMBalance * frac);
-    if (lpTokensWithdraw > holdLPtokens)
+    auto const& rules = sb.rules();
+    // Pre-fixCleanup3_4_0 only a strictly greater computed LP amount takes
+    // the withdraw-all path. Equality left the last holder unable to be
+    // fully clawed. The amendment treats equality as withdraw-all.
+    if (rules.enabled(fixCleanup3_4_0) ? lpTokensWithdraw >= holdLPtokens
+                                       : lpTokensWithdraw > holdLPtokens)
     {
-        // if lptoken balance less than what the issuer intended to clawback,
-        // clawback all the tokens. Because we are doing a two-asset withdrawal,
-        // tfee is actually not used, so pass tfee as 0.
         return AMMWithdraw::equalWithdrawTokens(
             sb,
             ammSle,
@@ -348,7 +350,6 @@ AMMClawback::equalWithdrawMatchingOneAmount(
             ctx_.journal);
     }
 
-    auto const& rules = sb.rules();
     if (rules.enabled(fixAMMClawbackRounding))
     {
         auto tokensAdj = getRoundedLPTokens(rules, lptAMMBalance, frac, IsDeposit::No);
diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp
index 90bface1fb..230d148ff9 100644
--- a/src/test/app/AMMClawback_test.cpp
+++ b/src/test/app/AMMClawback_test.cpp
@@ -13,7 +13,9 @@
 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -2713,6 +2715,67 @@ class AMMClawback_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testExactLPTokenEquality(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        if (!features[fixAMMv1_3] || !features[fixAMMClawbackRounding])
+            return;
+
+        testcase("test exact LP token equality boundary");
+
+        Env env(*this, features);
+        Account const gw{"gateway"}, alice{"alice"}, bob{"bob"};
+        env.fund(XRP(100000), gw, alice, bob);
+        env.close();
+        env(fset(gw, asfAllowTrustLineClawback));
+        env.close();
+
+        auto const usd = gw["USD"];
+        env.trust(usd(100000), alice);
+        env(pay(gw, alice, usd(50000)));
+        env.trust(usd(100000), bob);
+        env(pay(gw, bob, usd(40000)));
+        env.close();
+
+        // bob keeps alice from being the sole LP, otherwise the clawback
+        // first rewrites the AMM's LP balance to alice's tokens and the
+        // boundary is no longer distinguishable.
+        AMM amm(env, alice, XRP(2), usd(1));
+        amm.deposit(alice, IOUAmount{1'876123487565916, -15});
+        amm.deposit(bob, IOUAmount{1'000'000});
+
+        auto const [amountBalance, amount2Balance, lptAMMBalance] = amm.balances(usd, XRP);
+        auto const aliceLP = amm.getLPTokensBalance(alice);
+        auto const holderLPTokens = STAmount{aliceLP, amm.lptIssue()};
+        BEAST_EXPECT(lptAMMBalance > holderLPTokens);
+
+        // Clawing alice's pro-rata share lands the transactor's computed LP
+        // amount exactly on her balance.
+        auto const amount = toSTAmount(usd, Number{amountBalance} * holderLPTokens / lptAMMBalance);
+        BEAST_EXPECT(
+            toSTAmount(lptAMMBalance.asset(), lptAMMBalance * (Number{amount} / amountBalance)) ==
+            holderLPTokens);
+
+        env(amm::ammClawback(gw, alice, usd, XRP, amount));
+        env.close();
+
+        auto const aliceLPAfter = amm.getLPTokensBalance(alice);
+        if (features[fixCleanup3_4_0])
+        {
+            // Equality takes the withdraw-all path, redeeming alice's tokens
+            // exactly.
+            BEAST_EXPECT(aliceLPAfter == IOUAmount(0));
+        }
+        else
+        {
+            // The fall-through re-rounds the LP amount against the much
+            // larger pool balance, leaving alice with dust.
+            BEAST_EXPECT(aliceLPAfter != IOUAmount(0) && aliceLPAfter < aliceLP);
+        }
+    }
+
     void
     run() override
     {
@@ -2746,6 +2809,7 @@ class AMMClawback_test : public beast::unit_test::Suite
             testAssetFrozen(features);
             testSingleDepositAndClawback(features);
             testLastHolderLPTokenBalance(features);
+            testExactLPTokenEquality(features);
         }
     }
 };

From 36c165f74df17cb813c0b0aa42c1d6954e1fee40 Mon Sep 17 00:00:00 2001
From: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
Date: Wed, 26 Aug 2026 13:38:24 +0000
Subject: [PATCH 32/43] fix: Prevent early loan impairment and due-date
 manipulation (#6557)

Co-authored-by: Ed Hennis 
Co-authored-by: Timur Yalymov <36795566+tyalymov@users.noreply.github.com>
---
 include/xrpl/ledger/helpers/LendingHelpers.h  |   6 +
 src/libxrpl/ledger/helpers/LendingHelpers.cpp |  25 +-
 .../tx/transactors/lending/LoanManage.cpp     |  59 +-
 .../tx/transactors/lending/LoanPay.cpp        |   8 +-
 .../tx/transactors/vault/VaultDelete.cpp      |   1 +
 .../app/invariants/InvariantsVault_test.cpp   |  12 +
 src/test/app/lending/LoanCashBasis_test.cpp   |  11 +-
 .../app/lending/LoanCoverFreezeAuth_test.cpp  |   4 +
 src/test/app/lending/LoanPay_test.cpp         | 393 ++++++++++++
 src/test/app/lending/LoanRounding_test.cpp    |   2 +
 src/test/app/lending/LoanSecurity_test.cpp    | 580 ++++++++++++++++++
 src/test/app/lending/LoanTestBase.h           |  70 ++-
 src/test/app/lending/LoanValidation_test.cpp  |   7 +-
 src/test/app/vault/VaultPrecisionFixture.h    |  22 +-
 .../app/vault/VaultSoleShareholder_test.cpp   |  20 +-
 15 files changed, 1175 insertions(+), 45 deletions(-)

diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h
index 4aa89ea672..f3fc82eacb 100644
--- a/include/xrpl/ledger/helpers/LendingHelpers.h
+++ b/include/xrpl/ledger/helpers/LendingHelpers.h
@@ -324,6 +324,12 @@ computeFullPaymentInterest(
     std::uint32_t startDate,
     TenthBips32 closeInterestRate);
 
+// Returns true if the loan's next payment is late per protocol rules. The
+// boundary is amendment-gated: with fixCleanup3_4_0 the due date must be
+// strictly in the past, otherwise the exact due-date instant counts as late.
+[[nodiscard]] bool
+isPaymentLate(ReadView const& view, SLE::const_ref loanSle);
+
 // Deltas applied to Vault.AssetsTotal and LoanBroker.DebtTotal at a single
 // accounting touch point (origination, payment, impair/unimpair/default).
 struct AccountingDeltas
diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
index cf1bd4915f..10c7e62c6c 100644
--- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
@@ -169,6 +169,16 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale)
         roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
 }
 
+[[nodiscard]] bool
+isPaymentLate(ReadView const& view, SLE::const_ref loanSle)
+{
+    return hasExpired(
+        view,
+        loanSle->at(sfNextPaymentDueDate),
+        view.rules().enabled(fixCleanup3_4_0) ? ExpiryComparison::Exclusive
+                                              : ExpiryComparison::Inclusive);
+}
+
 namespace accrual {
 
 AccountingDeltas
@@ -514,7 +524,7 @@ loanLatePaymentInterest(
     // If the payment is not late by any amount of time, then there's no late
     // interest
     if (now <= nextPaymentDueDate)
-        return 0;
+        return kNumZero;
 
     // Equation (3) from XLS-66 spec, Section A-2 Equation Glossary
     auto const secondsOverdue = now - nextPaymentDueDate;
@@ -1035,7 +1045,7 @@ doOverpayment(
 std::expected
 computeLatePayment(
     Asset const& asset,
-    ApplyView const& view,
+    ReadView const& view,
     SLE::const_ref loan,
     ExtendedPaymentComponents const& periodic,
     STAmount const& amount,
@@ -1046,8 +1056,11 @@ computeLatePayment(
     std::int32_t const loanScale = loan->at(sfLoanScale);
 
     // Check if the due date has passed. If not, reject the payment as
-    // being too soon
-    if (!hasExpired(view, nextDueDate))
+    // being too soon. Uses isPaymentLate() so this agrees with the
+    // regular payment path on whether the loan is actually late at the
+    // exact due date boundary (amendment-gated: Exclusive once
+    // fixCleanup3_4_0 is enabled, Inclusive otherwise).
+    if (!isPaymentLate(view, loan))
         return std::unexpected(tecTOO_SOON);
 
     // Calculate the penalty interest based on how long the payment is overdue.
@@ -1128,7 +1141,7 @@ computeLatePayment(
 std::expected
 computeFullPayment(
     Asset const& asset,
-    ApplyView& view,
+    ReadView const& view,
     SLE::const_ref loan,
     Number const& periodicRate,
     STAmount const& amount,
@@ -2270,7 +2283,7 @@ loanMakePayment(
 
     // -------------------------------------------------------------
     // A late payment not flagged as late overrides all other options.
-    if (paymentType != LoanPaymentType::Late && hasExpired(view, nextDueDateProxy))
+    if (paymentType != LoanPaymentType::Late && isPaymentLate(view, loan))
     {
         // If the payment is late, and the late flag was not set, it's not
         // valid
diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp
index a312dba3b3..2d710ceebe 100644
--- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp
@@ -104,7 +104,11 @@ LoanManage::preclaim(PreclaimContext const& ctx)
         return tecNO_PERMISSION;
     }
     if (tx.isFlag(tfLoanDefault) &&
-        !hasExpired(ctx.view, loanSle->at(sfNextPaymentDueDate) + loanSle->at(sfGracePeriod)))
+        !hasExpired(
+            ctx.view,
+            loanSle->at(sfNextPaymentDueDate) + loanSle->at(sfGracePeriod),
+            ctx.view.rules().enabled(fixCleanup3_4_0) ? ExpiryComparison::Exclusive
+                                                      : ExpiryComparison::Inclusive))
     {
         JLOG(ctx.j.warn()) << "A loan can not be defaulted before the next payment due date.";
         return tecTOO_SOON;
@@ -287,6 +291,14 @@ LoanManage::impairLoan(
     Asset const& vaultAsset,
     beast::Journal j)
 {
+    bool const fixEnabled340 = view.rules().enabled(fixCleanup3_4_0);
+
+    if (fixEnabled340 && !isPaymentLate(view, loanSle))
+    {
+        JLOG(j.warn()) << "Cannot impair a loan that is not late";
+        return tecTOO_SOON;
+    }
+
     Number const lossUnrealized = loanVaultExposure(vaultSle, loanSle);
 
     // The vault may be at a different scale than the loan. Reduce rounding
@@ -301,20 +313,22 @@ LoanManage::impairLoan(
     {
         // Having a loss greater than the vault's unavailable assets
         // will leave the vault in an invalid / inconsistent state.
-        JLOG(j.warn()) << "Vault unrealized loss is too large, and will "
-                          "corrupt the vault.";
+        JLOG(j.warn()) << "Vault unrealized loss is too large, and will corrupt the vault.";
         return tecLIMIT_EXCEEDED;
     }
     view.update(vaultSle);
 
     // Update the Loan object
     loanSle->setFlag(lsfLoanImpaired);
-    auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate);
-    if (!hasExpired(view, loanNextDueProxy))
+
+    if (!fixEnabled340)
     {
-        // loan payment is not yet late -
-        // move the next payment due date to now
-        loanNextDueProxy = view.parentCloseTime().time_since_epoch().count();
+        auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate);
+        if (!isPaymentLate(view, loanSle))
+        {
+            // loan payment is not yet late move the next payment due date to now
+            loanNextDueProxy = view.parentCloseTime().time_since_epoch().count();
+        }
     }
     view.update(loanSle);
 
@@ -351,19 +365,24 @@ LoanManage::unimpairLoan(
 
     // Update the Loan object
     loanSle->clearFlag(lsfLoanImpaired);
-    auto const paymentInterval = loanSle->at(sfPaymentInterval);
-    auto const normalPaymentDueDate =
-        std::max(loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) + paymentInterval;
-    if (!hasExpired(view, normalPaymentDueDate))
+    if (!view.rules().enabled(fixCleanup3_4_0))
     {
-        // loan was unimpaired within the payment interval
-        loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate;
-    }
-    else
-    {
-        // loan was unimpaired after the original payment due date
-        loanSle->at(sfNextPaymentDueDate) =
-            view.parentCloseTime().time_since_epoch().count() + paymentInterval;
+        auto const paymentInterval = loanSle->at(sfPaymentInterval);
+        auto const normalPaymentDueDate =
+            std::max(loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) +
+            paymentInterval;
+
+        if (!hasExpired(view, normalPaymentDueDate))
+        {
+            // loan was unimpaired within the payment interval
+            loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate;
+        }
+        else
+        {
+            // loan was unimpaired after the original payment due date
+            loanSle->at(sfNextPaymentDueDate) =
+                view.parentCloseTime().time_since_epoch().count() + paymentInterval;
+        }
     }
     view.update(loanSle);
 
diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
index 6e3487ec8e..18886b2682 100644
--- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
@@ -7,7 +7,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -134,10 +133,13 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx)
         return normalCost;
     }
 
-    if (hasExpired(view, loanSle->at(sfNextPaymentDueDate)))
+    if (isPaymentLate(view, loanSle))
     {
         // If the payment is late, and the late payment flag is not set, it'll
-        // fail
+        // fail. Uses isPaymentLate() so the fee matches apply at the exact
+        // NextPaymentDueDate boundary (Exclusive once fixCleanup3_4_0 is
+        // enabled): a catch-up at that instant can still process up to
+        // kLoanMaximumPaymentsPerTransaction payments.
         return normalCost;
     }
 
diff --git a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
index f3a587d5a4..35bf80c29f 100644
--- a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
@@ -34,6 +34,7 @@ VaultDelete::preflight(PreflightContext const& ctx)
     if (ctx.tx.isFieldPresent(sfMemoData) && !ctx.rules.enabled(featureLendingProtocolV1_1))
         return temDISABLED;
 
+    // The sfMemoData field is an optional field used to record the deletion reason.
     if (!validDataLength(ctx.tx[~sfMemoData], kMaxDataPayloadLength))
         return temMALFORMED;
 
diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp
index 5b2511626e..4b6002580b 100644
--- a/src/test/app/invariants/InvariantsVault_test.cpp
+++ b/src/test/app/invariants/InvariantsVault_test.cpp
@@ -2029,6 +2029,18 @@ class InvariantsVault_test : public InvariantsBase
                 Fee(env.current()->fees().base * 200));
             env.close();
 
+            // Under fixCleanup3_4_0 impair requires the payment to already
+            // be late, so advance past the loan's due date first.
+            if (env.current()->rules().enabled(fixCleanup3_4_0))
+            {
+                auto const loanSle = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loanSle))
+                    return vaultKeylet;
+                std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
+                env.close(
+                    NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+            }
+
             env(manage(owner, loanKeylet.key, tfLoanImpair));
             env.close();
         }
diff --git a/src/test/app/lending/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp
index 11053b6fd0..a3ca28437d 100644
--- a/src/test/app/lending/LoanCashBasis_test.cpp
+++ b/src/test/app/lending/LoanCashBasis_test.cpp
@@ -562,6 +562,7 @@ private:
             BEAST_EXPECT(vaultBeforeImpair);
             Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized);
 
+            advancePastDueDate(env, loanKeylet);
             env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
 
@@ -612,6 +613,7 @@ private:
                 ? principalOutstanding
                 : totalValueOutstanding - managementFeeOutstanding;
 
+            advancePastDueDate(env, loanKeylet);
             env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
 
@@ -822,12 +824,17 @@ private:
         Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding);
         Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair;
 
+        // With fixCleanup3_4_0, impairment is only allowed once the
+        // payment is late. After the earlier LoanPay the due date advanced by
+        // one interval, so use the current due date rather than startDate.
+        std::uint32_t const dueDateBeforeImpair = loanBeforeImpair->at(sfNextPaymentDueDate);
+        env.close(NetClock::time_point{NetClock::duration{dueDateBeforeImpair}} + 1s);
+
         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
         env.close();
 
-        LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet);
         env.close(
-            stateAtImpair.startDate + std::chrono::seconds(paymentInterval) +
+            NetClock::time_point{NetClock::duration{dueDateBeforeImpair}} +
             std::chrono::seconds(gracePeriod) + 60s);
 
         auto const vaultBeforeDefault = env.le(broker.vaultKeylet());
diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
index b0c43190c5..f8bdb5a3d7 100644
--- a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
+++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
@@ -238,6 +238,9 @@ private:
             Ter(tesSUCCESS));
         env.close();
 
+        // Under fixCleanup3_4_0 impair requires the payment to be late.
+        advancePastDueDate(env, loanKeylet);
+
         // Impair the loan to create unrealized loss
         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
         env.close();
@@ -461,6 +464,7 @@ private:
         auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
 
         // Realize a loss via impairment before locking.
+        advancePastDueDate(env, loanKeylet);
         env(manage(lender, loanKeylet.key, tfLoanImpair));
         env.close();
 
diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp
index ce08e71932..038ef4067b 100644
--- a/src/test/app/lending/LoanPay_test.cpp
+++ b/src/test/app/lending/LoanPay_test.cpp
@@ -13,8 +13,11 @@
 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -33,6 +36,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -1031,10 +1035,399 @@ private:
             borrowerAfter + vaultAfter + lenderAfter);
     }
 
+    // Env::close() cannot land the ledger's parentCloseTime on an arbitrary
+    // instant: it always rounds the requested time forward to the next
+    // close-time-resolution boundary (see Env::close() and
+    // roundCloseTime()/effCloseTime() in LedgerTiming.h), so it can only be
+    // used to reach times strictly *after* a given due date, never exactly
+    // on it. To pin the exact-boundary behavior of isPaymentLate(), directly
+    // overwrite the loan's NextPaymentDueDate so that it matches the
+    // *current* (already fixed) parentCloseTime of the open ledger, without
+    // closing again. This exercises the same comparison
+    // (parentCloseTime vs. NextPaymentDueDate) at the exact boundary that
+    // env.close() cannot reliably reach.
+    void
+    setLoanNextPaymentDueDate(jtx::Env& env, Keylet const& loanKeylet, std::uint32_t dueDate)
+    {
+        using namespace jtx;
+        bool const ok = env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+            auto const sle = view.read(loanKeylet);
+            if (!sle)
+                return false;
+            auto replacement = std::make_shared(*sle);
+            (*replacement)[sfNextPaymentDueDate] = dueDate;
+            view.rawReplace(replacement);
+            return true;
+        });
+        BEAST_EXPECT(ok);
+    }
+
+    // With fixCleanup3_4_0, isPaymentLate() uses a strict (Exclusive)
+    // comparison: a payment due exactly "now" is not yet late. A plain
+    // (non-late) LoanPay submitted at the exact NextPaymentDueDate instant
+    // must therefore succeed, advance the due date by exactly one
+    // PaymentInterval, and charge only the regular periodic payment amount
+    // (no late interest / late fee).
+    void
+    testLoanPayAtExactDueDateSucceedsPostAmendment()
+    {
+        testcase("LoanPay at exact due date succeeds with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        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)));
+
+        // Set a large, non-zero late interest rate and late fee so that if
+        // the late-payment path were incorrectly taken, the extra charge
+        // would be large and easy to detect (far more than any rounding
+        // slack in the regular periodic payment amount).
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLateInterestRate(TenthBips32(percentageToTenthBips(24))),
+            kLatePaymentFee(asset(50).value()),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        STAmount const roundedPeriodicPayment{
+            asset, roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale)};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        STAmount const payFee{env.current()->fees().base};
+        auto const borrowerBefore = env.balance(borrower, asset).number();
+
+        // A plain payment (no tfLoanLatePayment) for exactly the regular
+        // periodic amount must succeed: at this instant the payment is not
+        // yet late.
+        //
+        // Note: deliberately not calling env.close() after this: closing
+        // the ledger re-derives the resulting state from the last validated
+        // ledger plus the recorded transaction set, which would discard the
+        // direct NextPaymentDueDate override made above via rawReplace().
+        // Reading state from the still-open ledger (as env.le()/env.balance()
+        // do) reflects the transaction as it was actually applied.
+        env(pay(borrower, loanKeylet.key, roundedPeriodicPayment), Fee(payFee), Ter(tesSUCCESS));
+
+        auto const borrowerAfter = env.balance(borrower, asset).number();
+
+        // No more than the regular periodic amount (plus the transaction
+        // fee) was charged: if the late-payment path had wrongly been
+        // taken, the (large, non-zero) late interest and late fee set above
+        // would have pushed the charge well past this bound.
+        Number const charged = borrowerBefore - borrowerAfter - Number{payFee};
+        BEAST_EXPECT(charged > Number{});
+        BEAST_EXPECT(charged <= Number{roundedPeriodicPayment});
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining - 1);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate + stateBefore.paymentInterval);
+    }
+
+    // Pins the amendment gate itself (as opposed to
+    // testLoanPayAtExactDueDateSucceedsPostAmendment, which pins the
+    // comparison operator): without fixCleanup3_4_0, isPaymentLate() keeps
+    // using the pre-amendment Inclusive comparison, so a payment due exactly
+    // "now" is already considered late, and a plain (non-late) LoanPay is
+    // rejected.
+    void
+    testLoanPayAtExactDueDateFailsPreAmendment()
+    {
+        testcase("LoanPay at exact due date fails without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        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)));
+
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        STAmount const roundedPeriodicPayment{
+            asset, roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale)};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // Without the amendment, the due date is already considered late at
+        // this exact instant, so a plain payment must be rejected.
+        //
+        // Note: deliberately not calling env.close() after this: closing
+        // the ledger re-derives the resulting state from the last validated
+        // ledger plus the recorded transaction set, which would discard the
+        // direct NextPaymentDueDate override made above via rawReplace().
+        // Reading state from the still-open ledger (as env.le() does)
+        // reflects the transaction as it was actually applied.
+        env(pay(borrower, loanKeylet.key, roundedPeriodicPayment), Ter(tecEXPIRED));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
+    // computeLatePayment() must agree with isPaymentLate() at the exact
+    // due-date boundary: once fixCleanup3_4_0 is enabled, a payment due
+    // exactly "now" is not yet late, so a tfLoanLatePayment submitted at
+    // that same instant must be rejected with tecTOO_SOON rather than being
+    // admitted and charged the late interest/fee.
+    void
+    testLoanLatePaymentAtExactDueDateRejectedPostAmendment()
+    {
+        testcase("LoanPay(tfLoanLatePayment) at exact due date rejected with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        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)));
+
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLateInterestRate(TenthBips32(percentageToTenthBips(24))),
+            kLatePaymentFee(asset(50).value()),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        // Overpay generously so that, if the late-payment path were
+        // incorrectly admitted, funds would not be the limiting factor;
+        // we want to isolate the timing check itself.
+        STAmount const generousAmount{
+            asset,
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) * 2};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // At this exact instant the loan is not yet late (Exclusive
+        // comparison), so even an explicit late payment must be rejected
+        // as premature, matching the plain-payment path.
+        //
+        // Note: deliberately not calling env.close() after this, for the
+        // same reason given in testLoanPayAtExactDueDateSucceedsPostAmendment
+        // above: closing would discard the direct NextPaymentDueDate
+        // override made via rawReplace().
+        env(pay(borrower, loanKeylet.key, generousAmount, tfLoanLatePayment), Ter(tecTOO_SOON));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
+    // calculateBaseFee must use isPaymentLate(), not a raw inclusive
+    // hasExpired(): once fixCleanup3_4_0 is enabled, a plain catch-up at
+    // exactly NextPaymentDueDate succeeds and can process many payments, so
+    // the fee has to scale with that work. Charging a single base fee here
+    // would disagree with apply (and with the fixCleanup3_1_3 cap).
+    void
+    testLoanPayCatchUpFeeAtExactDueDatePostAmendment()
+    {
+        testcase("LoanPay catch-up fee at exact due date with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace lending;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        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)));
+
+        env(set(borrower, broker.brokerID, asset(10'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(50),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 50);
+        BEAST_EXPECT(stateBefore.paymentRemaining > kLoanPaymentsPerFeeIncrement);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        Number const regularPayment =
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) +
+            loanSle->at(sfLoanServiceFee);
+        int const payCount = kLoanPaymentsPerFeeIncrement * 4;
+        STAmount const catchUp{asset, regularPayment * payCount};
+        XRPAmount const baseFee = env.current()->fees().base;
+        XRPAmount const escalatedFee{baseFee * (payCount / kLoanPaymentsPerFeeIncrement)};
+
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // Under-fee: apply would process `payCount` payments, so a single
+        // base fee is not enough.
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(baseFee), Ter(telINSUF_FEE_P));
+
+        // Same catch-up with the scaled fee must succeed at this instant.
+        // Do not env.close() after the SLE override (see
+        // testLoanPayAtExactDueDateSucceedsPostAmendment).
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(escalatedFee), Ter(tesSUCCESS));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining - payCount);
+    }
+
+    // Without the amendment, inclusive hasExpired still treats the exact
+    // due-date instant as late, so calculateBaseFee correctly charges a
+    // single base fee and apply rejects a plain LoanPay with tecEXPIRED.
+    void
+    testLoanPayCatchUpFeeAtExactDueDatePreAmendment()
+    {
+        testcase("LoanPay catch-up fee at exact due date without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace lending;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        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)));
+
+        env(set(borrower, broker.brokerID, asset(10'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(50),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 50);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        Number const regularPayment =
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) +
+            loanSle->at(sfLoanServiceFee);
+        int const payCount = kLoanPaymentsPerFeeIncrement * 4;
+        STAmount const catchUp{asset, regularPayment * payCount};
+        XRPAmount const baseFee = env.current()->fees().base;
+
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(baseFee), Ter(tecEXPIRED));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
     void
     runAmendmentIndependent()
     {
         testLoanSetNearZeroInterestRateSucceeds();
+        testLoanPayAtExactDueDateSucceedsPostAmendment();
+        testLoanPayAtExactDueDateFailsPreAmendment();
+        testLoanLatePaymentAtExactDueDateRejectedPostAmendment();
+        testLoanPayCatchUpFeeAtExactDueDatePostAmendment();
+        testLoanPayCatchUpFeeAtExactDueDatePreAmendment();
         testRepayIntoUnauthorizedVault();
     }
 
diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp
index b666281fee..ded1c816a2 100644
--- a/src/test/app/lending/LoanRounding_test.cpp
+++ b/src/test/app/lending/LoanRounding_test.cpp
@@ -948,6 +948,7 @@ private:
         env.close();
 
         // Impair the loan so LossUnrealized > 0.
+        advancePastDueDate(env, loanKeylet);
         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
         env.close();
 
@@ -1017,6 +1018,7 @@ private:
                 Ter(tesSUCCESS));
             env.close();
 
+            advancePastDueDate(env, iouLoanKeylet);
             env(manage(iouLender, iouLoanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
 
diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp
index b878547a70..463273e227 100644
--- a/src/test/app/lending/LoanSecurity_test.cpp
+++ b/src/test/app/lending/LoanSecurity_test.cpp
@@ -5,27 +5,36 @@
 #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 
+#include 
 #include 
 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -33,6 +42,30 @@ namespace xrpl::test {
 class LoanSecurity_test : public LoanTestBase
 {
 private:
+    // Env::close() cannot land the ledger's parentCloseTime on an arbitrary
+    // instant: it always rounds the requested time forward to the next
+    // close-time-resolution boundary (see Env::close() and
+    // roundCloseTime()/effCloseTime() in LedgerTiming.h), so it can only be
+    // used to reach times strictly *after* a given due date, never exactly
+    // on it. To pin the exact-boundary behavior of isPaymentLate(), directly
+    // overwrite the loan's NextPaymentDueDate instead, without closing the
+    // ledger again.
+    void
+    setLoanNextPaymentDueDate(jtx::Env& env, Keylet const& loanKeylet, std::uint32_t dueDate)
+    {
+        using namespace jtx;
+        bool const ok = env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+            auto const sle = view.read(loanKeylet);
+            if (!sle)
+                return false;
+            auto replacement = std::make_shared(*sle);
+            (*replacement)[sfNextPaymentDueDate] = dueDate;
+            view.rawReplace(replacement);
+            return true;
+        });
+        BEAST_EXPECT(ok);
+    }
+
     void
     testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features)
     {
@@ -516,10 +549,557 @@ private:
             PaymentParameters{.showStepBalances = true});
     }
 
+    // Verify that with fixCleanup3_4_0:
+    // 1. A loan cannot be impaired before its payment is late.
+    // 2. Impairing a late loan does not change sfNextPaymentDueDate.
+    // 3. The unimpair operation does not change sfNextPaymentDueDate.
+    void
+    testImpairmentPaymentDateUnchanged()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impairment does not change payment due date");
+
+        Env env(*this, all_ | fixCleanup3_4_0);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        // 1. Impairment must fail when payment is not yet late
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        // 1b. Impairment must still fail at the exact due date instant: a
+        // payment due "now" is not yet late (strict/Exclusive comparison).
+        // Temporarily set NextPaymentDueDate to exactly the current
+        // parentCloseTime (without closing the ledger again), exercise the
+        // check, then restore the original due date.
+        {
+            std::uint32_t const exactNow =
+                env.current()->parentCloseTime().time_since_epoch().count();
+            setLoanNextPaymentDueDate(env, loanKeylet, exactNow);
+
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+
+            setLoanNextPaymentDueDate(env, loanKeylet, originalNextDueDate);
+        }
+
+        {
+            auto const loan = env.le(loanKeylet);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+
+        // 2. Impairment succeeds when payment is late
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        // 3. Unimpair also does not change sfNextPaymentDueDate
+        env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+    }
+
+    // Verify that without fixCleanup3_4_0, the pre-amendment
+    // impair/unimpair behaviour is preserved:
+    // 1. Impairing a loan before its payment is late moves
+    //    sfNextPaymentDueDate to "now".
+    // 2a. Unimpair within the original payment interval restores
+    //     sfNextPaymentDueDate to StartDate + PaymentInterval.
+    // 2b. Unimpair after the original due date sets
+    //     sfNextPaymentDueDate to now + PaymentInterval.
+    void
+    testImpairmentPaymentDatePreAmendment()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Pre-amendment impair/unimpair date restoration");
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        Number const principalRequest{1, 3};
+        auto createNewLoan = [&]() {
+            auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(sleBroker))
+                return keylet::loan(uint256{});
+            auto const lk =
+                keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+            env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+                Sig(sfCounterpartySignature, lender),
+                kPaymentTotal(12),
+                kPaymentInterval(600),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+            return lk;
+        };
+
+        // Default + delete a loan and replenish first-loss capital so the
+        // broker is ready for the next loan.
+        auto cleanupLoan = [&](Keylet const& loanKeylet, std::uint32_t dueDate) {
+            env.close(NetClock::time_point{NetClock::duration{dueDate + 60}} + 1s);
+            env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+            env.close();
+
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(brokerSle))
+                return;
+            auto const coverNeeded =
+                broker.asset(broker.params.coverDeposit).value() - brokerSle->at(sfCoverAvailable);
+            if (coverNeeded > 0)
+            {
+                env(loan_broker::coverDeposit(
+                    lender, broker.brokerID, STAmount{broker.asset, coverNeeded}));
+                env.close();
+            }
+            env(del(lender, loanKeylet.key));
+            env.close();
+        };
+
+        // ---- Case A: impair before late, unimpair within original interval ----
+        {
+            auto const loanKeylet = createNewLoan();
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return;
+            std::uint32_t const startDate = loanSle->at(sfStartDate);
+            std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+            BEAST_EXPECT(originalNextDueDate == startDate + 600);
+
+            // Payment is not late yet - impair succeeds and moves due date
+            // to now (pre-amendment allows immediate impairment)
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+                std::uint32_t const movedDueDate = loan->at(sfNextPaymentDueDate);
+                BEAST_EXPECT(movedDueDate != originalNextDueDate);
+                BEAST_EXPECT(movedDueDate < originalNextDueDate);
+            }
+
+            // Unimpair while still within the original payment interval. The
+            // normal due date (startDate + 600) has not yet expired, so it
+            // should be restored.
+            env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+                BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+            }
+
+            cleanupLoan(loanKeylet, originalNextDueDate);
+        }
+
+        // ---- Case B: impair before late, unimpair after original due date ----
+        {
+            auto const loanKeylet = createNewLoan();
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return;
+            std::uint32_t const startDate = loanSle->at(sfStartDate);
+            std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+            BEAST_EXPECT(originalNextDueDate == startDate + 600);
+
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+            env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 10s);
+
+            auto const timeBeforeUnimpair =
+                env.current()->header().parentCloseTime.time_since_epoch().count();
+
+            env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+                std::uint32_t const newDueDate = loan->at(sfNextPaymentDueDate);
+                BEAST_EXPECT(newDueDate > originalNextDueDate);
+                BEAST_EXPECT(newDueDate == timeBeforeUnimpair + 600);
+            }
+        }
+    }
+
+    // FN-68: a borrower must not be able to bypass late-payment charges by
+    // paying an impaired, overdue loan with a plain LoanPay. Under
+    // fixCleanup3_4_0 impairment no longer moves the due date, so
+    // the payment logic sees the real (overdue) date: a regular payment is
+    // rejected with tecEXPIRED, and only a tfLoanLatePayment (which charges
+    // the late fee + late interest) is accepted.
+    void
+    testImpairedOverdueLoanPayRequiresLateFlag()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impaired overdue LoanPay requires late-payment flag");
+
+        Env env(*this, all_ | fixCleanup3_4_0);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        // Loan with non-zero late-payment terms, so the late path carries a
+        // real penalty that the exploit would otherwise avoid.
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLatePaymentFee(broker.asset(3).number()),
+            kLateInterestRate(TenthBips32{30322}),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        std::uint32_t const paymentsBefore = loanSle->at(sfPaymentRemaining);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        // Advance past the due date so the loan is overdue, then impair it
+        // (impairment is only allowed once the payment is late).
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        auto const payAmount = broker.asset(500).value();
+
+        // The exploit: a plain LoanPay (Flags = 0) on an impaired, overdue
+        // loan must be rejected. Before FN-9 the auto-unimpair pushed the due
+        // date into the future and this returned tesSUCCESS, letting the
+        // borrower skip the late fee and late interest.
+        env(pay(borrower, loanKeylet.key, payAmount), Ter(tecEXPIRED));
+        env.close();
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        env(pay(borrower, loanKeylet.key, payAmount, tfLoanLatePayment), Ter(tesSUCCESS));
+        env.close();
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore - 1);
+        }
+
+        {
+            auto const vaultSle = env.le(broker.vaultKeylet());
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
+        }
+    }
+
+    // FN-68 (pre-amendment): documents the original vulnerability. Without
+    // fixCleanup3_4_0, impairing moves the due date and LoanPay
+    // auto-unimpair pushes it into the future before the late check, so a
+    // plain (Flags = 0) LoanPay on an impaired, overdue loan is accepted as
+    // on-time (tesSUCCESS) and the borrower dodges the late-payment charges.
+    // This is what testImpairedOverdueLoanPayRequiresLateFlag closes once the
+    // amendment is enabled.
+    void
+    testImpairedOverdueLoanPayBypassPreAmendment()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impaired overdue LoanPay bypass (pre-amendment)");
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLatePaymentFee(broker.asset(3).number()),
+            kLateInterestRate(TenthBips32{30322}),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+        }
+
+        auto const payAmount = broker.asset(500).value();
+
+        // The bug: a plain LoanPay is accepted as on-time and clears the
+        // loan's impaired flag, so the late fee / late interest are never
+        // charged.
+        env(pay(borrower, loanKeylet.key, payAmount), Ter(tesSUCCESS));
+        env.close();
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+        }
+    }
+
+    // Default uses NextPaymentDueDate + GracePeriod. Once fixCleanup3_4_0
+    // is enabled, that gate is Exclusive, matching impair/isPaymentLate:
+    // default is allowed only after grace has passed, not at the instant
+    // it expires.
+    void
+    testLoanDefaultAtExactGraceExpiryRejectedPostAmendment()
+    {
+        testcase("LoanManage default at exact grace expiry rejected with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, broker.asset(Number{1, 3}).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kGracePeriod(60),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        // Advance far enough that parentCloseTime > GracePeriod, so
+        // (now - grace) cannot underflow when pinning the exact expiry.
+        env.close(env.now() + 1000s);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        auto const grace = loanSle->at(sfGracePeriod);
+        std::uint32_t const now = env.current()->parentCloseTime().time_since_epoch().count();
+        BEAST_EXPECT(now > grace + 1);
+
+        // parentCloseTime == NextPaymentDueDate + GracePeriod: grace expires
+        // this instant, so default must still be too soon.
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanDefault));
+        }
+
+        // One second after grace expires, default succeeds.
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace - 1);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanDefault));
+        }
+    }
+
+    void
+    testLoanDefaultAtExactGraceExpirySucceedsPreAmendment()
+    {
+        testcase("LoanManage default at exact grace expiry succeeds without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, broker.asset(Number{1, 3}).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kGracePeriod(60),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        env.close(env.now() + 1000s);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        auto const grace = loanSle->at(sfGracePeriod);
+        std::uint32_t const now = env.current()->parentCloseTime().time_since_epoch().count();
+        BEAST_EXPECT(now > grace);
+
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanDefault));
+        }
+    }
+
     void
     runAmendmentIndependent()
     {
         testRIPD3901();
+        testImpairmentPaymentDateUnchanged();
+        testImpairmentPaymentDatePreAmendment();
+        testImpairedOverdueLoanPayRequiresLateFlag();
+        testImpairedOverdueLoanPayBypassPreAmendment();
+        testLoanDefaultAtExactGraceExpiryRejectedPostAmendment();
+        testLoanDefaultAtExactGraceExpirySucceedsPreAmendment();
     }
 
     // Tests run under each entry in amendmentCombinations().
diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h
index 99b387938e..c9d4a3185b 100644
--- a/src/test/app/lending/LoanTestBase.h
+++ b/src/test/app/lending/LoanTestBase.h
@@ -674,6 +674,23 @@ protected:
         return true;
     }
 
+    // Under fixCleanup3_4_0, LoanManage rejects tfLoanImpair with tecTOO_SOON
+    // unless the loan payment is already late. Advance the ledger past the
+    // loan's sfNextPaymentDueDate so shared lifecycle flows still exercise
+    // the tesSUCCESS branch when the amendment is active. No-op when the
+    // amendment is disabled.
+    void
+    advancePastDueDate(jtx::Env& env, Keylet const& loanKeylet)
+    {
+        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+            return;
+        auto const loan = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loan))
+            return;
+        std::uint32_t const dueDate = loan->at(sfNextPaymentDueDate);
+        env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+    }
+
     enum class AssetType { XRP = 0, IOU = 1, MPT = 2 };
 
     // Specify the accounts as params to allow other accounts to be used
@@ -1592,12 +1609,30 @@ protected:
 
         // Check the vault
         bool const canImpair = canImpairLoan(env, broker, state);
-        // Impair the loan, if possible
-        env(manage(lender, keylet.key, tfLoanImpair),
-            canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
-        // Unimpair the loan
-        env(manage(lender, keylet.key, tfLoanUnimpair),
-            canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION));
+        // Under fixCleanup3_4_0, impair rejects a not-yet-late loan with
+        // tecTOO_SOON. Advancing time to satisfy the gate here would push
+        // the loan into a "late" state and break the toEndOfLife flows
+        // (singlePayment/fullPayment) that expect a fresh loan without the
+        // tfLoanLatePayment flag. The tesSUCCESS/tecLIMIT_EXCEEDED impair
+        // path is already covered under fixCleanup3_4_0 by dedicated tests
+        // in LoanSecurity_test.cpp and LoanCashBasis_test.cpp.
+        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+        {
+            // Impair the loan, if possible
+            env(manage(lender, keylet.key, tfLoanImpair),
+                canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
+            // Unimpair the loan
+            env(manage(lender, keylet.key, tfLoanUnimpair),
+                canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION));
+        }
+        else
+        {
+            // With the fix on, a not-yet-late loan can never be impaired
+            // (tecTOO_SOON) and the follow-up unimpair on an unimpaired
+            // loan is still tecNO_PERMISSION.
+            env(manage(lender, keylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+            env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION));
+        }
 
         auto const nextDueDate = startDate + *loanParams.payInterval;
 
@@ -2188,6 +2223,11 @@ protected:
                 {
                     // Check the vault
                     bool const canImpair = canImpairLoan(env, broker, state);
+                    // Under fixCleanup3_4_0 impair requires the payment to
+                    // already be late. Advance past the loan's next due
+                    // date so this exercises the tesSUCCESS branch. No-op
+                    // when the fix is disabled.
+                    advancePastDueDate(env, loanKeylet);
                     // Impair the loan, if possible
                     env(manage(lender, loanKeylet.key, tfLoanImpair),
                         canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
@@ -2195,7 +2235,11 @@ protected:
                     if (canImpair)
                     {
                         state.flags |= tfLoanImpair;
-                        state.nextPaymentDate = env.now().time_since_epoch().count();
+                        // Prior to fixCleanup3_4_0 impair rewrote
+                        // sfNextPaymentDueDate to parentCloseTime. Under the
+                        // fix, the due date is preserved.
+                        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+                            state.nextPaymentDate = env.now().time_since_epoch().count();
 
                         // Once the loan is impaired, it can't be impaired again
                         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
@@ -2815,7 +2859,17 @@ protected:
 
                     auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
 
-                    if (canImpairLoan(env, broker, state))
+                    // Under fixCleanup3_4_0 impair requires the payment to
+                    // already be late. This periodic-payment loop stays
+                    // within each payment interval, so the loan is never
+                    // late here; skip the impair rather than perturb the
+                    // payment schedule.
+                    auto const loanSle = env.le(loanKeylet);
+                    bool const impairAllowed = BEAST_EXPECT(loanSle) &&
+                        canImpairLoan(env, broker, state) &&
+                        (!env.current()->rules().enabled(fixCleanup3_4_0) ||
+                         isPaymentLate(*env.current(), loanSle));
+                    if (impairAllowed)
                     {
                         // Making a payment will unimpair the loan
                         env(manage(lender, loanKeylet.key, tfLoanImpair));
diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp
index ebbef70f40..169a02c462 100644
--- a/src/test/app/lending/LoanValidation_test.cpp
+++ b/src/test/app/lending/LoanValidation_test.cpp
@@ -345,7 +345,12 @@ private:
         env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze));
         env.close();
 
-        // The payment is late by this point
+        // The payment is late by this point. With fixCleanup3_4_0,
+        // isPaymentLate() uses a strict (Exclusive) comparison, so advance
+        // one more ledger close to be sure the due date instant itself has
+        // passed, not merely reached.
+        env.close();
+
         env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED));
         env.close();
         env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment));
diff --git a/src/test/app/vault/VaultPrecisionFixture.h b/src/test/app/vault/VaultPrecisionFixture.h
index c324161347..d1067e245d 100644
--- a/src/test/app/vault/VaultPrecisionFixture.h
+++ b/src/test/app/vault/VaultPrecisionFixture.h
@@ -13,7 +13,9 @@
 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -224,17 +226,29 @@ protected:
         // Loan 2: sibling loan of principal 11.
         f.loan2Keylet = setLoan(Number{11});
 
-        // Impair loan 1 → drives sfLossUnrealized to loan 1's value.
-        env(jtx::loan::manage(f.lender, f.loan1Keylet.key, tfLoanImpair), bigFee);
-        env.close();
-
         // Pay off loan 2 in full so its total value flows into the vault
         // and pushes T-A upward, meeting the residual loss.  Generous
         // upper bound; the transactor takes only what is due.
+        //
+        // This happens before the impair below because impair under
+        // fixCleanup3_4_0 requires loan 1 to already be late, and the two
+        // loans are originated close enough together that advancing past
+        // loan 1's due date also makes loan 2 late — which would reject
+        // this full payment with tecEXPIRED.
         auto const payoff = asset(Number{50}).value();
         env(pay(f.borrower, f.loan2Keylet.key, payoff, tfLoanFullPayment), bigFee);
         env.close();
 
+        // Impair loan 1 → drives sfLossUnrealized to loan 1's value.
+        if (env.current()->rules().enabled(fixCleanup3_4_0))
+        {
+            std::uint32_t const dueDate = env.le(f.loan1Keylet)->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+        }
+
+        env(jtx::loan::manage(f.lender, f.loan1Keylet.key, tfLoanImpair), bigFee);
+        env.close();
+
         return f;
     }
 };
diff --git a/src/test/app/vault/VaultSoleShareholder_test.cpp b/src/test/app/vault/VaultSoleShareholder_test.cpp
index 62cbc28bcd..92d5dd04d4 100644
--- a/src/test/app/vault/VaultSoleShareholder_test.cpp
+++ b/src/test/app/vault/VaultSoleShareholder_test.cpp
@@ -13,6 +13,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -133,6 +134,7 @@ private:
 
         {
             using namespace loan;
+            using namespace std::chrono_literals;
             env(set(f.borrower, f.brokerID, kStuckPrincipal),
                 Sig(sfCounterpartySignature, f.lender),
                 kPaymentTotal(kStuckPayTotal),
@@ -140,6 +142,15 @@ private:
                 Fee(env.current()->fees().base * 2),
                 Ter(tesSUCCESS));
             env.close();
+
+            // Impairment requires the payment to be late, so advance past
+            // the due date before impairing.
+            auto const loanSle = env.le(*f.loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return f;
+            std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + 1s);
+
             env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
         }
@@ -592,7 +603,14 @@ private:
         BEAST_EXPECT(retainedShares == f.sharesLender - 750'018'750);
 
         // Borrower repays the loan in full (pays more than the outstanding
-        // total; the loan transactor caps the receivable).
+        // total each time; the loan transactor caps the receivable). The
+        // loan is still overdue from the impairment setup, so the first
+        // (and only remaining, since kStuckPayTotal == 2) outstanding
+        // installment must be caught up with a late payment before the
+        // final regular payment can close the loan out.
+        env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2), tfLoanLatePayment),
+            Ter(tesSUCCESS));
+        env.close();
         env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
         env.close();
 

From 42502e4263f7011158264ddcdaed29c2e969ef53 Mon Sep 17 00:00:00 2001
From: Sergey Kuznetsov 
Date: Wed, 26 Aug 2026 13:42:19 +0000
Subject: [PATCH 33/43] 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 34/43] 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 35/43] 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 36/43] 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 37/43] 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 38/43] 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 39/43] 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 40/43] 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 41/43] 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 42/43] 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 43/43] 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;