diff --git a/.codecov.yml b/.codecov.yml index cd52e2604d..4268758e44 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,10 +1,32 @@ codecov: require_ci_to_pass: true + # The C++ and Rust uploads land minutes apart; without this gate Codecov + # publishes a near-zero total from whichever one arrives first. + notify: + after_n_builds: 2 + wait_for_ci: true comment: behavior: default layout: reach,diff,flags,tree,reach - show_carryforward_flags: false + show_carryforward_flags: true + after_n_builds: 2 + +# C++ and Rust coverage upload from independent workflows under the `cpp` and +# `rust` flags; carryforward keeps one language's total when only the other reran. +flag_management: + default_rules: + carryforward: true + individual_flags: + - name: cpp + carryforward: true + paths: + - include/ + - src/ + - name: rust + carryforward: true + paths: + - crates/ coverage: range: "70..85" diff --git a/.cspell.config.yaml b/.cspell.config.yaml index 21b0145f43..e8c5f3c30f 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -7,6 +7,7 @@ ignorePaths: - cmake/** - LICENSE.md - .clang-tidy + - nix/check-tools/*.txt # generated, and full of Nix store hashes language: en allowCompoundWords: true # TODO (#6334) ignoreRandomStrings: true @@ -68,6 +69,7 @@ words: - Buildx - canonicality - canonicalised + - cctools - changespq - checkme - choco @@ -109,6 +111,7 @@ words: - disablerepo - distro - doxyfile + - dsymutil - dxrpl - elgamal - enabled @@ -167,6 +170,7 @@ words: - LOCALGOOD - logwstream - Lombrozo + - lresolv - lseq - lsmf - ltype @@ -220,6 +224,7 @@ words: - Nyffenegger - onlatest - ostr + - otool - oxalica - pargs - partitioner @@ -249,11 +254,15 @@ words: - Raphson - rcflags - replayer + - repodata + - repomd - rerandomize - rerandomization - rerandomized - rerandomizes - rerere + - retargeted + - retargets - retriable - RIPD - ripdtop @@ -289,6 +298,7 @@ words: - sles - soci - socidb + - Sonatype - sponsee - sponsees - SRPMS @@ -308,6 +318,7 @@ words: - summands - superpeer - superpeers + - Swatinem - takergets - takerpays - ters @@ -363,12 +374,15 @@ words: - wthread - xbridge - xchain + - xcrun - ximinez - XMACRO + - xored - xrpkuwait - xrpl - xrpld - xrplf - xxhash - xxhasher + - zstdio - CGNAT diff --git a/.envrc b/.envrc index cecf4b4767..ec38b75f5c 100644 --- a/.envrc +++ b/.envrc @@ -1,3 +1,7 @@ watch_file nix/*.nix +# 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 + use flake diff --git a/.github/actions/generate-version/action.yml b/.github/actions/generate-version/action.yml deleted file mode 100644 index 50b3166596..0000000000 --- a/.github/actions/generate-version/action.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Generate build version number -description: "Generate build version number." - -outputs: - version: - description: "The generated build version number." - value: ${{ steps.version.outputs.version }} - -runs: - using: composite - steps: - # When a tag is pushed, the version is used as-is. - - name: Generate version for tag event - if: ${{ startsWith(github.ref, 'refs/tags/') }} - shell: bash - env: - VERSION: ${{ github.ref_name }} - run: echo "VERSION=${VERSION}" >>"${GITHUB_ENV}" - - # When a tag is not pushed, then the version (e.g. 1.2.3-b0) is extracted - # from the BuildInfo.cpp file and the shortened commit hash appended to it. - # We use a plus sign instead of a hyphen because Conan recipe versions do - # not support two hyphens. - - name: Generate version for non-tag event - if: ${{ !startsWith(github.ref, 'refs/tags/') }} - shell: bash - run: | - echo 'Extracting version from BuildInfo.cpp.' - VERSION="$(cat src/libxrpl/protocol/BuildInfo.cpp | grep "versionString =" | awk -F '"' '{print $2}')" - if [[ -z "${VERSION}" ]]; then - echo 'Unable to extract version from BuildInfo.cpp.' - exit 1 - fi - - echo 'Appending shortened commit hash to version.' - SHA='${{ github.sha }}' - VERSION="${VERSION}+${SHA:0:7}" - - echo "VERSION=${VERSION}" >>"${GITHUB_ENV}" - - - name: Output version - id: version - shell: bash - run: echo "version=${VERSION}" >>"${GITHUB_OUTPUT}" diff --git a/.github/actions/release-info/action.yml b/.github/actions/release-info/action.yml new file mode 100644 index 0000000000..7f1061df93 --- /dev/null +++ b/.github/actions/release-info/action.yml @@ -0,0 +1,90 @@ +name: Release info +description: "Derive the version, release channel and package release number for this build." + +outputs: + version: + description: "The build version number." + value: ${{ steps.version.outputs.version }} + channel: + description: "The release channel this build belongs to." + value: ${{ steps.channel.outputs.channel }} + pkg_release: + description: "The package release number: 1 for a tag, the run number otherwise." + value: ${{ steps.pkg_release.outputs.pkg_release }} + +runs: + using: composite + steps: + # A tag names its own version. Anything else takes it from BuildInfo.cpp and + # appends the commit hash as build metadata, joined with a plus sign because a + # Conan version cannot contain two hyphens. + - name: Determine version + id: version + shell: bash + env: + IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + run: | + if [[ "${IS_TAG}" == "true" ]]; then + version="${REF_NAME}" + else + version="$(awk -F'"' '/versionString =/ { print $2 }' src/libxrpl/protocol/BuildInfo.cpp)" + if [[ -z "${version}" ]]; then + echo "Unable to read versionString from BuildInfo.cpp." >&2 + exit 1 + fi + version="${version}+${SHA:0:7}" + fi + + 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=unstable + elif [[ "${pre_release}" =~ ^b(0|[1-9][0-9]*)(\+.*)?$ ]]; then + channel=experimental + 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}" diff --git a/.github/actions/setup-nix-env/action.yml b/.github/actions/setup-nix-env/action.yml new file mode 100644 index 0000000000..a95053e536 --- /dev/null +++ b/.github/actions/setup-nix-env/action.yml @@ -0,0 +1,69 @@ +name: Setup Nix environment +description: "Build the flake's CI environment and put its tools on PATH." + +# The environment from nix/ci-env.nix, the same one the Linux CI images bake in +# (see nix/docker). Exported onto PATH rather than entered with `nix develop`: +# the composite actions below run plain `bash` and would escape a dev shell. + +runs: + using: composite + + steps: + - name: Build the CI environment + id: build + shell: bash + env: + # --out-link doubles as a GC root for the length of the job. + OUT_LINK: ${{ runner.temp }}/xrpld-ci-env + run: | + # --extra-experimental-features: flakes may not be on in the runner's nix.conf. + nix --extra-experimental-features "nix-command flakes" \ + build .#default --out-link "${OUT_LINK}" --print-build-logs + echo "path=$(readlink -f "${OUT_LINK}")" >>"${GITHUB_OUTPUT}" + + - name: Export the environment + shell: bash + env: + ENV_PATH: ${{ steps.build.outputs.path }} + run: | + echo "${ENV_PATH}/bin" >>"${GITHUB_PATH}" + + # Already KEY=VALUE per line. See `darwinEnv` in nix/ci-env.nix. + ENV_FILE="${ENV_PATH}/share/xrpld-ci-env/env" + if [ -f "${ENV_FILE}" ]; then + cat "${ENV_FILE}" >>"${GITHUB_ENV}" + fi + + # XrplSanity.cmake otherwise rejects a Nix compiler as one that leaked. + echo "XRPL_DEVSHELL=ci-env" >>"${GITHUB_ENV}" + + # Unlike the Linux nix images, macOS needs no SSL_CERT_FILE: it has its + # own trust store, and pinning would break TLS to hosts relying on it. + + # Workspace-local, so `cleanup-workspace` clears it, but not the + # `.conan2` prepare-runner hands the system toolchain: that Conan is a + # different version, and the two would migrate each other's cache. + echo "CONAN_HOME=${{ github.workspace }}/.conan2-nix" >>"${GITHUB_ENV}" + + # Config, profiles and remote, exactly as the dev shell sets them up on + # entry; the `setup-conan` action is skipped for this toolchain. + - name: Setup Conan + shell: bash + run: ./conan/init.sh + + # `Check tools` runs later but swallows failures; a bad export would just + # build with the system toolchain. + - name: Verify the toolchain resolves into the Nix store + shell: bash + run: | + for tool in clang clang++ cmake ninja conan; do + path="$(command -v "${tool}" || true)" + echo "${tool} -> ${path:-}" + case "${path}" in + /nix/store/*) ;; + *) + echo "::error::${tool} does not resolve into the Nix store" + exit 1 + ;; + esac + done diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fcac44c44c..da37f79007 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,7 @@ updates: directories: - / - .github/actions/build-deps/ - - .github/actions/generate-version/ + - .github/actions/release-info/ - .github/actions/set-compiler-env/ - .github/actions/setup-conan/ schedule: @@ -19,3 +19,19 @@ updates: github-actions: patterns: - "*" + + - package-ecosystem: cargo + directory: /crates + schedule: + interval: weekly + day: monday + time: "04:00" + timezone: Etc/GMT + commit-message: + prefix: "chore: [DEPENDABOT] " + target-branch: develop + open-pull-requests-limit: 10 + groups: + rust-dependencies: + patterns: + - "*" diff --git a/.github/scripts/rename/binary.sh b/.github/scripts/rename/binary.sh index 89d884538c..4a3e86675a 100755 --- a/.github/scripts/rename/binary.sh +++ b/.github/scripts/rename/binary.sh @@ -49,7 +49,7 @@ ${SED_COMMAND} -i -E 's@ripple/xrpld@XRPLF/rippled@g' BUILD.md ${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' BUILD.md ${SED_COMMAND} -i -E 's@xrpld \(`xrpld`\)@xrpld@g' BUILD.md ${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' CONTRIBUTING.md -${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' docs/build/install.md +${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' docs/install.md popd echo "Processing complete." diff --git a/.github/scripts/rename/docs.sh b/.github/scripts/rename/docs.sh index 9f080b06e5..9d7be209a3 100755 --- a/.github/scripts/rename/docs.sh +++ b/.github/scripts/rename/docs.sh @@ -77,8 +77,8 @@ ${SED_COMMAND} -i 's/Ripple integrators/XRPL developers/' README.md ${SED_COMMAND} -i 's/sanitizer-configuration-for-rippled/sanitizer-configuration-for-xrpld/' docs/build/sanitizers.md ${SED_COMMAND} -i 's/rippled/xrpld/g' .github/scripts/levelization/README.md ${SED_COMMAND} -i 's/rippled/xrpld/g' .github/scripts/strategy-matrix/generate.py -${SED_COMMAND} -i 's@/rippled@/xrpld@g' docs/build/install.md -${SED_COMMAND} -i 's@github.com/XRPLF/xrpld@github.com/XRPLF/rippled@g' docs/build/install.md +${SED_COMMAND} -i 's@/rippled@/xrpld@g' docs/install.md +${SED_COMMAND} -i 's@github.com/XRPLF/xrpld@github.com/XRPLF/rippled@g' docs/install.md ${SED_COMMAND} -i 's/rippled/xrpld/g' docs/Doxyfile ${SED_COMMAND} -i 's/ripple_basics/basics/' include/xrpl/basics/CountedObject.h ${SED_COMMAND} -i 's/ None: if isinstance(self.build_type, str): @@ -137,6 +146,7 @@ class MatrixEntry: sanitizers: str image: str = "" # container image; empty for macOS/Windows (runs natively) compiler: str = "" # compiler name ("gcc" or "clang"); empty for macOS/Windows + toolchain: str = "" # "nix" for the flake's CI environment; see PlatformConfig @dataclasses.dataclass @@ -215,7 +225,7 @@ 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. - Packaging uses vanilla distro images (debian:bookworm, ubi9, …) instead of + 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'. @@ -253,9 +263,12 @@ def expand_platform_matrix(pf: PlatformFile, minimal: bool) -> list[MatrixEntry] if minimal and not cfg.minimal: continue for build_type in cfg.build_type: + name = f"{platform_name}-{arch}-{build_type.lower()}" + if cfg.toolchain: + name += f"-{cfg.toolchain}" entries.append( MatrixEntry( - config_name=f"{platform_name}-{arch}-{build_type.lower()}", + config_name=name, cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args), cmake_target="install" if is_windows else "all", build_only=cfg.build_only, @@ -263,6 +276,7 @@ def expand_platform_matrix(pf: PlatformFile, minimal: bool) -> list[MatrixEntry] build_type=build_type, architecture=Architecture(platform=pf.platform, runner=pf.runner), sanitizers="", + toolchain=cfg.toolchain, ) ) return entries diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 33146cff3b..e739a42d5a 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -1,5 +1,5 @@ { - "image_tag": "sha-fecfc0c", + "image_tag": "sha-a0074f8", "configs": { "ubuntu": [ { @@ -92,7 +92,7 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-577d745" + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-a6983f8" } ], @@ -102,7 +102,7 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-577d745" + "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-a6983f8" } ] } diff --git a/.github/scripts/strategy-matrix/macos.json b/.github/scripts/strategy-matrix/macos.json index 98e0f13141..554031009c 100644 --- a/.github/scripts/strategy-matrix/macos.json +++ b/.github/scripts/strategy-matrix/macos.json @@ -12,6 +12,19 @@ "extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", "build_only": true, "minimal": false + }, + { + "build_type": "Release", + "extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", + "toolchain": "nix", + "minimal": false + }, + { + "build_type": "Debug", + "extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", + "toolchain": "nix", + "build_only": true, + "minimal": false } ] } diff --git a/.github/workflows/build-packaging-images.yml b/.github/workflows/build-packaging-images.yml index 43b276bdf1..fbabc25ac3 100644 --- a/.github/workflows/build-packaging-images.yml +++ b/.github/workflows/build-packaging-images.yml @@ -36,8 +36,9 @@ jobs: distro: - name: debian base_image: debian:bookworm + # AlmaLinux rather than UBI9, which does not ship rpm-sign. - name: rhel - base_image: registry.access.redhat.com/ubi9/ubi:latest + base_image: almalinux:9 uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@9e7e4e80af9e684c116b38369add8eea64451f32 with: image_name: xrpld/packaging-${{ matrix.distro.name }} diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml new file mode 100644 index 0000000000..d167e52e61 --- /dev/null +++ b/.github/workflows/cargo-audit.yml @@ -0,0 +1,80 @@ +name: Cargo audit + +on: + schedule: + # 06:32 UTC every Monday. + - cron: "32 6 * * 1" + push: + branches: + - "develop" + - "release/*" + paths: + - "crates/**/Cargo.toml" + - "crates/Cargo.lock" + - ".github/workflows/cargo-audit.yml" + pull_request: + paths: + - "crates/**/Cargo.toml" + - "crates/Cargo.lock" + - ".github/workflows/cargo-audit.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + working-directory: crates + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + permissions: + contents: read + # Needed to open an issue on scheduled failures. + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run cargo audit + id: audit + continue-on-error: true + run: | + set -o pipefail + cargo audit | tee /tmp/cargo-audit.txt + + - name: Prepare issue body + if: ${{ steps.audit.outcome != 'success' && github.event_name == 'schedule' }} + run: | + { + echo "## \`cargo audit\` found advisories" + echo + echo '```' + cat /tmp/cargo-audit.txt + echo '```' + echo + echo "---" + echo "*This issue was automatically created by the cargo-audit workflow.*" + } >/tmp/cargo-audit-issue.md + + - name: Create issue + if: ${{ steps.audit.outcome != 'success' && github.event_name == 'schedule' }} + uses: XRPLF/actions/create-issue@2b8bc36af85b88bca0dd7bfac2e2dc05f94ad712 + with: + title: "cargo audit found vulnerabilities" + body_file: /tmp/cargo-audit-issue.md + labels: "Bug,Security" + + - name: Fail if advisories were found + if: ${{ steps.audit.outcome != 'success' }} + run: | + echo "cargo audit found advisories!" + cat /tmp/cargo-audit.txt + exit 1 diff --git a/.github/workflows/check-tools.yml b/.github/workflows/check-tools.yml index 99dddd7d96..c7a00e8b49 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@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f + uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c with: enable_ccache: false diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 0a4e4b1f49..933c7b8a54 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -77,24 +77,28 @@ jobs: # Keep the paths below in sync with those in `on-trigger.yml`. .github/actions/build-deps/** - .github/actions/generate-version/** + .github/actions/release-info/** .github/actions/setup-conan/** + .github/actions/setup-nix-env/** .github/scripts/strategy-matrix/** .github/workflows/reusable-build-test-config.yml .github/workflows/reusable-build-test.yml .github/workflows/reusable-check-autogen.yml .github/workflows/reusable-clang-tidy.yml .github/workflows/reusable-package.yml + .github/workflows/reusable-rust.yml .github/workflows/reusable-strategy-matrix.yml .github/workflows/reusable-test.yml .github/workflows/reusable-upload-recipe.yml .clang-tidy .codecov.yml + bin/check-nix-store-refs.sh bin/check-tools.sh bin/default-loader-path.sh cfg/** cmake/** conan/** + crates/** external/** include/** src/** @@ -102,6 +106,9 @@ jobs: CMakeLists.txt conanfile.py conan.lock + flake.lock + flake.nix + nix/** LICENSE.md package/** README.md @@ -168,6 +175,13 @@ jobs: secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + rust: + needs: should-run + if: ${{ needs.should-run.outputs.go == 'true' }} + uses: ./.github/workflows/reusable-rust.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + package: needs: [should-run, build-test] # Packaging consumes the debian/rhel release binaries, which are only built @@ -211,6 +225,7 @@ jobs: - check-rename - clang-tidy - build-test + - rust - package - upload-recipe - notify-clio diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml index abedc13d69..d8a9a5113e 100644 --- a/.github/workflows/on-tag.yml +++ b/.github/workflows/on-tag.yml @@ -1,5 +1,9 @@ -# This workflow uploads the libxrpl recipe to the Conan remote and builds -# release packages when a versioned tag is pushed. +# When a versioned tag is pushed, this workflow: +# +# - uploads the libxrpl recipe to the Conan remote +# - builds and tests the release binaries +# - builds the DEB and RPM packages +# - publishes those packages to the XRPLF package repositories name: Tag on: @@ -24,7 +28,7 @@ jobs: remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} build-test: - if: ${{ github.repository == 'XRPLF/rippled' }} + if: ${{ github.repository_owner == 'XRPLF' }} uses: ./.github/workflows/reusable-build-test.yml strategy: fail-fast: true @@ -37,6 +41,12 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} package: - if: ${{ github.repository == 'XRPLF/rippled' }} + if: ${{ github.repository_owner == 'XRPLF' }} needs: build-test uses: ./.github/workflows/reusable-package.yml + with: + publish: true + secrets: + remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} + remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} + signing_key: ${{ secrets.NEXUS_PACKAGES_PRIVATE_KEY }} diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml index 73f918d528..2099f5f739 100644 --- a/.github/workflows/on-trigger.yml +++ b/.github/workflows/on-trigger.yml @@ -15,24 +15,28 @@ on: # Keep the paths below in sync with those in `on-pr.yml`. - ".github/actions/build-deps/**" - - ".github/actions/generate-version/**" + - ".github/actions/release-info/**" - ".github/actions/setup-conan/**" + - ".github/actions/setup-nix-env/**" - ".github/scripts/strategy-matrix/**" - ".github/workflows/reusable-build-test-config.yml" - ".github/workflows/reusable-build-test.yml" - ".github/workflows/reusable-check-autogen.yml" - ".github/workflows/reusable-clang-tidy.yml" - ".github/workflows/reusable-package.yml" + - ".github/workflows/reusable-rust.yml" - ".github/workflows/reusable-strategy-matrix.yml" - ".github/workflows/reusable-test.yml" - ".github/workflows/reusable-upload-recipe.yml" - ".clang-tidy" - ".codecov.yml" + - "bin/check-nix-store-refs.sh" - "bin/check-tools.sh" - "bin/default-loader-path.sh" - "cfg/**" - "cmake/**" - "conan/**" + - "crates/**" - "external/**" - "include/**" - "src/**" @@ -40,6 +44,9 @@ on: - "CMakeLists.txt" - "conanfile.py" - "conan.lock" + - "flake.lock" + - "flake.nix" + - "nix/**" - "LICENSE.md" - "package/**" - "README.md" @@ -96,6 +103,11 @@ jobs: secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + rust: + uses: ./.github/workflows/reusable-rust.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + upload-recipe: needs: build-test # Only run when pushing to the develop branch. @@ -108,3 +120,11 @@ jobs: package: needs: build-test uses: ./.github/workflows/reusable-package.yml + with: + # Packages are built on every trigger; only develop pushes in XRPLF/rippled + # publish them, matching upload-recipe above. + publish: ${{ github.repository == 'XRPLF/rippled' && github.event_name == 'push' && github.ref == 'refs/heads/develop' }} + secrets: + remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} + remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} + signing_key: ${{ secrets.NEXUS_PACKAGES_PRIVATE_KEY }} diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index a3e096315c..3b863f2b33 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -41,13 +41,13 @@ env: jobs: build: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-fecfc0c + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f + uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c with: enable_ccache: false diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index d8550efc4c..89bfc7463b 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -69,6 +69,12 @@ on: type: string default: "" + toolchain: + description: 'Where the toolchain comes from ("nix" to build the flake CI environment on the runner, empty for the system one). macOS only: Linux always builds in a Nix image, and Nix has no Windows support.' + required: false + type: string + default: "" + secrets: CODECOV_TOKEN: description: "The Codecov token to use for uploading coverage reports." @@ -111,6 +117,9 @@ jobs: VOIDSTAR_ENABLED: ${{ contains(inputs.cmake_args, '-Dvoidstar=ON') }} VALIDATOR_KEYS_ENABLED: ${{ contains(inputs.cmake_args, '-Dvalidator_keys=ON') }} SANITIZERS_ENABLED: ${{ inputs.sanitizers != '' }} + # The binaries reusable-package.yml consumes. A private repository skips + # them except on a tag push, which is what produces its release packages. + PACKAGING_ARTIFACTS_ENABLED: ${{ github.event.repository.visibility == 'public' || startsWith(github.ref, 'refs/tags/') }} steps: - name: Cleanup workspace (macOS and Windows) if: ${{ runner.os == 'macOS' || runner.os == 'Windows' }} @@ -120,10 +129,15 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f + uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c with: enable_ccache: ${{ inputs.ccache_enabled }} + # Before any step that uses a build tool, composite actions included. + - name: Setup Nix environment + if: ${{ inputs.toolchain == 'nix' }} + uses: ./.github/actions/setup-nix-env + - name: Set ccache log file if: ${{ inputs.ccache_enabled && runner.debug == '1' }} run: echo "CCACHE_LOGFILE=${{ runner.temp }}/ccache.log" >>"${GITHUB_ENV}" @@ -148,7 +162,22 @@ jobs: with: compiler: ${{ inputs.compiler }} + - name: Use cargo artifacts cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + 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 + workspaces: | + crates + crates -> ${{ runner.os == 'Windows' && format('../{0}/x64/{1}/cargo', env.BUILD_DIR, inputs.build_type) || format('../{0}/cargo', env.BUILD_DIR) }} + + # `setup-nix-env` already did this for the Nix toolchain. - name: Setup Conan + if: ${{ inputs.toolchain != 'nix' }} env: SANITIZERS: ${{ inputs.sanitizers }} uses: ./.github/actions/setup-conan @@ -212,6 +241,24 @@ jobs: --target "${CMAKE_TARGET}" \ 2>&1 | tee "${GITHUB_WORKSPACE}/build.log" + # Nothing may reference the store, so whole trees are checked - the Conan + # cache included, since what it holds is what gets uploaded and reused. + - name: Check the build output for Nix store references (Nix toolchain) + if: ${{ inputs.toolchain == 'nix' }} + run: ./bin/check-nix-store-refs.sh "${BUILD_DIR}" + + - name: Check the Conan cache for Nix store references (Nix toolchain) + if: ${{ inputs.toolchain == 'nix' }} + run: ./bin/check-nix-store-refs.sh "${CONAN_HOME}" + + # Only what PatchNixBinary.cmake retargets: the toolchain in the Linux + # images always references the store. Same condition it uses. + - name: Check for Nix store references (Linux) + if: ${{ runner.os == 'Linux' && env.SANITIZERS_ENABLED == 'false' }} + run: | + ./bin/check-nix-store-refs.sh "${BUILD_DIR}/xrpld" + ./bin/check-nix-store-refs.sh "${BUILD_DIR}/xrpl_tests" + - name: Show ccache statistics if: ${{ inputs.ccache_enabled }} run: | @@ -222,7 +269,7 @@ jobs: fi - name: Upload the binary (Linux) - if: ${{ github.event.repository.visibility == 'public' && runner.os == 'Linux' }} + if: ${{ env.PACKAGING_ARTIFACTS_ENABLED == 'true' && runner.os == 'Linux' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: xrpld-${{ inputs.config_name }} @@ -236,7 +283,7 @@ jobs: run: ./validator-keys --unittest - name: Upload the validator-keys binary - if: ${{ github.event.repository.visibility == 'public' && env.VALIDATOR_KEYS_ENABLED == 'true' }} + if: ${{ env.PACKAGING_ARTIFACTS_ENABLED == 'true' && env.VALIDATOR_KEYS_ENABLED == 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: validator-keys-${{ inputs.config_name }} @@ -323,6 +370,11 @@ jobs: LD_PRELOAD="$PRELOAD" ./xrpld --unittest --unittest-jobs "${BUILD_NPROC}" 2>&1 | tee "${GITHUB_WORKSPACE}/unittest.log" + - name: Run Rust tests + if: ${{ !inputs.build_only }} + working-directory: crates + run: cargo nextest run --workspace --all-features --locked --no-tests=warn + # Smoke-run every benchmark module with a single repetition to confirm the # benchmarks still build and execute. This is a correctness check, not a # performance measurement, so there is nothing to gain from repeating it @@ -394,6 +446,7 @@ jobs: disable_telem: true fail_ci_if_error: true files: ${{ env.BUILD_DIR }}/coverage.xml + flags: cpp plugins: noop token: ${{ secrets.CODECOV_TOKEN }} verbose: true diff --git a/.github/workflows/reusable-build-test.yml b/.github/workflows/reusable-build-test.yml index 5368274a16..7ea106f438 100644 --- a/.github/workflows/reusable-build-test.yml +++ b/.github/workflows/reusable-build-test.yml @@ -51,5 +51,6 @@ jobs: config_name: ${{ matrix.config_name }} sanitizers: ${{ matrix.sanitizers }} compiler: ${{ matrix.compiler || '' }} + toolchain: ${{ matrix.toolchain || '' }} secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index f1fdc0569a..8dd1af9d99 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-fecfc0c" + container: "ghcr.io/xrplf/xrpld/nix-debian:sha-a0074f8" permissions: contents: read issues: write @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f + uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c with: enable_ccache: false @@ -59,6 +59,13 @@ jobs: with: compiler: ${{ env.COMPILER }} + - name: Use cargo artifacts cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + 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 uses: ./.github/actions/setup-conan @@ -80,13 +87,13 @@ jobs: -Dwerr=ON \ -Dxrpld=ON \ -Dverify_headers=ON \ + -Drust=ON \ .. - # clang-tidy needs headers generated from proto files - - name: Build libxrpl.libpb + - name: Build clang-tidy prerequisites working-directory: ${{ env.BUILD_DIR }} run: | - ninja -j ${{ steps.nproc.outputs.nproc }} xrpl.libpb + ninja -j ${{ steps.nproc.outputs.nproc }} tidy_prerequisites - name: Run clang tidy id: run_clang_tidy diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index b45cae52d9..cfae706ee1 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -1,17 +1,37 @@ -# Build Linux packages (DEB and RPM) from pre-built binary artifacts (xrpld and -# validator-keys). Discovers which configurations to package from linux.json -# (configs in "package_configs") and fans out one job per distro. Only -# linux/amd64 is supported; the runner is hardcoded in the job below. +# 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 +# - with 'publish: true' a job also uploads what it built +# (see package/publish_pkg.sh) +# +# Only linux/amd64 is supported; the runner is hardcoded in the job below. name: Package on: workflow_call: inputs: - pkg_release: - description: "Package release number. Increment when repackaging the same executable." + publish: + description: "Whether to publish the packages after building them." + required: false + type: boolean + default: false + nexus_url: + description: "The base URL of the Nexus instance hosting the deb and rpm repositories." required: false type: string - default: "1" + default: https://packages.xrplf.org + + secrets: + remote_username: + description: "The username of a Nexus account with write access to the repositories." + required: false + remote_password: + description: "The password or token for that Nexus account." + required: false + signing_key: + description: "Armoured PGP private key used to sign the RPMs. Required when publishing." + required: false defaults: run: @@ -41,7 +61,7 @@ jobs: package: needs: [generate-matrix] - if: ${{ github.event.repository.visibility == 'public' }} + if: ${{ github.event.repository.visibility == 'public' || startsWith(github.ref, 'refs/tags/') }} strategy: fail-fast: false matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} @@ -71,11 +91,24 @@ jobs: - name: Make binaries executable run: chmod +x "${BUILD_DIR}/xrpld" "${BUILD_DIR}/validator-keys" + - name: Determine release info + id: release_info + uses: ./.github/actions/release-info + - name: Build package env: - PKG_RELEASE: ${{ inputs.pkg_release }} + PKG_RELEASE: ${{ steps.release_info.outputs.pkg_release }} + PKG_CHANNEL: ${{ steps.release_info.outputs.channel }} run: ./package/build_pkg.sh + # 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' }} + env: + PKG_SIGNING_KEY: ${{ secrets.signing_key }} + run: ./package/sign_rpm.sh "${BUILD_DIR}" + - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -85,3 +118,12 @@ jobs: ${{ env.BUILD_DIR }}/debbuild/*.ddeb ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/*.rpm if-no-files-found: error + + - name: Publish package + if: ${{ inputs.publish }} + env: + CHANNEL: ${{ steps.release_info.outputs.channel }} + NEXUS_URL: ${{ inputs.nexus_url }} + NEXUS_USERNAME: ${{ secrets.remote_username }} + NEXUS_PASSWORD: ${{ secrets.remote_password }} + run: ./package/publish_pkg.sh "${CHANNEL}" "${BUILD_DIR}" diff --git a/.github/workflows/reusable-rust.yml b/.github/workflows/reusable-rust.yml new file mode 100644 index 0000000000..e9d281c692 --- /dev/null +++ b/.github/workflows/reusable-rust.yml @@ -0,0 +1,86 @@ +# Clippy, coverage and documentation for the Rust crates in crates/. Each runs +# as an independent job on a GitHub-hosted runner, but inside the same container +# image used to build the crates in the C++/Corrosion path, so the toolchain +# (and therefore the lints, coverage instrumentation and the cargo cache) matches +# what production builds use. +# +# Rust unit tests are deliberately NOT run here. They run as part of the C++ +# build (reusable-build-test-config.yml), which already compiles the crates on a +# self-hosted runner, so there is no need to provision a toolchain again. +name: Rust + +on: + workflow_call: + secrets: + CODECOV_TOKEN: + description: "The Codecov token to use for uploading coverage reports." + required: true + +defaults: + run: + shell: bash + working-directory: crates + +permissions: + contents: read + +jobs: + clippy: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Use cargo artifacts cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: crates + + - name: Run clippy + run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + + coverage: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Use cargo artifacts cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: crates + + - name: Generate coverage report + run: cargo llvm-cov nextest --workspace --all-features --locked --no-tests=warn --lcov --output-path lcov.info + + - name: Upload coverage report + if: ${{ github.repository == 'XRPLF/rippled' }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + disable_search: true + disable_telem: true + fail_ci_if_error: true + files: crates/lcov.info + flags: rust + plugins: noop + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + + doc: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Use cargo artifacts cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: crates + + - name: Build documentation + env: + RUSTDOCFLAGS: "-D warnings" + run: cargo doc --workspace --no-deps --all-features --locked diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index b4ab638dee..680d95fb97 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-fecfc0c + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 env: REMOTE_NAME: ${{ inputs.remote_name }} CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }} @@ -49,9 +49,9 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Generate build version number - id: version - uses: ./.github/actions/generate-version + - name: Determine release info + id: release_info + uses: ./.github/actions/release-info - name: Set up Conan uses: ./.github/actions/setup-conan @@ -64,8 +64,8 @@ jobs: - name: Upload Conan recipe (version) run: | - conan export . --version=${{ steps.version.outputs.version }} - conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/${{ steps.version.outputs.version }} + conan export . --version=${{ steps.release_info.outputs.version }} + conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/${{ steps.release_info.outputs.version }} # When this workflow is triggered by a push event, it will always be when merging into the # 'develop' branch, see on-trigger.yml. @@ -92,4 +92,4 @@ jobs: conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/release outputs: - ref: xrpl/${{ steps.version.outputs.version }} + ref: xrpl/${{ steps.release_info.outputs.version }} diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml index eb58650bdf..65a3f9c5b6 100644 --- a/.github/workflows/upload-conan-deps.yml +++ b/.github/workflows/upload-conan-deps.yml @@ -72,6 +72,11 @@ jobs: with: enable_ccache: false + # Before any step that uses a build tool, composite actions included. + - name: Setup Nix environment + if: ${{ matrix.toolchain == 'nix' }} + uses: ./.github/actions/setup-nix-env + - name: Print build environment uses: XRPLF/actions/print-build-env@59dec886e4afb05a1724443af08baccbc045b574 @@ -87,7 +92,9 @@ jobs: with: compiler: ${{ matrix.compiler }} + # `setup-nix-env` already did this for the Nix toolchain. - name: Setup Conan + if: ${{ matrix.toolchain != 'nix' }} env: SANITIZERS: ${{ matrix.sanitizers }} uses: ./.github/actions/setup-conan @@ -106,6 +113,10 @@ jobs: log_verbosity: ${{ runner.os == 'Windows' && 'quiet' || 'verbose' }} sanitizers: ${{ matrix.sanitizers }} + - name: Check the Conan cache for Nix store references (Nix toolchain) + if: ${{ matrix.toolchain == 'nix' }} + run: ./bin/check-nix-store-refs.sh "${CONAN_HOME}" + - name: Log into Conan remote if: ${{ github.repository == 'XRPLF/rippled' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} run: conan remote login "${CONAN_REMOTE_NAME}" "${{ secrets.NEXUS_REMOTE_USERNAME }}" --password "${{ secrets.NEXUS_REMOTE_PASSWORD }}" diff --git a/.gitignore b/.gitignore index 13b59a7e2c..c5af8eb7b4 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,6 @@ target/ # clangd cache /.cache + +# Rust build directory +crates/target diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d339cb29ed..e5e69759fd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,6 +62,15 @@ repos: types_or: [c++, c, proto] exclude: ^include/xrpl/protocol_autogen/(transactions|ledger_entries)/ + - repo: local + hooks: + - id: cargo-fmt + name: cargo fmt + entry: cargo fmt --manifest-path crates/Cargo.toml --all + language: system + types: [rust] + pass_filenames: false # rustfmt formats the whole workspace + - repo: https://github.com/BlankSpruce/gersemi-pre-commit rev: e98930bdc210d3387007f9252d8c1694ea7e410f # frozen: 0.27.7 hooks: diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index 79fb8ff522..c853cfb07c 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -54,6 +54,8 @@ 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) +- `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) ## XRP Ledger server version 3.1.0 diff --git a/BUILD.md b/BUILD.md index 238c10e17c..e98d204d0b 100644 --- a/BUILD.md +++ b/BUILD.md @@ -4,34 +4,14 @@ ## Minimum Requirements -See [System Requirements](https://xrpl.org/system-requirements.html). +For the hardware needed to run a node, see +[System Requirements](https://xrpl.org/system-requirements.html). -Building xrpld generally requires Git, Python, Conan, CMake, and a C++ -compiler. - -- [Python](https://www.python.org/downloads/) -- [Conan](https://conan.io/downloads.html) -- [CMake](https://cmake.org/download/) - -You can verify that the required tools are installed and runnable with: - -```bash -./bin/check-tools.sh -``` - -`xrpld` is written in the C++23 dialect. The [tested compiler versions][cpp23-support] are: - -| Compiler | Version | -| ----------- | --------------- | -| GCC | 15.2 | -| Clang | 22 | -| Apple Clang | 21 | -| MSVC | 19.44[^windows] | +For the software needed to build xrpld, see the +[environment setup guide](./docs/build/environment.md). ## Operating Systems -Please see the [environment setup guide](./docs/build/environment.md) for detailed instructions for all platforms. - ### Linux The Ubuntu Linux distribution has received the highest level of quality @@ -47,9 +27,8 @@ CI testing is done in macOS 26 (Tahoe), but the build defaults `CMAKE_OSX_DEPLOY ### Windows -Windows is used by some engineers for development only. - -[^windows]: Windows is not recommended for production use. +Windows is used by some engineers for development only, and is not recommended +for production use. ## Steps @@ -74,37 +53,25 @@ releases](https://github.com/XRPLF/rippled/releases). ### Set Up Conan -After you have a [C++ development environment](./docs/build/environment.md) ready with Git, Python, -Conan, CMake, and a C++ compiler, you may need to set up your Conan profile. - -These instructions assume a basic familiarity with Conan and CMake. If you are -unfamiliar with Conan, then please read [this crash course](./docs/build/conan.md) or the official -[Getting Started][conan-getting-started] walkthrough. - -#### Profiles - -We recommend that you install our Conan profiles: +Once your [development environment](./docs/build/environment.md) is ready, set +Conan up for this repository: ```bash -conan config install conan/profiles/ -tf $(conan config home)/profiles/ +./conan/init.sh ``` -You can check your Conan profile by running: +That installs our [`global.conf`](./conan/global.conf), our Conan +[profiles](./conan/profiles), and the `xrplf` remote that hosts some of our +dependencies. It honours `CONAN_HOME` and never deletes an existing Conan home, +so it is safe to re-run — it only overwrites the files it manages. -```bash -conan profile show -``` +> [!TIP] +> In the [Nix development shell](./docs/build/nix.md#conan-configuration) this is +> already done for you: the script runs on entry. -If the default profile is not suitable for your environment, you can create a custom profile and pass it to Conan. -More information on customizing Conan can be found in the [Advanced Conan configuration](./docs/build/advanced_conan.md). - -#### Add xrplf remote - -Run the following command to add the `xrplf` remote, which hosts some of our dependencies: - -```bash -conan remote add --index 0 --force xrplf https://conan.xrplf.org/repository/conan/ -``` +You can inspect the resulting profile with `conan profile show`. If it is not +suitable for your environment, create a custom profile and pass it to Conan — see +[Advanced Conan configuration](./docs/build/advanced_conan.md). ### Set Up Ccache @@ -269,10 +236,14 @@ which is only enabled when the `coverage` option is set, e.g. with Prerequisites for the coverage report: - [gcovr tool][gcovr] (can be installed e.g. with [pip][python-pip]) -- `gcov` for GCC (installed with the compiler by default) or -- `llvm-cov` for Clang (installed with the compiler by default) +- `gcov` for GCC or `llvm-cov` for Clang, usually installed with the compiler - `Debug` build type +> [!NOTE] +> Clang coverage is not available in the [Nix development shell](./docs/build/nix.md#building-xrpld-in-the-nix-shell): +> its `clang` shells do not ship `llvm-cov`. Use a `gcc` shell instead (`.#gcc`, +> or `.#gcc-plain` on Linux), which provides a `gcov` matching its compiler. + A coverage report is created when the following steps are completed, in order: 1. `xrpld` binary built with instrumentation data, enabled by the `coverage` @@ -333,6 +304,7 @@ See [Sanitizers docs](./docs/build/sanitizers.md) for more details. | ---------------- | ------------- | ----------------------------------------------------------------------------- | | `assert` | OFF | Force enabling assertions. | | `coverage` | OFF | Prepare the coverage report. | +| `rust` | OFF | Build the Rust crates and the C++ code that depends on them. | | `tests` | OFF | Build tests. | | `unity` | OFF | Configure a unity build. | | `verify_headers` | ON | Make the `verify-headers` target available to compile each header on its own. | @@ -345,6 +317,30 @@ memory) since they concatenate sources into fewer translation units. Non-unity builds may be faster for incremental builds, and can be helpful for detecting `#include` omissions. +### Rust crates + +The Rust crates in `crates/` are only part of the build when `rust` is ON. With +`-Drust=OFF` (the default) the `crates` directory is not added to the build, no +cxxbridge bindings are generated, and the C++ tests that exercise the Rust +interop are not compiled — so no Rust toolchain is needed. CI builds always pass +`-Drust=ON`. + +With `-Drust=ON` you need one extra dependency: a Rust toolchain (`cargo`, +`rustc`) matching the channel pinned in +[`rust-toolchain.toml`](./rust-toolchain.toml), which compiles the crates and +generates the cxxbridge bindings. It is provided by the +[Nix development shell](./docs/build/nix.md), so `-Drust=ON` works there without +any extra setup; otherwise install it as described in +[Rust](./docs/build/environment.md#rust). + +The crates also have their own Rust unit tests. Those are run with `cargo` and +need only the Rust toolchain, independently of CMake and of the `rust` option +(CI runs them with `cargo nextest`): + +```bash +cargo test --manifest-path crates/Cargo.toml --workspace +``` + ### Verifying headers The regular build only compiles `.cpp` files, so a header is only ever checked @@ -389,10 +385,14 @@ After any updates or changes to dependencies, you may need to do the following: 4. [Regenerate lockfile](./docs/build/advanced_conan.md#conan-lockfile). 5. Re-run [conan install](#build-and-test). +If you are using the Nix development shell, whether prebuilt Conan binaries apply +depends on your platform — see +[Prebuilt packages](./docs/build/nix.md#prebuilt-packages). + #### ERROR: Package not resolved If you're seeing an error like `ERROR: Package 'snappy/1.1.10' not resolved: Unable to find 'snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1756234314.246' in remotes.`, -please [add `xrplf` remote](#add-xrplf-remote) or re-run `conan export` for [patched recipes](./docs/build/advanced_conan.md#patched-recipes). +please [set Conan up](#set-up-conan) so the `xrplf` remote is configured, or re-run `conan export` for [patched recipes](./docs/build/advanced_conan.md#patched-recipes). ### `protobuf/port_def.inc` file not found @@ -412,7 +412,6 @@ For example, if you want to build Debug: 1. For conan install, pass `--settings build_type=Debug` 2. For cmake, pass `-DCMAKE_BUILD_TYPE=Debug` -[cpp23-support]: https://en.cppreference.com/w/cpp/compiler_support/23 [conan-getting-started]: https://docs.conan.io/en/latest/getting_started.html [unity-build]: https://en.wikipedia.org/wiki/Unity_build [gcovr]: https://gcovr.com/en/stable/getting-started.html diff --git a/CMakeLists.txt b/CMakeLists.txt index 4765a5a708..54a52cf21d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -159,7 +159,13 @@ if(coverage) include(XrplCov) endif() +add_custom_target(tidy_prerequisites) + +if(rust) + add_subdirectory(crates) +endif() include(XrplCore) + include(XrplProtocolAutogen) include(XrplInstall) include(XrplValidatorKeys) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc385cf6ed..35309a9824 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -225,8 +225,9 @@ environment, so you don't need to install most of the individual tools yourself. The version of each hook sourced from an external repository (`clang-format`, `gersemi`, etc.) is pinned in that file, so running the hooks locally uses exactly the same versions as CI. A few `local` hooks — most notably -`clang-tidy` — run tools from your own environment; see -[Installing clang-tidy](#installing-clang-tidy) for how to get those. +`clang-tidy` and `cargo fmt` — run tools from your own environment; see +[Installing clang-tidy](#installing-clang-tidy) and +[Rust](./docs/build/environment.md#rust) for how to get those. To get started, install `pre-commit` and enable the git hook scripts: @@ -255,6 +256,7 @@ The hooks configured in this repository include, among others: - `clang-tidy` — C++ static analysis (see [Clang-tidy](#clang-tidy)); opt in with `TIDY=1` - `fix-include-style`, `fix-pragma-once`, `check-doxygen-style` — C++ hygiene - `gersemi` — CMake formatting +- `cargo fmt` — Rust formatting for the crates in `crates/` - `prettier`, `black`, `shfmt` — formatting for JavaScript/JSON/Markdown, Python, and shell - `cspell` — spell checking @@ -319,7 +321,11 @@ See the [environment setup guide](./docs/build/environment.md#clang-tidy) for ho ### Running clang-tidy locally -Before running clang-tidy, you must build the project to generate required files (particularly protobuf headers). Refer to [`BUILD.md`](./BUILD.md) for build instructions. +Before running clang-tidy, you must generate the files it depends on (protobuf headers, and, when the project is configured with `-Drust=ON`, the cxxbridge headers from the Rust crates). Configure the project as described in [`BUILD.md`](./BUILD.md), then build the `tidy_prerequisites` target, which generates all of them: + +```bash +cmake --build build --target tidy_prerequisites +``` #### Via pre-commit (recommended) diff --git a/README.md b/README.md index 88c7943ebb..a0d30ef68b 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Here are some good places to start learning the source code: | `./docs` | Source documentation files and doxygen config. | | `./cfg` | Example configuration files. | | `./src` | Source code. | +| `./crates` | Rust source code. | Some of the directories under `src` are external repositories included using git-subtree. See those directories' README files for more details. diff --git a/bin/check-nix-store-refs.sh b/bin/check-nix-store-refs.sh new file mode 100755 index 0000000000..70413df75e --- /dev/null +++ b/bin/check-nix-store-refs.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Fail if a binary under records a /nix/store path it resolves at run +# time. See docs/build/nix.md#prebuilt-packages for why that matters. +# +# is a file or a directory. macOS: nothing may reference the store, so +# point it at whole trees. Linux: the toolchain always writes the store into +# PT_INTERP and RUNPATH, so only at what cmake/PatchNixBinary.cmake retargets. +# +# Only Mach-O / ELF is inspected. Static archives hold store paths in debug info +# alone; the scripts in a Conan cache are all git hook samples and autotools +# scratch, 36 false positives to 0 real. +# +# Usage: bin/check-nix-store-refs.sh + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +if [ ! -e "$1" ]; then + echo "$0: no such path: $1" >&2 + exit 2 +fi + +case "$(uname -s)" in + Darwin) + format=Mach-O + recorded_paths=macho_recorded_paths + tool=otool + ;; + Linux) + format=ELF + recorded_paths=elf_recorded_paths + tool=readelf + ;; + *) + echo "Unsupported OS - skipping the Nix store reference check." + exit 0 + ;; +esac + +# `pipefail` would catch this too, but only as a bare nonzero exit. +if ! command -v "${tool}" >/dev/null; then + echo "$0: ${tool} not found; cannot inspect binaries" >&2 + exit 2 +fi + +# Both list what the file records. `ldd` would answer what this machine resolves +# now, which is wrong both ways: store paths for a correctly patched binary, +# silence for a store RUNPATH that resolves nowhere. + +# `name` covers LC_ID_DYLIB and LC_LOAD*_DYLIB, `path` covers LC_RPATH. +macho_recorded_paths() { + otool -l "$1" | sed -nE 's#^ *(name|path) ([^ ]*).*#\2#p' +} + +# RPATH and RUNPATH are colon-separated. +elf_recorded_paths() { + readelf -ldW "$1" | + sed -nE \ + -e 's#.*program interpreter: ([^]]*)\].*#\1#p' \ + -e 's#.*\((RPATH|RUNPATH|NEEDED)\).*\[([^]]*)\].*#\2#p' | + tr ':' '\n' +} + +checked=0 +skipped=0 +leaked=0 + +while IFS= read -r file; do + case "$(file -b "${file}" 2>/dev/null)" in + *"${format}"*) ;; + *) + skipped=$((skipped + 1)) + continue + ;; + esac + checked=$((checked + 1)) + + # Filter after extracting, or a search path starting elsewhere ($ORIGIN) + # hides the rest. `sed` not `grep`: grep calls "no matches" a failure, and + # the `|| true` that would need masks a broken pipeline too. + refs="$("${recorded_paths}" "${file}" | sed -n '\#^/nix/store/#p' | sort -u)" + if [ -n "${refs}" ]; then + leaked=$((leaked + 1)) + echo "::error file=${file}::references the Nix store at run time" + echo "${file}" + echo "${refs}" | sed 's/^/ /' + fi +done < <(find "$1" -type f \( -perm -u+x -o -name '*.dylib' -o -name '*.so*' \)) + +echo "$1: checked ${checked}, skipped ${skipped}, ${leaked} with Nix store references." + +if [ "${leaked}" -ne 0 ]; then + cat >&2 <<'EOF' + +Fixes, in order of preference: + - A Conan package built before this check existed: drop it + (`conan remove '/*'`) and rebuild. + - A binary that should have been retargeted to the system loader: check that + cmake/PatchNixBinary.cmake ran for it. + - Link the macOS system library instead of the Nix one - see + libresolvSystemStub in nix/darwin.nix. + - No system library exists (libstdc++): link it statically. + - None of the above: pin the toolchain into the package ID, following + `user.package:libc_version` in conan/profiles/ci. +EOF + exit 1 +fi diff --git a/bin/check-tools.sh b/bin/check-tools.sh index e230302742..8273375428 100755 --- a/bin/check-tools.sh +++ b/bin/check-tools.sh @@ -15,10 +15,14 @@ # - Windows: the core build tools only (CMake, Conan, Git, Python). # MSVC is expected to be provided separately and is not checked here. # -# Some tools (clang-format, doxygen, gcovr, gh, git-cliff, gpg, pre-commit, -# run-clang-tidy) are present in our Linux CI images and in local development -# setups, but not in the macOS CI environment. They are checked everywhere -# except when running in CI on macOS. +# Some tools (clang-format, clang-tidy, doxygen, gcovr, gh, git-cliff, gpg, +# pre-commit, run-clang-tidy) are present in our Linux CI images and in local +# development setups, but not in the macOS CI environment. They are checked +# everywhere except when running in CI on macOS. +# +# Tools that Nix also exposes under a version-suffixed name (`clang-tidy-22`, +# `g++-15`, ...) are probed under both names: a suffixed name can break while +# the plain one still works (see mkVersionedToolLinks in nix/packages.nix). # # Environment variables: # CI if set, skip the tools above when on macOS. @@ -26,14 +30,27 @@ set -uo pipefail +# Version suffixes of the Nix tool links, tracking nix/packages.nix. +gcc_version=15 +llvm_version=22 + missing=() checked=0 +# tool_path +# Fully resolved path of a tool, so the snapshots record which derivation +# provides it. Prints nothing when it isn't on PATH. +tool_path() { + local path + path="$(command -v "$1" 2>/dev/null)" || return 0 + readlink -f "${path}" 2>/dev/null || printf '%s' "${path}" +} + # check [probe-command...] # Runs the probe (default: " --version"), capturing both stdout and -# stderr, and prints one aligned line: the status, the name, and the first -# non-blank line of the probe output (its version). Records as missing -# if the command is not found or exits non-zero. +# stderr, and prints three lines: the status and name, the first non-blank line +# of the probe output (its version, or the error when it failed), and the tool's +# resolved path. Records as missing if it is not found or exits non-zero. check() { local name="$1" shift @@ -43,14 +60,17 @@ check() { fi checked=$((checked + 1)) - local output version + local output version path + path="$(tool_path "${name}")" if output="$("${probe[@]}" 2>&1)"; then - version="$(printf '%s\n' "${output}" | grep -m1 '[^[:space:]]' || true)" - printf ' [ ok ] %-20s %s\n' "${name}" "${version}" + printf ' ✅ %s\n' "${name}" else - printf ' [MISS] %s\n' "${name}" + printf ' ❌ %s\n' "${name}" missing+=("${name}") fi + version="$(printf '%s\n' "${output}" | grep -m1 '[^[:space:]]' || true)" + printf ' %s\n' "${version:-(no output)}" + printf ' %s\n' "${path:-(not found)}" } case "$(uname -s)" in @@ -82,7 +102,9 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then echo "Development tooling:" check ccache check clang + check "clang-${llvm_version}" check clang++ + check "clang++-${llvm_version}" check ClangBuildAnalyzer check curl check file @@ -101,7 +123,14 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then # setups, but not in the macOS CI environment. So check them everywhere # except when running in CI on macOS. if [ "${os}" = "linux" ] || [ -z "${CI:-}" ]; then + check clang-apply-replacements + check "clang-apply-replacements-${llvm_version}" check clang-format + check "clang-format-${llvm_version}" + # clang-tidy leads --version with the LLVM banner, not the version. + tidy_probe="--version | grep -m1 -oE 'LLVM version [0-9.]+'" + check clang-tidy sh -c "clang-tidy ${tidy_probe}" + check "clang-tidy-${llvm_version}" sh -c "clang-tidy-${llvm_version} ${tidy_probe}" check dot check doxygen check gcovr @@ -112,6 +141,7 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then # pre-commit, or its alternative implementation prek check pre-commit sh -c 'pre-commit --version || prek --version' check run-clang-tidy run-clang-tidy --help + check "run-clang-tidy-${llvm_version}" "run-clang-tidy-${llvm_version}" --help fi fi @@ -126,7 +156,7 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then check cargo-audit cargo audit --version check cargo-llvm-cov cargo llvm-cov --version check cargo-nextest cargo nextest --version - check clippy clippy-driver --version + check clippy-driver check rust-analyzer check rustc check rustfmt @@ -138,7 +168,11 @@ if [ "${os}" = "linux" ]; then echo echo "GCC toolchain:" check gcc + check "gcc-${gcc_version}" check g++ + check "g++-${gcc_version}" + check cpp + check "cpp-${gcc_version}" check gcov echo @@ -163,9 +197,9 @@ else checked=$((checked + 1)) tmp_clone="$(mktemp -d)" if git clone --depth 1 https://github.com/XRPLF/actions.git "${tmp_clone}/actions" >/dev/null 2>&1; then - printf ' [ ok ] git clone over HTTPS\n' + printf ' ✅ git clone over HTTPS\n' else - printf ' [MISS] git clone over HTTPS\n' + printf ' ❌ git clone over HTTPS\n' missing+=("git-https-clone") fi rm -rf "${tmp_clone}" @@ -173,9 +207,9 @@ fi echo if [ "${#missing[@]}" -eq 0 ]; then - echo "All ${checked} checked tools are present and runnable." + echo "✅ All ${checked} checked tools are present and runnable." else - echo "Missing or non-functional tools (${#missing[@]} of ${checked}):" >&2 + echo "❌ Missing or non-functional tools (${#missing[@]} of ${checked}):" >&2 for tool in "${missing[@]}"; do echo " - ${tool}" >&2 done diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index e262acf1c9..2b46739d97 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -266,10 +266,50 @@ elseif(use_lld) ) if("${LD_VERSION}" MATCHES "LLD") target_link_libraries(common INTERFACE -fuse-ld=lld) + # remembered for the linker flag probe below + set(fuse_ld_flag "-fuse-ld=lld") endif() unset(LD_VERSION) endif() +# Linker warnings are errors where we control the toolchain and the dependencies: CI and the Nix dev shell. +# On non-Nix macOS we suppress the deployment target warning: an old Conan profile may not pin os.version. +# Only the new Apple linker understands the flag, so probe the actual linker (lld may be selected above). +if(is_macos OR is_linux) + if(is_ci OR is_nix_compiler) + if(is_macos) + set(fatal_warnings_flag "-Wl,-fatal_warnings") + else() + set(fatal_warnings_flag "-Wl,--fatal-warnings") + endif() + message( + STATUS + "Treating all linker warnings as errors (${fatal_warnings_flag})" + ) + target_link_options(common INTERFACE "${fatal_warnings_flag}") + unset(fatal_warnings_flag) + elseif(is_macos) + set(silence_flag "-Wl,-deployment_target_mismatches,suppress") + set(probe_flags ${fuse_ld_flag} "${silence_flag}") + include(CheckLinkerFlag) + check_linker_flag( + CXX + "${probe_flags}" + have_deployment_target_mismatches + ) + if(have_deployment_target_mismatches) + message( + STATUS + "Silencing macOS deployment target mismatch warnings (${silence_flag})" + ) + target_link_options(common INTERFACE "${silence_flag}") + endif() + unset(probe_flags) + unset(silence_flag) + endif() +endif() +unset(fuse_ld_flag) + if(assert) foreach(var_ CMAKE_C_FLAGS_RELEASE CMAKE_CXX_FLAGS_RELEASE) string(REGEX REPLACE "[-/]DNDEBUG" "" ${var_} "${${var_}}") diff --git a/cmake/XrplCore.cmake b/cmake/XrplCore.cmake index 7c1eb5688a..4cb251c857 100644 --- a/cmake/XrplCore.cmake +++ b/cmake/XrplCore.cmake @@ -51,6 +51,8 @@ target_compile_options( target_link_libraries(xrpl.libpb PUBLIC protobuf::libprotobuf gRPC::grpc++) +add_dependencies(tidy_prerequisites xrpl.libpb) + # TODO: Clean up the number of library targets later. add_library(xrpl.imports.main INTERFACE) diff --git a/cmake/XrplCov.cmake b/cmake/XrplCov.cmake index 86ba534a88..05d9ed3806 100644 --- a/cmake/XrplCov.cmake +++ b/cmake/XrplCov.cmake @@ -44,6 +44,7 @@ setup_target_for_coverage_gcovr( EXCLUDE "src/test" "src/tests" + "src/benchmarks" "include/xrpl/beast/test" "include/xrpl/beast/unit_test" "${CMAKE_BINARY_DIR}/pb-xrpl.libpb" diff --git a/cmake/XrplSettings.cmake b/cmake/XrplSettings.cmake index be9bf1fda2..58b902baa1 100644 --- a/cmake/XrplSettings.cmake +++ b/cmake/XrplSettings.cmake @@ -32,6 +32,11 @@ endif() option(benchmark "Build benchmarks" ON) +# When OFF, the crates directory is not added to the build at all: no Rust +# toolchain is required, no cxxbridge bindings are generated, and the C++ tests +# that consume those bindings are left out of the build tree. +option(rust "Build the Rust crates and the C++ code that depends on them" OFF) + # Enabled by default so every header is compiled on its own as the main file of # its own compile_commands.json entry - this is what lets clang-tidy (and clangd # and IDEs) analyse a header's own includes directly. The per-header objects are diff --git a/conan.lock b/conan.lock index 9bedf3ac64..02016b83b7 100644 --- a/conan.lock +++ b/conan.lock @@ -24,6 +24,7 @@ "fast_float/8.2.10#f6f28d6bb22112078e7dbda611caf681%1782494504.298", "ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1782307148.15562", "date/3.0.4#862e11e80030356b53c2c38599ceb32b%1782392402.538492", + "corrosion/0.6.1#bfa292df0a957bc70a450ff316cd9435%1786119416.131296", "c-ares/1.34.6#545240bb1c40e2cacd4362d6b8967650%1782392402.681654", "bzip2/1.0.8#c470882369c2d95c5c77e970c0c7e321%1782392402.296732", "boost/1.91.0#ea540ca2133d831b560036aa24dece3c%1782392419.475605", diff --git a/conan/init.sh b/conan/init.sh new file mode 100755 index 0000000000..287ee83001 --- /dev/null +++ b/conan/init.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Install our Conan configuration, profiles and the xrplf remote into CONAN_HOME. +# Safe to re-run; never deletes the Conan home. + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CONAN_DIR="$(conan config home)" + +echo "Installing Conan configuration into ${CONAN_DIR}" +conan config install "${SCRIPT_DIR}/global.conf" +conan config install "${SCRIPT_DIR}/profiles" -tf "${CONAN_DIR}/profiles" +# This script manages these files, so make them read-only - Conan does not +# preserve the source mode. Only the files: the directories must stay writable +# for `conan config install` to replace them. +chmod a-w "${CONAN_DIR}/global.conf" +find "${CONAN_DIR}/profiles" -type f -exec chmod a-w {} + + +echo "Adding the xrplf Conan remote" +# --index 0: our patched recipes must win over Conan Center. +conan remote add --index 0 --force xrplf https://conan.xrplf.org/repository/conan/ diff --git a/conan/profiles/default b/conan/profiles/default index f2d93213ac..1b7eaff980 100644 --- a/conan/profiles/default +++ b/conan/profiles/default @@ -1,10 +1,7 @@ {% set os = detect_api.detect_os() %} {% set arch = detect_api.detect_arch() %} {% set compiler, version, compiler_exe = detect_api.detect_default_compiler() %} -{% set compiler_version = version %} -{% if os == "Linux" %} {% set compiler_version = detect_api.default_compiler_version(compiler, version) %} -{% endif %} {% if os == "Macos" %} {# Minimum macOS the dependencies target. #} {# Without this, Conan builds each dependency against the (possibly newer) host SDK, so the #} diff --git a/conanfile.py b/conanfile.py index 77be8a24c5..d0cb95a0e6 100644 --- a/conanfile.py +++ b/conanfile.py @@ -28,6 +28,7 @@ class Xrpl(ConanFile): } requires = [ + "corrosion/0.6.1", "ed25519/2015.03", "fast_float/8.2.10", "grpc/1.81.1", diff --git a/crates/.cargo/config.toml b/crates/.cargo/config.toml new file mode 100644 index 0000000000..fc29aa80f7 --- /dev/null +++ b/crates/.cargo/config.toml @@ -0,0 +1,17 @@ +# The Rust static libraries are linked into C++ targets, so the runtime linkage +# here has to match what the C++ build uses (see cmake/XrplCompiler.cmake). +# +# macOS needs nothing: AppleClang cannot link libgcc/libc++ statically, so the +# C++ build skips those flags on Apple as well. + +# Both amd64 and arm64 Linux builds link libgcc statically. This only affects +# links that rustc itself drives (`cargo test` binaries and the like) — the +# `staticlib` crates consumed by CMake are archived, not linked, so rustc +# silently ignores link args for them. Keeping libgcc_s.so.1 off the xrpld link +# line is handled in crates/CMakeLists.txt instead. +[target.'cfg(target_os = "linux")'] +rustflags = ["-C", "link-args=-static-libgcc"] + +# Windows builds use the static MSVC runtime. +[target.'cfg(windows)'] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/crates/CMakeLists.txt b/crates/CMakeLists.txt new file mode 100644 index 0000000000..3f83045cdb --- /dev/null +++ b/crates/CMakeLists.txt @@ -0,0 +1,104 @@ +find_package(Corrosion REQUIRED) + +corrosion_import_crate(MANIFEST_PATH ${CMAKE_CURRENT_SOURCE_DIR}/Cargo.toml) + +# The generated C++ lands in the build tree, so put a .clang-tidy next to it to +# keep clang-tidy from analyzing code we don't own. +configure_file( + generated.clang-tidy + "${CMAKE_CURRENT_BINARY_DIR}/.clang-tidy" + COPYONLY +) + +add_custom_target(xrpl_crates) +add_dependencies(tidy_prerequisites xrpl_crates) + +# On macOS, ld warns `ignoring duplicate libraries` when linking a crate. +# Corrosion is the source of both duplicates it names: +# +# * The crate archive and its cxxbridge archive, because +# `corrosion_add_cxxbridge` makes the two depend on each other, and CMake +# repeats a static library cycle on the link line so single-pass linkers can +# resolve it. (LINK_INTERFACE_MULTIPLICITY can only raise that count.) +# * `-lSystem`, which Corrosion copies from rustc's `native-static-libs` even +# though the compiler driver always links libSystem. +# +# ld needs neither: it resolves the cycle from one copy of each archive and +# links libSystem once. So silence the warning rather than rewrite Corrosion's +# link interface, which the cycle is also part of. The option itself is old — +# Xcode 15 is only where the warning became the default — and the check below +# leaves it out on a linker that does not know it. +if(is_macos) + include(CheckLinkerFlag) + check_linker_flag( + CXX + -Wl,-no_warn_duplicate_libraries + have_no_warn_duplicate_libraries + ) +endif() + +function(_unlink_libgcc_s crate) + if(NOT (is_linux AND static)) + return() + endif() + + # Corrosion exposes a crate's staticlib as an imported `-static` + # target and puts the native libs in its INTERFACE_LINK_LIBRARIES. If either + # of those changes, warn instead of silently letting libgcc_s.so.1 return. + set(imported "${crate}-static") + if(NOT TARGET ${imported}) + message( + FATAL_ERROR + "Corrosion did not create the imported target '${imported}', so " + "libgcc_s cannot be removed from the link interface of '${crate}'. " + "xrpld will link libgcc_s.so.1 dynamically. Check where Corrosion " + "${CORROSION_VERSION} now records `native-static-libs`." + ) + return() + endif() + + get_target_property(libs ${imported} INTERFACE_LINK_LIBRARIES) + if(NOT "gcc_s" IN_LIST libs) + message( + WARNING + "'gcc_s' was not in the link interface of '${imported}' as " + "expected. If the Rust toolchain stopped reporting it this " + "workaround is obsolete and can be deleted; otherwise xrpld may " + "link libgcc_s.so.1 dynamically. Verify with: " + "objdump -p xrpld | grep NEEDED" + ) + return() + endif() + + list(REMOVE_ITEM libs gcc_s) + set_property(TARGET ${imported} PROPERTY INTERFACE_LINK_LIBRARIES ${libs}) +endfunction() + +function(add_xrpl_crate name) + cmake_parse_arguments(ARG "" "CRATE" "FILES" ${ARGN}) + _unlink_libgcc_s(${ARG_CRATE}) + # `cc` picks its runtime flag from `crt-static` alone, so it compiles a + # crate's C++ with `-MT`; Debug needs `-MTd` (to match cmake/XrplCompiler.cmake). + if(is_msvc) + corrosion_set_env_vars( + ${ARG_CRATE} + "$<$:CXXFLAGS=-MTd>" + ) + endif() + corrosion_add_cxxbridge(${name}_cxxbridge CRATE ${ARG_CRATE} FILES + ${ARG_FILES} + ) + # Generated cxxbridge headers don't exist at configure time; CMake 3.28+ + # validates INTERFACE_SOURCES on consuming targets. Clear it to skip the + # existence check — build-time ordering is enforced by the custom commands. + set_target_properties(${name}_cxxbridge PROPERTIES INTERFACE_SOURCES "") + if(have_no_warn_duplicate_libraries) + target_link_options( + ${name}_cxxbridge + INTERFACE -Wl,-no_warn_duplicate_libraries + ) + endif() + add_dependencies(xrpl_crates ${name}_cxxbridge) +endfunction() + +add_xrpl_crate(rs_hello_world CRATE rs_hello_world FILES lib.rs) diff --git a/crates/Cargo.lock b/crates/Cargo.lock new file mode 100644 index 0000000000..bc38558c16 --- /dev/null +++ b/crates/Cargo.lock @@ -0,0 +1,301 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "cxx" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2" +dependencies = [ + "cc", + "codespan-reporting", + "indexmap", + "proc-macro2", + "quote", + "scratch", + "syn 3.0.3", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d" +dependencies = [ + "clap", + "codespan-reporting", + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "link-cplusplus" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +dependencies = [ + "cc", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rs-hello_world" +version = "0.1.0" +dependencies = [ + "cxx", +] + +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/crates/Cargo.toml b/crates/Cargo.toml new file mode 100644 index 0000000000..0bb0e9c550 --- /dev/null +++ b/crates/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = ["hello_world"] +resolver = "3" + +[workspace.dependencies] +cxx = { version = "1.0.198", features = ["c++20"] } + +[workspace.package] +edition = "2024" + +[profile.release] +opt-level = 3 +overflow-checks = true +lto = true +debug = true diff --git a/crates/generated.clang-tidy b/crates/generated.clang-tidy new file mode 100644 index 0000000000..8e2202d44a --- /dev/null +++ b/crates/generated.clang-tidy @@ -0,0 +1,10 @@ +--- +# Neutralizes clang-tidy for the corrosion/cxxbridge-generated C++. Copied into +# the crates build directory by crates/CMakeLists.txt, next to the generated +# sources, so clang-tidy picks it up instead of the top-level configuration. +# +# One check is kept enabled to avoid clang-tidy's "no checks enabled" error. +Checks: "-*,google-readability-todo" +WarningsAsErrors: "" +HeaderFilterRegex: "" +InheritParentConfig: false diff --git a/crates/hello_world/Cargo.toml b/crates/hello_world/Cargo.toml new file mode 100644 index 0000000000..2e5a329c9a --- /dev/null +++ b/crates/hello_world/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "rs-hello_world" +version = "0.1.0" +edition.workspace = true + +[lib] +crate-type = ["staticlib"] + +[dependencies] +cxx.workspace = true diff --git a/crates/hello_world/src/lib.rs b/crates/hello_world/src/lib.rs new file mode 100644 index 0000000000..b1cb121fa0 --- /dev/null +++ b/crates/hello_world/src/lib.rs @@ -0,0 +1,10 @@ +#[cxx::bridge(namespace = "rs::hello_world")] +mod ffi { + extern "Rust" { + fn hello_world() -> String; + } +} + +pub fn hello_world() -> String { + "hello_world".to_string() +} diff --git a/docs/NodeStoreRefactoringCaseStudy.pdf b/docs/NodeStoreRefactoringCaseStudy.pdf deleted file mode 100644 index 6cde8a2eed..0000000000 Binary files a/docs/NodeStoreRefactoringCaseStudy.pdf and /dev/null differ diff --git a/docs/build/environment.md b/docs/build/environment.md index e639ed2d5f..51580b12a5 100644 --- a/docs/build/environment.md +++ b/docs/build/environment.md @@ -6,22 +6,55 @@ This document explains how to set one up. ## Tested compiler versions -`xrpld` is built in the **C++23** dialect by default. -Make sure your toolchain is recent enough — the compiler versions currently tested in CI are: +`xrpld` is built in the **C++23** dialect by default, so your toolchain has to +support it — see [compiler support for C++23][cpp23-support]. +The versions currently tested in CI are: -| Compiler | Version | -| ----------- | ------- | -| GCC | 15.2 | -| Clang | 22 | -| Apple Clang | 17 | -| MSVC | 19.44 | +| Compiler | Version | +| ----------- | ------------------ | +| GCC | 15.2 | +| Clang | 22 | +| Apple Clang | 21 | +| MSVC | Visual Studio 2026 | LLVM tools (`clang-tidy` and `clang-format`) are also pinned to version 22. +### Older compilers + Older compilers may fail to build the latest `develop` code: the codebase now relies on C++23 features and has been adjusted for `clang-tidy`. If the latest code doesn't build for you, update your build toolchain first. +If updating isn't an option for you, we do accept pull requests that fix builds +on older compilers, as long as the change is small and doesn't make the code +harder to read. What we can't promise is that older compilers will keep working: +only the versions in the table above are tested in CI, and we won't hold back +the use of C++23 features or add invasive workarounds to keep an untested +compiler building. Treat support for anything outside the table as best-effort. + +## Required tools + +Besides a compiler, building `xrpld` requires: + +| Tool | Minimum version | +| ------------------------------------------- | --------------- | +| [Git](https://git-scm.com/downloads) | any recent | +| [Python](https://www.python.org/downloads/) | 3.11 | +| [Conan](https://conan.io/downloads.html) | 2.17 | +| [CMake](https://cmake.org/download/) | 3.16 | + +On Linux and macOS, the [Nix development shell](./nix.md) provides all of them +(see below). On Windows they have to be installed manually. + +Building with `-Drust=ON` additionally requires a Rust toolchain, see +[Rust](#rust). A default build does not, so it is not in the table above. + +Once they are in place, verify that everything is installed and runnable with: + +```bash +./bin/check-tools.sh +``` + ## Linux and macOS The **recommended way** to get a development environment on Linux and macOS is @@ -39,20 +72,15 @@ Clang. If you instead opt to use your system-wide Apple Clang (via below). See [Using the Nix development shell](./nix.md) for installation and usage -details, including how to select a different compiler. - -> [!NOTE] -> Using Nix is not mandatory. Any custom environment (Homebrew packages or -> anything else) will continue to work, but then it is up to you to keep it in -> sync with the environment used in CI. Nix unifies the development environment -> for everyone and synchronizes updates, which is why we recommend it. +details, including how to select a different compiler and why we recommend Nix +over a hand-maintained environment. ### macOS: managing the Apple Clang version If you use your system-wide Apple Clang on macOS (via `nix develop .#apple-clang`), the compiler version is whatever your installed Xcode (or Command Line Tools) provides. The following command should return a version greater than or equal to -the [minimum required](#tested-compiler-versions): +the [tested one](#tested-compiler-versions): ```bash clang --version @@ -89,23 +117,42 @@ building xrpld. You may want to install and pin a specific version of Xcode: Nix is not available on Windows, so the required tools have to be installed manually: -- [Visual Studio 2022](https://visualstudio.microsoft.com/) with the +- [Visual Studio 2026](https://visualstudio.microsoft.com/) with the **"Desktop development with C++"** workload — this provides MSVC and the - "x64 Native Tools Command Prompt". + "x64 Native Tools Command Prompt". CI configures CMake with the + `Visual Studio 18 2026` generator. - [Git for Windows](https://git-scm.com/download/win) -- [Python 3.11](https://www.python.org/downloads/), or higher -- [Conan 2.17](https://conan.io/downloads.html), or higher -- [CMake 3.22](https://cmake.org/download/), or higher +- Python, Conan, and CMake, at the versions listed in + [Required tools](#required-tools). +- a [Rust toolchain](https://rustup.rs) — only needed to build with + `-Drust=ON`, see [Rust](#rust) -> [!NOTE] -> Windows is used for development only and is not recommended for production. +## Rust + +The repository contains a Rust workspace in [`crates/`](../../crates), whose +crates are exposed to C++ through [cxx](https://cxx.rs) bindings. It is **not** +part of a default build: the CMake `rust` option is OFF by default, and with it +off no Rust toolchain is needed. It is only required when configuring with +`-Drust=ON` (which is what CI does), see [Options](../../BUILD.md#options). + +The toolchain (`cargo`, `rustc`) is pinned to the channel in +[`rust-toolchain.toml`](../../rust-toolchain.toml) at the repository root. If +you install Rust with [rustup](https://rustup.rs), that file is picked up +automatically, and `cargo`/`rustc` in the repository will use the pinned +version. + +Everything else the Rust build needs on the CMake side comes from Conan along +with the rest of the dependencies, so there is nothing further to install. ## Clang-tidy `clang-tidy` is required to run static analysis checks locally (see [CONTRIBUTING.md](../../CONTRIBUTING.md)). It is not required to build the -project. This project currently uses `clang-tidy` version 22. +project. The version this project uses is listed in +[Tested compiler versions](#tested-compiler-versions). -On Linux and macOS, the [Nix development shell](./nix.md) provides `clang-tidy` -22 out of the box — run it via `run-clang-tidy`. No separate installation is -needed. +On Linux and macOS, the [Nix development shell](./nix.md) provides that exact +version out of the box — run it via `run-clang-tidy`. No separate installation +is needed. + +[cpp23-support]: https://en.cppreference.com/w/cpp/compiler_support/23 diff --git a/docs/build/nix.md b/docs/build/nix.md index d0001294e3..0b701b39f3 100644 --- a/docs/build/nix.md +++ b/docs/build/nix.md @@ -7,7 +7,7 @@ This guide explains how to use Nix to set up a reproducible development environm ## Benefits of Using Nix - **Reproducible environment**: Everyone gets the same versions of tools and compilers -- **Matches CI**: The Linux CI runs in Docker images built from this exact Nix environment +- **Matches CI**: The Linux CI runs in Docker images built from this exact Nix environment, and CI builds some macOS configurations in it as well - **No system pollution**: Dependencies are isolated and don't affect your system packages - **Consistent compilers**: The GCC and Clang shells use the same versions as CI - **Quick setup**: Get started with a single command @@ -68,7 +68,7 @@ A compiler can be chosen by providing its name with the `.#` prefix, e.g. `nix d On Linux, `.#gcc` and `.#clang` provide the exact toolchain CI uses: the compiler (pinned in [`nix/packages.nix`](../../nix/packages.nix)) -rebuilt against the pinned custom glibc (see [`nix/compilers.nix`](../../nix/compilers.nix)). +rebuilt against the pinned custom glibc (see [`nix/linux.nix`](../../nix/linux.nix)). Building that toolchain the first time is slow unless it is fetched from a Nix binary cache. If you don't need the custom glibc, the Linux-only `.#gcc-plain` and `.#clang-plain` give you the stock nixpkgs compilers of the same versions. @@ -120,7 +120,7 @@ nix develop -c "$SHELL" > > If it doesn't, either adjust your shell configuration so it doesn't override `$PATH`, or use [direnv](#automatic-activation-with-direnv) (below), which loads the environment _after_ your shell config and so takes precedence regardless of the shell you use. -## Building xrpld with Nix +## Building xrpld in the Nix shell Once inside the Nix development shell, follow the standard [build instructions](../../BUILD.md#steps). The Nix shell provides all necessary tools (CMake, Ninja, Conan, etc.). @@ -128,6 +128,100 @@ Coverage builds (`-Dcoverage=ON`) work in the `gcc` shell (and `gcc-plain` on Li each ships a `gcov` matching its compiler, since Nix's cc-wrapper does not expose one. The `clang` shells do not include `llvm-cov`, so use a `gcc` shell for coverage. +Builds of the Rust crates (`-Drust=ON`) also work out of the box: every shell +provides the Rust toolchain pinned in +[`rust-toolchain.toml`](../../rust-toolchain.toml) (see +[Rust](./environment.md#rust)), plus the `cargo-audit`, `cargo-llvm-cov` and +`cargo-nextest` plugins. + +## Conan configuration + +The shell runs [`conan/init.sh`](../../conan/init.sh) on entry, so +[Set Up Conan](../../BUILD.md#set-up-conan) is already done for you. It installs +into the shell's own Conan home: `CONAN_HOME=~/.conan2-nix`. + +### Prebuilt packages + +On **Linux**, the binaries on the `xrplf` remote are built in this same Nix +environment — CI runs in Docker images that bundle the dev shell's toolchain (see +[`nix/docker`](../../nix/docker)) — so `.#gcc` and `.#clang` can reuse them. The +`-plain` shells do not match that toolchain's glibc, so binaries from the remote +are not a reliable match there. + +On **macOS**, CI also builds in this Nix environment, in Debug and Release (the +`macos-arm64-*-nix` configurations — Debug because the profile defaults to it). +The Nix build resolves to `compiler=clang`, so it gets its own package IDs, +separate from the Apple Clang ones. The +[dependency upload](../../.github/workflows/upload-conan-deps.yml) publishes them +on pushes to `develop` and on manual runs — its nightly run rebuilds everything +from source but uploads nothing — so once a set has been published `nix develop` +can reuse it instead of compiling every dependency locally. These configurations +run outside the reduced pull-request matrix, so label a PR `Full CI build` when it +touches `flake.lock` or `nix/`. + +To compile everything from source, add `--build '*'` to the `conan install` +command. + +### Why the nixpkgs revision is not part of the package ID + +A Conan package ID records the compiler and its major version, but nothing about +the nixpkgs revision the toolchain came from — and `flake.lock` moves far more +often than the toolchain meaningfully changes, so folding it in would rebuild +every dependency on every bump for nothing. + +That is safe as long as no cached artifact resolves a `/nix/store` path at run +time, because store paths change on every update and the old ones disappear with +`nix-collect-garbage`. With the `clang` toolchain macOS CI and the dev shell use, +they do not: it links against `/usr/lib/libc++` and `/usr/lib/libSystem`, and +store paths reach the `.a` files only through debug info, which nothing resolves +at link or run time. + +> [!WARNING] +> This does not hold for `nix develop .#gcc` on macOS. There is no system +> libstdc++, so GCC links its own from the store and every binary keeps a +> `/nix/store` reference. That shell is fine for tooling, but it is not a build +> configuration CI covers, and no dependency binaries are published for it. + +This is checked rather than assumed. +[`bin/check-nix-store-refs.sh`](../../bin/check-nix-store-refs.sh) takes one file +or directory and fails if a binary under it resolves a store path at run time. +CI runs it over the build output and the Conan cache, and again in the upload job +before anything is published. You can run it yourself: + +```bash +bin/check-nix-store-refs.sh build +bin/check-nix-store-refs.sh ~/.conan2-nix +``` + +It works on Linux too, but asserts something narrower there: the toolchain always +writes the store into `PT_INTERP` and `RUNPATH`, and CI builds inside an image +whose store is fixed for its lifetime, so that is fine. Only the binaries +[`PatchNixBinary.cmake`](../../cmake/PatchNixBinary.cmake) retargets to the +system loader have to be clean, and those are what CI checks: + +```bash +bin/check-nix-store-refs.sh build/xrpld +``` + +### The libresolv stub + +This is not hypothetical: `xrpld` used to be caught by it. The c-ares package +tells the linker to pass `-lresolv`, and nixpkgs keeps `libresolv` out of the +macOS SDK and ships it as an ordinary store dylib — so every Nix-built `xrpld` +recorded a `/nix/store/…-libresolv-93/lib/libresolv.9.dylib` load command and +stopped running once that path was collected. Nothing in the link uses a single +symbol from it. + +Both environments now put a stub on the linker search path +(`libresolvSystemStub` in [`nix/darwin.nix`](../../nix/darwin.nix)): the +same library with its install name set to `/usr/lib/libresolv.9.dylib`, which is +exactly the load command the Apple Clang build records. + +Package IDs did not change, so Conan keeps serving anything built before the +stub landed. If a binary fails to start with `Library not loaded: /nix/store/…`, +see [that entry](./nix_troubleshooting.md#library-not-loaded-nixstore-from-a-binary-that-used-to-work) +in the troubleshooting guide. + ## Automatic Activation with direnv [direnv](https://direnv.net/) or [nix-direnv](https://github.com/nix-community/nix-direnv) can automatically activate the Nix development shell when you enter the repository directory. @@ -142,14 +236,6 @@ The repository already ships an `.envrc` at its root that activates the Nix flak > [!NOTE] > direnv only caches the `.direnv` directory (already listed in `.gitignore`); no other repository files are affected. -## Conan and Prebuilt Packages - -Please note that there is no guarantee that binaries from conan cache will work when using nix. If you encounter any errors, please use `--build '*'` to force conan to compile everything from source: - -```bash -conan install .. --output-folder . --build '*' --settings build_type=Release -``` - ## Updating `flake.lock` file To update `flake.lock` to the latest revision use `nix flake update` command. diff --git a/docs/build/nix_troubleshooting.md b/docs/build/nix_troubleshooting.md index fa766c0ee9..49088ab6b4 100644 --- a/docs/build/nix_troubleshooting.md +++ b/docs/build/nix_troubleshooting.md @@ -131,3 +131,91 @@ once it picks up that rebuild, then re-run the `grep libgit2` check above to confirm it reports `1.9.4` or newer. Until then, prefer the workarounds above. + +## `wint_t` / `uint32_t` errors from the Nix libc++ headers + +A build that mixes the Nix toolchain with the system SDK fails in libc++ itself, +with errors that look nothing like your code: + +``` +/nix/store/...-libcxx-.../include/c++/v1/cwchar:136:9: error: target of using declaration conflicts with declaration already in scope + 136 | using ::wint_t _LIBCPP_USING_IF_EXISTS; +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/_types/_wint_t.h:32:25: note: target of using declaration +... +error: use of undeclared identifier 'UINT32_C' +``` + +The give-away is the second path: Nix's libc++ headers are being combined with +the **Xcode Command Line Tools** SDK instead of the Nix one. + +### Why it happens + +`SDKROOT` and `DEVELOPER_DIR` are what point the toolchain at the Nix SDK, and +they are not baked into the compiler — a dev shell gets them from the +`apple-sdk` setup hook. CMake, finding neither, asks `xcrun`, which answers with +the system SDK. Nix's `libc++` and Apple's headers then declare the same types +twice. + +### Fix + +Run the build from inside the dev shell (`nix develop`), or from an environment +that exports both variables. To confirm which SDK a configured build is using: + +```bash +grep -o '\-isysroot [^ ]*' build/compile_commands.json | sort -u +``` + +It should print a `/nix/store/...-apple-sdk-*` path. If it prints +`/Library/Developer/CommandLineTools/...`, re-configure from within the shell — +CMake caches the sysroot, so an existing `build/` directory keeps the wrong one. + +## `Library not loaded: /nix/store/…` from a binary that used to work + +A binary stops starting after a `nix flake update`, or after +`nix-collect-garbage` removes the paths the previous toolchain used: + +``` +dyld[57271]: Library not loaded: /nix/store/…-libresolv-93/lib/libresolv.9.dylib +``` + +[`bin/check-nix-store-refs.sh`](../../bin/check-nix-store-refs.sh) finds the same +thing without having to run anything, and names the file: + +``` +$ bin/check-nix-store-refs.sh ~/.conan2-nix +::error file=/Users/you/.conan2-nix/p/b/c-area24ded30c388c/p/bin/adig::references the Nix store at run time +/Users/you/.conan2-nix/p/b/c-area24ded30c388c/p/bin/adig + /nix/store/p4lp3xq4imd1qzqh08x8vcq2zfhi7rca-libresolv-93/lib/libresolv.9.dylib +/Users/you/.conan2-nix: checked 135, skipped 2495, 1 with Nix store references. +``` + +Conan's cache folders are named after a truncated package name plus a hash, so +ask Conan which package the offending one belongs to — pass the folder holding +the hash, not the file itself: + +``` +$ conan cache ref ~/.conan2-nix/p/b/c-area24ded30c388c +c-ares/1.34.6#545240bb1c40e2cacd4362d6b8967650:dab5992496abe6d219defb7986ecbf367615a5e5#… +``` + +### Why it happens + +The binary records a store path that no longer exists. Nothing we build should: +see [Prebuilt packages](./nix.md#prebuilt-packages) for why, and +`libresolvSystemStub` in [`nix/darwin.nix`](../../nix/darwin.nix) for the one +dependency that needed help to comply. + +A Conan package ID does not encode the nixpkgs revision, so a package built +before that stub existed stays in your local cache and keeps being reused. The +dev shell is also what tends to produce one: it is a slightly _less_ isolated +build environment than CI's, because `mkShell` puts every tool's headers and +libraries on the compiler's search path — which is how c-ares found the Nix +`libresolv` in the first place. + +### Fix + +Drop that package and let Conan refetch or rebuild it: + +```bash +conan remove 'c-ares/*' +``` diff --git a/docs/build/install.md b/docs/install-legacy.md similarity index 87% rename from docs/build/install.md rename to docs/install-legacy.md index d3ce1e9d87..0a6800f17f 100644 --- a/docs/build/install.md +++ b/docs/install-legacy.md @@ -1,3 +1,10 @@ +# Installing xrpld 3.3.0 and earlier + +> [!IMPORTANT] +> These instructions apply to xrpld 3.3.0 and earlier, published to +> repos.ripple.com. +> For later releases see [install.md](./install.md). + This document contains instructions for installing xrpld. The APT package manager is common on Debian-based Linux distributions like Ubuntu, @@ -52,7 +59,7 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and 5. Add the appropriate XRPL repository for your operating system version: - echo "deb [signed-by=/usr/local/share/keyrings/ripple-key.gpg] https://repos.ripple.com/repos/xrpld-deb focal stable" | \ + echo "deb [signed-by=/usr/local/share/keyrings/ripple-key.gpg] https://repos.ripple.com/repos/rippled-deb focal stable" | \ sudo tee -a /etc/apt/sources.list.d/ripple.list The above example is appropriate for **Ubuntu 20.04 Focal Fossa**. For other operating systems, replace the word `focal` with one of the following: @@ -106,8 +113,8 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and enabled=1 gpgcheck=0 repo_gpgcheck=1 - baseurl=https://repos.ripple.com/repos/xrpld-rpm/stable/ - gpgkey=https://repos.ripple.com/repos/xrpld-rpm/stable/repodata/repomd.xml.key + baseurl=https://repos.ripple.com/repos/rippled-rpm/stable/ + gpgkey=https://repos.ripple.com/repos/rippled-rpm/stable/repodata/repomd.xml.key REPOFILE _Unstable_ @@ -118,8 +125,8 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and enabled=1 gpgcheck=0 repo_gpgcheck=1 - baseurl=https://repos.ripple.com/repos/xrpld-rpm/unstable/ - gpgkey=https://repos.ripple.com/repos/xrpld-rpm/unstable/repodata/repomd.xml.key + baseurl=https://repos.ripple.com/repos/rippled-rpm/unstable/ + gpgkey=https://repos.ripple.com/repos/rippled-rpm/unstable/repodata/repomd.xml.key REPOFILE _Nightly_ @@ -130,8 +137,8 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and enabled=1 gpgcheck=0 repo_gpgcheck=1 - baseurl=https://repos.ripple.com/repos/xrpld-rpm/nightly/ - gpgkey=https://repos.ripple.com/repos/xrpld-rpm/nightly/repodata/repomd.xml.key + baseurl=https://repos.ripple.com/repos/rippled-rpm/nightly/ + gpgkey=https://repos.ripple.com/repos/rippled-rpm/nightly/repodata/repomd.xml.key REPOFILE 2. Fetch the latest repo updates: diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000000..9699150fdb --- /dev/null +++ b/docs/install.md @@ -0,0 +1,144 @@ +# Installing xrpld + +> [!NOTE] +> These instructions apply to packages published from 2026-08-19 onwards. +> For xrpld 3.3.0 and earlier see [install-legacy.md](./install-legacy.md). + +`xrpld` is published as DEB and RPM packages for 64-bit x86 Linux. +Use APT on Debian-based distributions such as Debian and Ubuntu, +and YUM on Red Hat-based distributions such as RHEL, AlmaLinux, and Rocky Linux. +To build from source instead, see [BUILD.md](../BUILD.md). + +## Release channels + +Packages are published to four channels: + +- `stable` - the latest production release +- `unstable` - release candidates +- `experimental` - 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. + +The instructions below use `stable`. +To follow another channel, replace `stable` with its name +wherever it appears in the repository configuration. + +> [!WARNING] +> Channels other than `stable` may be broken at any time. +> Do not use them for production servers. + +## Install the xrpld package + +### With the APT package manager + +1. Install utilities: + + ```bash + sudo apt update -y + sudo apt install -y apt-transport-https ca-certificates curl gnupg + ``` + +2. Add the XRPL Foundation package-signing key to your list of trusted keys: + + ```bash + sudo install -d -m 0755 /etc/apt/keyrings + sudo curl -fsS https://packages.xrplf.org/xrplf.asc -o /etc/apt/keyrings/xrplf.asc + ``` + +3. Check the fingerprint of the newly-added key: + + ```bash + gpg --show-keys /etc/apt/keyrings/xrplf.asc + ``` + + The output should be: + + ```text + pub rsa4096 2026-08-18 [SC] + B655416741221F780FBCFBC9AA84D41A11D29FA9 + uid XRPLF Packages + ``` + + In particular, make sure that the fingerprint matches. + +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" | \ + sudo tee /etc/apt/sources.list.d/xrplf.list + ``` + +5. Fetch the repository: + + ```bash + sudo apt -y update + ``` + +6. Install the `xrpld` software package: + + ```bash + sudo apt -y install xrpld + ``` + +### With the YUM package manager + +1. Add the XRPL Foundation package-signing key: + + ```bash + sudo rpm --import https://packages.xrplf.org/xrplf.asc + ``` + +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 + [xrplf-stable] + name=XRP Ledger Packages + enabled=1 + baseurl=https://packages.xrplf.org/repository/rpm-stable/ + gpgcheck=1 + repo_gpgcheck=0 + 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. + +3. Install the `xrpld` package: + + ```bash + sudo yum install -y xrpld + ``` + +## The xrpld service + +Both package managers install a systemd unit and enable it, so `xrpld` starts on boot. +Check whether it is already running: + +```bash +systemctl status xrpld.service +``` + +The APT packages start it immediately as well; the YUM packages do not, so start it yourself: + +```bash +sudo systemctl start xrpld.service +``` + +### Optional: binding to privileged ports + +To serve incoming API requests on port 80 or 443, grant the service the capability to bind them. +You must also update the config file's port settings. + +```bash +sudo install -d -m 0755 /etc/systemd/system/xrpld.service.d +sudo tee /etc/systemd/system/xrpld.service.d/privileged-ports.conf >/dev/null <<'EOF' +[Service] +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +AmbientCapabilities=CAP_NET_BIND_SERVICE +EOF +sudo systemctl daemon-reload +sudo systemctl restart xrpld.service +``` diff --git a/docs/sample_chart.doc b/docs/sample_chart.doc deleted file mode 100644 index 631c0554b2..0000000000 --- a/docs/sample_chart.doc +++ /dev/null @@ -1,24 +0,0 @@ -/*! - \page somestatechart Example state diagram - - \startuml SomeState "my state diagram" - scale 600 width - - [*] -> State1 - State1 --> State2 : Succeeded - State1 --> [*] : Aborted - State2 --> State3 : Succeeded - State2 --> [*] : Aborted - state State3 { - state "Accumulate Enough Data\nLong State Name" as long1 - long1 : Just a test - [*] --> long1 - long1 --> long1 : New Data - long1 --> ProcessData : Enough Data - } - State3 --> State3 : Failed - State3 --> [*] : Succeeded / Save Result - State3 --> [*] : Aborted - - \enduml -*/ diff --git a/include/xrpl/basics/Archive.h b/include/xrpl/basics/Archive.h index 66d6a019af..67261352e9 100644 --- a/include/xrpl/basics/Archive.h +++ b/include/xrpl/basics/Archive.h @@ -1,6 +1,6 @@ #pragma once -#include +#include namespace xrpl { @@ -13,6 +13,6 @@ namespace xrpl { * @throws runtime_error */ void -extractTarLz4(boost::filesystem::path const& src, boost::filesystem::path const& dst); +extractTarLz4(std::filesystem::path const& src, std::filesystem::path const& dst); } // namespace xrpl diff --git a/include/xrpl/basics/Buffer.h b/include/xrpl/basics/Buffer.h index 05af6c409a..00a6b7ecf9 100644 --- a/include/xrpl/basics/Buffer.h +++ b/include/xrpl/basics/Buffer.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -156,6 +157,19 @@ public: } /** @} */ + /** + * Set every byte in the buffer to the given value. + * + * The size is unchanged, and this is a no-op on an empty buffer. + * + * @param value the byte to write to every position. + */ + void + fill(std::uint8_t value) noexcept + { + std::fill_n(p_.get(), size_, value); + } + /** * Reset the buffer. * All memory is deallocated. The resulting size is 0. @@ -226,10 +240,4 @@ operator==(Buffer const& lhs, Buffer const& rhs) noexcept return std::memcmp(lhs.data(), rhs.data(), lhs.size()) == 0; } -inline bool -operator!=(Buffer const& lhs, Buffer const& rhs) noexcept -{ - return !(lhs == rhs); -} - } // namespace xrpl diff --git a/include/xrpl/basics/FileUtilities.h b/include/xrpl/basics/FileUtilities.h index c7a427b8a9..ca3435be03 100644 --- a/include/xrpl/basics/FileUtilities.h +++ b/include/xrpl/basics/FileUtilities.h @@ -1,24 +1,79 @@ #pragma once -#include -#include - #include +#include #include #include +#include namespace xrpl { std::string getFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& sourcePath, + std::error_code& ec, + std::filesystem::path const& sourcePath, std::optional maxSize = std::nullopt); void writeFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& destPath, + std::error_code& ec, + std::filesystem::path const& destPath, std::string const& contents); +/** + * Generate a unique, non-existing path under @p base whose filename starts with + * @p prefix and ends with a random hex suffix. + * + * Attempts up to @p maxAttempts paths. Throws `std::runtime_error` if a unique + * path cannot be found or if the filesystem returns an error while checking for + * existence. + */ +std::filesystem::path +uniqueRandomPath( + std::filesystem::path const& base, + std::string const& prefix = "", + std::size_t maxAttempts = 100); + +/** + * RAII temporary directory. + * + * The directory and all its contents are deleted when + * the instance of `TempDir` is destroyed. + */ +class TempDir +{ + std::filesystem::path path_; + +public: +#if !GENERATING_DOCS + TempDir(TempDir const&) = delete; + TempDir& + operator=(TempDir const&) = delete; +#endif + + /** + * Construct a temporary directory. + */ + TempDir(); + + /** + * Destroy a temporary directory. + */ + ~TempDir(); + + /** + * Get the native path for the temporary directory. + */ + [[nodiscard]] std::string + path() const; + + /** + * Get the native path for a file. + * + * The file does not need to exist. + */ + [[nodiscard]] std::string + file(std::string const& name) const; +}; + } // namespace xrpl diff --git a/include/xrpl/basics/IntrusivePointer.h b/include/xrpl/basics/IntrusivePointer.h index 59853ad4d0..b978016860 100644 --- a/include/xrpl/basics/IntrusivePointer.h +++ b/include/xrpl/basics/IntrusivePointer.h @@ -96,9 +96,6 @@ public: SharedIntrusive& operator=(SharedIntrusive const& rhs); - bool - operator!=(std::nullptr_t) const; - bool operator==(std::nullptr_t) const; diff --git a/include/xrpl/basics/IntrusivePointer.ipp b/include/xrpl/basics/IntrusivePointer.ipp index 67d43b05d6..6c2a71f7eb 100644 --- a/include/xrpl/basics/IntrusivePointer.ipp +++ b/include/xrpl/basics/IntrusivePointer.ipp @@ -111,13 +111,6 @@ SharedIntrusive::operator=(SharedIntrusive&& rhs) return *this; } -template -bool -SharedIntrusive::operator!=(std::nullptr_t) const -{ - return this->get() != nullptr; -} - template bool SharedIntrusive::operator==(std::nullptr_t) const diff --git a/include/xrpl/basics/Log.h b/include/xrpl/basics/Log.h index 945dc1b4ec..3aceac5f4a 100644 --- a/include/xrpl/basics/Log.h +++ b/include/xrpl/basics/Log.h @@ -3,8 +3,8 @@ #include #include -#include +#include #include #include #include @@ -84,7 +84,7 @@ private: * @return `true` if the file was opened. */ bool - open(boost::filesystem::path const& path); + open(std::filesystem::path const& path); /** * Close and re-open the system file associated with the log @@ -133,7 +133,7 @@ private: private: std::unique_ptr stream_; - boost::filesystem::path path_; + std::filesystem::path path_; }; std::mutex mutable mutex_; @@ -152,7 +152,7 @@ public: virtual ~Logs() = default; bool - open(boost::filesystem::path const& pathToLogFile); + open(std::filesystem::path const& pathToLogFile); beast::Journal::Sink& get(std::string const& name); diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index f90800c715..f6ce0b300d 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -304,7 +304,7 @@ concept Integral64 = std::is_same_v || std::is_same_v inline std::size_t extract(SHAMapHash const& key) diff --git a/include/xrpl/basics/Slice.h b/include/xrpl/basics/Slice.h index 75c9b8c7bd..92b777ab98 100644 --- a/include/xrpl/basics/Slice.h +++ b/include/xrpl/basics/Slice.h @@ -208,12 +208,6 @@ operator==(Slice const& lhs, Slice const& rhs) noexcept return std::memcmp(lhs.data(), rhs.data(), lhs.size()) == 0; } -inline bool -operator!=(Slice const& lhs, Slice const& rhs) noexcept -{ - return !(lhs == rhs); -} - inline bool operator<(Slice const& lhs, Slice const& rhs) noexcept { diff --git a/include/xrpl/basics/StringUtilities.h b/include/xrpl/basics/StringUtilities.h index 2b360d2fda..e3b91c2f25 100644 --- a/include/xrpl/basics/StringUtilities.h +++ b/include/xrpl/basics/StringUtilities.h @@ -2,7 +2,6 @@ #include -#include #include #include @@ -125,9 +124,31 @@ struct ParsedUrl bool parseUrl(ParsedUrl& pUrl, std::string const& strUrl); +/** + * Remove leading and trailing ASCII whitespace. + * + * Whitespace is the fixed set " \t\n\v\f\r"; the current locale is not + * consulted, so the result depends only on the input. + * + * @param str The string to trim. + * @return @p str without leading or trailing whitespace. + */ std::string trimWhitespace(std::string str); +/** + * Fold ASCII upper case letters to lower case. + * + * Only 'A' through 'Z' are remapped; every other byte is left alone and the + * current locale is not consulted, so the result depends only on the input. + * + * @param str The string to fold. + * @return @p str with each ASCII upper case letter replaced by its lower case + * equivalent. + */ +std::string +toLower(std::string str); + std::optional toUInt64(std::string const& s); diff --git a/include/xrpl/basics/partitioned_unordered_map.h b/include/xrpl/basics/partitioned_unordered_map.h index e78043e252..c6b0107b93 100644 --- a/include/xrpl/basics/partitioned_unordered_map.h +++ b/include/xrpl/basics/partitioned_unordered_map.h @@ -116,12 +116,6 @@ public: { return lhs.map == rhs.map && lhs.ait == rhs.ait && lhs.mit == rhs.mit; } - - friend bool - operator!=(Iterator const& lhs, Iterator const& rhs) - { - return !(lhs == rhs); - } }; struct ConstIterator @@ -189,12 +183,6 @@ public: { return lhs.map == rhs.map && lhs.ait == rhs.ait && lhs.mit == rhs.mit; } - - friend bool - operator!=(ConstIterator const& lhs, ConstIterator const& rhs) - { - return !(lhs == rhs); - } }; private: diff --git a/include/xrpl/beast/container/detail/aged_ordered_container.h b/include/xrpl/beast/container/detail/aged_ordered_container.h index 5b60ef7e6d..9dd83d466b 100644 --- a/include/xrpl/beast/container/detail/aged_ordered_container.h +++ b/include/xrpl/beast/container/detail/aged_ordered_container.h @@ -1038,25 +1038,6 @@ public: Compare, OtherAllocator> const& other) const; - template < - bool OtherIsMulti, - bool OtherIsMap, - class OtherT, - class OtherDuration, - class OtherAllocator> - bool - operator!=(AgedOrderedContainer< - OtherIsMulti, - OtherIsMap, - Key, - OtherT, - OtherDuration, - Compare, - OtherAllocator> const& other) const - { - return !(this->operator==(other)); - } - template < bool OtherIsMulti, bool OtherIsMap, diff --git a/include/xrpl/beast/container/detail/aged_unordered_container.h b/include/xrpl/beast/container/detail/aged_unordered_container.h index c4287b1ca1..ea271feed0 100644 --- a/include/xrpl/beast/container/detail/aged_unordered_container.h +++ b/include/xrpl/beast/container/detail/aged_unordered_container.h @@ -1340,28 +1340,6 @@ public: OtherAllocator> const& other) const requires MaybeMulti; - template < - bool OtherIsMulti, - bool OtherIsMap, - class OtherKey, - class OtherT, - class OtherDuration, - class OtherHash, - class OtherAllocator> - bool - operator!=(AgedUnorderedContainer< - OtherIsMulti, - OtherIsMap, - OtherKey, - OtherT, - OtherDuration, - OtherHash, - KeyEqual, - OtherAllocator> const& other) const - { - return !(this->operator==(other)); - } - private: bool wouldExceed(size_type additional) const diff --git a/include/xrpl/beast/core/List.h b/include/xrpl/beast/core/List.h index b9b6829d31..076ac3028b 100644 --- a/include/xrpl/beast/core/List.h +++ b/include/xrpl/beast/core/List.h @@ -82,13 +82,6 @@ public: return node_ == other.node_; } - template - bool - operator!=(ListIterator const& other) const noexcept - { - return !((*this) == other); - } - reference operator*() const noexcept { diff --git a/include/xrpl/beast/net/IPEndpoint.h b/include/xrpl/beast/net/IPEndpoint.h index d4d3b2ab12..a5fb5b4318 100644 --- a/include/xrpl/beast/net/IPEndpoint.h +++ b/include/xrpl/beast/net/IPEndpoint.h @@ -110,12 +110,6 @@ public: operator==(Endpoint const& lhs, Endpoint const& rhs); friend bool operator<(Endpoint const& lhs, Endpoint const& rhs); - - friend bool - operator!=(Endpoint const& lhs, Endpoint const& rhs) - { - return !(lhs == rhs); - } friend bool operator>(Endpoint const& lhs, Endpoint const& rhs) { diff --git a/include/xrpl/beast/rfc2616.h b/include/xrpl/beast/rfc2616.h index 1986568553..87d63b0260 100644 --- a/include/xrpl/beast/rfc2616.h +++ b/include/xrpl/beast/rfc2616.h @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace beast::rfc2616 { @@ -186,7 +187,7 @@ splitCommas(FwdIt first, FwdIt last) template > Result -splitCommas(boost::beast::string_view const& s) +splitCommas(std::string_view s) { return splitCommas(s.begin(), s.end()); } @@ -229,12 +230,6 @@ public: return other.it_ == it_ && other.end_ == end_ && other.value_.size() == value_.size(); } - bool - operator!=(ListIterator const& other) const - { - return !(*this == other); - } - reference operator*() const { diff --git a/include/xrpl/beast/unit_test/reporter.h b/include/xrpl/beast/unit_test/reporter.h index 0fe77a7862..cbd1c7e70d 100644 --- a/include/xrpl/beast/unit_test/reporter.h +++ b/include/xrpl/beast/unit_test/reporter.h @@ -8,7 +8,6 @@ #include #include -#include #include #include @@ -188,7 +187,7 @@ Reporter::fmtdur(clock_type::duration const& d) using namespace std::chrono; auto const ms = duration_cast(d); if (ms < seconds{1}) - return boost::lexical_cast(ms.count()) + "ms"; + return std::to_string(ms.count()) + "ms"; std::stringstream ss; ss << std::fixed << std::setprecision(1) << (ms.count() / 1000.) << "s"; return ss.str(); diff --git a/include/xrpl/beast/unit_test/suite.h b/include/xrpl/beast/unit_test/suite.h index e24904a87b..2b06fb4e05 100644 --- a/include/xrpl/beast/unit_test/suite.h +++ b/include/xrpl/beast/unit_test/suite.h @@ -6,11 +6,10 @@ #include -#include -#include #include #include +#include #include #include #include @@ -27,10 +26,10 @@ makeReason(String const& reason, char const* file, int line) std::string s(reason); if (!s.empty()) s.append(": "); - namespace fs = boost::filesystem; + namespace fs = std::filesystem; s.append(fs::path{file}.filename().string()); s.append("("); - s.append(boost::lexical_cast(line)); + s.append(std::to_string(line)); s.append(")"); return s; } diff --git a/include/xrpl/beast/utility/temp_dir.h b/include/xrpl/beast/utility/temp_dir.h deleted file mode 100644 index a0ff1e6940..0000000000 --- a/include/xrpl/beast/utility/temp_dir.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include - -#include - -namespace beast { - -/** - * RAII temporary directory. - * - * The directory and all its contents are deleted when - * the instance of `temp_dir` is destroyed. - */ -class TempDir -{ - boost::filesystem::path path_; - -public: -#if !GENERATING_DOCS - TempDir(TempDir const&) = delete; - TempDir& - operator=(TempDir const&) = delete; -#endif - - /** - * Construct a temporary directory. - */ - TempDir() - { - auto const dir = boost::filesystem::temp_directory_path(); - do - { - path_ = dir / boost::filesystem::unique_path(); - } while (boost::filesystem::exists(path_)); - boost::filesystem::create_directory(path_); - } - - /** - * Destroy a temporary directory. - */ - ~TempDir() - { - // use non-throwing calls in the destructor - boost::system::error_code ec; - boost::filesystem::remove_all(path_, ec); - // TODO: warn/notify if ec set ? - } - - /** - * Get the native path for the temporary directory - */ - [[nodiscard]] std::string - path() const - { - return path_.string(); - } - - /** - * Get the native path for the a file. - * - * The file does not need to exist. - */ - [[nodiscard]] std::string - file(std::string const& name) const - { - return (path_ / name).string(); - } -}; - -} // namespace beast diff --git a/include/xrpl/conditions/Condition.h b/include/xrpl/conditions/Condition.h index 365a41a087..04e571a028 100644 --- a/include/xrpl/conditions/Condition.h +++ b/include/xrpl/conditions/Condition.h @@ -92,10 +92,4 @@ operator==(Condition const& lhs, Condition const& rhs) lhs.fingerprint == rhs.fingerprint; } -inline bool -operator!=(Condition const& lhs, Condition const& rhs) -{ - return !(lhs == rhs); -} - } // namespace xrpl::cryptoconditions diff --git a/include/xrpl/conditions/Fulfillment.h b/include/xrpl/conditions/Fulfillment.h index 11f3165a58..6fd75aa5a3 100644 --- a/include/xrpl/conditions/Fulfillment.h +++ b/include/xrpl/conditions/Fulfillment.h @@ -93,12 +93,6 @@ operator==(Fulfillment const& lhs, Fulfillment const& rhs) lhs.fingerprint() == rhs.fingerprint(); } -inline bool -operator!=(Fulfillment const& lhs, Fulfillment const& rhs) -{ - return !(lhs == rhs); -} - /** * Determine whether the given fulfillment and condition match */ diff --git a/include/xrpl/config/BasicConfig.h b/include/xrpl/config/BasicConfig.h index 607a0c3e5f..2278a0fa68 100644 --- a/include/xrpl/config/BasicConfig.h +++ b/include/xrpl/config/BasicConfig.h @@ -2,7 +2,6 @@ #include -#include #include #include diff --git a/include/xrpl/core/PerfLog.h b/include/xrpl/core/PerfLog.h index f09665e291..dd78a8f9a6 100644 --- a/include/xrpl/core/PerfLog.h +++ b/include/xrpl/core/PerfLog.h @@ -4,10 +4,9 @@ #include #include -#include - #include #include +#include #include #include #include @@ -44,7 +43,7 @@ public: */ struct Setup { - boost::filesystem::path perfLog; + std::filesystem::path perfLog; // log_interval is in milliseconds to support faster testing. milliseconds logInterval{seconds(1)}; }; @@ -149,7 +148,7 @@ public: }; PerfLog::Setup -setupPerfLog(Section const& section, boost::filesystem::path const& configDir); +setupPerfLog(Section const& section, std::filesystem::path const& configDir); std::unique_ptr makePerfLog( diff --git a/include/xrpl/json/Output.h b/include/xrpl/json/Output.h index 53d453c277..f73bd38c77 100644 --- a/include/xrpl/json/Output.h +++ b/include/xrpl/json/Output.h @@ -1,20 +1,19 @@ #pragma once -#include - #include #include +#include namespace json { class Value; -using Output = std::function; +using Output = std::function; inline Output stringOutput(std::string& s) { - return [&](boost::beast::string_view const& b) { s.append(b.data(), b.size()); }; + return [&](std::string_view b) { s.append(b.data(), b.size()); }; } /** diff --git a/include/xrpl/json/json_value.h b/include/xrpl/json/json_value.h index 260917face..57936a774f 100644 --- a/include/xrpl/json/json_value.h +++ b/include/xrpl/json/json_value.h @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -72,36 +73,18 @@ operator==(StaticString x, StaticString y) return strcmp(x.cStr(), y.cStr()) == 0; } -inline bool -operator!=(StaticString x, StaticString y) -{ - return !(x == y); -} - inline bool operator==(std::string const& x, StaticString y) { return strcmp(x.c_str(), y.cStr()) == 0; } -inline bool -operator!=(std::string const& x, StaticString y) -{ - return !(x == y); -} - inline bool operator==(StaticString x, std::string const& y) { return y == x; } -inline bool -operator!=(StaticString x, std::string const& y) -{ - return !(y == x); -} - /** * @brief Represents a JSON value. * @@ -489,12 +472,6 @@ toJson(xrpl::Number const& number) bool operator==(Value const&, Value const&); -inline bool -operator!=(Value const& x, Value const& y) -{ - return !(x == y); -} - bool operator<(Value const&, Value const&); @@ -548,6 +525,7 @@ public: class ValueIteratorBase { public: + using iterator_category = std::bidirectional_iterator_tag; using size_t = unsigned int; using difference_type = int; using SelfType = ValueIteratorBase; @@ -562,12 +540,6 @@ public: return isEqual(other); } - bool - operator!=(SelfType const& other) const - { - return !isEqual(other); - } - /** * Return either the index or the member name of the referenced value as a * Value. diff --git a/include/xrpl/ledger/BookDirs.h b/include/xrpl/ledger/BookDirs.h index dc4361136d..b9aa87ae52 100644 --- a/include/xrpl/ledger/BookDirs.h +++ b/include/xrpl/ledger/BookDirs.h @@ -49,12 +49,6 @@ public: bool operator==(const_iterator const& other) const; - bool - operator!=(const_iterator const& other) const - { - return !(*this == other); - } - reference operator*() const; diff --git a/include/xrpl/ledger/CanonicalTXSet.h b/include/xrpl/ledger/CanonicalTXSet.h index 11aadf4e92..3fe17d6eef 100644 --- a/include/xrpl/ledger/CanonicalTXSet.h +++ b/include/xrpl/ledger/CanonicalTXSet.h @@ -59,12 +59,6 @@ private: return lhs.txId_ == rhs.txId_; } - friend bool - operator!=(Key const& lhs, Key const& rhs) - { - return !(lhs == rhs); - } - [[nodiscard]] uint256 const& getAccount() const { diff --git a/include/xrpl/ledger/Dir.h b/include/xrpl/ledger/Dir.h index 233719cdeb..eb70b3b6a3 100644 --- a/include/xrpl/ledger/Dir.h +++ b/include/xrpl/ledger/Dir.h @@ -59,12 +59,6 @@ public: bool operator==(ConstIterator const& other) const; - bool - operator!=(ConstIterator const& other) const - { - return !(*this == other); - } - reference operator*() const; diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 768e518008..f7fd5b5a8c 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -35,6 +35,11 @@ enum class SkipEntry : bool { No = false, Yes }; // //------------------------------------------------------------------------------ +/** + * Whether an expiration check should be inclusive or exclusive. + */ +enum class ExpiryComparison { Inclusive, Exclusive }; + /** * Determines whether the given expiration time has passed. * @@ -54,11 +59,16 @@ enum class SkipEntry : bool { No = false, Yes }; * * @param view The ledger whose parent time is used as the clock. * @param exp The optional expiration time we want to check. + * @param comparison Whether the boundary is inclusive (`now >= exp`, the + * default) or exclusive (`now > exp`). * * @return `true` if `exp` is in the past; `false` otherwise. */ [[nodiscard]] bool -hasExpired(ReadView const& view, std::optional const& exp); +hasExpired( + ReadView const& view, + std::optional const& exp, + ExpiryComparison comparison = ExpiryComparison::Inclusive); // Note, depth parameter is used to limit the recursion depth [[nodiscard]] bool @@ -68,6 +78,13 @@ isVaultPseudoAccountFrozen( MPTIssue const& mptShare, std::uint8_t depth); +[[nodiscard]] bool +isVaultPseudoAccountFrozen( + ReadView const& view, + AccountID const& account, + SLE const& issuanceSle, + std::uint8_t depth); + [[nodiscard]] bool isLPTokenFrozen( ReadView const& view, @@ -75,6 +92,26 @@ isLPTokenFrozen( Asset const& asset, Asset const& asset2); +/** + * Check whether an AMM LPToken may be transferred between @p from and @p to. + * + * @p lpTokenIssuer is the issuer of the LPToken being moved. If it is not an + * AMM account the token is not an LPToken and the transfer is unconditionally + * permitted. Otherwise, for each MPT pool asset of that AMM, canTransfer() must + * permit the transfer (which exempts the MPT issuer). Non-MPT pool assets are + * always transferable by this check, so it is implicitly gated by + * featureMPTokensV2 (MPTs can only be AMM pool assets once V2 is enabled). + * + * @return tesSUCCESS if permitted, otherwise the canTransfer() failure code + * (e.g. tecNO_AUTH) of the first MPT pool asset that disallows it. + */ +[[nodiscard]] TER +canTransferLPToken( + ReadView const& view, + AccountID const& from, + AccountID const& to, + AccountID const& lpTokenIssuer); + // Return the list of enabled amendments [[nodiscard]] std::set getEnabledAmendments(ReadView const& view); diff --git a/include/xrpl/ledger/detail/ReadViewFwdRange.h b/include/xrpl/ledger/detail/ReadViewFwdRange.h index 19ac0698c2..bfa2527bbd 100644 --- a/include/xrpl/ledger/detail/ReadViewFwdRange.h +++ b/include/xrpl/ledger/detail/ReadViewFwdRange.h @@ -85,9 +85,6 @@ public: bool operator==(Iterator const& other) const; - bool - operator!=(Iterator const& other) const; - // Can throw reference operator*() const; diff --git a/include/xrpl/ledger/detail/ReadViewFwdRange.ipp b/include/xrpl/ledger/detail/ReadViewFwdRange.ipp index c7cbc5ee61..2003280ea6 100644 --- a/include/xrpl/ledger/detail/ReadViewFwdRange.ipp +++ b/include/xrpl/ledger/detail/ReadViewFwdRange.ipp @@ -64,13 +64,6 @@ ReadViewFwdRange::Iterator::operator==(Iterator const& other) const return impl_ == other.impl_; } -template -bool -ReadViewFwdRange::Iterator::operator!=(Iterator const& other) const -{ - return !(*this == other); -} - template auto ReadViewFwdRange::Iterator::operator*() const -> reference diff --git a/include/xrpl/ledger/helpers/AMMHelpers.h b/include/xrpl/ledger/helpers/AMMHelpers.h index 7d41bfce81..a68171c426 100644 --- a/include/xrpl/ledger/helpers/AMMHelpers.h +++ b/include/xrpl/ledger/helpers/AMMHelpers.h @@ -226,7 +226,7 @@ getAMMOfferStartWithTakerGets( auto getAmounts = [&pool, &tfee](Number const& nTakerGetsProposed) { // Round downward to minimize the offer and to maximize the quality. - // This has the most impact when takerGets is XRP. + // This has the most impact when takerGets is integral. auto const takerGets = toAmount(getAsset(pool.out), nTakerGetsProposed, Number::RoundingMode::Downward); return TAmounts{swapAssetOut(pool, takerGets, tfee), takerGets}; @@ -294,7 +294,7 @@ getAMMOfferStartWithTakerPays( auto getAmounts = [&pool, &tfee](Number const& nTakerPaysProposed) { // Round downward to minimize the offer and to maximize the quality. - // This has the most impact when takerPays is XRP. + // This has the most impact when takerPays is integral. auto const takerPays = toAmount(getAsset(pool.in), nTakerPaysProposed, Number::RoundingMode::Downward); return TAmounts{takerPays, swapAssetIn(pool, takerPays, tfee)}; @@ -313,11 +313,11 @@ getAMMOfferStartWithTakerPays( * is equal to LOB quality (in this case AMM offer quality is * better than LOB quality) or AMM offer is equal to LOB quality * (in this case SPQ is better than LOB quality). - * Pre-amendment code calculates takerPays first. If takerGets is XRP, - * it is rounded down, which results in worse offer quality than - * LOB quality, and the offer might fail to generate. - * Post-amendment code calculates the XRP offer side first. The result - * is rounded down, which makes the offer quality better. + * Pre-amendment code calculates takerPays first. If takerGets is the + * economically coarser integral side, it is rounded down, which results in + * worse offer quality than LOB quality, and the offer might fail to generate. + * Post-amendment code calculates the economically coarser integral offer side + * first. The result is rounded down, which makes the offer quality better. * It might not be possible to match either SPQ or AMM offer to LOB * quality. This generally happens at higher fees. * @param pool AMM pool balances @@ -396,10 +396,18 @@ changeSpotPriceQuality( return std::nullopt; } - // Generate the offer starting with XRP side. Return seated offer amounts - // if the offer can be generated, otherwise nullopt. auto amounts = [&]() { - if (isXRP(getAsset(pool.out))) + bool const inIntegral = getAsset(pool.in).integral(); + bool const outIntegral = getAsset(pool.out).integral(); + + // Preserve historical behavior for fractional pairs and XRP/IOU-style + // one-integral-side pairs. For two integral assets, pick the side whose + // minimum unit is economically coarser at this quality. + // + // Quality::rate() is input units per output unit, so one output unit is + // coarser when it costs at least one input unit. Ties use takerGets, + // matching the historical XRP-output behavior. + if (outIntegral && (!inIntegral || Number(quality.rate()) >= 1)) return getAMMOfferStartWithTakerGets(pool, quality, tfee); return getAMMOfferStartWithTakerPays(pool, quality, tfee); }(); diff --git a/include/xrpl/ledger/helpers/CredentialHelpers.h b/include/xrpl/ledger/helpers/CredentialHelpers.h index 8e78a00923..8b1c819bf4 100644 --- a/include/xrpl/ledger/helpers/CredentialHelpers.h +++ b/include/xrpl/ledger/helpers/CredentialHelpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -34,7 +35,7 @@ deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j); // Amendment and parameters checks for sfCredentialIDs field NotTEC -checkFields(STTx const& tx, beast::Journal j); +checkFields(STTx const& tx, Rules const& rules, beast::Journal j); // Accessing the ledger to check if provided credentials are valid. Do not use // in doApply (only in preclaim) since it does not remove expired credentials. diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index c69efff964..4aa89ea672 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include // IWYU pragma: keep #include @@ -21,6 +22,7 @@ #include #include +#include #include #include @@ -58,6 +60,42 @@ canApplyToBrokerCover( bool checkLendingProtocolDependencies(Rules const& rules, STTx const& tx); +/** + * The accounts and asset that LoanManage::defaultLoan's fixCleanup3_4_0 + * freeze/lock exemption applies to. + * + * `defaultLoan` moves funds from the LoanBroker pseudo-account to the Vault + * pseudo-account via `accountSend`. Since neither is the vault asset's + * issuer, this is a third-party transfer that transits through the issuer in + * two hops (broker -> issuer, issuer -> vault; see + * `directSendNoLimitIOU`/`directSendNoLimitMPT`), so the exemption must cover + * both the issuer/broker and issuer/vault pairs, not a direct broker/vault + * pair. `asset` scopes it further to the vault's own currency/MPT issuance, + * so an unrelated one the same accounts happen to hold is still protected. + */ +struct LoanDefaultFreezeExemptAccounts +{ + AccountID issuer; + AccountID broker; + AccountID vault; + Asset asset; +}; + +/** + * Resolves the accounts and asset a LoanManage default transaction is + * exempt from freeze/lock for. + * + * @param view Ledger view used to resolve the Loan -> LoanBroker -> Vault + * chain. + * @param tx The transaction under invariant review. + * @return The exempt accounts and asset if `tx` is a `ttLOAN_MANAGE` + * transaction with the `tfLoanDefault` flag set, `fixCleanup3_4_0` is + * enabled, and the loan/broker/vault objects it references can all be + * resolved; `std::nullopt` otherwise. + */ +[[nodiscard]] std::optional +getLoanDefaultFreezeExemptAccounts(ReadView const& view, STTx const& tx); + static constexpr std::uint32_t kSecondsInYear = 365 * 24 * 60 * 60; Number diff --git a/include/xrpl/ledger/helpers/MPTokenHelpers.h b/include/xrpl/ledger/helpers/MPTokenHelpers.h index 5418e5b26a..6d26cf3cbc 100644 --- a/include/xrpl/ledger/helpers/MPTokenHelpers.h +++ b/include/xrpl/ledger/helpers/MPTokenHelpers.h @@ -29,6 +29,9 @@ namespace xrpl { [[nodiscard]] bool isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue); +[[nodiscard]] bool +isGlobalFrozen(SLE const& issuanceSle); + /** * Returns true if @p account's MPToken for @p mptIssue carries the * individual-lock flag (lsfMPTLocked). @@ -40,9 +43,29 @@ isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue); * receive tokens — it combines isIndividualFrozen, isGlobalFrozen, and * isVaultPseudoAccountFrozen into a single complete check. */ + [[nodiscard]] bool isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue); +[[nodiscard]] bool +isIndividualFrozen(SLE const& mptSle); + +/** + * Returns true if @p account cannot send or receive tokens of @p mptIssue + * because a freeze applies. This is the complete check callers should use + * before moving MPT value: it combines @ref isGlobalFrozen (issuance-level + * lock), @ref isIndividualFrozen (per-holder lock bit), and the transitive + * vault pseudo-account check (if @p mptIssue is a vault share, the underlying + * asset is checked, and so on recursively up to @c maxAssetCheckDepth). + * + * The @c SLE overload takes an already-loaded ltMPTOKEN or ltMPTOKEN_ISSUANCE + * ledger entry; for ltMPTOKEN it can skip the per-holder individual-lock lookup. + * @ref isAnyFrozen answers the same question for a set of accounts and returns true + * if the freeze applies to any of them. + * + * @param depth Current recursion depth for the vault-share walk. Callers + * outside this module should leave it at the default. + */ [[nodiscard]] bool isFrozen( ReadView const& view, @@ -50,6 +73,18 @@ isFrozen( MPTIssue const& mptIssue, std::uint8_t depth = 0); +/** + * SLE overload: pass an already-loaded ltMPTOKEN (holder row) or + * ltMPTOKEN_ISSUANCE to reuse it for the freeze checks and avoid re-reading + * the same object. For an ltMPTOKEN, @p sle is used directly for the + * individual-lock check and the issuance is read once for global-freeze and + * vault-pseudo-account. For an ltMPTOKEN_ISSUANCE, @p sle is used directly + * for global-freeze and vault-pseudo-account, and the caller's holder row is + * read for the individual-lock check. + */ +[[nodiscard]] bool +isFrozen(ReadView const& view, AccountID const& account, SLE const& sle, std::uint8_t depth = 0); + [[nodiscard]] bool isAnyFrozen( ReadView const& view, @@ -261,6 +296,14 @@ checkCreateMPT( xrpl::MPTIssue const& mptIssue, xrpl::AccountID const& holder, SLE::ref sponsorSle, + std::uint32_t flags, + beast::Journal j); + +TER +checkCreateMPT( + xrpl::ApplyView& view, + xrpl::MPTIssue const& mptIssue, + xrpl::AccountID const& holder, beast::Journal j); //------------------------------------------------------------------------------ diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 5681cc57e8..c898e9e148 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -1,15 +1,20 @@ #pragma once +#include #include #include +#include #include #include #include +#include #include namespace xrpl { +class STTx; + /** * From the perspective of a vault, return the number of shares to give * depositor when they offer a fixed amount of assets. Note, since shares are @@ -52,6 +57,38 @@ 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. + * + * @param vault The vault SLE. + * @param waive Whether to waive (i.e. not subtract) the vault's 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. + * + * @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. + */ +[[nodiscard]] bool +debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount); + /** * From the perspective of a vault, return the number of shares to demand from * the depositor when they ask to withdraw a fixed amount of assets. Since @@ -123,4 +160,82 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref [[nodiscard]] VaultVersion getVaultVersion(SLE::const_ref vault); +/** + * Resolves the VaultKind of a vault SLE. Returns VaultKind::ClosedEnded when + * sfVaultKind is present and equal to that value; anything else (including an + * absent field or an unrecognised value) is treated as VaultKind::OpenEnded. + * + * @param vault The vault SLE. + */ +[[nodiscard]] VaultKind +getVaultKind(SLE::const_ref vault); + +/** + * Reads sfVaultKind from a transaction. An absent field resolves to + * VaultKind::OpenEnded (matching the on-ledger default); any unrecognised + * value is also treated as VaultKind::OpenEnded, mirroring the SLE overload. + * Callers that need to reject out-of-range values (e.g. preflight) should + * gate on isValidVaultKind() first. + * + * @param tx The transaction. + */ +[[nodiscard]] VaultKind +getVaultKind(STTx const& tx); + +/** + * Returns true iff sfVaultKind is either absent from @p tx or is present and + * equal to a recognised VaultKind enumerator. Intended for use in preflight + * to reject malformed transactions before decoding with getVaultKind(). + * + * @param tx The transaction. + */ +[[nodiscard]] bool +isValidVaultKind(STTx const& tx); + +/** + * Returns true iff the (SubscriptionDate, RedemptionDate) gap of a + * closed-ended vault satisfies + * kMinInvestmentPeriod <= (red - sub) < kMaxInvestmentPeriod. The arithmetic + * is performed in std::int64_t so that @p sub near UINT32_MAX does not + * overflow. Shared by VaultCreate::preflight and the ValidVault invariant. + * + * @param sub The value of sfSubscriptionDate. + * @param red The value of sfRedemptionDate. + */ +[[nodiscard]] bool +isValidClosedEndedGap(std::uint32_t sub, std::uint32_t red); + +/** + * Returns the current lifecycle phase of a vault. Open-ended + * vaults are always NoPhase. For closed-ended vaults the phase is derived + * from the parent ledger close time and the vault's immutable + * SubscriptionDate and RedemptionDate. + * + * @param view The ledger view whose parent close time is used as the clock. + * @param vault The vault SLE. + */ +[[nodiscard]] VaultPhase +getVaultPhase(ReadView const& view, SLE::const_ref vault); + +/** + * Raw-fields overload of getVaultPhase. Derives the phase from an already + * decomposed vault snapshot: an absent or non-ClosedEnded @p vaultKind + * resolves to VaultPhase::NoPhase; otherwise the phase is computed from + * @p subscriptionDate and @p redemptionDate against the view's parent + * close time using the same boundary semantics as the SLE overload + * (Subscription is inclusive of now == SubscriptionDate; Investment starts + * strictly after). + * + * @param view The ledger view whose parent close time is used as the clock. + * @param vaultKind The value of sfVaultKind, or nullopt if absent. + * @param subscriptionDate The value of sfSubscriptionDate, or nullopt if absent. + * @param redemptionDate The value of sfRedemptionDate, or nullopt if absent. + */ +[[nodiscard]] VaultPhase +getVaultPhase( + ReadView const& view, + std::optional vaultKind, + std::optional subscriptionDate, + std::optional redemptionDate); + } // namespace xrpl diff --git a/include/xrpl/net/HTTPClientSSLContext.h b/include/xrpl/net/HTTPClientSSLContext.h index 51b50a084c..43467faa89 100644 --- a/include/xrpl/net/HTTPClientSSLContext.h +++ b/include/xrpl/net/HTTPClientSSLContext.h @@ -8,11 +8,11 @@ #include #include #include -#include #include #include +#include #include #include #include @@ -38,8 +38,8 @@ public: if (ec && sslVerifyDir.empty()) { - Throw(boost::str( - boost::format("Failed to set_default_verify_paths: %s") % ec.message())); + Throw( + std::format("Failed to set_default_verify_paths: {}", ec.message())); } } else @@ -54,7 +54,7 @@ public: if (ec) { Throw( - boost::str(boost::format("Failed to add verify path: %s") % ec.message())); + std::format("Failed to add verify path: {}", ec.message())); } } } diff --git a/include/xrpl/proto/xrpl.proto b/include/xrpl/proto/xrpl.proto index b9cb94e668..644e099179 100644 --- a/include/xrpl/proto/xrpl.proto +++ b/include/xrpl/proto/xrpl.proto @@ -1,10 +1,10 @@ syntax = "proto2"; package protocol; -// Unused numbers in the list below may have been used previously. Please don't -// reassign them for reuse unless you are 100% certain that there won't be a -// conflict. Even if you're sure, it's probably best to assign a new type. enum MessageType { + // Previously used - don't reuse. + reserved 0 to 1, 4, 6 to 14, 16 to 29, 36 to 40, 43 to 54, 61 to 62; + mtMANIFESTS = 2; mtPING = 3; mtCLUSTER = 5; @@ -17,7 +17,6 @@ enum MessageType { mtHAVE_SET = 35; mtVALIDATION = 41; mtGET_OBJECTS = 42; - mtVALIDATOR_LIST = 54; mtSQUELCH = 55; mtVALIDATOR_LIST_COLLECTION = 56; mtPROOF_PATH_REQ = 57; @@ -162,14 +161,6 @@ message TMHaveTransactionSet { required bytes hash = 2; } -// Validator list (UNL) -message TMValidatorList { - required bytes manifest = 1; - required bytes blob = 2; - required bytes signature = 3; - required uint32 version = 4; -} - // Validator List v2 message ValidatorBlobInfo { optional bytes manifest = 1; diff --git a/include/xrpl/protocol/AMMCore.h b/include/xrpl/protocol/AMMCore.h index a3666c7960..1e11f6cd8b 100644 --- a/include/xrpl/protocol/AMMCore.h +++ b/include/xrpl/protocol/AMMCore.h @@ -47,7 +47,7 @@ ammLPTIssue(Asset const& asset1, Asset const& asset2, AccountID const& ammAccoun /** * Validate the amount. - * If validZero is false and amount is beast::zero then invalid amount. + * If validZero is false and amount is beast::kZero then invalid amount. * Return error code if invalid amount. * If pair then validate amount's issue matches one of the pair's issue. */ diff --git a/include/xrpl/protocol/AmountConversions.h b/include/xrpl/protocol/AmountConversions.h index 3bcd80e827..ed68be62fe 100644 --- a/include/xrpl/protocol/AmountConversions.h +++ b/include/xrpl/protocol/AmountConversions.h @@ -154,7 +154,7 @@ T toAmount(Asset const& asset, Number const& n, Number::RoundingMode mode = Number::getround()) { SaveNumberRoundMode const rm(Number::getround()); - if (isXRP(asset)) + if (asset.integral()) Number::setround(mode); if constexpr (std::is_same_v) diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 567f66d339..345baef853 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -327,6 +328,36 @@ enum class VaultVersion : uint8_t { CashBasis, }; +/** + * Vault kind. Distinguishes closed-ended vaults from the default open-ended + * kind. Persisted as sfVaultKind (UINT8); absent means OpenEnded. + */ +enum class VaultKind : std::uint8_t { + OpenEnded = 0, + ClosedEnded = 1, +}; + +/** + * Lifecycle phase of a vault. Open-ended vaults are always NoPhase; the other + * three values are the phases of a closed-ended vault. + */ +enum class VaultPhase : std::uint8_t { + NoPhase = 0, + Subscription, + Investment, + Redemption, +}; + +/** + * Bounds on the length of a closed-ended vault's Investment phase + * (RedemptionDate - SubscriptionDate). At vault creation the gap must satisfy + * kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod. + */ +constexpr std::uint32_t kMinInvestmentPeriod = + std::chrono::seconds{std::chrono::minutes{1}}.count(); +// This is 946708560 seconds which 30 x 365.2425 days (the average length of a Gregorian year). +constexpr std::uint32_t kMaxInvestmentPeriod = std::chrono::seconds{std::chrono::years{30}}.count(); + /** * Maximum recursion depth for vault shares being put as an asset inside * another vault; counted from 0 diff --git a/include/xrpl/protocol/Quality.h b/include/xrpl/protocol/Quality.h index 3475efa977..d0d0f10cd2 100644 --- a/include/xrpl/protocol/Quality.h +++ b/include/xrpl/protocol/Quality.h @@ -75,13 +75,6 @@ operator==(TAmounts const& lhs, TAmounts const& rhs) noexcept return lhs.in == rhs.in && lhs.out == rhs.out; } -template -bool -operator!=(TAmounts const& lhs, TAmounts const& rhs) noexcept -{ - return !(lhs == rhs); -} - //------------------------------------------------------------------------------ // XRPL specific constant used for parsing qualities and other things @@ -271,12 +264,6 @@ public: return lhs.value_ == rhs.value_; } - friend bool - operator!=(Quality const& lhs, Quality const& rhs) noexcept - { - return !(lhs == rhs); - } - friend std::ostream& operator<<(std::ostream& os, Quality const& quality) { diff --git a/include/xrpl/protocol/QualityFunction.h b/include/xrpl/protocol/QualityFunction.h index 128b37ce12..4fcc730c42 100644 --- a/include/xrpl/protocol/QualityFunction.h +++ b/include/xrpl/protocol/QualityFunction.h @@ -60,6 +60,15 @@ public: std::optional outFromAvgQ(Quality const& quality); + /** + * Return whether `out` produces at least the requested + * average quality. + * @param quality requested average quality (quality limit) + * @param out output amount to test + */ + [[nodiscard]] bool + satisfiesAvgQ(Quality const& quality, Number const& out) const; + /** * Return true if the quality function is constant */ diff --git a/include/xrpl/protocol/Rules.h b/include/xrpl/protocol/Rules.h index 2c2136b6e8..d67e0d8654 100644 --- a/include/xrpl/protocol/Rules.h +++ b/include/xrpl/protocol/Rules.h @@ -98,9 +98,6 @@ public: */ bool operator==(Rules const&) const; - - bool - operator!=(Rules const& other) const; }; std::optional const& diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index cc80481582..4b2f1cc9fb 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -642,12 +642,6 @@ operator==(STAmount const& lhs, STAmount const& rhs); bool operator<(STAmount const& lhs, STAmount const& rhs); -inline bool -operator!=(STAmount const& lhs, STAmount const& rhs) -{ - return !(lhs == rhs); -} - inline bool operator>(STAmount const& lhs, STAmount const& rhs) { diff --git a/include/xrpl/protocol/STArray.h b/include/xrpl/protocol/STArray.h index 573bb6dad8..e88563fb1a 100644 --- a/include/xrpl/protocol/STArray.h +++ b/include/xrpl/protocol/STArray.h @@ -133,9 +133,6 @@ public: bool operator==(STArray const& s) const; - bool - operator!=(STArray const& s) const; - iterator erase(iterator pos); @@ -283,12 +280,6 @@ STArray::operator==(STArray const& s) const return v_ == s.v_; } -inline bool -STArray::operator!=(STArray const& s) const -{ - return v_ != s.v_; -} - inline STArray::iterator STArray::erase(iterator pos) { diff --git a/include/xrpl/protocol/STBase.h b/include/xrpl/protocol/STBase.h index acc5500a57..a8bda8f614 100644 --- a/include/xrpl/protocol/STBase.h +++ b/include/xrpl/protocol/STBase.h @@ -140,8 +140,6 @@ public: bool operator==(STBase const& t) const; - bool - operator!=(STBase const& t) const; template D& diff --git a/include/xrpl/protocol/STCurrency.h b/include/xrpl/protocol/STCurrency.h index 18642b20cf..933abaedb8 100644 --- a/include/xrpl/protocol/STCurrency.h +++ b/include/xrpl/protocol/STCurrency.h @@ -93,12 +93,6 @@ operator==(STCurrency const& lhs, STCurrency const& rhs) return lhs.currency() == rhs.currency(); } -inline bool -operator!=(STCurrency const& lhs, STCurrency const& rhs) -{ - return !operator==(lhs, rhs); -} - inline bool operator<(STCurrency const& lhs, STCurrency const& rhs) { diff --git a/include/xrpl/protocol/STObject.h b/include/xrpl/protocol/STObject.h index c7fc4fa796..dcbd08170e 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -432,8 +432,6 @@ public: bool operator==(STObject const& o) const; - bool - operator!=(STObject const& o) const; class FieldErr; @@ -667,36 +665,6 @@ public: return !lhs.engaged() || *lhs == *rhs; } - friend bool - operator!=(OptionalProxy const& lhs, std::nullopt_t) noexcept - { - return !(lhs == std::nullopt); - } - - friend bool - operator!=(std::nullopt_t, OptionalProxy const& rhs) noexcept - { - return !(rhs == std::nullopt); - } - - friend bool - operator!=(OptionalProxy const& lhs, optional_type const& rhs) noexcept - { - return !(lhs == rhs); - } - - friend bool - operator!=(optional_type const& lhs, OptionalProxy const& rhs) noexcept - { - return !(lhs == rhs); - } - - friend bool - operator!=(OptionalProxy const& lhs, OptionalProxy const& rhs) noexcept - { - return !(lhs == rhs); - } - // Emulate std::optional::value_or [[nodiscard]] value_type valueOr(value_type val) const; @@ -1202,12 +1170,6 @@ STObject::setFieldH160(SField const& field, BaseUInt<160, Tag> const& v) } } -inline bool -STObject::operator!=(STObject const& o) const -{ - return !(*this == o); -} - template V STObject::getFieldByValue(SField const& field) const diff --git a/include/xrpl/protocol/STPathSet.h b/include/xrpl/protocol/STPathSet.h index d527e2479f..5768721111 100644 --- a/include/xrpl/protocol/STPathSet.h +++ b/include/xrpl/protocol/STPathSet.h @@ -115,9 +115,6 @@ public: bool operator==(STPathElement const& t) const; - bool - operator!=(STPathElement const& t) const; - private: static std::size_t getHash(STPathElement const& element); @@ -432,12 +429,6 @@ STPathElement::operator==(STPathElement const& t) const accountID_ == t.accountID_ && assetID_ == t.assetID_ && issuerID_ == t.issuerID_; } -inline bool -STPathElement::operator!=(STPathElement const& t) const -{ - return !operator==(t); -} - // ------------ STPath ------------ inline STPath::STPath(std::vector p) : path_(std::move(p)) diff --git a/include/xrpl/protocol/SeqProxy.h b/include/xrpl/protocol/SeqProxy.h index fa72914591..3686d123d6 100644 --- a/include/xrpl/protocol/SeqProxy.h +++ b/include/xrpl/protocol/SeqProxy.h @@ -123,12 +123,6 @@ public: return (lhs.value() == rhs.value()); } - friend constexpr bool - operator!=(SeqProxy lhs, SeqProxy rhs) - { - return !(lhs == rhs); - } - friend constexpr bool operator<(SeqProxy lhs, SeqProxy rhs) { diff --git a/include/xrpl/protocol/Serializer.h b/include/xrpl/protocol/Serializer.h index 73bd9c8289..c1ea5c16ba 100644 --- a/include/xrpl/protocol/Serializer.h +++ b/include/xrpl/protocol/Serializer.h @@ -265,20 +265,10 @@ public: return v == data_; } bool - operator!=(Blob const& v) const - { - return v != data_; - } - bool operator==(Serializer const& v) const { return v.data_ == data_; } - bool - operator!=(Serializer const& v) const - { - return v.data_ != data_; - } static int decodeLengthLength(int b1); diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 14bc0571e9..40edf2239b 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -425,8 +425,7 @@ inline constexpr FlagValue tfDepositSubTx = ASF_FLAG(asfDefaultRipple, 8) \ ASF_FLAG(asfDepositAuth, 9) \ ASF_FLAG(asfAuthorizedNFTokenMinter, 10) \ - /* 11 is reserved for Hooks amendment */ \ - /* ASF_FLAG(asfTshCollect, 11) */ \ + /* 11 is unused */ \ ASF_FLAG(asfDisallowIncomingNFTokenOffer, 12) \ ASF_FLAG(asfDisallowIncomingCheck, 13) \ ASF_FLAG(asfDisallowIncomingPayChan, 14) \ diff --git a/include/xrpl/protocol/Units.h b/include/xrpl/protocol/Units.h index 169ee2c543..94afd72f53 100644 --- a/include/xrpl/protocol/Units.h +++ b/include/xrpl/protocol/Units.h @@ -258,13 +258,6 @@ public: return value_ == other; } - template Other> - constexpr bool - operator!=(ValueUnit const& other) const - { - return !operator==(other); - } - constexpr bool operator<(ValueUnit const& other) const { diff --git a/include/xrpl/protocol/detail/STVar.h b/include/xrpl/protocol/detail/STVar.h index 12026f3d09..56f868b665 100644 --- a/include/xrpl/protocol/detail/STVar.h +++ b/include/xrpl/protocol/detail/STVar.h @@ -152,10 +152,4 @@ operator==(STVar const& lhs, STVar const& rhs) return lhs.get().isEquivalent(rhs.get()); } -inline bool -operator!=(STVar const& lhs, STVar const& rhs) -{ - return !(lhs == rhs); -} - } // namespace xrpl::detail diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index ffcd025f01..f166473d7f 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -506,6 +506,9 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfWithdrawalPolicy, SoeRequired}, {sfScale, SoeDefault}, {sfLEVersion, SoeDefault}, + {sfVaultKind, SoeDefault}, + {sfSubscriptionDate, SoeOptional}, + {sfRedemptionDate, SoeOptional}, // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index cff075e738..ec05804253 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -23,9 +23,11 @@ TYPED_SFIELD(sfLEVersion, UINT8, 6) // 8-bit integers (uncommon) TYPED_SFIELD(sfTickSize, UINT8, 16) TYPED_SFIELD(sfUNLModifyDisabling, UINT8, 17) -TYPED_SFIELD(sfHookResult, UINT8, 18) +// 18 unused TYPED_SFIELD(sfWasLockingChainSend, UINT8, 19) TYPED_SFIELD(sfWithdrawalPolicy, UINT8, 20) +TYPED_SFIELD(sfContractResult, UINT8, 21) +TYPED_SFIELD(sfVaultKind, UINT8, 22) // 16-bit integers (common) TYPED_SFIELD(sfLedgerEntryType, UINT16, 1, SField::kSmdNever) @@ -37,10 +39,7 @@ TYPED_SFIELD(sfDiscountedFee, UINT16, 6) // 16-bit integers (uncommon) TYPED_SFIELD(sfVersion, UINT16, 16) -TYPED_SFIELD(sfHookStateChangeCount, UINT16, 17) -TYPED_SFIELD(sfHookEmitCount, UINT16, 18) -TYPED_SFIELD(sfHookExecutionIndex, UINT16, 19) -TYPED_SFIELD(sfHookApiVersion, UINT16, 20) +// 17 to 20 unused TYPED_SFIELD(sfLedgerFixType, UINT16, 21) TYPED_SFIELD(sfManagementFeeRate, UINT16, 22) // 1/10 basis points (bips) @@ -91,9 +90,7 @@ TYPED_SFIELD(sfTicketSequence, UINT32, 41) TYPED_SFIELD(sfNFTokenTaxon, UINT32, 42) TYPED_SFIELD(sfMintedNFTokens, UINT32, 43) TYPED_SFIELD(sfBurnedNFTokens, UINT32, 44) -TYPED_SFIELD(sfHookStateCount, UINT32, 45) -TYPED_SFIELD(sfEmitGeneration, UINT32, 46) -// 47 reserved for Hooks +// 45 to 47 unused TYPED_SFIELD(sfVoteWeight, UINT32, 48) TYPED_SFIELD(sfFirstNFTokenSequence, UINT32, 50) TYPED_SFIELD(sfOracleDocumentID, UINT32, 51) @@ -120,6 +117,8 @@ TYPED_SFIELD(sfSponsoringOwnerCount, UINT32, 71) TYPED_SFIELD(sfSponsoringAccountCount, UINT32, 72) TYPED_SFIELD(sfRemainingOwnerCount, UINT32, 73) TYPED_SFIELD(sfSponsorFlags, UINT32, 74) +TYPED_SFIELD(sfSubscriptionDate, UINT32, 75) +TYPED_SFIELD(sfRedemptionDate, UINT32, 76) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -137,9 +136,7 @@ TYPED_SFIELD(sfNFTokenOfferNode, UINT64, 12) TYPED_SFIELD(sfEmitBurden, UINT64, 13) // 64-bit integers (uncommon) -TYPED_SFIELD(sfHookOn, UINT64, 16) -TYPED_SFIELD(sfHookInstructionCount, UINT64, 17) -TYPED_SFIELD(sfHookReturnCode, UINT64, 18) +// 16 to 18 unused TYPED_SFIELD(sfReferenceCount, UINT64, 19) TYPED_SFIELD(sfXChainClaimID, UINT64, 20) TYPED_SFIELD(sfXChainAccountCreateCount, UINT64, 21) @@ -203,10 +200,7 @@ TYPED_SFIELD(sfPreviousPageMin, UINT256, 26) TYPED_SFIELD(sfNextPageMin, UINT256, 27) TYPED_SFIELD(sfNFTokenBuyOffer, UINT256, 28) TYPED_SFIELD(sfNFTokenSellOffer, UINT256, 29) -TYPED_SFIELD(sfHookStateKey, UINT256, 30) -TYPED_SFIELD(sfHookHash, UINT256, 31) -TYPED_SFIELD(sfHookNamespace, UINT256, 32) -TYPED_SFIELD(sfHookSetTxnID, UINT256, 33) +// 30 to 33 unused TYPED_SFIELD(sfDomainID, UINT256, 34) TYPED_SFIELD(sfVaultID, UINT256, 35, SField::kSmdPseudoAccount | SField::kSmdDefault) @@ -237,7 +231,7 @@ TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::kSmdNeedsAsset TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16) TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset | SField::kSmdDefault) -// int32 +// 32-bit signed (common) TYPED_SFIELD(sfLoanScale, INT32, 1) TYPED_SFIELD(sfRemainingOwnerCountDelta, INT32, 2) @@ -261,15 +255,13 @@ TYPED_SFIELD(sfMinimumOffer, AMOUNT, 16) TYPED_SFIELD(sfRippleEscrow, AMOUNT, 17) TYPED_SFIELD(sfDeliveredAmount, AMOUNT, 18) TYPED_SFIELD(sfNFTokenBrokerFee, AMOUNT, 19) - -// Reserve 20 & 21 for Hooks. - +// 20 to 21 unused // currency amount (fees) TYPED_SFIELD(sfBaseFeeDrops, AMOUNT, 22) TYPED_SFIELD(sfReserveBaseDrops, AMOUNT, 23) TYPED_SFIELD(sfReserveIncrementDrops, AMOUNT, 24) -// currency amount (AMM) +// currency amount (more) TYPED_SFIELD(sfLPTokenOut, AMOUNT, 25) TYPED_SFIELD(sfLPTokenIn, AMOUNT, 26) TYPED_SFIELD(sfEPrice, AMOUNT, 27) @@ -304,10 +296,7 @@ TYPED_SFIELD(sfMasterSignature, VL, 18, SField::kSmdDefault, SFi TYPED_SFIELD(sfUNLModifyValidator, VL, 19) TYPED_SFIELD(sfValidatorToDisable, VL, 20) TYPED_SFIELD(sfValidatorToReEnable, VL, 21) -TYPED_SFIELD(sfHookStateData, VL, 22) -TYPED_SFIELD(sfHookReturnString, VL, 23) -TYPED_SFIELD(sfHookParameterName, VL, 24) -TYPED_SFIELD(sfHookParameterValue, VL, 25) +// 22 to 25 unused TYPED_SFIELD(sfDIDDocument, VL, 26) TYPED_SFIELD(sfData, VL, 27) TYPED_SFIELD(sfAssetClass, VL, 28) @@ -345,7 +334,7 @@ TYPED_SFIELD(sfHolder, ACCOUNT, 11) TYPED_SFIELD(sfDelegate, ACCOUNT, 12) // account (uncommon) -TYPED_SFIELD(sfHookAccount, ACCOUNT, 16) +// 16 unused TYPED_SFIELD(sfOtherChainSource, ACCOUNT, 18) TYPED_SFIELD(sfOtherChainDestination, ACCOUNT, 19) TYPED_SFIELD(sfAttestationSignerAccount, ACCOUNT, 20) @@ -398,7 +387,7 @@ UNTYPED_SFIELD(sfMemo, OBJECT, 10) UNTYPED_SFIELD(sfSignerEntry, OBJECT, 11) UNTYPED_SFIELD(sfNFToken, OBJECT, 12) UNTYPED_SFIELD(sfEmitDetails, OBJECT, 13) -UNTYPED_SFIELD(sfHook, OBJECT, 14) +// 14 unused UNTYPED_SFIELD(sfPermission, OBJECT, 15) // inner object (uncommon) @@ -406,11 +395,7 @@ UNTYPED_SFIELD(sfSigner, OBJECT, 16) // 17 unused UNTYPED_SFIELD(sfMajority, OBJECT, 18) UNTYPED_SFIELD(sfDisabledValidator, OBJECT, 19) -UNTYPED_SFIELD(sfEmittedTxn, OBJECT, 20) -UNTYPED_SFIELD(sfHookExecution, OBJECT, 21) -UNTYPED_SFIELD(sfHookDefinition, OBJECT, 22) -UNTYPED_SFIELD(sfHookParameter, OBJECT, 23) -UNTYPED_SFIELD(sfHookGrant, OBJECT, 24) +// 20 to 24 unused UNTYPED_SFIELD(sfVoteEntry, OBJECT, 25) UNTYPED_SFIELD(sfAuctionSlot, OBJECT, 26) UNTYPED_SFIELD(sfAuthAccount, OBJECT, 27) @@ -438,16 +423,14 @@ UNTYPED_SFIELD(sfSufficient, ARRAY, 7) UNTYPED_SFIELD(sfAffectedNodes, ARRAY, 8) UNTYPED_SFIELD(sfMemos, ARRAY, 9) UNTYPED_SFIELD(sfNFTokens, ARRAY, 10) -UNTYPED_SFIELD(sfHooks, ARRAY, 11) +// 11 unused UNTYPED_SFIELD(sfVoteSlots, ARRAY, 12) UNTYPED_SFIELD(sfAdditionalBooks, ARRAY, 13) // array of objects (uncommon) UNTYPED_SFIELD(sfMajorities, ARRAY, 16) UNTYPED_SFIELD(sfDisabledValidators, ARRAY, 17) -UNTYPED_SFIELD(sfHookExecutions, ARRAY, 18) -UNTYPED_SFIELD(sfHookParameters, ARRAY, 19) -UNTYPED_SFIELD(sfHookGrants, ARRAY, 20) +// 18 to 20 unused UNTYPED_SFIELD(sfXChainClaimAttestations, ARRAY, 21) UNTYPED_SFIELD(sfXChainCreateAccountAttestations, ARRAY, 22) // 23 unused diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 1f9603dbae..f8676d3b63 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -862,6 +862,9 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, {sfWithdrawalPolicy, SoeOptional}, {sfData, SoeOptional}, {sfScale, SoeOptional}, + {sfVaultKind, SoeOptional}, + {sfSubscriptionDate, SoeOptional}, + {sfRedemptionDate, SoeOptional}, })) /** This transaction updates a single asset vault. */ diff --git a/include/xrpl/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index a6ab54cb0a..389ffb4c46 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -311,6 +311,78 @@ public: { return this->sle_->isFieldPresent(sfLEVersion); } + + /** + * @brief Get sfVaultKind (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getVaultKind() const + { + if (hasVaultKind()) + return this->sle_->at(sfVaultKind); + return std::nullopt; + } + + /** + * @brief Check if sfVaultKind is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasVaultKind() const + { + return this->sle_->isFieldPresent(sfVaultKind); + } + + /** + * @brief Get sfSubscriptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getSubscriptionDate() const + { + if (hasSubscriptionDate()) + return this->sle_->at(sfSubscriptionDate); + return std::nullopt; + } + + /** + * @brief Check if sfSubscriptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasSubscriptionDate() const + { + return this->sle_->isFieldPresent(sfSubscriptionDate); + } + + /** + * @brief Get sfRedemptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getRedemptionDate() const + { + if (hasRedemptionDate()) + return this->sle_->at(sfRedemptionDate); + return std::nullopt; + } + + /** + * @brief Check if sfRedemptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasRedemptionDate() const + { + return this->sle_->isFieldPresent(sfRedemptionDate); + } }; /** @@ -543,6 +615,39 @@ public: return *this; } + /** + * @brief Set sfVaultKind (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setVaultKind(std::decay_t const& value) + { + object_[sfVaultKind] = value; + return *this; + } + + /** + * @brief Set sfSubscriptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setSubscriptionDate(std::decay_t const& value) + { + object_[sfSubscriptionDate] = value; + return *this; + } + + /** + * @brief Set sfRedemptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setRedemptionDate(std::decay_t const& value) + { + object_[sfRedemptionDate] = value; + return *this; + } + /** * @brief Build and return the completed Vault wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/transactions/VaultCreate.h b/include/xrpl/protocol_autogen/transactions/VaultCreate.h index b7e1527754..e206925e02 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultCreate.h +++ b/include/xrpl/protocol_autogen/transactions/VaultCreate.h @@ -214,6 +214,84 @@ public: { return this->tx_->isFieldPresent(sfScale); } + + /** + * @brief Get sfVaultKind (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getVaultKind() const + { + if (hasVaultKind()) + { + return this->tx_->at(sfVaultKind); + } + return std::nullopt; + } + + /** + * @brief Check if sfVaultKind is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasVaultKind() const + { + return this->tx_->isFieldPresent(sfVaultKind); + } + + /** + * @brief Get sfSubscriptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getSubscriptionDate() const + { + if (hasSubscriptionDate()) + { + return this->tx_->at(sfSubscriptionDate); + } + return std::nullopt; + } + + /** + * @brief Check if sfSubscriptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasSubscriptionDate() const + { + return this->tx_->isFieldPresent(sfSubscriptionDate); + } + + /** + * @brief Get sfRedemptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getRedemptionDate() const + { + if (hasRedemptionDate()) + { + return this->tx_->at(sfRedemptionDate); + } + return std::nullopt; + } + + /** + * @brief Check if sfRedemptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasRedemptionDate() const + { + return this->tx_->isFieldPresent(sfRedemptionDate); + } }; /** @@ -338,6 +416,39 @@ public: return *this; } + /** + * @brief Set sfVaultKind (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setVaultKind(std::decay_t const& value) + { + object_[sfVaultKind] = value; + return *this; + } + + /** + * @brief Set sfSubscriptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setSubscriptionDate(std::decay_t const& value) + { + object_[sfSubscriptionDate] = value; + return *this; + } + + /** + * @brief Set sfRedemptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setRedemptionDate(std::decay_t const& value) + { + object_[sfRedemptionDate] = value; + return *this; + } + /** * @brief Build and return the VaultCreate wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/rdb/DBInit.h b/include/xrpl/rdb/DBInit.h index 10b04905f2..e6e7e87b6b 100644 --- a/include/xrpl/rdb/DBInit.h +++ b/include/xrpl/rdb/DBInit.h @@ -2,6 +2,9 @@ #include #include +#include +#include +#include namespace xrpl { @@ -9,9 +12,29 @@ namespace xrpl { // These pragmas are built at startup and applied to all database // connections, unless otherwise noted. -inline constexpr char const* kCommonDbPragmaJournal{"PRAGMA journal_mode=%s;"}; -inline constexpr char const* kCommonDbPragmaSync{"PRAGMA synchronous=%s;"}; -inline constexpr char const* kCommonDbPragmaTemp{"PRAGMA temp_store=%s;"}; +// +// They are exposed as functions rather than as format-string constants so +// that the un-substituted template can never reach sqlite: an unrecognized +// pragma value is silently ignored, so forgetting to interpolate would +// leave the setting at its default instead of failing loudly. +[[nodiscard]] inline std::string +commonDbPragmaJournal(std::string_view journalMode) +{ + return std::format("PRAGMA journal_mode={};", journalMode); +} + +[[nodiscard]] inline std::string +commonDbPragmaSync(std::string_view synchronous) +{ + return std::format("PRAGMA synchronous={};", synchronous); +} + +[[nodiscard]] inline std::string +commonDbPragmaTemp(std::string_view tempStore) +{ + return std::format("PRAGMA temp_store={};", tempStore); +} + // A warning will be logged if any lower-safety sqlite tuning settings // are used and at least this much ledger history is configured. This // includes full history nodes. This is because such a large amount of diff --git a/include/xrpl/rdb/DatabaseCon.h b/include/xrpl/rdb/DatabaseCon.h index 90aed04337..5c20f65784 100644 --- a/include/xrpl/rdb/DatabaseCon.h +++ b/include/xrpl/rdb/DatabaseCon.h @@ -6,13 +6,12 @@ #include #include -#include - #include #include #include #include +#include #include #include #include @@ -80,7 +79,7 @@ public: StartUpType startUp = StartUpType::Normal; bool standAlone = false; - boost::filesystem::path dataDir; + std::filesystem::path dataDir; // Indicates whether or not to return the `globalPragma` // from commonPragma() bool useGlobalPragma = false; @@ -143,7 +142,7 @@ public: template DatabaseCon( - boost::filesystem::path const& dataDir, + std::filesystem::path const& dataDir, std::string const& dbName, std::array const& pragma, std::array const& initSQL, @@ -155,7 +154,7 @@ public: // Use this constructor to setup checkpointing template DatabaseCon( - boost::filesystem::path const& dataDir, + std::filesystem::path const& dataDir, std::string const& dbName, std::array const& pragma, std::array const& initSQL, @@ -190,7 +189,7 @@ private: template DatabaseCon( - boost::filesystem::path const& pPath, + std::filesystem::path const& pPath, std::vector const* commonPragma, std::array const& pragma, std::array const& initSQL, diff --git a/include/xrpl/rdb/RelationalDatabase.h b/include/xrpl/rdb/RelationalDatabase.h index e5784c7418..e858f578f8 100644 --- a/include/xrpl/rdb/RelationalDatabase.h +++ b/include/xrpl/rdb/RelationalDatabase.h @@ -14,7 +14,6 @@ #include #include -#include #include #include diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index 786967b057..1b726f2c0c 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -306,12 +306,6 @@ operator==(Manifest const& lhs, Manifest const& rhs) lhs.serialized == rhs.serialized; } -inline bool -operator!=(Manifest const& lhs, Manifest const& rhs) -{ - return !(lhs == rhs); -} - struct ValidatorToken { std::string manifest; diff --git a/include/xrpl/server/State.h b/include/xrpl/server/State.h index 8590f6e18f..b79253c12c 100644 --- a/include/xrpl/server/State.h +++ b/include/xrpl/server/State.h @@ -4,8 +4,6 @@ #include #include -#include - #include namespace xrpl { diff --git a/include/xrpl/server/Wallet.h b/include/xrpl/server/Wallet.h index ed8378989f..95486cc468 100644 --- a/include/xrpl/server/Wallet.h +++ b/include/xrpl/server/Wallet.h @@ -10,6 +10,10 @@ #include #include +// boost::optional (not std::optional) appears in the declarations below, +// because SOCI's into()/use() bindings only support boost::optional. +#include + #include #include #include diff --git a/include/xrpl/server/detail/BaseWSPeer.h b/include/xrpl/server/detail/BaseWSPeer.h index b1670865bd..403d7f92ee 100644 --- a/include/xrpl/server/detail/BaseWSPeer.h +++ b/include/xrpl/server/detail/BaseWSPeer.h @@ -25,6 +25,7 @@ #include #include #include +#include #include #include @@ -62,8 +63,7 @@ private: bool pingActive_ = false; boost::beast::websocket::ping_data payload_; error_code ec_; - std::function - controlCallback_; + std::function controlCallback_; public: template @@ -151,7 +151,7 @@ protected: onPing(error_code const& ec); void - onPingPong(boost::beast::websocket::frame_type kind, boost::beast::string_view payload); + onPingPong(boost::beast::websocket::frame_type kind, std::string_view payload); void onTimer(error_code ec); @@ -189,9 +189,9 @@ BaseWSPeer::run() impl().ws_.set_option(port().pmdOptions); // Must manage the control callback memory outside of the `control_callback` // function - controlCallback_ = [this]( - boost::beast::websocket::frame_type kind, - boost::beast::string_view payload) { onPingPong(kind, payload); }; + controlCallback_ = [this](boost::beast::websocket::frame_type kind, std::string_view payload) { + onPingPong(kind, payload); + }; impl().ws_.control_callback(controlCallback_); startTimer(); closeOnTimer_ = true; @@ -430,11 +430,11 @@ template void BaseWSPeer::onPingPong( boost::beast::websocket::frame_type kind, - boost::beast::string_view payload) + std::string_view payload) { if (kind == boost::beast::websocket::frame_type::pong) { - boost::beast::string_view const p(payload_.begin()); + std::string_view const p(payload_.begin(), payload_.size()); if (payload == p) { closeOnTimer_ = false; diff --git a/include/xrpl/shamap/SHAMap.h b/include/xrpl/shamap/SHAMap.h index e198c472fa..05de33ddf3 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -484,31 +484,36 @@ private: // returns the first item at or below this node SHAMapLeafNode* - firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch = 0) const; + firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch = 0u) const; // returns the last item at or below this node SHAMapLeafNode* - lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch = kBranchFactor) const; + lastBelow( + SHAMapTreeNodePtr node, + SharedPtrNodeStack& stack, + unsigned int branch = kBranchFactor) const; + + // direction in which belowHelper scans an inner node's branches + enum class BelowDirection { First, Last }; // helper function for firstBelow and lastBelow SHAMapLeafNode* belowHelper( SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, - int branch, - std::tuple, std::function> const& loopParams) - const; + unsigned int branch, + BelowDirection direction) const; // Simple descent // Get a child of the specified node SHAMapTreeNode* - descend(SHAMapInnerNode*, int branch) const; + descend(SHAMapInnerNode*, unsigned int branch) const; SHAMapTreeNode* - descendThrow(SHAMapInnerNode*, int branch) const; + descendThrow(SHAMapInnerNode*, unsigned int branch) const; SHAMapTreeNodePtr - descend(SHAMapInnerNode&, int branch) const; + descend(SHAMapInnerNode&, unsigned int branch) const; SHAMapTreeNodePtr - descendThrow(SHAMapInnerNode&, int branch) const; + descendThrow(SHAMapInnerNode&, unsigned int branch) const; // Descend with filter // If pending, callback is called as if it called fetchNodeNT @@ -516,7 +521,7 @@ private: SHAMapTreeNode* descendAsync( SHAMapInnerNode* parent, - int branch, + unsigned int branch, SHAMapSyncFilter const* filter, bool& pending, descendCallback&&) const; @@ -525,13 +530,13 @@ private: descend( SHAMapInnerNode* parent, SHAMapNodeID const& parentID, - int branch, + unsigned int branch, SHAMapSyncFilter const* filter) const; // Non-storing // Does not hook the returned node to its parent SHAMapTreeNodePtr - descendNoStore(SHAMapInnerNode&, int branch) const; + descendNoStore(SHAMapInnerNode&, unsigned int branch) const; /** * If there is only one leaf below this node, get its contents @@ -581,8 +586,8 @@ private: using StackEntry = std::tuple< SHAMapInnerNode*, // pointer to the node SHAMapNodeID, // the node's ID - int, // while child we check first - int, // which child we check next + unsigned int, // which child we check first + unsigned int, // which child we check next bool>; // whether we've found any missing children yet // We explicitly choose to specify the use of std::deque here, because @@ -596,7 +601,7 @@ private: using DeferredNode = std::tuple< SHAMapInnerNode*, // parent node SHAMapNodeID, // parent node ID - int, // branch + unsigned int, // branch SHAMapTreeNodePtr>; // node int deferred; @@ -789,12 +794,6 @@ operator==(SHAMap::ConstIterator const& x, SHAMap::ConstIterator const& y) return x.item_ == y.item_; } -inline bool -operator!=(SHAMap::ConstIterator const& x, SHAMap::ConstIterator const& y) -{ - return !(x == y); -} - inline SHAMap::ConstIterator SHAMap::begin() const { diff --git a/include/xrpl/shamap/SHAMapInnerNode.h b/include/xrpl/shamap/SHAMapInnerNode.h index 44d3bd6279..83d039172f 100644 --- a/include/xrpl/shamap/SHAMapInnerNode.h +++ b/include/xrpl/shamap/SHAMapInnerNode.h @@ -62,8 +62,8 @@ private: * * @param i index of the requested child */ - std::optional - getChildIndex(int i) const; + std::optional + getChildIndex(unsigned int i) const; /** * Call the `f` callback for all 16 (branchFactor) branches - even if @@ -125,28 +125,28 @@ public: isEmpty() const; bool - isEmptyBranch(int m) const; + isEmptyBranch(unsigned int branch) const; - int + unsigned int getBranchCount() const; SHAMapHash const& - getChildHash(int m) const; + getChildHash(unsigned int branch) const; void - setChild(int m, SHAMapTreeNodePtr child); + setChild(unsigned int branch, SHAMapTreeNodePtr child); void - shareChild(int m, SHAMapTreeNodePtr const& child); + shareChild(unsigned int branch, SHAMapTreeNodePtr const& child); SHAMapTreeNode* - getChildPointer(int branch); + getChildPointer(unsigned int branch); SHAMapTreeNodePtr - getChild(int branch); + getChild(unsigned int branch); SHAMapTreeNodePtr - canonicalizeChild(int branch, SHAMapTreeNodePtr node); + canonicalizeChild(unsigned int branch, SHAMapTreeNodePtr node); // sync functions bool @@ -190,12 +190,12 @@ SHAMapInnerNode::isEmpty() const } inline bool -SHAMapInnerNode::isEmptyBranch(int m) const +SHAMapInnerNode::isEmptyBranch(unsigned int branch) const { - return (isBranch_ & (1 << m)) == 0; + return (isBranch_ & (1u << branch)) == 0u; } -inline int +inline unsigned int SHAMapInnerNode::getBranchCount() const { return popcnt16(isBranch_); diff --git a/include/xrpl/shamap/SHAMapNodeID.h b/include/xrpl/shamap/SHAMapNodeID.h index 6094892091..f35ba2d2a7 100644 --- a/include/xrpl/shamap/SHAMapNodeID.h +++ b/include/xrpl/shamap/SHAMapNodeID.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -52,7 +53,21 @@ public: } [[nodiscard]] SHAMapNodeID - getChildNodeID(unsigned int m) const; + getChildNodeID(unsigned int branch) const; + + /** + * Test whether this node ID lies on the path to the given leaf key + * + * A node at depth d identifies the tree path spelled by the first d + * nibbles of its key, so any leaf beneath it must agree on that prefix. + * A node ID that fails this test names a different subtree than the one + * it was built for. + * + * @param key the key of a leaf below this node + * @return whether this node ID is a prefix of the leaf key + */ + [[nodiscard]] bool + isPrefixOf(uint256 const& key) const; /** * Create a SHAMapNodeID of a node with the depth of the node and @@ -63,47 +78,34 @@ public: * @return SHAMapNodeID of the node */ static SHAMapNodeID - createID(int depth, uint256 const& key); + createID(unsigned int depth, uint256 const& key); - // FIXME-C++20: use spaceship and operator synthesis /** * Comparison operators + * + * <, >, <= and >= are synthesized from the spaceship. It is written out + * rather than defaulted because the ordering is by depth first, and the + * members are not declared in that order. */ - bool - operator<(SHAMapNodeID const& n) const + std::strong_ordering + operator<=>(SHAMapNodeID const& n) const { - return std::tie(depth_, id_) < std::tie(n.depth_, n.id_); - } - - bool - operator>(SHAMapNodeID const& n) const - { - return n < *this; - } - - bool - operator<=(SHAMapNodeID const& n) const - { - return !(n < *this); - } - - bool - operator>=(SHAMapNodeID const& n) const - { - return !(*this < n); + return std::tie(depth_, id_) <=> std::tie(n.depth_, n.id_); } + /** + * Equality, which the spaceship above does not provide. + * + * Only a *defaulted* operator<=> implicitly declares a defaulted + * operator==; the one above is user-provided, so == has to be written. + * It cannot be defaulted either, because a defaulted == would also compare + * the CountedObject base, which is not equality comparable. + */ bool operator==(SHAMapNodeID const& n) const { return (depth_ == n.depth_) && (id_ == n.id_); } - - bool - operator!=(SHAMapNodeID const& n) const - { - return !(*this == n); - } }; inline std::string diff --git a/include/xrpl/shamap/detail/TaggedPointer.h b/include/xrpl/shamap/detail/TaggedPointer.h index 509e6cc58d..705681be1d 100644 --- a/include/xrpl/shamap/detail/TaggedPointer.h +++ b/include/xrpl/shamap/detail/TaggedPointer.h @@ -219,11 +219,11 @@ public: * * @param i index of the requested child */ - [[nodiscard]] std::optional - getChildIndex(std::uint16_t isBranch, int i) const; + [[nodiscard]] std::optional + getChildIndex(std::uint16_t isBranch, unsigned int i) const; }; -[[nodiscard]] inline int +[[nodiscard]] inline unsigned int popcnt16(std::uint16_t a) { #if __cpp_lib_bitops @@ -234,11 +234,11 @@ popcnt16(std::uint16_t a) // fallback to table lookup static constexpr auto tbl = []() { std::array ret{}; - for (int i = 0; i != 256; ++i) + for (auto i = 0u; i != 256u; ++i) { - for (int j = 0; j != 8; ++j) + for (auto j = 0u; j != 8u; ++j) { - if (i & (1 << j)) + if (i & (1u << j)) ret[i]++; } } diff --git a/include/xrpl/shamap/detail/TaggedPointer.ipp b/include/xrpl/shamap/detail/TaggedPointer.ipp index 9275f3d15a..7db101b3cb 100644 --- a/include/xrpl/shamap/detail/TaggedPointer.ipp +++ b/include/xrpl/shamap/detail/TaggedPointer.ipp @@ -22,6 +22,11 @@ static_assert( static_assert( kBoundaries.back() == SHAMapInnerNode::kBranchFactor, "Last element of boundaries must be number of children in a dense array"); +static_assert( + kBoundaries.front() >= 1, + "TaggedPointer.ipp subtracts 1 from a numAllocated value derived from " + "kBoundaries, as an unsigned quantity, in several places; the smallest " + "boundary must stay non-zero or those subtractions underflow."); // Terminology: A chunk is the memory being allocated from a block. A block // contains multiple chunks. This is the terminology the boost documentation @@ -148,16 +153,16 @@ TaggedPointer::iterChildren(std::uint16_t isBranch, F&& f) const if (numAllocated == SHAMapInnerNode::kBranchFactor) { // dense case - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) f(hashes[i]); } else { // sparse case - int curHashI = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto curHashI = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if ((1 << i) & isBranch) + if ((1u << i) & isBranch) { f(hashes[curHashI++]); } @@ -176,9 +181,9 @@ TaggedPointer::iterNonEmptyChildIndexes(std::uint16_t isBranch, F&& f) const if (capacity() == SHAMapInnerNode::kBranchFactor) { // dense case - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if ((1 << i) & isBranch) + if ((1u << i) & isBranch) { f(i, i); } @@ -187,10 +192,10 @@ TaggedPointer::iterNonEmptyChildIndexes(std::uint16_t isBranch, F&& f) const else { // sparse case - int curHashI = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto curHashI = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if ((1 << i) & isBranch) + if ((1u << i) & isBranch) { f(i, curHashI++); } @@ -216,14 +221,14 @@ TaggedPointer::destroyHashesAndChildren() deallocateArrays(tag, ptr); } -inline std::optional -TaggedPointer::getChildIndex(std::uint16_t isBranch, int i) const +inline std::optional +TaggedPointer::getChildIndex(std::uint16_t isBranch, unsigned int i) const { if (isDense()) return i; // Sparse case - if ((isBranch & (1 << i)) == 0) + if ((isBranch & (1u << i)) == 0u) { // Empty branch. Sparse children do not store empty branches return {}; @@ -273,10 +278,10 @@ inline TaggedPointer::TaggedPointer( *this = std::move(other); auto [srcDstNumAllocated, srcDstHashes, srcDstChildren] = getHashesAndChildren(); bool const srcDstIsDense = isDense(); - int srcDstIndex = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto srcDstIndex = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - auto const mask = (1 << i); + auto const mask = (1u << i); bool const inSrc = (srcBranches & mask) != 0; bool const inDst = (dstBranches & mask) != 0; if (inSrc && inDst) @@ -298,13 +303,13 @@ inline TaggedPointer::TaggedPointer( // sparse // need to shift all the elements to the left by // one - for (int c = srcDstIndex; c < srcDstNumAllocated - 1; ++c) + for (auto c = srcDstIndex; c + 1 < srcDstNumAllocated; ++c) { srcDstHashes[c] = srcDstHashes[c + 1]; srcDstChildren[c] = std::move(srcDstChildren[c + 1]); } - srcDstHashes[srcDstNumAllocated - 1].zero(); - srcDstChildren[srcDstNumAllocated - 1].reset(); + srcDstHashes[srcDstNumAllocated - 1u].zero(); + srcDstChildren[srcDstNumAllocated - 1u].reset(); // do not increment the index } } @@ -321,7 +326,7 @@ inline TaggedPointer::TaggedPointer( // sparse // need to create a hole by shifting all the elements to the // right by one - for (int c = srcDstNumAllocated - 1; c > srcDstIndex; --c) + for (auto c = srcDstNumAllocated - 1u; c > srcDstIndex; --c) { srcDstHashes[c] = srcDstHashes[c - 1]; srcDstChildren[c] = std::move(srcDstChildren[c - 1]); @@ -352,10 +357,10 @@ inline TaggedPointer::TaggedPointer( auto [srcNumAllocated, srcHashes, srcChildren] = src.getHashesAndChildren(); bool const srcIsDense = src.isDense(); bool const dstIsDense = dst.isDense(); - int srcIndex = 0, dstIndex = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto srcIndex = 0u, dstIndex = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - auto const mask = (1 << i); + auto const mask = (1u << i); bool const inSrc = (srcBranches & mask) != 0; bool const inDst = (dstBranches & mask) != 0; if (inSrc && inDst) @@ -409,7 +414,7 @@ inline TaggedPointer::TaggedPointer( !dstIsDense || dstIndex == dstNumAllocated, "xrpl::TaggedPointer::TaggedPointer(TaggedPointer&& ...) : " "non-sparse or valid sparse"); - for (int i = dstIndex; i < dstNumAllocated; ++i) + for (auto i = dstIndex; i < dstNumAllocated; ++i) { new (&dstHashes[i]) SHAMapHash{}; new (&dstChildren[i]) SHAMapTreeNodePtr{}; @@ -448,9 +453,9 @@ inline TaggedPointer::TaggedPointer( new (&newChildren[branchNum]) SHAMapTreeNodePtr{std::move(oldChildren[indexNum])}; }); // Run the constructors for the remaining elements - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if (((1 << i) & isBranch) != 0) + if (((1u << i) & isBranch) != 0u) continue; new (&newHashes[i]) SHAMapHash{}; new (&newChildren[i]) SHAMapTreeNodePtr{}; @@ -459,7 +464,7 @@ inline TaggedPointer::TaggedPointer( else { // new arrays are sparse, old arrays may be sparse or dense - int curCompressedIndex = 0; + auto curCompressedIndex = 0u; iterNonEmptyChildIndexes(isBranch, [&](auto branchNum, auto indexNum) { new (&newHashes[curCompressedIndex]) SHAMapHash{oldHashes[indexNum]}; new (&newChildren[curCompressedIndex]) @@ -467,7 +472,7 @@ inline TaggedPointer::TaggedPointer( ++curCompressedIndex; }); // Run the constructors for the remaining elements - for (int i = curCompressedIndex; i < newNumAllocated; ++i) + for (auto i = curCompressedIndex; i < newNumAllocated; ++i) { new (&newHashes[i]) SHAMapHash{}; new (&newChildren[i]) SHAMapTreeNodePtr{}; diff --git a/include/xrpl/tx/ApplyContext.h b/include/xrpl/tx/ApplyContext.h index 472afdf624..e827e69f01 100644 --- a/include/xrpl/tx/ApplyContext.h +++ b/include/xrpl/tx/ApplyContext.h @@ -17,7 +17,6 @@ #include #include #include -#include namespace xrpl { @@ -130,16 +129,6 @@ public: view_->rawDestroyXRP(fee); } - /** - * Applies all invariant checkers one by one. - * - * @param result the result generated by processing this transaction. - * @param fee the fee charged for this transaction - * @return the result code that should be returned for this transaction. - */ - TER - checkInvariants(TER const result, XRPAmount const fee); - ApplyViewContext getApplyViewContext() { @@ -150,13 +139,6 @@ public: } private: - static TER - failInvariantCheck(TER const result); - - template - TER - checkInvariantsHelper(TER const result, XRPAmount const fee, std::index_sequence); - OpenView& base_; ApplyFlags flags_; std::optional view_; diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index a71285f70e..96ad7e00bc 100644 --- a/include/xrpl/tx/Transactor.h +++ b/include/xrpl/tx/Transactor.h @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -147,7 +148,7 @@ struct FeePayer FeePayerType type{FeePayerType::Account}; }; -class Transactor +class Transactor : public TxInvariantCheck { protected: ApplyContext& ctx_; @@ -158,7 +159,7 @@ protected: XRPAmount preFeeBalance_{}; // Balance before fees. public: - virtual ~Transactor() = default; + ~Transactor() override = default; Transactor(Transactor const&) = delete; Transactor& operator=(Transactor const&) = delete; @@ -183,20 +184,50 @@ public: return ctx_.view(); } + /** + * Which invariant layers to check. + * + * Full runs the protocol invariants plus the transaction-specific + * check. This is always the scope of the initial pass, even when the + * tentative TER is a tec: a bug or exploit could still mutate ledger + * state, so transaction-specific invariants must run for failed + * transactions too. + * + * ProtocolOnly runs only the protocol invariants and is used + * exclusively for the second invariant pass that follows a + * fee-claim reset — specifically, the reset that + * Transactor::operator() performs when the initial invariant pass + * returns tecINVARIANT_FAILED, rolling the transaction's effects back + * to a fee-claim-only state. In that reduced state the + * transaction-specific post-conditions no longer apply, but the + * protocol invariants must still hold against the fee claim itself. + * ProtocolOnly is not intended for other context discards (e.g. the + * reset used to handle tecOVERSIZE/tecKILLED/etc. in + * processPersistentChanges, or the ctx_.discard() done under + * TapFailHard); those paths do not re-run invariants at all. + */ + enum class InvariantScope { Full, ProtocolOnly }; + /** * Check all invariants for the current transaction. * - * Runs transaction-specific invariants first (visitInvariantEntry + - * finalizeInvariants), then protocol-level invariants. Both layers - * always run; the worst failure code is returned. + * Delegates to the free xrpl::checkInvariants runner. When @p scope is + * InvariantScope::Full, this transactor is passed so both layers + * share a single walk of the modified ledger entries. A failure in + * either layer fails the transaction the same way: tecINVARIANT_FAILED + * on the first pass, which the caller may respond to by rolling the + * transaction back to a fee-claim state and re-invoking this with + * InvariantScope::ProtocolOnly; a failure on that post-reset pass + * escalates to tefINVARIANT_FAILED. * * @param result the tentative TER from transaction processing. * @param fee the fee consumed by the transaction. + * @param scope which invariant layers to check. * * @return the final TER after all invariant checks. */ [[nodiscard]] TER - checkInvariants(TER result, XRPAmount fee); + checkInvariants(TER result, XRPAmount fee, InvariantScope scope); ///////////////////////////////////////////////////// /* @@ -538,20 +569,30 @@ private: preflightUniversal(PreflightContext const& ctx); /** - * Check transaction-specific invariants only. - * - * Walks every modified ledger entry via visitInvariantEntry, then - * calls finalizeInvariants on the derived transactor. Returns - * tecINVARIANT_FAILED if any transaction invariant is violated. - * - * @param result the tentative TER from transaction processing. - * @param fee the fee consumed by the transaction. - * - * @return the original result if all invariants pass, or - * tecINVARIANT_FAILED otherwise. + * Bridges the two-phase TxInvariantCheck interface to this transactor's + * visitInvariantEntry/finalizeInvariants hooks. Declared private (rather + * than protected, like the hooks they forward to) so that neither this + * transactor nor any subclass can call them directly through a + * Transactor& — only through the TxInvariantCheck& that the free + * xrpl::checkInvariants runner holds, which is where the two-phase + * ordering is enforced. */ - [[nodiscard]] TER - checkTransactionInvariants(TER result, XRPAmount fee); + void + visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) final + { + visitInvariantEntry(isDelete, before, after); + } + + [[nodiscard]] bool + finalize( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) final + { + return finalizeInvariants(tx, result, fee, view, j); + } }; inline bool diff --git a/include/xrpl/tx/invariants/FreezeInvariant.h b/include/xrpl/tx/invariants/FreezeInvariant.h index 4b3e9beec4..301e464daf 100644 --- a/include/xrpl/tx/invariants/FreezeInvariant.h +++ b/include/xrpl/tx/invariants/FreezeInvariant.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -11,6 +12,7 @@ #include #include +#include #include namespace xrpl { @@ -69,7 +71,9 @@ private: IssuerChanges const& changes, STTx const& tx, beast::Journal const& j, - bool enforce); + bool enforce, + bool fixOverrideFreeze, + std::optional const& loanDefaultAccounts); static bool validateFrozenState( @@ -78,7 +82,9 @@ private: STTx const& tx, beast::Journal const& j, bool enforce, - bool globalFreeze); + bool globalFreeze, + bool fixOverrideFreeze, + std::optional const& loanDefaultAccounts); }; } // namespace xrpl diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index 1239305e79..e8dafbd301 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -198,7 +198,7 @@ public: /** * @brief Invariant: An account XRP balance must be in XRP and take a value - * between 0 and INITIAL_XRP drops, inclusive. + * between 0 and kInitialXRP drops, inclusive. * * We iterate all account roots modified by the transaction and ensure that * their XRP balances are reasonable. @@ -290,7 +290,7 @@ public: /** * @brief Invariant: an escrow entry must take a value between 0 and - * INITIAL_XRP drops exclusive. + * kInitialXRP drops exclusive. */ class NoZeroEscrow { diff --git a/include/xrpl/tx/invariants/InvariantRunner.h b/include/xrpl/tx/invariants/InvariantRunner.h new file mode 100644 index 0000000000..29a9dc09b2 --- /dev/null +++ b/include/xrpl/tx/invariants/InvariantRunner.h @@ -0,0 +1,140 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +/** + * @brief Runtime interface for a transaction-specific invariant check. + * + * The free checkInvariants runner drives two layers of checks over a single + * walk of the modified ledger entries: + * + * - Protocol checks are the concrete types in InvariantChecks, held in a + * std::tuple and dispatched statically by a compile-time fold (no + * virtual calls). They are duck-typed against the two-phase contract + * described below; see InvariantChecker_PROTOTYPE in InvariantCheck.h. + * - The transaction-specific check is injected at runtime through this + * interface, so the runner can call it without depending on the concrete + * transactor type. Transactor implements this interface directly (see + * Transactor.h) so that the interface's access can stay narrower than + * Transactor's own public surface: calling through a TxInvariantCheck& + * (all the runner ever holds) is public, but calling through a + * Transactor& is not, since Transactor overrides these as private + * (forwarding to its own protected visitInvariantEntry/finalizeInvariants). + * + * Both layers honour the same two-phase protocol: + * + * Phase 1 — state collection (visitEntry). Called once for each ledger + * entry created, modified, or deleted by the transaction. Implementations + * accumulate whatever state they need to evaluate their post-conditions. + * Must not throw. + * + * Phase 2 — condition evaluation (finalize). Called once after every + * modified entry has been visited. Returns true if all post-conditions + * hold, false to fail the transaction. + * + * Rule: invariants must run regardless of transaction result. finalize + * MUST perform meaningful checks even when the transaction has failed + * (when result is not tesSUCCESS). A bug or exploit could cause a failed + * transaction to mutate ledger state in unexpected ways; invariants are the + * last line of defense. + * + * The typical pattern: an invariant that expects a domain-specific state + * change (e.g. a Vault being created) should expect that change only when + * the transaction succeeded. A failed VaultCreate must not have created a + * Vault. + * + * Rule: privilege-gated checks apply to failed transactions too. Failed + * transactions carry no privileges. Any privilege-gated assertion must + * therefore also be enforced for failed transactions. + */ +class TxInvariantCheck +{ +public: + virtual ~TxInvariantCheck() = default; + + /** + * @brief Called for each ledger entry modified by the transaction. + * + * @param isDelete true if the SLE is being deleted. + * @param before the entry's state before the transaction (nullptr for + * newly created entries). + * @param after the entry's state after the transaction. For deletions + * this is the SLE being erased; use @p isDelete rather than + * a null @p after to detect deletions. @p after is + * never null. + */ + virtual void + visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) = 0; + + /** + * @brief Called after all entries have been visited. + * + * @param tx the transaction being applied. + * @param result the tentative TER result of the transaction. + * @param fee the fee consumed by the transaction. + * @param view read-only view of the ledger after the transaction. + * @param j journal for logging invariant failures. + * @return true if all invariants hold; false to fail with + * tecINVARIANT_FAILED / tefINVARIANT_FAILED. + */ + [[nodiscard]] virtual bool + finalize( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) = 0; +}; + +/** + * @brief Run all protocol invariant checks plus the transaction-specific check + * in a single pass over the modified entries. + * + * Both layers share one walk of the modified-entry set: @p txCheck's + * visitEntry accumulates state on the same traversal that drives the + * protocol checkers, then both layers' finalize run on the complete state. + * + * Any failure (a finalize returning false or an exception anywhere in the + * check) returns failInvariantCheck(result). On the first pass that yields + * tecINVARIANT_FAILED, which the transactor treats as a signal to roll the + * transaction's effects back to a fee-claim-only state and re-run this + * runner against the reduced state (see Transactor::InvariantScope). If + * that second pass also fails, the result escalates to tefINVARIANT_FAILED, + * which excludes the transaction from the ledger entirely. + * + * The whole traversal — both layers' visitEntry calls and both layers' + * finalize calls — runs under a single try/catch. There is no per-layer + * isolation: an exception anywhere aborts the remaining traversal and + * finalize calls and fails the transaction. + * + * @param ctx the apply context for the current transaction. + * @param result the tentative TER from transaction processing. + * @param fee the fee consumed by the transaction. + * @param txCheck the transaction-specific invariant check. + * @return the final TER after all invariant checks. + */ +[[nodiscard]] TER +checkInvariants( + ApplyContext& ctx, + TER result, + XRPAmount fee, + std::optional> txCheck); + +[[nodiscard]] inline TER +checkInvariants(ApplyContext& ctx, TER result, XRPAmount fee) +{ + return checkInvariants(ctx, result, fee, std::nullopt); +} + +} // namespace xrpl diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index 0648881423..fc72b8d420 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -16,6 +16,8 @@ namespace xrpl { * @brief Invariants: Loans are internally consistent * * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` + * 2. A newly-created Loan against a closed-ended vault must satisfy + * `StartDate + PaymentInterval * PaymentRemaining < Vault.RedemptionDate`. * */ class ValidLoan diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 136c6c4a25..2ba42f0ab4 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -38,7 +38,17 @@ namespace xrpl { * - vault set must not alter the vault assets or shares balance * - no vault transaction can change loss unrealized (it's updated by loan * transactions) + * - a created closed-ended vault must satisfy + * MIN_INVESTMENT_PERIOD <= RedemptionDate - SubscriptionDate < + * MAX_INVESTMENT_PERIOD + * - vault deposit may only succeed when the vault phase is NoPhase or + * Subscription + * - vault withdrawal may not succeed when the vault phase is Investment + * - closed-ended loan origination (ttLOAN_SET) may only succeed when the + * vault phase is Investment * + * Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced + * by NoModifiedUnmodifiableFields (see InvariantCheck.cpp). */ class ValidVault { @@ -55,6 +65,9 @@ class ValidVault Number assetsAvailable = 0; Number assetsMaximum = 0; Number lossUnrealized = 0; + std::optional vaultKind; + std::optional subscriptionDate; + std::optional redemptionDate; Vault static make(SLE const&); }; @@ -153,6 +166,17 @@ private: [[nodiscard]] static bool isVaultEmpty(Vault const& vault); + /** + * @brief Invariant check for @c ttLOAN_SET. + * + * For a closed-ended vault, a loan may only be originated while the vault is in the Investment + * phase (strictly past @c SubscriptionDate and before @c RedemptionDate). Open-ended vaults (@c + * NoPhase) are unaffected. The complementary maturity bound (final payment strictly precedes @c + * RedemptionDate) is enforced by @c ValidLoan. + */ + [[nodiscard]] bool + finalizeLoanSet(ReadView const& view, beast::Journal const& j) const; + public: // Compute the coarsest scale required to represent all numbers [[nodiscard]] static std::int32_t diff --git a/include/xrpl/tx/paths/detail/Steps.h b/include/xrpl/tx/paths/detail/Steps.h index 8ee37c026c..1d68860adc 100644 --- a/include/xrpl/tx/paths/detail/Steps.h +++ b/include/xrpl/tx/paths/detail/Steps.h @@ -274,19 +274,6 @@ public: return lhs.equal(rhs); } - /** - * Return true if lhs != rhs. - * - * @param lhs Step to compare. - * @param rhs Step to compare. - * @return true if lhs != rhs. - */ - friend bool - operator!=(Step const& lhs, Step const& rhs) - { - return !(lhs == rhs); - } - /** * Streaming operator for a Step. */ diff --git a/include/xrpl/tx/paths/detail/StrandFlow.h b/include/xrpl/tx/paths/detail/StrandFlow.h index c932c49cca..fcca97ecfc 100644 --- a/include/xrpl/tx/paths/detail/StrandFlow.h +++ b/include/xrpl/tx/paths/detail/StrandFlow.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -373,7 +374,7 @@ qualityUpperBound(ReadView const& v, Strand const& strand) * increases quality of AMM steps, increasing the strand's composite * quality as the result. */ -template +template inline TOutAmt limitOut( ReadView const& v, @@ -411,21 +412,29 @@ limitOut( auto const out = qf->outFromAvgQ(limitQuality); if (!out) return remainingOut; - if constexpr (std::is_same_v) + if constexpr (std::is_same_v || std::is_same_v) { - return XRPAmount{*out}; + auto const roundedOut = TOutAmt{*out}; + // Integral outputs that round above the continuous target can + // realize worse average quality than the requested limit. Keep the + // default rounded value when it still satisfies the limit, since it + // is the largest matching offer; otherwise round down. + if (v.rules().enabled(featureMPTokensV2) && roundedOut > *out && + !qf->satisfiesAvgQ(limitQuality, roundedOut)) + { + NumberRoundModeGuard const g(Number::RoundingMode::Downward); + return TOutAmt{*out}; + } + return roundedOut; } else if constexpr (std::is_same_v) { return IOUAmount{*out}; } - else if constexpr (std::is_same_v) - { - return MPTAmount{*out}; - } else { - return STAmount{remainingOut.asset(), out->mantissa(), out->exponent()}; + static constexpr bool kAlwaysFalse = !std::is_same_v; + static_assert(kAlwaysFalse, "Unhandled StepAmount type"); } }(); // A tiny difference could be due to the round off diff --git a/include/xrpl/tx/transactors/dex/AMMWithdraw.h b/include/xrpl/tx/transactors/dex/AMMWithdraw.h index 7004dd57c1..6861fa7bc4 100644 --- a/include/xrpl/tx/transactors/dex/AMMWithdraw.h +++ b/include/xrpl/tx/transactors/dex/AMMWithdraw.h @@ -118,6 +118,7 @@ public: Sandbox& view, SLE const& ammSle, AccountID const account, + std::optional const& clawbackIssuer, AccountID const& ammAccount, STAmount const& amountBalance, STAmount const& amount2Balance, @@ -138,6 +139,11 @@ public: * @param view * @param ammSle AMM ledger entry * @param ammAccount AMM account + * @param clawbackIssuer when set (AMMClawback path), the issuer performing + * the clawback. A recreated MPToken is only auto-authorized when the + * asset's issuer matches this account, so a clawback cannot grant + * authorization on behalf of a different (paired-asset) issuer. + * @param account LP account * @param amountBalance current LP asset1 balance * @param amountWithdraw asset1 withdraw amount * @param amount2Withdraw asset2 withdraw amount @@ -153,6 +159,7 @@ public: Sandbox& view, SLE const& ammSle, AccountID const& ammAccount, + std::optional const& clawbackIssuer, AccountID const& account, STAmount const& amountBalance, STAmount const& amountWithdraw, diff --git a/nix/check-tools/README.md b/nix/check-tools/README.md index 5b7538f2ca..f23b2dcc21 100644 --- a/nix/check-tools/README.md +++ b/nix/check-tools/README.md @@ -1,7 +1,8 @@ # check-tools snapshots These files capture the output of [`bin/check-tools.sh`](../../bin/check-tools.sh) -— the versions of the development tooling — in each Nix environment: +— the version and resolved store path of each development tool — in each Nix +environment: | File | Environment | | ---------------------- | ------------------------------------ | @@ -17,9 +18,13 @@ So if you change the environment (bump the image tag in and commit the affected snapshots. Each snapshot is `check-tools.sh` stdout with the git-clone connectivity check -skipped (`CHECK_TOOLS_SKIP_CLONE=1`), so it contains only deterministic version -data. On macOS the dev-shell greeting that `nix develop` prints first is dropped -with `sed -n '/^Detected OS:/,$p'`. +skipped (`CHECK_TOOLS_SKIP_CLONE=1`), so it is deterministic for a given +environment. On macOS the dev-shell greeting that `nix develop` prints first is +dropped with `sed -n '/^Detected OS:/,$p'`. + +The store paths carry their derivation hash, so they change whenever a tool is +rebuilt — a `flake.lock` update generally rewrites most of them even when no +version moves. That is deliberate: it makes tooling changes visible in review. ## Regenerating diff --git a/nix/check-tools/macos.txt b/nix/check-tools/macos.txt index 93cc926181..8e99aa28e4 100644 --- a/nix/check-tools/macos.txt +++ b/nix/check-tools/macos.txt @@ -1,47 +1,143 @@ Detected OS: macos (Darwin arm64) Core build tools: - [ ok ] cmake cmake version 4.1.2 - [ ok ] conan Conan version 2.28.1 - [ ok ] git git version 2.54.0 - [ ok ] python3 Python 3.13.13 + ✅ cmake + cmake version 4.1.2 + /nix/store/gvabsb4yqb5xsqzqph54rijnn4zpihnp-cmake-4.1.2/bin/cmake + ✅ conan + Conan version 2.28.1 + /nix/store/9jiyxmkpwmn6dcqs0765s83riw3l5ail-conan-2.28.1/bin/conan + ✅ git + git version 2.54.0 + /nix/store/a14yxcqvv9x2l9mllgpirzhvz93pgprg-git-2.54.0/bin/git + ✅ python3 + Python 3.13.13 + /nix/store/ygxqin6ydzjfawywqpp5pal8wv6sf5bh-python3-3.13.13/bin/python3.13 Development tooling: - [ ok ] ccache ccache version 4.13.6 - [ ok ] clang clang version 22.1.7 - [ ok ] clang++ clang version 22.1.7 - [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 - [ ok ] curl curl 8.20.0 (aarch64-apple-darwin25.3.0) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 - [ ok ] file file-5.47 - [ ok ] less less 692 (PCRE2 regular expressions) - [ ok ] make GNU Make 4.4.1 - [ ok ] netstat present - [ ok ] ninja 1.13.2 - [ ok ] perl v5.42.0 - [ ok ] pkg-config 0.29.2 - [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) - [ ok ] zip Zip 3.0 - [ ok ] clang-format clang-format version 22.1.7 - [ ok ] dot dot - graphviz version 12.2.1 (0) - [ ok ] doxygen 1.16.1 - [ ok ] gcovr gcovr 8.4 - [ ok ] gh gh version 2.94.0 (nixpkgs) - [ ok ] git-cliff git-cliff 2.13.1 - [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; darwin arm64; go 1.26.3) - [ ok ] gpg gpg (GnuPG) 2.4.9 - [ ok ] pre-commit pre-commit 4.5.1 - [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + ✅ ccache + ccache version 4.13.6 + /nix/store/57davyvs6p6dkrl3svzwg1ph18wsy4cz-ccache-4.13.6/bin/ccache + ✅ clang + clang version 22.1.7 + /nix/store/192glrb2cldvziyf3378mzjqbzx3ih4g-clang-wrapper-22.1.7/bin/clang + ✅ clang-22 + clang version 22.1.7 + /nix/store/rbap7zqq7mw00fyqa02p5rj7gqjp4w5i-clang-22/bin/clang-22 + ✅ clang++ + clang version 22.1.7 + /nix/store/192glrb2cldvziyf3378mzjqbzx3ih4g-clang-wrapper-22.1.7/bin/clang++ + ✅ clang++-22 + clang version 22.1.7 + /nix/store/v9haf787f7bcz0mq1sad4bpyx21pj6li-clang++-22/bin/clang++-22 + ✅ ClangBuildAnalyzer + ClangBuildAnalyzer 1.6.0 + /nix/store/4l50ds9fa2mkvh7wg8qzrlbmjs12sb8l-clangbuildanalyzer-1.6.0/bin/ClangBuildAnalyzer + ✅ curl + curl 8.20.0 (aarch64-apple-darwin25.3.0) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + /nix/store/kclq0czaxvsgh4ym9ld7b6iwy50l1snk-curl-8.20.0-bin/bin/curl + ✅ file + file-5.47 + /nix/store/dax63li7wwcbqxxkkgzc4g2rx7d4w86x-file-5.47/bin/file + ✅ less + less 692 (PCRE2 regular expressions) + /nix/store/lvr16y75r1pdxpdv0aph5ak2yd0hkvqm-less-692/bin/less + ✅ make + GNU Make 4.4.1 + /nix/store/8wwiw8pwyhrkzyq28hqzxfl4z84lks81-gnumake-4.4.1/bin/make + ✅ netstat + present + /nix/store/qsd1kzqb0ahrk433vmyl245gp623j19s-network_cmds-730.80.3/bin/netstat + ✅ ninja + 1.13.2 + /nix/store/bqykhrblarkj4fl0hz2mf8ngwfv6x6bz-ninja-1.13.2/bin/ninja + ✅ perl + v5.42.0 + /nix/store/js13ri9fvm0ajk1fpd3acigys2a9whdv-perl-5.42.0/bin/perl + ✅ pkg-config + 0.29.2 + /nix/store/lzrwr375jqhhbca116kja96xf1md83l8-pkg-config-wrapper-0.29.2/bin/pkg-config + ✅ vim + VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + /nix/store/6vbkykg92w603c0sw3mkk7p7mfaawbns-vim-9.2.0389/bin/vim + ✅ zip + Zip 3.0 + /nix/store/z6ph729vcakbvz3wh8ln1wk6mi06w487-zip-3.0/bin/zip + ✅ clang-apply-replacements + clang-apply-replacements version 22.1.7 + /nix/store/vzyyjf3cm1hbj9wcr2qcb66x6j98zpy7-clang-tools-22.1.7/bin/clang-apply-replacements + ✅ clang-apply-replacements-22 + clang-apply-replacements version 22.1.7 + /nix/store/m3ii69rca4077lf4wlk7m3jcag1fs577-clang-apply-replacements-22/bin/clang-apply-replacements-22 + ✅ clang-format + clang-format version 22.1.7 + /nix/store/vzyyjf3cm1hbj9wcr2qcb66x6j98zpy7-clang-tools-22.1.7/bin/clang-format + ✅ clang-format-22 + clang-format version 22.1.7 + /nix/store/4fawqy6ngqcsqd2ygyyzm93q0xy3f5gs-clang-format-22/bin/clang-format-22 + ✅ clang-tidy + LLVM version 22.1.7 + /nix/store/vzyyjf3cm1hbj9wcr2qcb66x6j98zpy7-clang-tools-22.1.7/bin/clang-tidy + ✅ clang-tidy-22 + LLVM version 22.1.7 + /nix/store/jqw4280saixaxxihwdba9ldm2fsm6dr3-clang-tidy-22/bin/clang-tidy-22 + ✅ dot + dot - graphviz version 12.2.1 (0) + /nix/store/ijb4fbnqa6wzlpqnhb6q9knqpf7qqn5z-graphviz-12.2.1/bin/dot + ✅ doxygen + 1.16.1 + /nix/store/kbryjdpq9jizjb0ws0nzbf2h2ymbdiwm-doxygen-1.16.1/bin/doxygen + ✅ gcovr + gcovr 8.4 + /nix/store/wn8jiyh9p0bybs96s4163qp3k8vfmczx-python3.13-gcovr-8.4/bin/gcovr + ✅ gh + gh version 2.94.0 (nixpkgs) + /nix/store/fhnpw0hs0gjms1ha6ap02jq7rx13gkbp-gh-2.94.0/bin/gh + ✅ git-cliff + git-cliff 2.13.1 + /nix/store/cy0wwhgxa7yvrz97zydbq6sqmixc90fq-git-cliff-2.13.1/bin/git-cliff + ✅ git-lfs + git-lfs/3.7.1 (3.7.1; darwin arm64; go 1.26.3) + /nix/store/k9r7zjfjplqa4d5s71cqvf2iv73jd9mc-git-lfs-3.7.1/bin/git-lfs + ✅ gpg + gpg (GnuPG) 2.4.9 + /nix/store/cgh6iwzz5jgx9z5whka4vgj210i6npc6-gnupg-2.4.9/bin/gpg + ✅ pre-commit + pre-commit 4.5.1 + /nix/store/z3cca68620w0w10f090szgzdnmh1waf2-pre-commit-4.5.1/bin/pre-commit + ✅ run-clang-tidy + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/4x28x911z2f9y7adqlh3qspp4a16dig7-run-clang-tidy/bin/run-clang-tidy + ✅ run-clang-tidy-22 + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/x8iymrh76sk5q91ryg5pa7i32s6gfh34-run-clang-tidy-22/bin/run-clang-tidy-22 Rust toolchain: - [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) - [ ok ] cargo-audit cargo-audit-audit 0.22.1 - [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 - [ ok ] cargo-nextest cargo-nextest 0.9.137 - [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) - [ ok ] rust-analyzer rust-analyzer 1.95.0 (59807616 2026-04-14) - [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) - [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/snwkga2f5gyf404h7mmp9wriwxb8v65f-cargo-audit-0.22.1/bin/cargo-audit + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/fpiqdh91gwyxalqp409ynm0s0g086w7w-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + 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 + ✅ 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 + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/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 Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -All 36 checked tools are present and runnable. +✅ All 44 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-amd64.txt b/nix/check-tools/nix-ubuntu-amd64.txt index b922cca4a8..a5857c93f1 100644 --- a/nix/check-tools/nix-ubuntu-amd64.txt +++ b/nix/check-tools/nix-ubuntu-amd64.txt @@ -1,55 +1,171 @@ Detected OS: linux (Linux x86_64) Core build tools: - [ ok ] cmake cmake version 4.1.2 - [ ok ] conan Conan version 2.28.1 - [ ok ] git git version 2.54.0 - [ ok ] python3 Python 3.13.13 + ✅ cmake + cmake version 4.1.2 + /nix/store/r9941n32g4wyvggz2703dlplbdq8a6rd-cmake-4.1.2/bin/cmake + ✅ conan + Conan version 2.28.1 + /nix/store/lxny9y4jvjdws7hgz1mygvb7hjrpmna5-conan-2.28.1/bin/conan + ✅ git + git version 2.54.0 + /nix/store/bcnisk3ydfgv26v2gw3zlky24g00yww2-git-2.54.0/bin/git + ✅ python3 + Python 3.13.13 + /nix/store/60m4rxhg2fldqaak400c0lry96ijrzqn-python3-3.13.13/bin/python3.13 Development tooling: - [ ok ] ccache ccache version 4.13.6 - [ ok ] clang clang version 22.1.7 - [ ok ] clang++ clang version 22.1.7 - [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 - [ ok ] curl curl 8.20.0 (x86_64-pc-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 - [ ok ] file file-5.47 - [ ok ] less less 692 (PCRE2 regular expressions) - [ ok ] make GNU Make 4.4.1 - [ ok ] netstat net-tools 2.10 - [ ok ] ninja 1.13.2 - [ ok ] perl v5.42.0 - [ ok ] pkg-config 0.29.2 - [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) - [ ok ] zip Zip 3.0 - [ ok ] clang-format clang-format version 22.1.7 - [ ok ] dot dot - graphviz version 12.2.1 (0) - [ ok ] doxygen 1.16.1 - [ ok ] gcovr gcovr 8.4 - [ ok ] gh gh version 2.94.0 (nixpkgs) - [ ok ] git-cliff git-cliff 2.13.1 - [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; linux amd64; go 1.26.3) - [ ok ] gpg gpg (GnuPG) 2.4.9 - [ ok ] pre-commit pre-commit 4.5.1 - [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + ✅ ccache + ccache version 4.13.6 + /nix/store/c9wwl7s5i6rsfwvf4v0xbbmzx5m6jgfr-ccache-4.13.6/bin/ccache + ✅ clang + clang version 22.1.7 + /nix/store/ff0hrp9r9i3pa5arkdw0sgmzp8d576qi-clang-wrapper-22.1.7/bin/clang + ✅ clang-22 + clang version 22.1.7 + /nix/store/dagc2rq44gfbr7w7yvvqca3yqpc9gqbq-clang-22/bin/clang-22 + ✅ clang++ + clang version 22.1.7 + /nix/store/ff0hrp9r9i3pa5arkdw0sgmzp8d576qi-clang-wrapper-22.1.7/bin/clang++ + ✅ clang++-22 + clang version 22.1.7 + /nix/store/l5m8clin1npl605wdkd8mr18ggxww3z4-clang++-22/bin/clang++-22 + ✅ ClangBuildAnalyzer + ClangBuildAnalyzer 1.6.0 + /nix/store/bshlmn8fqw55nsnm581xqlfbahfkykxx-clangbuildanalyzer-1.6.0/bin/ClangBuildAnalyzer + ✅ curl + curl 8.20.0 (x86_64-pc-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + /nix/store/zbwymrp4lcfjc4kkk0n4779v0kjjz58z-curl-8.20.0-bin/bin/curl + ✅ file + file-5.47 + /nix/store/bizyfqdw0h67wzqmp10knmf9s2pqahdb-file-5.47/bin/file + ✅ less + less 692 (PCRE2 regular expressions) + /nix/store/c6bacbn93qg4a7g9n4czww8rg24dvysr-less-692/bin/less + ✅ make + GNU Make 4.4.1 + /nix/store/d3bwqm6bymhy3pdgbvf7vxjqfp31m3j1-gnumake-4.4.1/bin/make + ✅ netstat + net-tools 2.10 + /nix/store/jmyzqvgflnswmws7rnxx6g3zbj680xvd-net-tools-2.10/bin/netstat + ✅ ninja + 1.13.2 + /nix/store/7a235m7crqbb4h49sak20fqxpw3n7hr0-ninja-1.13.2/bin/ninja + ✅ perl + v5.42.0 + /nix/store/6plwsm6pkq79yjv4xvy8csk2pd4hzr67-perl-5.42.0/bin/perl + ✅ pkg-config + 0.29.2 + /nix/store/1m05k7xgfnw6jc21xxk5681ni3ar97wf-pkg-config-wrapper-0.29.2/bin/pkg-config + ✅ vim + VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + /nix/store/hvyqx52g4g2fxhgpans3fksjj6lmlyaw-vim-9.2.0389/bin/vim + ✅ zip + Zip 3.0 + /nix/store/qnd2ag67hrjj0b6vbmisdshf50r6s72n-zip-3.0/bin/zip + ✅ clang-apply-replacements + clang-apply-replacements version 22.1.7 + /nix/store/4zp1rjpj2xijrv4kqpwsy3ixwb2r6nlk-clang-tools-22.1.7/bin/clang-apply-replacements + ✅ clang-apply-replacements-22 + clang-apply-replacements version 22.1.7 + /nix/store/py2wihg0a96qcppv4hjmww547xabr0fb-clang-apply-replacements-22/bin/clang-apply-replacements-22 + ✅ clang-format + clang-format version 22.1.7 + /nix/store/4zp1rjpj2xijrv4kqpwsy3ixwb2r6nlk-clang-tools-22.1.7/bin/clang-format + ✅ clang-format-22 + clang-format version 22.1.7 + /nix/store/kz820ccifjlwqnwqjsx7kbiajrgsmbrh-clang-format-22/bin/clang-format-22 + ✅ clang-tidy + LLVM version 22.1.7 + /nix/store/4zp1rjpj2xijrv4kqpwsy3ixwb2r6nlk-clang-tools-22.1.7/bin/clang-tidy + ✅ clang-tidy-22 + LLVM version 22.1.7 + /nix/store/gdrkvpw846lkyzh8y9p3zx50g6ml2v84-clang-tidy-22/bin/clang-tidy-22 + ✅ dot + dot - graphviz version 12.2.1 (0) + /nix/store/12rgns2296s4qcja778gvcbx61z77rc4-graphviz-12.2.1/bin/dot + ✅ doxygen + 1.16.1 + /nix/store/k0vzr5lvgq1byraknzwvk51wcgpnsrkh-doxygen-1.16.1/bin/doxygen + ✅ gcovr + gcovr 8.4 + /nix/store/iyzi7fpyclqrha054adnizvif02lg49x-python3.13-gcovr-8.4/bin/gcovr + ✅ gh + gh version 2.94.0 (nixpkgs) + /nix/store/pidh15szlsb1vc41xdsa3xbdghdazvby-gh-2.94.0/bin/gh + ✅ git-cliff + git-cliff 2.13.1 + /nix/store/1q851fs62shgjhc03fxxdkpzxdjg7k11-git-cliff-2.13.1/bin/git-cliff + ✅ git-lfs + git-lfs/3.7.1 (3.7.1; linux amd64; go 1.26.3) + /nix/store/6ljwpal7b1756708m33vj0crpral7mvl-git-lfs-3.7.1/bin/git-lfs + ✅ gpg + gpg (GnuPG) 2.4.9 + /nix/store/wx7vk8babxkgy813r70yc67vcwnmagbx-gnupg-2.4.9/bin/gpg + ✅ pre-commit + pre-commit 4.5.1 + /nix/store/bj6i9vl34cij5h0r165y40hrjqak0bmz-pre-commit-4.5.1/bin/pre-commit + ✅ run-clang-tidy + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/sbg911hs9dbclrzlp04br3iyfpgnaj6r-run-clang-tidy/bin/run-clang-tidy + ✅ run-clang-tidy-22 + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/n8yak1ap308gvi7gmrniw0ybsx80fjws-run-clang-tidy-22/bin/run-clang-tidy-22 Rust toolchain: - [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) - [ ok ] cargo-audit cargo-audit-audit 0.22.1 - [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 - [ ok ] cargo-nextest cargo-nextest 0.9.137 - [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) - [ ok ] rust-analyzer rust-analyzer 1.95.0 (5980761 2026-04-14) - [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) - [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/85qbwr3vzfs58m7ywnjblz105p8ahbrv-cargo-1.95.0-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 + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/jjpdf1l6izz6607a346ykra9sndzaw7h-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + 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 + ✅ 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 + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/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 GCC toolchain: - [ ok ] gcc gcc (GCC) 15.2.0 - [ ok ] g++ g++ (GCC) 15.2.0 - [ ok ] gcov gcov (GCC) 15.2.0 + ✅ gcc + gcc (GCC) 15.2.0 + /nix/store/3dd6y3pq00i3r85l45jvz63wjya403nl-gcc-wrapper-15.2.0/bin/gcc + ✅ gcc-15 + gcc (GCC) 15.2.0 + /nix/store/d6iri2s6bzqq5ac3fg25j6hgnn1lz44f-gcc-15/bin/gcc-15 + ✅ g++ + g++ (GCC) 15.2.0 + /nix/store/3dd6y3pq00i3r85l45jvz63wjya403nl-gcc-wrapper-15.2.0/bin/g++ + ✅ g++-15 + g++ (GCC) 15.2.0 + /nix/store/gm3msmmxq055lm9gprkfjj9d2gdz1mpg-g++-15/bin/g++-15 + ✅ cpp + cpp (GCC) 15.2.0 + /nix/store/3dd6y3pq00i3r85l45jvz63wjya403nl-gcc-wrapper-15.2.0/bin/cpp + ✅ cpp-15 + cpp (GCC) 15.2.0 + /nix/store/bn3gmn0m7g4gn2i0yml46fljc7mghiq5-cpp-15/bin/cpp-15 + ✅ gcov + gcov (GCC) 15.2.0 + /nix/store/xvv5sm5i8x0ks6ypfkzl7c4j9srnxz7k-gcc-15.2.0/bin/gcov Mold: - [ ok ] mold mold 2.41.0 (compatible with GNU ld) + ✅ mold + mold 2.41.0 (compatible with GNU ld) + /nix/store/2w6fpgxjzzyqmd25wzplm23dfa49a0p2-mold-unwrapped-wrapper-2.41.0/bin/mold Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -All 40 checked tools are present and runnable. +✅ All 52 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-arm64.txt b/nix/check-tools/nix-ubuntu-arm64.txt index 5267839682..820c6de086 100644 --- a/nix/check-tools/nix-ubuntu-arm64.txt +++ b/nix/check-tools/nix-ubuntu-arm64.txt @@ -1,55 +1,171 @@ Detected OS: linux (Linux aarch64) Core build tools: - [ ok ] cmake cmake version 4.1.2 - [ ok ] conan Conan version 2.28.1 - [ ok ] git git version 2.54.0 - [ ok ] python3 Python 3.13.13 + ✅ cmake + cmake version 4.1.2 + /nix/store/nkcpxjifkambzlrwh27a8igvhnbchibg-cmake-4.1.2/bin/cmake + ✅ conan + Conan version 2.28.1 + /nix/store/8i2gyqgc00xvxg9xm6y7n0ilncdv8imw-conan-2.28.1/bin/conan + ✅ git + git version 2.54.0 + /nix/store/ixp98f9avf8ikpdrmp40cj33g0dazyp9-git-2.54.0/bin/git + ✅ python3 + Python 3.13.13 + /nix/store/lqn6mbgzzdrqq2qkwddcmxj9z6amdd86-python3-3.13.13/bin/python3.13 Development tooling: - [ ok ] ccache ccache version 4.13.6 - [ ok ] clang clang version 22.1.7 - [ ok ] clang++ clang version 22.1.7 - [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 - [ ok ] curl curl 8.20.0 (aarch64-unknown-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 - [ ok ] file file-5.47 - [ ok ] less less 692 (PCRE2 regular expressions) - [ ok ] make GNU Make 4.4.1 - [ ok ] netstat net-tools 2.10 - [ ok ] ninja 1.13.2 - [ ok ] perl v5.42.0 - [ ok ] pkg-config 0.29.2 - [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) - [ ok ] zip Zip 3.0 - [ ok ] clang-format clang-format version 22.1.7 - [ ok ] dot dot - graphviz version 12.2.1 (0) - [ ok ] doxygen 1.16.1 - [ ok ] gcovr gcovr 8.4 - [ ok ] gh gh version 2.94.0 (nixpkgs) - [ ok ] git-cliff git-cliff 2.13.1 - [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; linux arm64; go 1.26.3) - [ ok ] gpg gpg (GnuPG) 2.4.9 - [ ok ] pre-commit pre-commit 4.5.1 - [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + ✅ ccache + ccache version 4.13.6 + /nix/store/2q39xi2kbi04ibga7635f2sl148d1mzv-ccache-4.13.6/bin/ccache + ✅ clang + clang version 22.1.7 + /nix/store/xjqffrq9i7la058s9865ig71l9sp1ys5-clang-wrapper-22.1.7/bin/clang + ✅ clang-22 + clang version 22.1.7 + /nix/store/vcf6ilfwn57828hwzyp6zlyr24j9j6yw-clang-22/bin/clang-22 + ✅ clang++ + clang version 22.1.7 + /nix/store/xjqffrq9i7la058s9865ig71l9sp1ys5-clang-wrapper-22.1.7/bin/clang++ + ✅ clang++-22 + clang version 22.1.7 + /nix/store/xby0f6gamr7m27zp5cndsvghbp9lgb3c-clang++-22/bin/clang++-22 + ✅ ClangBuildAnalyzer + ClangBuildAnalyzer 1.6.0 + /nix/store/h893hd4q1bb6ily2lby5dzyfrrzd2nvj-clangbuildanalyzer-1.6.0/bin/ClangBuildAnalyzer + ✅ curl + curl 8.20.0 (aarch64-unknown-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + /nix/store/i1s0lqwlrmjd2dxzgy2p84cxqqsb0bmk-curl-8.20.0-bin/bin/curl + ✅ file + file-5.47 + /nix/store/dx973zg9km2w9albsib2vw9wyvacfrlw-file-5.47/bin/file + ✅ less + less 692 (PCRE2 regular expressions) + /nix/store/1blb3s7hhsr77wqi598m6k1qkfp3ms0w-less-692/bin/less + ✅ make + GNU Make 4.4.1 + /nix/store/9ngw1ippk25jjj5fjxv36xbp6iq7rxdx-gnumake-4.4.1/bin/make + ✅ netstat + net-tools 2.10 + /nix/store/7vdsz21f0s499s5yyqzp5s4676q4yxdd-net-tools-2.10/bin/netstat + ✅ ninja + 1.13.2 + /nix/store/8ksx98gsbn5lmlizcmw57yd4sg0k2p58-ninja-1.13.2/bin/ninja + ✅ perl + v5.42.0 + /nix/store/5wnly69vv1i3y97al4v3xrqymf9hlzgq-perl-5.42.0/bin/perl + ✅ pkg-config + 0.29.2 + /nix/store/c7vwy0gl1q0agl2h22gi0m9dg7xxad2l-pkg-config-wrapper-0.29.2/bin/pkg-config + ✅ vim + VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + /nix/store/v8c7pvx26irvy9k5sbwd183cyvckzzb3-vim-9.2.0389/bin/vim + ✅ zip + Zip 3.0 + /nix/store/5mh19mvbv9ym2sm9vymyyaac5l2cj2jq-zip-3.0/bin/zip + ✅ clang-apply-replacements + clang-apply-replacements version 22.1.7 + /nix/store/s53p2m776iqaz7acgr5csgpsd18w15h7-clang-tools-22.1.7/bin/clang-apply-replacements + ✅ clang-apply-replacements-22 + clang-apply-replacements version 22.1.7 + /nix/store/bg4kn8z81hk7b9284rjqvr51wpfjqc24-clang-apply-replacements-22/bin/clang-apply-replacements-22 + ✅ clang-format + clang-format version 22.1.7 + /nix/store/s53p2m776iqaz7acgr5csgpsd18w15h7-clang-tools-22.1.7/bin/clang-format + ✅ clang-format-22 + clang-format version 22.1.7 + /nix/store/79v57mzcw8ng8kl7p961ck08ymhp31v7-clang-format-22/bin/clang-format-22 + ✅ clang-tidy + LLVM version 22.1.7 + /nix/store/s53p2m776iqaz7acgr5csgpsd18w15h7-clang-tools-22.1.7/bin/clang-tidy + ✅ clang-tidy-22 + LLVM version 22.1.7 + /nix/store/wdyd6cb9z1lyi37lbzvwldgcc7yv1n5c-clang-tidy-22/bin/clang-tidy-22 + ✅ dot + dot - graphviz version 12.2.1 (0) + /nix/store/58rrk4yzwpmyxvl8cqm18h3dhv24zf00-graphviz-12.2.1/bin/dot + ✅ doxygen + 1.16.1 + /nix/store/hq32kzwpl89wgr49iq0gmqn9r5n072zq-doxygen-1.16.1/bin/doxygen + ✅ gcovr + gcovr 8.4 + /nix/store/sml3xbbfhhlhk6h7jnlg19pdbx9b764b-python3.13-gcovr-8.4/bin/gcovr + ✅ gh + gh version 2.94.0 (nixpkgs) + /nix/store/7hh2qi0gj2ifbxbl56cjzbiyfc379bji-gh-2.94.0/bin/gh + ✅ git-cliff + git-cliff 2.13.1 + /nix/store/bidn3pz53yd6qlg711917xx0q10hqmqv-git-cliff-2.13.1/bin/git-cliff + ✅ git-lfs + git-lfs/3.7.1 (3.7.1; linux arm64; go 1.26.3) + /nix/store/4rsklvkbac5bayy0zv12kxyvspi4sshd-git-lfs-3.7.1/bin/git-lfs + ✅ gpg + gpg (GnuPG) 2.4.9 + /nix/store/ka4i8zz5ni3rzqnzcxbfvwr95fk8pn6q-gnupg-2.4.9/bin/gpg + ✅ pre-commit + pre-commit 4.5.1 + /nix/store/n981w6hjfar2l81kxbxs2wxl64vwa5kj-pre-commit-4.5.1/bin/pre-commit + ✅ run-clang-tidy + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/4z2fyklg78klallr7x9j02kz92hnxp4m-run-clang-tidy/bin/run-clang-tidy + ✅ run-clang-tidy-22 + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/f8m0p9ad40brp9ahy4i0h27kqjkya1j9-run-clang-tidy-22/bin/run-clang-tidy-22 Rust toolchain: - [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) - [ ok ] cargo-audit cargo-audit-audit 0.22.1 - [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 - [ ok ] cargo-nextest cargo-nextest 0.9.137 - [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) - [ ok ] rust-analyzer rust-analyzer 1.95.0 (5980761 2026-04-14) - [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) - [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/yw1rs50s6qpsw0zyl7j3dpm18swbl0ag-cargo-1.95.0-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 + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/vwjsi159n89szrx4yh5pc3jlf2gp4fld-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + 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 + ✅ 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 + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/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 GCC toolchain: - [ ok ] gcc gcc (GCC) 15.2.0 - [ ok ] g++ g++ (GCC) 15.2.0 - [ ok ] gcov gcov (GCC) 15.2.0 + ✅ gcc + gcc (GCC) 15.2.0 + /nix/store/rn6svg593xsmn8qcjzk8x9pa1i62c4kb-gcc-wrapper-15.2.0/bin/gcc + ✅ gcc-15 + gcc (GCC) 15.2.0 + /nix/store/h489d1rmjisfbxh5kmsb0a7c35j8qsdf-gcc-15/bin/gcc-15 + ✅ g++ + g++ (GCC) 15.2.0 + /nix/store/rn6svg593xsmn8qcjzk8x9pa1i62c4kb-gcc-wrapper-15.2.0/bin/g++ + ✅ g++-15 + g++ (GCC) 15.2.0 + /nix/store/9ywmhz8bmzknrn3pn84g46z8hj3vrmw5-g++-15/bin/g++-15 + ✅ cpp + cpp (GCC) 15.2.0 + /nix/store/rn6svg593xsmn8qcjzk8x9pa1i62c4kb-gcc-wrapper-15.2.0/bin/cpp + ✅ cpp-15 + cpp (GCC) 15.2.0 + /nix/store/vmjilh1b830qz9yh0a1jj5ads0jxizdk-cpp-15/bin/cpp-15 + ✅ gcov + gcov (GCC) 15.2.0 + /nix/store/rmwf5hpi1y2m1wpnfvlxmrhksm4djk2j-gcc-15.2.0/bin/gcov Mold: - [ ok ] mold mold 2.41.0 (compatible with GNU ld) + ✅ mold + mold 2.41.0 (compatible with GNU ld) + /nix/store/f5qh5a0bx1dslmnf5n5gx0s6aljbswq3-mold-unwrapped-wrapper-2.41.0/bin/mold Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -All 40 checked tools are present and runnable. +✅ All 52 checked tools are present and runnable. diff --git a/nix/ci-env.nix b/nix/ci-env.nix index 787b94406e..779b5b7230 100644 --- a/nix/ci-env.nix +++ b/nix/ci-env.nix @@ -1,67 +1,39 @@ +# The environment CI builds in: every tool on PATH, no Nix stdenv setup hooks. +# Baked into the `nix-*` Docker images on Linux (see nix/docker), built on the +# runner on macOS (see .github/actions/setup-nix-env). { pkgs, customGlibc, ... }: let - inherit (import ./packages.nix { inherit pkgs; }) - commonPackages - gccVersion - llvmVersion - mkVersionedToolLinks - ; + inherit (import ./packages.nix { inherit pkgs; }) commonPackages; - # Custom-glibc toolchain, shared with the Linux dev shell (see compilers.nix). - inherit (import ./compilers.nix { inherit pkgs customGlibc; }) - customGcc - customClang - customBinutils - customGcov - ; + # Each forces something absent on the other platform, so both stay lazy. + linux = import ./linux.nix { inherit pkgs customGlibc; }; + darwin = import ./darwin.nix { inherit pkgs; }; - # Strip the generic cc/c++/cpp symlinks from the clang wrapper so it can - # coexist with the gcc wrapper in buildEnv. gcc remains the default - # compiler (cc/c++/cpp); clang is invoked explicitly as clang/clang++. - customClangForCiEnv = pkgs.symlinkJoin { - name = "clang-wrapper-custom-for-ci-env"; - paths = [ customClang ]; - postBuild = '' - rm -f $out/bin/cc $out/bin/c++ $out/bin/cpp - ''; - }; + # What a buildEnv cannot express: environment variables. $GITHUB_ENV format; + # `set -a; . env; set +a` loads it in a shell. + darwinEnv = pkgs.writeTextDir "share/xrpld-ci-env/env" ( + pkgs.lib.concatStrings ( + pkgs.lib.mapAttrsToList (name: value: "${name}=${value}\n") (darwin.sdkEnv // darwin.libresolvEnv) + ) + ); + toolchain = if pkgs.stdenv.isLinux then linux.toolchain else (darwin.toolchain ++ [ darwinEnv ]); in { default = pkgs.buildEnv { name = "xrpld-ci-env"; - paths = commonPackages ++ [ - customGcc - customGcov - customClangForCiEnv - customBinutils - (mkVersionedToolLinks { - name = "gcc"; - package = customGcc; - version = gccVersion; - tools = [ - "gcc" - "g++" - "cpp" - ]; - }) - (mkVersionedToolLinks { - name = "clang"; - package = customClang; - version = llvmVersion; - tools = [ - "clang" - "clang++" - ]; - }) - # CA certificate bundle so HTTPS clients (git, curl, conan) can verify - # TLS connections without ca-certificates being installed in the system. - pkgs.cacert - ]; + paths = + commonPackages + ++ toolchain + ++ [ + # CA certificate bundle so HTTPS clients (git, curl, conan) can verify + # TLS connections without ca-certificates being installed in the system. + pkgs.cacert + ]; pathsToLink = [ "/bin" "/etc/ssl/certs" diff --git a/nix/darwin.nix b/nix/darwin.nix new file mode 100644 index 0000000000..837752fc6a --- /dev/null +++ b/nix/darwin.nix @@ -0,0 +1,80 @@ +# The darwin toolchain, counterpart to linux.nix. Split by consumer: a dev +# shell's stdenv provides the SDK variables, nothing provides libresolv. +# +# darwin only - `libresolv` does not exist on Linux. +{ pkgs }: +let + inherit (import ./packages.nix { inherit pkgs; }) + llvmVersion + llvmPackages + mkVersionedToolLinks + ; + + # nixpkgs keeps libresolv out of the macOS SDK, so neither c-ares' `-lresolv` + # nor grpc's resolves. Headers can come from nixpkgs; the + # library cannot, or its store path lands in xrpld - hence this copy. + libresolvSystemStub = + pkgs.runCommand "libresolv-system-stub" + { + nativeBuildInputs = [ llvmPackages.bintools ]; + } + '' + mkdir -p "$out/lib" + cp ${pkgs.darwin.libresolv}/lib/libresolv.9.dylib "$out/lib/" + chmod +w "$out/lib/libresolv.9.dylib" + llvm-install-name-tool -id /usr/lib/libresolv.9.dylib "$out/lib/libresolv.9.dylib" + ln -s libresolv.9.dylib "$out/lib/libresolv.dylib" + ''; +in +{ + # For an environment that only puts binaries on PATH. + toolchain = [ + llvmPackages.clang + # The wrappers re-export only part of cctools; a bare env has no stdenv to + # supply the rest, and without `dsymutil` even `clang -g` cannot link. One + # by one, because buildEnv rejects any name a wrapper owns (notably `ld`). + (pkgs.linkFarm "cctools-extra" ( + map + (tool: { + name = "bin/${tool}"; + path = "${llvmPackages.clang.bintools.bintools}/bin/${tool}"; + }) + [ + "codesign_allocate" + "dsymutil" + "dwarfdump" + "install_name_tool" + "lipo" + "otool" + ] + )) + (mkVersionedToolLinks { + name = "clang"; + package = llvmPackages.clang; + version = llvmVersion; + tools = [ + "clang" + "clang++" + ]; + }) + ]; + + # Without these CMake asks `xcrun` and gets the Command Line Tools SDK, whose + # headers clash with the Nix libc++ ones. + sdkEnv = { + DEVELOPER_DIR = "${pkgs.apple-sdk}"; + SDKROOT = "${pkgs.apple-sdk}/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"; + }; + + # Salted names: the wrappers only read plain NIX_CFLAGS_COMPILE / NIX_LDFLAGS + # through role variables a Nix stdenv would set. The salt is the target + # platform, so this fits the gcc wrapper too. + # + # No space after -isystem: these are written one per line as KEY=VALUE, and a + # shell sourcing that reads the space as the end of the assignment. + libresolvEnv = { + "NIX_CFLAGS_COMPILE_${llvmPackages.clang.suffixSalt}" = + "-isystem${pkgs.darwin.libresolv.dev}/include"; + "NIX_LDFLAGS_${llvmPackages.clang.bintools.suffixSalt}" = "-L${libresolvSystemStub}/lib"; + }; +} diff --git a/nix/devshell.nix b/nix/devshell.nix index cb4a99c76a..07f7143c5b 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -14,26 +14,55 @@ let plainGccStdenv = pkgs."gcc${toString gccVersion}Stdenv"; plainClangStdenv = llvmPackages.stdenv; - # Custom-glibc stdenvs, matching the CI environment (see compilers.nix). The - # pinned glibc snapshot only builds on Linux, so on darwin these fall back to - # the plain stdenvs; the `if isLinux` guard keeps `customGlibc` from being - # forced (and erroring) on macOS. - customCompilers = import ./compilers.nix { inherit pkgs customGlibc; }; - customGccStdenv = if pkgs.stdenv.isLinux then customCompilers.customStdenv else plainGccStdenv; - customClangStdenv = - if pkgs.stdenv.isLinux then customCompilers.customClangStdenv else plainClangStdenv; + # Each forces something absent on the other platform, so both stay lazy. + linux = import ./linux.nix { inherit pkgs customGlibc; }; + darwin = import ./darwin.nix { inherit pkgs; }; + + # Custom-glibc stdenvs, matching the CI environment. darwin has no custom + # glibc, so there they fall back to the plain nixpkgs stdenvs. + customGccStdenv = if pkgs.stdenv.isLinux then linux.gccStdenv else plainGccStdenv; + customClangStdenv = if pkgs.stdenv.isLinux then linux.clangStdenv else plainClangStdenv; # gcov matching each gcc shell, so `-Dcoverage=ON` builds work in the shell. plainGcov = mkGcov { name = "plain"; cc = gccPackage.cc; }; - customGccGcov = if pkgs.stdenv.isLinux then customCompilers.customGcov else plainGcov; + customGccGcov = if pkgs.stdenv.isLinux then linux.gcov else plainGcov; + + # Whole directory: init.sh locates the profiles relative to itself. + conanDir = ../conan; + + # Own Conan home, so Nix-built packages never share a cache with a system + # Conan. The stamp holds a content-addressed store path, so init.sh re-runs + # only when something in conan/ changes. + conanHook = '' + export CONAN_HOME=~/.conan2-nix + _xrpl_conan_stamp="$CONAN_HOME/.xrpld-devshell" + if [ "$(cat "$_xrpl_conan_stamp" 2>/dev/null)" != "${conanDir}" ]; then + if ${conanDir}/init.sh; then + printf '%s' "${conanDir}" >"$_xrpl_conan_stamp" + else + echo "⚠️ Conan setup failed - run ./conan/init.sh from the repository root to retry." + fi + fi + unset _xrpl_conan_stamp + ''; + + # Not sdkEnv: a shell's stdenv already sets that up. Prepended so the stub + # beats the nixpkgs libresolv this shell's tooling drags in. + darwinLibresolvHook = pkgs.lib.optionalString pkgs.stdenv.isDarwin ( + pkgs.lib.concatLines ( + pkgs.lib.mapAttrsToList ( + name: value: ''export ${name}="${value} ''${${name}:-}"'' + ) darwin.libresolvEnv + ) + ); # Shown when entering a *-plain shell. These exist only on Linux (see below), # where the stock toolchain diverges from CI. plainWarningHook = '' - echo "⚠️ WARNING: this is the stock nixpkgs toolchain and does not match CI's glibc. Prefer 'nix develop .#gcc' / '.#clang' unless you need to skip the custom-glibc build." + echo "⚠️ WARNING: this is the stock nixpkgs toolchain and does not match CI's glibc. Prefer 'nix develop .#gcc' / '.#clang' unless you need to skip the custom-glibc build." ''; # Tools to expose under version-suffixed names (see mkVersionedToolLinks). @@ -87,6 +116,8 @@ let shellHook = '' echo "Welcome to xrpld development shell"; ${compilerVersionHook} + ${darwinLibresolvHook} + ${conanHook} ${warningHook} ''; } diff --git a/nix/docker/Dockerfile b/nix/docker/Dockerfile index 74c630cb61..5506bc3c77 100644 --- a/nix/docker/Dockerfile +++ b/nix/docker/Dockerfile @@ -8,7 +8,7 @@ RUN mkdir -p ~/.config/nix && \ # Copy our source and setup our working dir. COPY nix/ci-env.nix /tmp/build/nix/ci-env.nix -COPY nix/compilers.nix /tmp/build/nix/compilers.nix +COPY nix/linux.nix /tmp/build/nix/linux.nix COPY nix/packages.nix /tmp/build/nix/packages.nix COPY nix/utils.nix /tmp/build/nix/utils.nix COPY flake.nix /tmp/build/ diff --git a/nix/compilers.nix b/nix/linux.nix similarity index 75% rename from nix/compilers.nix rename to nix/linux.nix index 90856afacc..ea808fbf50 100644 --- a/nix/compilers.nix +++ b/nix/linux.nix @@ -1,7 +1,9 @@ -# Custom-glibc compiler toolchain shared by the CI environment (ci-env.nix) and -# the Linux dev shell (devshell.nix): gcc / clang / binutils rebuilt to target -# the pinned custom glibc. Linux only — the pinned glibc snapshot does not build -# on darwin, so callers must not evaluate this on macOS. +# The Linux toolchain: gcc / clang / binutils rebuilt to target the pinned +# custom glibc, shared by the CI environment (ci-env.nix) and the dev shell +# (devshell.nix). The counterpart to darwin.nix. +# +# Linux only — the pinned glibc snapshot does not build on darwin, so callers +# must not evaluate this on macOS. { pkgs, customGlibc, @@ -9,9 +11,11 @@ let inherit (import ./packages.nix { inherit pkgs; }) gccPackage + gccVersion llvmPackages llvmVersion mkGcov + mkVersionedToolLinks ; # binutils wrapped to emit binaries that reference the custom glibc @@ -103,15 +107,46 @@ let echo "-isystem ${customCompilerRt.dev}/include" >> $out/nix-support/cc-cflags ''; }; + # Strip the generic cc/c++/cpp symlinks from the clang wrapper so it can + # coexist with the gcc wrapper in buildEnv. gcc remains the default + # compiler (cc/c++/cpp); clang is invoked explicitly as clang/clang++. + customClangForCiEnv = pkgs.symlinkJoin { + name = "clang-wrapper-custom-for-ci-env"; + paths = [ customClang ]; + postBuild = '' + rm -f $out/bin/cc $out/bin/c++ $out/bin/cpp + ''; + }; in { - inherit + # For an environment that only puts binaries on PATH. + toolchain = [ customGcc - customClang - customBinutils - customStdenv customGcov - ; + customClangForCiEnv + customBinutils + (mkVersionedToolLinks { + name = "gcc"; + package = customGcc; + version = gccVersion; + tools = [ + "gcc" + "g++" + "cpp" + ]; + }) + (mkVersionedToolLinks { + name = "clang"; + package = customClang; + version = llvmVersion; + tools = [ + "clang" + "clang++" + ]; + }) + ]; - customClangStdenv = pkgs.stdenvAdapters.overrideCC pkgs.stdenv customClang; + gccStdenv = customStdenv; + clangStdenv = pkgs.stdenvAdapters.overrideCC pkgs.stdenv customClang; + gcov = customGcov; } diff --git a/nix/packages.nix b/nix/packages.nix index 01ab2ecf9a..c7972c9843 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -50,6 +50,9 @@ let # environment (the plain stdenv compiler in the dev shell, the custom-glibc # wrappers in ci-env.nix), so those callers pass their own `package`; the # clang tooling is environment-independent and is linked in commonPackages. + # + # Exec wrappers, not symlinks: the nixpkgs clang-tools wrapper dispatches on + # `$(basename $0)-unwrapped`, which a suffixed symlink turns into a dead path. mkVersionedToolLinks = { name, @@ -57,12 +60,15 @@ let version, tools, }: - pkgs.linkFarm "${name}-${toString version}-versioned-links" ( - map (tool: { - name = "bin/${tool}-${toString version}"; - path = "${package}/bin/${tool}"; - }) tools - ); + pkgs.symlinkJoin { + name = "${name}-${toString version}-versioned-links"; + paths = map ( + tool: + pkgs.writeShellScriptBin "${tool}-${toString version}" '' + exec "${package}/bin/${tool}" "$@" + '' + ) tools; + }; # The cc-wrapper doesn't re-export gcov, but coverage tooling (gcovr) needs a # gcov that exactly matches the compiler. Surface it from a gcc `cc` output. @@ -129,15 +135,6 @@ in perl # needed for openssl pkg-config pre-commit - # protoc generates the Go gRPC bindings and embeds its own version string into every committed - # .pb.go file. To allow CI to verify those files with a plain `git diff`, we pin the version to - # `protobuf_34` rather than the rolling `protobuf` to keep regeneration reproducible across the - # Nix frequently changing unstable channel. The protoc-gen-go* plugins have no versioned - # attributes in nixpkgs; protoc-gen-go's version is in turn constrained by the go.mod require - # on google.golang.org/protobuf. - protobuf_34 # provides protoc - protoc-gen-go # protoc plugin for the Go message bindings - protoc-gen-go-grpc # protoc plugin for the Go gRPC service stubs python3 runClangTidy vim @@ -146,7 +143,6 @@ in cargo-audit cargo-llvm-cov cargo-nextest - corrosion rustToolchain ]; } diff --git a/package/Dockerfile b/package/Dockerfile index 6cb2a09933..978b569bd8 100644 --- a/package/Dockerfile +++ b/package/Dockerfile @@ -2,13 +2,6 @@ ARG BASE_IMAGE=debian:bookworm FROM ${BASE_IMAGE} -# Packaging runs in a vanilla distro image, so the tooling has to come -# from the distro's archive: debhelper for deb, rpm-build (and the -# systemd / find-debuginfo macros it depends on) for rpm. -# The container also uses git (real history) for -# build_pkg.sh's SOURCE_DATE_EPOCH; otherwise it falls back to a tarball -# download and the timestamp comes from wall-clock time. - 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 887509b60b..9c40861530 100644 --- a/package/README.md +++ b/package/README.md @@ -8,7 +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) + 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) rpm/ xrpld.spec RPM spec debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format) @@ -31,7 +33,7 @@ package manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). | Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required | | ------------ | ---------------------------------------------------------- | --------------------------------------------------- | -| RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild` | +| 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 | To print the full packaging matrix (artifact names and images) for the current @@ -87,7 +89,7 @@ docker run --rm \ ./package/build_pkg.sh --pkg-release "${PKG_RELEASE}" # Output: -# build/debbuild/*.deb (DEB + dbgsym .ddeb) +# build/debbuild/*.deb (DEB + dbgsym; Debian names both .deb) # build/rpmbuild/RPMS/x86_64/*.rpm ``` @@ -120,6 +122,53 @@ The package version is not a CMake input on this path: `build_pkg.sh` 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`. +## Publishing packages + +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: + +| 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` | + +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 +version during a release cycle, which would send develop builds into `stable`. +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. + +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 +`NEXUS_REMOTE_USERNAME` / `NEXUS_REMOTE_PASSWORD` secrets already used for the +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`. +- 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 + failure can leave one format published alone. Re-running is safe: both the apt + POST and the yum PUT replace an existing asset. +- 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 `build_pkg.sh` derives the `xrpld` software version from @@ -151,10 +200,9 @@ 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 repository component: final releases use -`stable`, `b0` builds, including `b0+metadata`, use `develop`, and `bN`/`rcN` -pre-releases use `unstable`. -Build metadata on a final release, such as `3.2.0+abc123`, is rejected. +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. The RPM path intentionally uses `~` in `Version`, matching the Debian pre-release ordering convention, so RPM filenames/NVRs begin with forms like @@ -168,8 +216,12 @@ fail early. 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` and `PKG_RELEASE` via env -and lets the script use defaults for the rest. +`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. + +Signing is not part of this script. `sign_rpm.sh` 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 `stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files, @@ -186,14 +238,9 @@ what catches a binary still linked against the Nix store's ELF loader (see 3. Runs `rpmbuild -bb`, passing the normalized package metadata version as the `pkg_version` RPM macro and `PKG_RELEASE` as the `pkg_release` RPM macro. The spec uses manual `install` commands to place files, disables `dwz`, and - writes uncompressed RPM payloads while generating debuginfo packages. + generates debuginfo packages. 4. Output: `rpmbuild/RPMS/x86_64/xrpld-*.rpm` -The uncompressed RPM payload setting is intentionally unconditional for -generated RPMs. It trades larger RPM artifacts for much shorter package -build/validation time, which keeps RPM package validation in the same rough time -class as Debian package validation. - RPM upgrades intentionally do not restart a running `xrpld` service. The spec uses `%systemd_postun`, matching Debian's `dh_installsystemd --no-stop-on-upgrade` behavior; operators pick up the new binary on the next @@ -209,17 +256,20 @@ service restart. 5. Generates a minimal `debian/changelog` using `${pkg_version}-${PKG_RELEASE}`, where `pkg_version` is derived from the binary-reported `xrpld` version. 6. Runs `dpkg-buildpackage -b --no-sign -d` (`-d` skips the build-dependency check, since the binary is already built). `debian/rules` uses manual `install` commands. -7. Output: `debbuild/*.deb` and `debbuild/*.ddeb` (dbgsym package) +7. Output: `debbuild/*.deb`, the binary package and the `-dbgsym` package. + Debian gives dbgsym packages a `.deb` extension; only Ubuntu uses `.ddeb`. ## Post-build verification ```bash # DEB dpkg-deb -c debbuild/*.deb | grep -E 'systemd|sysusers|tmpfiles' -lintian -I debbuild/*.deb # RPM rpm -qlp rpmbuild/RPMS/x86_64/*.rpm + +# Optional, and not in the packaging image: apt-get install -y lintian +lintian -I debbuild/*.deb ``` ## Reproducibility diff --git a/package/build_pkg.sh b/package/build_pkg.sh index d853bf95b7..cca3be7248 100755 --- a/package/build_pkg.sh +++ b/package/build_pkg.sh @@ -16,6 +16,8 @@ Options (each can also be set via the env var shown): 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 @@ -32,6 +34,7 @@ need_arg() { 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 @@ -51,6 +54,11 @@ while [[ $# -gt 0 ]]; do PKG_RELEASE="$2" shift 2 ;; + --channel) + need_arg "$@" + PKG_CHANNEL="$2" + shift 2 + ;; --source-date-epoch) need_arg "$@" SOURCE_DATE_EPOCH="$2" @@ -198,7 +206,6 @@ stage_common() { build_rpm() { local topdir="${BUILD_DIR}/rpmbuild" - rm -rf "${topdir}" mkdir -p "${topdir}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} cp "${SRC_DIR}/package/rpm/xrpld.spec" "${topdir}/SPECS/xrpld.spec" @@ -214,7 +221,6 @@ build_rpm() { build_deb() { local staging="${BUILD_DIR}/debbuild/source" - rm -rf "${staging}" mkdir -p "${staging}" stage_common "${staging}" @@ -225,25 +231,9 @@ build_deb() { cp "${staging}/xrpld.tmpfiles" "${staging}/debian/xrpld.tmpfiles" cp "${staging}/xrpld.logrotate" "${staging}/debian/xrpld.logrotate" - # Choose the Debian repository component for this package. - # 3.2.0 -> stable, *-b0[+metadata] -> develop, - # bN/rcN pre-releases -> unstable. - local deb_component - if [[ -z "${pre_release}" ]]; then - deb_component="stable" - elif [[ "${pre_release}" =~ ^b0(\+.*)?$ ]]; then - deb_component="develop" - elif [[ "${pre_release}" =~ ^(b[1-9][0-9]*|rc[0-9]+)(\+.*)?$ ]]; then - deb_component="unstable" - else - 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 - # Debian version is [~
]-.
     cat >"${staging}/debian/changelog" <  ${CHANGELOG_DATE}
@@ -255,4 +245,8 @@ EOF
     (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/install-packaging-tools.sh b/package/install-packaging-tools.sh
index a26159a204..2326d8f2ac 100755
--- a/package/install-packaging-tools.sh
+++ b/package/install-packaging-tools.sh
@@ -22,12 +22,24 @@ case "${ID}" in
         ;;
 esac
 
+# Packaging runs in a vanilla distro image, so the tooling comes from the distro's
+# archive rather than from nixpkgs:
+#
+#   - debhelper and dpkg-dev build the DEB
+#   - 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
 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 \
@@ -36,8 +48,10 @@ function install() {
 
         rhel | centos | rocky | almalinux)
             dnf install -y --setopt=install_weak_deps=False \
+                curl-minimal \
                 git \
                 rpm-build \
+                rpm-sign \
                 redhat-rpm-config \
                 systemd-rpm-macros
             ;;
diff --git a/package/publish_pkg.sh b/package/publish_pkg.sh
new file mode 100755
index 0000000000..be36b531de
--- /dev/null
+++ b/package/publish_pkg.sh
@@ -0,0 +1,106 @@
+#!/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-' repository pair
+#   package-dir  searched recursively for *.deb, *.ddeb and *.rpm ('build' by
+#                default)
+#
+# 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}"
+
+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/rpm/xrpld.spec b/package/rpm/xrpld.spec
index 0e3ee2a968..23974c8900 100644
--- a/package/rpm/xrpld.spec
+++ b/package/rpm/xrpld.spec
@@ -19,8 +19,10 @@ BuildRequires: systemd-rpm-macros
 
 %undefine _debugsource_packages
 %debug_package
-# Intentionally trade larger RPM artifacts for faster package validation.
-%global _binary_payload w.ufdio
+# Level 3 rather than the el9 default of 19: it shrinks the multi-gigabyte
+# debuginfo package roughly fourfold in about a second, where 19 would spend
+# minutes on it.
+%global _binary_payload w3.zstdio
 %global _find_debuginfo_dwz_opts %{nil}
 
 %build_mtime_policy clamp_to_source_date_epoch
diff --git a/package/shared/xrpld.service b/package/shared/xrpld.service
index f54e47aa14..22e6359ef0 100644
--- a/package/shared/xrpld.service
+++ b/package/shared/xrpld.service
@@ -24,9 +24,5 @@ LogsDirectoryMode=0750
 LimitNOFILE=65536
 SystemCallArchitectures=native
 
-# Uncomment both lines to allow xrpld to bind to privileged ports (<1024)
-#CapabilityBoundingSet=CAP_NET_BIND_SERVICE
-#AmbientCapabilities=CAP_NET_BIND_SERVICE
-
 [Install]
 WantedBy=multi-user.target
diff --git a/package/sign_rpm.sh b/package/sign_rpm.sh
new file mode 100755
index 0000000000..7a1d6f00e3
--- /dev/null
+++ b/package/sign_rpm.sh
@@ -0,0 +1,65 @@
+#!/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.
+#
+# 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.
+#
+# There is no DEB equivalent: apt trusts the repository metadata, which Nexus
+# signs, rather than the packages themselves.
+
+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
diff --git a/src/benchmarks/libxrpl/nodestore/Backend.cpp b/src/benchmarks/libxrpl/nodestore/Backend.cpp
index cd3e15bd65..9d5937f869 100644
--- a/src/benchmarks/libxrpl/nodestore/Backend.cpp
+++ b/src/benchmarks/libxrpl/nodestore/Backend.cpp
@@ -41,10 +41,11 @@ struct RunState
     release()
     {
         harness.reset();
-        Batch{}.swap(present);
-        Batch{}.swap(recent);
-        std::vector{}.swap(missing);
-        std::vector{}.swap(shuffle);
+        present = Batch{};
+        recent = Batch{};
+        missing = std::vector{};
+        shuffle = std::vector{};
+        avgPayload = 0;
     }
 };
 
@@ -239,9 +240,13 @@ registerWorkload(BackendConfig const& bc, Workload const& w)
     if (!w.pinToPool)
     {
         auto rs = std::make_shared();
-        auto* b = benchmark::RegisterBenchmark(name, makeRunner(w, cfg, rs));
-        b->RangeMultiplier(10)->Range(kPoolSizes.front(), kPoolSizes.back());
-        b->Threads(1)->Threads(4)->Threads(8)->UseRealTime();
+        benchmark::RegisterBenchmark(name, makeRunner(w, cfg, rs))
+            ->RangeMultiplier(10)
+            ->Range(kPoolSizes.front(), kPoolSizes.back())
+            ->Threads(1)
+            ->Threads(4)
+            ->Threads(8)
+            ->UseRealTime();
 
         return;
     }
diff --git a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h
index debdc5d47a..a90207f26a 100644
--- a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h
+++ b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h
@@ -2,10 +2,10 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -227,7 +227,7 @@ sliceFixedBatches(Batch const& pool, std::size_t batchSize)
  */
 struct BackendHarness
 {
-    beast::TempDir tempDir;  ///< Declared first so it is destroyed last
+    TempDir tempDir;  ///< Declared first so it is destroyed last
     DummyScheduler scheduler;
     beast::Journal journal{beast::Journal::getNullSink()};
     std::unique_ptr backend;
@@ -257,7 +257,7 @@ struct BackendHarness
  */
 struct DatabaseHarness
 {
-    beast::TempDir tempDir;
+    TempDir tempDir;
     DummyScheduler scheduler;
     beast::Journal journal{beast::Journal::getNullSink()};
     std::unique_ptr db;
@@ -297,12 +297,11 @@ struct BackendConfig
 inline std::vector const&
 backendConfigs()
 {
+    // Use factory settings for each DB
     static std::vector const kConfigs = {
         {.name = "nudb", .config = "type=nudb"},
 #if XRPL_ROCKSDB_AVAILABLE
-        {.name = "rocksdb",
-         .config = "type=rocksdb,open_files=2000,filter_bits=12,cache_mb=256,"
-                   "file_size_mb=8,file_size_mult=2"},
+        {.name = "rocksdb", .config = "type=rocksdb"},
 #endif
     };
     return kConfigs;
diff --git a/src/libxrpl/basics/Archive.cpp b/src/libxrpl/basics/Archive.cpp
index bba144ed04..5ab0d88c1d 100644
--- a/src/libxrpl/basics/Archive.cpp
+++ b/src/libxrpl/basics/Archive.cpp
@@ -2,22 +2,20 @@
 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
 
 #include 
+#include 
 #include 
 #include 
 
 namespace xrpl {
 
 void
-extractTarLz4(boost::filesystem::path const& src, boost::filesystem::path const& dst)
+extractTarLz4(std::filesystem::path const& src, std::filesystem::path const& dst)
 {
-    if (!is_regular_file(src))
+    if (!std::filesystem::is_regular_file(src))
         Throw("Invalid source file");
 
     using archive_ptr = std::unique_ptr;
diff --git a/src/libxrpl/basics/FileUtilities.cpp b/src/libxrpl/basics/FileUtilities.cpp
index 1a6e604724..bed2b756ac 100644
--- a/src/libxrpl/basics/FileUtilities.cpp
+++ b/src/libxrpl/basics/FileUtilities.cpp
@@ -1,29 +1,31 @@
 #include 
 
-#include 
-#include 
-#include 
-#include 
-#include 
+#include 
 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
+#include 
+#include 
+#include 
 #include 
+#include 
 
 namespace xrpl {
 
 std::string
 getFileContents(
-    boost::system::error_code& ec,
-    boost::filesystem::path const& sourcePath,
+    std::error_code& ec,
+    std::filesystem::path const& sourcePath,
     std::optional maxSize)
 {
-    using namespace boost::filesystem;
-    using namespace boost::system::errc;
+    using namespace std::filesystem;
 
     path const fullPath{canonical(sourcePath, ec)};
     if (ec)
@@ -32,15 +34,15 @@ getFileContents(
     if (maxSize && (file_size(fullPath, ec) > *maxSize || ec))
     {
         if (!ec)
-            ec = make_error_code(file_too_large);
+            ec = make_error_code(std::errc::file_too_large);
         return {};
     }
 
-    std::ifstream fileStream(fullPath.string(), std::ios::in);
+    std::ifstream fileStream(fullPath, std::ios::in);
 
     if (!fileStream)
     {
-        ec = make_error_code(static_cast(errno));
+        ec.assign(errno, std::generic_category());
         return {};
     }
 
@@ -49,7 +51,7 @@ getFileContents(
 
     if (fileStream.bad())
     {
-        ec = make_error_code(static_cast(errno));
+        ec.assign(errno, std::generic_category());
         return {};
     }
 
@@ -58,18 +60,15 @@ getFileContents(
 
 void
 writeFileContents(
-    boost::system::error_code& ec,
-    boost::filesystem::path const& destPath,
+    std::error_code& ec,
+    std::filesystem::path const& destPath,
     std::string const& contents)
 {
-    using namespace boost::filesystem;
-    using namespace boost::system::errc;
-
-    std::ofstream fileStream(destPath.string(), std::ios::out | std::ios::trunc);
+    std::ofstream fileStream(destPath, std::ios::out | std::ios::trunc);
 
     if (!fileStream)
     {
-        ec = make_error_code(static_cast(errno));
+        ec.assign(errno, std::generic_category());
         return;
     }
 
@@ -77,9 +76,64 @@ writeFileContents(
 
     if (fileStream.bad())
     {
-        ec = make_error_code(static_cast(errno));
+        ec.assign(errno, std::generic_category());
         return;
     }
 }
 
+std::filesystem::path
+uniqueRandomPath(
+    std::filesystem::path const& base,
+    std::string const& prefix,
+    std::size_t maxAttempts)
+{
+    std::random_device rd;
+    for (std::size_t attempt = 0; attempt < maxAttempts; ++attempt)
+    {
+        std::ostringstream oss;
+        oss << prefix << std::hex << std::setfill('0') << std::setw(8) << rd() << std::setw(8)
+            << rd();
+        auto candidate = base / oss.str();
+        std::error_code ec;
+        bool const exists = std::filesystem::exists(candidate, ec);
+        if (ec)
+        {
+            Throw(
+                "Unable to check path '" + candidate.string() + "': " + ec.message());
+        }
+        if (!exists)
+            return candidate;
+    }
+    Throw("Unable to generate a unique path under '" + base.string() + "'");
+}
+
+TempDir::TempDir() : path_(uniqueRandomPath(std::filesystem::temp_directory_path()))
+{
+    std::filesystem::create_directory(path_);
+}
+
+TempDir::~TempDir()
+{
+    // use non-throwing calls in the destructor
+    std::error_code ec;
+    std::filesystem::remove_all(path_, ec);
+    if (ec)
+    {
+        std::cerr << "Unable to remove temporary directory '" << path_.string()
+                  << "': " << ec.message() << '\n';
+    }
+}
+
+std::string
+TempDir::path() const
+{
+    return path_.string();
+}
+
+std::string
+TempDir::file(std::string const& name) const
+{
+    return (path_ / name).string();
+}
+
 }  // namespace xrpl
diff --git a/src/libxrpl/basics/Log.cpp b/src/libxrpl/basics/Log.cpp
index d1e54a515f..68525f5a65 100644
--- a/src/libxrpl/basics/Log.cpp
+++ b/src/libxrpl/basics/Log.cpp
@@ -5,10 +5,10 @@
 #include 
 
 #include 
-#include 
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -54,7 +54,7 @@ Logs::File::isOpen() const noexcept
 }
 
 bool
-Logs::File::open(boost::filesystem::path const& path)
+Logs::File::open(std::filesystem::path const& path)
 {
     close();
 
@@ -114,7 +114,7 @@ Logs::Logs(beast::Severity thresh) : thresh_(thresh)  // default severity
 }
 
 bool
-Logs::open(boost::filesystem::path const& pathToLogFile)
+Logs::open(std::filesystem::path const& pathToLogFile)
 {
     return file_.open(pathToLogFile);
 }
diff --git a/src/libxrpl/basics/StringUtilities.cpp b/src/libxrpl/basics/StringUtilities.cpp
index 2b7deecb8e..9eb1bff995 100644
--- a/src/libxrpl/basics/StringUtilities.cpp
+++ b/src/libxrpl/basics/StringUtilities.cpp
@@ -5,15 +5,15 @@
 #include 
 
 #include 
-#include 
-#include 
 #include 
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -67,7 +67,7 @@ parseUrl(ParsedUrl& pUrl, std::string const& strUrl)
     }
 
     pUrl.scheme = smMatch[1];
-    boost::algorithm::to_lower(pUrl.scheme);
+    pUrl.scheme = toLower(pUrl.scheme);
     pUrl.username = smMatch[2];
     pUrl.password = smMatch[3];
     std::string const domain = smMatch[4];
@@ -93,10 +93,42 @@ parseUrl(ParsedUrl& pUrl, std::string const& strUrl)
     return true;
 }
 
+namespace {
+
+// Deliberately not std::isspace / std::tolower: those consult the current C
+// locale, so the same input could trim or fold differently depending on
+// process-wide state set by something else entirely. Everything these helpers
+// are used on (config keys and values, URL schemes, hex digests) is ASCII, and
+// the callers want a fixed answer, so spell the ASCII rules out.
+
+constexpr bool
+isAsciiSpace(char c)
+{
+    return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r';
+}
+
+constexpr char
+toAsciiLower(char c)
+{
+    return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c;
+}
+
+}  // namespace
+
 std::string
 trimWhitespace(std::string str)
 {
-    boost::trim(str);
+    auto const end = std::ranges::find_if_not(str | std::views::reverse, isAsciiSpace).base();
+    str.erase(end, str.end());
+    str.erase(str.begin(), std::ranges::find_if_not(str, isAsciiSpace));
+
+    return str;
+}
+
+std::string
+toLower(std::string str)
+{
+    std::ranges::transform(str, str.begin(), toAsciiLower);
     return str;
 }
 
diff --git a/src/libxrpl/crypto/RFC1751.cpp b/src/libxrpl/crypto/RFC1751.cpp
index 4b17e1443c..f6342928ab 100644
--- a/src/libxrpl/crypto/RFC1751.cpp
+++ b/src/libxrpl/crypto/RFC1751.cpp
@@ -1,11 +1,11 @@
 #include 
 
+#include 
 #include 
 
 #include 
 #include 
 #include 
-#include 
 #include 
 
 #include 
@@ -397,7 +397,7 @@ RFC1751::getKeyFromEnglish(std::string& strKey, std::string const& strHuman)
 
     std::string strTrimmed(strHuman);
 
-    boost::algorithm::trim(strTrimmed);
+    strTrimmed = trimWhitespace(strTrimmed);
 
     boost::algorithm::split(
         vWords, strTrimmed, boost::algorithm::is_space(), boost::algorithm::token_compress_on);
diff --git a/src/libxrpl/json/Writer.cpp b/src/libxrpl/json/Writer.cpp
index 4c922a0e33..c5ce4666ef 100644
--- a/src/libxrpl/json/Writer.cpp
+++ b/src/libxrpl/json/Writer.cpp
@@ -9,6 +9,7 @@
 #include   // IWYU pragma: keep
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -87,14 +88,14 @@ public:
     }
 
     void
-    output(boost::beast::string_view const& bytes)
+    output(std::string_view bytes)
     {
         markStarted();
         output_(bytes);
     }
 
     void
-    stringOutput(boost::beast::string_view const& bytes)
+    stringOutput(std::string_view bytes)
     {
         markStarted();
         std::size_t position = 0, writtenUntil = 0;
diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp
index 8116f4f641..e01ae2e492 100644
--- a/src/libxrpl/ledger/View.cpp
+++ b/src/libxrpl/ledger/View.cpp
@@ -45,20 +45,26 @@ namespace xrpl {
 //------------------------------------------------------------------------------
 
 bool
-hasExpired(ReadView const& view, std::optional const& exp)
+hasExpired(
+    ReadView const& view,
+    std::optional const& exp,
+    ExpiryComparison comparison)
 {
     using d = NetClock::duration;
     using tp = NetClock::time_point;
 
-    return exp && (view.parentCloseTime() >= tp{d{*exp}});
+    if (!exp)
+        return false;
+    auto const boundary = tp{d{*exp}};
+    return comparison == ExpiryComparison::Inclusive  //
+        ? view.parentCloseTime() >= boundary
+        : view.parentCloseTime() > boundary;
 }
 
-bool
-isVaultPseudoAccountFrozen(
-    ReadView const& view,
-    AccountID const& account,
-    MPTIssue const& mptShare,
-    std::uint8_t depth)
+namespace {
+
+std::optional
+checkVaultPseudoAccountFrozenPreconditions(ReadView const& view, std::uint8_t depth)
 {
     if (!view.rules().enabled(featureSingleAssetVault))
         return false;
@@ -66,26 +72,37 @@ isVaultPseudoAccountFrozen(
     if (depth >= kMaxAssetCheckDepth)
     {
         // LCOV_EXCL_START
-        UNREACHABLE("xrpl::View::isVaultPseudoAccountFrozen : reached asset check depth");
+        UNREACHABLE(
+            "xrpl::View::checkVaultPseudoAccountFrozenPreconditions : reached asset check depth");
         return true;
         // LCOV_EXCL_STOP
     }
 
-    auto const mptIssuance = view.read(keylet::mptokenIssuance(mptShare.getMptID()));
-    if (mptIssuance == nullptr)
-        return false;  // zero MPToken won't block deletion of MPTokenIssuance
+    return std::nullopt;
+}
 
-    auto const issuer = mptIssuance->getAccountID(sfIssuer);
+bool
+isVaultPseudoAccountFrozenForIssuance(
+    ReadView const& view,
+    AccountID const& account,
+    SLE const& issuanceSle,
+    std::uint8_t depth)
+{
+    XRPL_ASSERT(
+        issuanceSle.getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::isVaultPseudoAccountFrozenForIssuance : MPTokenIssuance SLE");
+
+    auto const issuer = issuanceSle.getAccountID(sfIssuer);
 
     // Post-fixCleanup3_2_0: vault shares carry sfReferenceHolding pointing
     // to the vault pseudo's MPToken or RippleState for the underlying.
     // Read it to derive the underlying asset and recurse, skipping the
     // issuer-account-then-vault chain. Pre-amendment shares (no field)
     // fall back to the chain lookup below.
-    if (mptIssuance->isFieldPresent(sfReferenceHolding))
+    if (issuanceSle.isFieldPresent(sfReferenceHolding))
     {
         auto const sleHolding =
-            view.read(keylet::unchecked(mptIssuance->getFieldH256(sfReferenceHolding)));
+            view.read(keylet::unchecked(issuanceSle.getFieldH256(sfReferenceHolding)));
         if (!sleHolding)
         {
             // LCOV_EXCL_START
@@ -94,7 +111,7 @@ isVaultPseudoAccountFrozen(
             // LCOV_EXCL_STOP
         }
         return isAnyFrozen(
-            view, {issuer, account}, assetOfHolding(*mptIssuance, *sleHolding), depth + 1);
+            view, {issuer, account}, assetOfHolding(issuanceSle, *sleHolding), depth + 1);
     }
 
     auto const mptIssuer = view.read(keylet::account(issuer));
@@ -120,6 +137,38 @@ isVaultPseudoAccountFrozen(
     return isAnyFrozen(view, {issuer, account}, vault->at(sfAsset), depth + 1);
 }
 
+}  // namespace
+
+bool
+isVaultPseudoAccountFrozen(
+    ReadView const& view,
+    AccountID const& account,
+    SLE const& issuanceSle,
+    std::uint8_t depth)
+{
+    if (auto const result = checkVaultPseudoAccountFrozenPreconditions(view, depth))
+        return *result;
+
+    return isVaultPseudoAccountFrozenForIssuance(view, account, issuanceSle, depth);
+}
+
+bool
+isVaultPseudoAccountFrozen(
+    ReadView const& view,
+    AccountID const& account,
+    MPTIssue const& mptShare,
+    std::uint8_t depth)
+{
+    if (auto const result = checkVaultPseudoAccountFrozenPreconditions(view, depth))
+        return *result;
+
+    auto const issuanceSle = view.read(keylet::mptokenIssuance(mptShare.getMptID()));
+    if (issuanceSle == nullptr)
+        return false;  // zero MPToken won't block deletion of MPTokenIssuance
+
+    return isVaultPseudoAccountFrozenForIssuance(view, account, *issuanceSle, depth);
+}
+
 bool
 isLPTokenFrozen(
     ReadView const& view,
@@ -130,6 +179,33 @@ isLPTokenFrozen(
     return isFrozen(view, account, asset) || isFrozen(view, account, asset2);
 }
 
+TER
+canTransferLPToken(
+    ReadView const& view,
+    AccountID const& from,
+    AccountID const& to,
+    AccountID const& lpTokenIssuer)
+{
+    // Only AMM-issued LPTokens are subject to this check. The LPToken's issuer
+    // is the AMM account; if it is not an AMM, this is not an LPToken.
+    auto const sleIssuer = view.read(keylet::account(lpTokenIssuer));
+    if (!sleIssuer || !sleIssuer->isFieldPresent(sfAMMID))
+        return tesSUCCESS;
+
+    auto const sleAmm = view.read(keylet::amm((*sleIssuer)[sfAMMID]));
+    if (!sleAmm)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    auto const transferable = [&](Asset const& a) -> TER {
+        if (!a.holds())
+            return tesSUCCESS;
+        return canTransfer(view, a.get(), from, to);
+    };
+    if (auto const err = transferable((*sleAmm)[sfAsset]); !isTesSuccess(err))
+        return err;
+    return transferable((*sleAmm)[sfAsset2]);
+}
+
 bool
 areCompatible(
     ReadView const& validLedger,
diff --git a/src/libxrpl/ledger/helpers/AMMHelpers.cpp b/src/libxrpl/ledger/helpers/AMMHelpers.cpp
index df6d335085..fcad22d2d5 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 
@@ -633,7 +634,7 @@ ammAccountHolds(ReadView const& view, AccountID const& ammAccountID, Asset const
     return asset.visit(
         [&](MPTIssue const& issue) {
             if (auto const sle = view.read(keylet::mptoken(issue, ammAccountID));
-                sle && !isFrozen(view, ammAccountID, issue))
+                sle && !isFrozen(view, ammAccountID, *sle))
                 return STAmount{issue, (*sle)[sfMPTAmount]};
             return STAmount{asset};
         },
diff --git a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
index 226ea100e9..5ba832957d 100644
--- a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
@@ -22,6 +22,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -52,6 +53,9 @@ removeExpired(ApplyView& view, STVector256 const& arr, beast::Journal const j)
     for (auto const& h : arr)
     {
         // Credentials already checked in preclaim. Look only for expired here.
+        if (view.rules().enabled(fixCleanup3_4_0) && h.isZero())
+            return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+
         auto const k = keylet::credential(h);
         auto const sleCred = view.peek(k);
 
@@ -124,7 +128,7 @@ deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j)
 }
 
 NotTEC
-checkFields(STTx const& tx, beast::Journal j)
+checkFields(STTx const& tx, Rules const& rules, beast::Journal j)
 {
     if (!tx.isFieldPresent(sfCredentialIDs))
         return tesSUCCESS;
@@ -137,6 +141,13 @@ checkFields(STTx const& tx, beast::Journal j)
         return temMALFORMED;
     }
 
+    if (rules.enabled(fixCleanup3_4_0) &&
+        std::ranges::any_of(credentials, [](uint256 const& id) { return id.isZero(); }))
+    {
+        JLOG(j.trace()) << "Malformed transaction: zero credential ID.";
+        return temMALFORMED;
+    }
+
     std::unordered_set duplicates;
     for (auto const& cred : credentials)
     {
@@ -160,6 +171,14 @@ valid(STTx const& tx, ReadView const& view, AccountID const& src, beast::Journal
     auto const& credIDs(tx.getFieldV256(sfCredentialIDs));
     for (auto const& h : credIDs)
     {
+        if (view.rules().enabled(fixCleanup3_4_0) && h.isZero())
+        {
+            // LCOV_EXCL_START
+            JLOG(j.trace()) << "Zero credential ID.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
         auto const sleCred = view.read(keylet::credential(h));
         if (!sleCred)
         {
@@ -234,6 +253,9 @@ authorizedDepositPreauth(ReadView const& view, STVector256 const& credIDs, Accou
     lifeExtender.reserve(credIDs.size());
     for (auto const& h : credIDs)
     {
+        if (view.rules().enabled(fixCleanup3_4_0) && h.isZero())
+            return tefINTERNAL;  // LCOV_EXCL_LINE
+
         auto sleCred = view.read(keylet::credential(h));
         if (!sleCred)            // already checked in preclaim
             return tefINTERNAL;  // LCOV_EXCL_LINE
diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
index 89b03a03a7..cf1bd4915f 100644
--- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
@@ -12,6 +12,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -20,12 +21,15 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -77,6 +81,40 @@ checkLendingProtocolDependencies(Rules const& rules, STTx const& tx)
     return true;
 }
 
+std::optional
+getLoanDefaultFreezeExemptAccounts(ReadView const& view, STTx const& tx)
+{
+    if (tx.getTxnType() != ttLOAN_MANAGE || !tx.isFlag(tfLoanDefault) ||
+        !view.rules().enabled(fixCleanup3_4_0))
+        return std::nullopt;
+
+    // Unlike the broker/vault lookups below, the submitter picks the LoanID,
+    // so a nonexistent Loan is an ordinary (if unusual) input, not a
+    // structural impossibility -- exercised directly in LendingHelpers_test.
+    auto const loanSle = view.read(keylet::loan(tx[sfLoanID]));
+    if (!loanSle)
+        return std::nullopt;
+
+    // A Loan can't outlive its LoanBroker (LoanBrokerDelete's preclaim
+    // rejects deletion while DebtTotal != 0), and a LoanBroker can't outlive
+    // its Vault (VaultDelete's preclaim has the equivalent guard) -- so these
+    // two lookups are structurally guaranteed to succeed here.
+    auto const brokerSle = view.read(keylet::loanBroker(loanSle->at(sfLoanBrokerID)));
+    if (!brokerSle)
+        return std::nullopt;  // LCOV_EXCL_LINE
+
+    auto const vaultSle = view.read(keylet::vault(brokerSle->at(sfVaultID)));
+    if (!vaultSle)
+        return std::nullopt;  // LCOV_EXCL_LINE
+
+    Asset const vaultAsset = vaultSle->at(sfAsset);
+    return LoanDefaultFreezeExemptAccounts{
+        .issuer = vaultAsset.getIssuer(),
+        .broker = brokerSle->at(sfAccount),
+        .vault = vaultSle->at(sfAccount),
+        .asset = vaultAsset};
+}
+
 LoanPaymentParts&
 LoanPaymentParts::operator+=(LoanPaymentParts const& other)
 {
diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
index 6fe7328fa7..73d5fdb1d5 100644
--- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
@@ -42,18 +42,35 @@ bool
 isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue)
 {
     if (auto const sle = view.read(keylet::mptokenIssuance(mptIssue.getMptID())))
-        return sle->isFlag(lsfMPTLocked);
+        return isGlobalFrozen(*sle);
     return false;
 }
 
+bool
+isGlobalFrozen(SLE const& issuanceSle)
+{
+    XRPL_ASSERT(
+        issuanceSle.getType() == ltMPTOKEN_ISSUANCE, "xrpl::isGlobalFrozen : MPTokenIssuance SLE");
+
+    return issuanceSle.isFlag(lsfMPTLocked);
+}
+
 bool
 isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue)
 {
     if (auto const sle = view.read(keylet::mptoken(mptIssue.getMptID(), account)))
-        return sle->isFlag(lsfMPTLocked);
+        return isIndividualFrozen(*sle);
     return false;
 }
 
+bool
+isIndividualFrozen(SLE const& mptSle)
+{
+    XRPL_ASSERT(mptSle.getType() == ltMPTOKEN, "xrpl::isIndividualFrozen : MPToken SLE");
+
+    return mptSle.isFlag(lsfMPTLocked);
+}
+
 bool
 isFrozen(
     ReadView const& view,
@@ -65,6 +82,34 @@ isFrozen(
         isVaultPseudoAccountFrozen(view, account, mptIssue, depth);
 }
 
+bool
+isFrozen(ReadView const& view, AccountID const& account, SLE const& sle, std::uint8_t depth)
+{
+    XRPL_ASSERT(
+        sle.getType() == ltMPTOKEN || sle.getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::isFrozen : MPToken or MPTokenIssuance SLE");
+
+    if (sle.getType() == ltMPTOKEN)
+    {
+        XRPL_ASSERT(sle[sfAccount] == account, "xrpl::isFrozen : valid MPToken holder");
+
+        MPTID const mptID = sle[sfMPTokenIssuanceID];
+        auto const issuanceSle = view.read(keylet::mptokenIssuance(mptID));
+
+        if ((issuanceSle && isGlobalFrozen(*issuanceSle)) || isIndividualFrozen(sle))
+            return true;
+
+        if (issuanceSle)
+            return isVaultPseudoAccountFrozen(view, account, *issuanceSle, depth);
+
+        return isVaultPseudoAccountFrozen(view, account, MPTIssue{mptID}, depth);
+    }
+
+    MPTIssue const mptIssue{sle[sfSequence], sle[sfIssuer]};
+    return isGlobalFrozen(sle) || isIndividualFrozen(view, account, mptIssue) ||
+        isVaultPseudoAccountFrozen(view, account, sle, depth);
+}
+
 [[nodiscard]] bool
 isAnyFrozen(
     ReadView const& view,
@@ -72,7 +117,8 @@ isAnyFrozen(
     MPTIssue const& mptIssue,
     std::uint8_t depth)
 {
-    if (isGlobalFrozen(view, mptIssue))
+    auto const issuanceSle = view.read(keylet::mptokenIssuance(mptIssue.getMptID()));
+    if (issuanceSle && isGlobalFrozen(*issuanceSle))
         return true;
 
     for (auto const& account : accounts)
@@ -81,9 +127,15 @@ isAnyFrozen(
             return true;
     }
 
-    return std::ranges::any_of(accounts, [&](auto const& account) {
-        return isVaultPseudoAccountFrozen(view, account, mptIssue, depth);
-    });
+    // Pass the issuance SLE when we have it to avoid re-reading it per account;
+    // otherwise defer to the MPTIssue overload, which handles a missing issuance.
+    auto const anyVaultFrozen = [&](auto const& shareOrIssuance) {
+        return std::ranges::any_of(accounts, [&](auto const& account) {
+            return isVaultPseudoAccountFrozen(view, account, shareOrIssuance, depth);
+        });
+    };
+
+    return issuanceSle ? anyVaultFrozen(*issuanceSle) : anyVaultFrozen(mptIssue);
 }
 
 Rate
@@ -952,6 +1004,7 @@ checkCreateMPT(
     xrpl::MPTIssue const& mptIssue,
     xrpl::AccountID const& holder,
     SLE::ref sponsorSle,
+    std::uint32_t flags,
     beast::Journal j)
 {
     if (mptIssue.getIssuer() == holder)
@@ -961,7 +1014,7 @@ checkCreateMPT(
     auto const mptokenID = keylet::mptoken(mptIssuanceID.key, holder);
     if (!view.exists(mptokenID))
     {
-        if (auto const err = createMPToken(view, mptIssue.getMptID(), holder, sponsorSle, 0);
+        if (auto const err = createMPToken(view, mptIssue.getMptID(), holder, sponsorSle, flags);
             !isTesSuccess(err))
         {
             return err;
@@ -977,6 +1030,16 @@ checkCreateMPT(
     return tesSUCCESS;
 }
 
+TER
+checkCreateMPT(
+    xrpl::ApplyView& view,
+    xrpl::MPTIssue const& mptIssue,
+    xrpl::AccountID const& holder,
+    beast::Journal j)
+{
+    return checkCreateMPT(view, mptIssue, holder, {}, 0, j);
+}
+
 std::int64_t
 maxMPTAmount(SLE const& sleIssuance)
 {
diff --git a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
index f3e4597558..ebe5271765 100644
--- a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
@@ -12,6 +12,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -773,6 +774,13 @@ tokenOfferCreatePreflight(
         return temBAD_AMOUNT;
     }
 
+    if (rules.enabled(fixCleanup3_4_0))
+    {
+        // We don't allow a non-native currency to use the currency code XRP.
+        if (badAsset() == amount.asset())
+            return temBAD_CURRENCY;
+    }
+
     if (!isXRP(amount))
     {
         if ((nftFlags & nft::kFlagOnlyXrp) != 0)
@@ -851,7 +859,13 @@ tokenOfferCreatePreclaim(
             return tefNFTOKEN_IS_NOT_TRANSFERABLE;
     }
 
-    if (isFrozen(view, acctID, amount.get().currency, amount.getIssuer()))
+    // The IOU issuer is not subject to their own global freeze when the offer
+    // is denominated in their own IOU (e.g. receiving their own transfer fees),
+    // and they cannot hold a trust line to themselves.
+    bool const acctIsIouIssuer =
+        view.rules().enabled(fixCleanup3_4_0) && acctID == amount.getIssuer();
+    if (!acctIsIouIssuer &&
+        isFrozen(view, acctID, amount.get().currency, amount.getIssuer()))
         return tecFROZEN;
 
     // If this is an offer to buy the token, the account must have the
diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp
index 79e10cdf79..7ebfa64bcf 100644
--- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp
@@ -309,6 +309,15 @@ getLineIfUsable(
                 }
             }
         }
+
+        // An LPToken whose AMM pool contains an MPT that forbids transfers is not
+        // spendable. Issuer is the LPToken's AMM account; canTransferLPToken is
+        // a no-op for non-AMM issuers and non-MPT pool assets, so this is implicitly
+        // gated by featureMPTokensV2.
+        if (!isTesSuccess(canTransferLPToken(view, account, account, issuer)))
+        {
+            return nullptr;
+        }
     }
 
     return sle;
@@ -430,7 +439,7 @@ accountHolds(
     auto const sleMpt = view.read(keylet::mptoken(mptIssue.getMptID(), account));
 
     if (!sleMpt ||
-        (zeroIfFrozen == FreezeHandling::ZeroIfFrozen && isFrozen(view, account, mptIssue)))
+        (zeroIfFrozen == FreezeHandling::ZeroIfFrozen && isFrozen(view, account, *sleMpt)))
     {
         amount.clear(mptIssue);
     }
diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
index 78f64d2077..b0d835a423 100644
--- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
@@ -3,6 +3,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
@@ -11,6 +12,7 @@
 #include 
 #include 
 #include   // IWYU pragma: keep
+#include 
 
 #include 
 #include 
@@ -65,6 +67,23 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
     return assets;
 }
 
+[[nodiscard]] Number
+assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive)
+{
+    Number assetTotal = vault->at(sfAssetsTotal);
+    if (waive == WaiveUnrealizedLoss::No)
+        assetTotal -= vault->at(sfLossUnrealized);
+    return assetTotal;
+}
+
+[[nodiscard]] bool
+debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount)
+{
+    if (amount == 0)
+        return false;
+    return STAmount{asset, total - amount} == STAmount{asset, total};
+}
+
 [[nodiscard]] std::optional
 assetsToSharesWithdraw(
     SLE::const_ref vault,
@@ -80,9 +99,7 @@ assetsToSharesWithdraw(
     if (assets.negative() || assets.asset() != vault->at(sfAsset))
         return std::nullopt;  // LCOV_EXCL_LINE
 
-    Number assetTotal = vault->at(sfAssetsTotal);
-    if (waive == WaiveUnrealizedLoss::No)
-        assetTotal -= vault->at(sfLossUnrealized);
+    Number const assetTotal = assetsTotalForWithdrawal(vault, waive);
     STAmount shares{vault->at(sfShareMPTID)};
     if (assetTotal == 0)
         return shares;
@@ -108,9 +125,7 @@ sharesToAssetsWithdraw(
     if (shares.negative() || shares.asset() != vault->at(sfShareMPTID))
         return std::nullopt;  // LCOV_EXCL_LINE
 
-    Number assetTotal = vault->at(sfAssetsTotal);
-    if (waive == WaiveUnrealizedLoss::No)
-        assetTotal -= vault->at(sfLossUnrealized);
+    Number const assetTotal = assetsTotalForWithdrawal(vault, waive);
     STAmount assets{vault->at(sfAsset)};
     if (assetTotal == 0)
         return assets;
@@ -157,4 +172,74 @@ getVaultVersion(SLE::const_ref vault)
     return static_cast(version);
 }
 
+namespace {
+
+[[nodiscard]] VaultKind
+decodeVaultKind(std::optional vaultKind)
+{
+    if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded))
+        return VaultKind::ClosedEnded;
+    return VaultKind::OpenEnded;
+}
+
+}  // namespace
+
+[[nodiscard]] VaultKind
+getVaultKind(SLE::const_ref vault)
+{
+    XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultKind : valid Vault sle");
+    return decodeVaultKind(vault->at(~sfVaultKind));
+}
+
+[[nodiscard]] VaultKind
+getVaultKind(STTx const& tx)
+{
+    return decodeVaultKind(tx[~sfVaultKind]);
+}
+
+[[nodiscard]] bool
+isValidVaultKind(STTx const& tx)
+{
+    auto const kindField = tx[~sfVaultKind];
+    if (!kindField)
+        return true;
+    return *kindField == std::to_underlying(VaultKind::OpenEnded) ||
+        *kindField == std::to_underlying(VaultKind::ClosedEnded);
+}
+
+[[nodiscard]] bool
+isValidClosedEndedGap(std::uint32_t sub, std::uint32_t red)
+{
+    auto const s = static_cast(sub);
+    auto const r = static_cast(red);
+    return r >= s + kMinInvestmentPeriod && r < s + kMaxInvestmentPeriod;
+}
+
+[[nodiscard]] VaultPhase
+getVaultPhase(ReadView const& view, SLE::const_ref vault)
+{
+    XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultPhase : valid Vault sle");
+    return getVaultPhase(
+        view, (*vault)[~sfVaultKind], (*vault)[~sfSubscriptionDate], (*vault)[~sfRedemptionDate]);
+}
+
+[[nodiscard]] VaultPhase
+getVaultPhase(
+    ReadView const& view,
+    std::optional vaultKind,
+    std::optional subscriptionDate,
+    std::optional redemptionDate)
+{
+    if (!vaultKind || *vaultKind != std::to_underlying(VaultKind::ClosedEnded))
+        return VaultPhase::NoPhase;
+
+    // Subscription includes now == SubscriptionDate; Investment starts
+    // strictly after SubscriptionDate.
+    if (!hasExpired(view, subscriptionDate, ExpiryComparison::Exclusive))
+        return VaultPhase::Subscription;
+    if (!hasExpired(view, redemptionDate))
+        return VaultPhase::Investment;
+    return VaultPhase::Redemption;
+}
+
 }  // namespace xrpl
diff --git a/src/libxrpl/nodestore/backend/NuDBFactory.cpp b/src/libxrpl/nodestore/backend/NuDBFactory.cpp
index bbf37f3edf..98173858e8 100644
--- a/src/libxrpl/nodestore/backend/NuDBFactory.cpp
+++ b/src/libxrpl/nodestore/backend/NuDBFactory.cpp
@@ -16,8 +16,6 @@
 #include 
 #include 
 
-#include 
-#include 
 #include 
 
 #include 
@@ -36,12 +34,14 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::node_store {
@@ -131,7 +131,7 @@ public:
     void
     open(bool createIfMissing, uint64_t appType, uint64_t uid, uint64_t salt) override
     {
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
         if (db.is_open())
         {
             // LCOV_EXCL_START
@@ -194,11 +194,12 @@ public:
 
             if (deletePath)
             {
-                boost::filesystem::remove_all(name, ec);
-                if (ec)
+                std::error_code fsec;
+                std::filesystem::remove_all(name, fsec);
+                if (fsec)
                 {
-                    JLOG(j.fatal())
-                        << "Filesystem remove_all of " << name << " failed with: " << ec.message();
+                    JLOG(j.fatal()) << "Filesystem remove_all of " << name
+                                    << " failed with: " << fsec.message();
                 }
             }
         }
@@ -352,7 +353,7 @@ private:
     static std::size_t
     parseBlockSize(std::string const& name, Section const& keyValues, beast::Journal journal)
     {
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
         auto const folder = path(name);
         auto const kp = (folder / "nudb.key").string();
 
diff --git a/src/libxrpl/nodestore/backend/RocksDBFactory.cpp b/src/libxrpl/nodestore/backend/RocksDBFactory.cpp
index 4b7a1171fe..6f00b762b2 100644
--- a/src/libxrpl/nodestore/backend/RocksDBFactory.cpp
+++ b/src/libxrpl/nodestore/backend/RocksDBFactory.cpp
@@ -19,9 +19,6 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
 #include 
@@ -37,6 +34,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -262,8 +260,8 @@ public:
             db.reset();
             if (deletePath_)
             {
-                boost::filesystem::path const dir = name;
-                boost::filesystem::remove_all(dir);
+                std::filesystem::path const dir = name;
+                std::filesystem::remove_all(dir);
             }
         }
     }
diff --git a/src/libxrpl/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp
index fe8a08c2ef..ecd4832928 100644
--- a/src/libxrpl/protocol/ConfidentialTransfer.cpp
+++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -124,7 +125,12 @@ std::optional
 makeEcPair(Slice const& buffer)
 {
     if (buffer.length() != 2 * kEcCiphertextComponentLength)
-        return std::nullopt;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::makeEcPair : callers must pre-validate ciphertext length");
+        return std::nullopt;
+        // LCOV_EXCL_STOP
+    }
 
     auto parsePubKey = [](Slice const& slice, secp256k1_pubkey& out) {
         return secp256k1_ec_pubkey_parse(secp256k1Context(), &out, slice.data(), slice.length());
@@ -266,7 +272,13 @@ std::optional
 encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, MPTID const& mptId)
 {
     if (pubKeySlice.size() != kEcPubKeyLength)
-        return std::nullopt;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::encryptCanonicalZeroAmount : callers must pre-validate public key length");
+        return std::nullopt;
+        // LCOV_EXCL_STOP
+    }
 
     EcPair pair{};
     secp256k1_pubkey pubKey;
@@ -274,14 +286,24 @@ encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, M
             secp256k1Context(), &pubKey, pubKeySlice.data(), kEcPubKeyLength);
         res != 1)
     {
-        return std::nullopt;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::encryptCanonicalZeroAmount : public key read from the ledger must already be "
+            "valid");
+        return std::nullopt;
+        // LCOV_EXCL_STOP
     }
 
     if (auto res = generate_canonical_encrypted_zero(
             secp256k1Context(), &pair.c1, &pair.c2, &pubKey, account.data(), mptId.data());
         res != 1)
     {
-        return std::nullopt;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::encryptCanonicalZeroAmount : canonical zero generation cannot fail for a "
+            "valid public key");
+        return std::nullopt;
+        // LCOV_EXCL_STOP
     }
 
     return serializeEcPair(pair);
@@ -301,7 +323,11 @@ verifyRevealedAmount(
         issuer.publicKey.size() != kEcPubKeyLength ||
         issuer.encryptedAmount.size() != kEcGamalEncryptedTotalLength)
     {
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifyRevealedAmount : callers must pre-validate holder/issuer field lengths");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     auto const holderP = toParticipant(holder);
@@ -313,7 +339,11 @@ verifyRevealedAmount(
         if (auditor->publicKey.size() != kEcPubKeyLength ||
             auditor->encryptedAmount.size() != kEcGamalEncryptedTotalLength)
         {
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::verifyRevealedAmount : callers must pre-validate auditor field lengths");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
         }
         auditorP = toParticipant(*auditor);
         auditorPtr = &auditorP;
@@ -337,7 +367,12 @@ checkEncryptedAmountFormat(STObject const& object)
     if (!object.isFieldPresent(sfHolderEncryptedAmount) ||
         !object.isFieldPresent(sfIssuerEncryptedAmount))
     {
-        return temMALFORMED;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::checkEncryptedAmountFormat : callers already enforce that these fields are "
+            "present");
+        return temMALFORMED;
+        // LCOV_EXCL_STOP
     }
 
     if (object[sfHolderEncryptedAmount].length() != kEcGamalEncryptedTotalLength ||
@@ -366,7 +401,12 @@ TER
 verifySchnorrProof(Slice const& pubKeySlice, Slice const& proofSlice, uint256 const& contextHash)
 {
     if (proofSlice.size() != kEcSchnorrProofLength || pubKeySlice.size() != kEcPubKeyLength)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::verifySchnorrProof : callers must pre-validate proof/public key length");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     if (mpt_verify_convert_proof(proofSlice.data(), pubKeySlice.data(), contextHash.data()) != 0)
         return tecBAD_PROOF;
@@ -385,7 +425,12 @@ verifyClawbackProof(
     if (ciphertext.size() != kEcGamalEncryptedTotalLength ||
         pubKeySlice.size() != kEcPubKeyLength || proof.size() != kEcClawbackProofLength)
     {
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifyClawbackProof : callers must pre-validate ciphertext/public "
+            "key/proof length");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     if (mpt_verify_clawback_proof(
@@ -420,7 +465,12 @@ verifySendProof(
         amountCommitment.size() != kEcPedersenCommitmentLength ||
         balanceCommitment.size() != kEcPedersenCommitmentLength)
     {
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifySendProof : callers must pre-validate proof/participant/commitment "
+            "lengths");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     std::vector participants;
@@ -433,12 +483,22 @@ verifySendProof(
         if (auditor->publicKey.size() != kEcPubKeyLength ||
             auditor->encryptedAmount.size() != kEcGamalEncryptedTotalLength)
         {
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+            // LCOV_EXCL_START
+            UNREACHABLE("xrpl::verifySendProof : callers must pre-validate auditor field lengths");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
         }
         participants.push_back(toParticipant(*auditor));
     }
     if (participants.size() != recipientCount)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifySendProof : participant count must match the requested recipient "
+            "count");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     if (mpt_verify_send_proof(
             proof.data(),
@@ -468,7 +528,12 @@ verifyConvertBackProof(
         spendingBalance.size() != kEcGamalEncryptedTotalLength ||
         balanceCommitment.size() != kEcPedersenCommitmentLength)
     {
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifyConvertBackProof : callers must pre-validate proof/public "
+            "key/balance/commitment lengths");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     if (mpt_verify_convert_back_proof(
diff --git a/src/libxrpl/protocol/ErrorCodes.cpp b/src/libxrpl/protocol/ErrorCodes.cpp
index e81f975844..802bae100d 100644
--- a/src/libxrpl/protocol/ErrorCodes.cpp
+++ b/src/libxrpl/protocol/ErrorCodes.cpp
@@ -105,10 +105,9 @@ static constexpr ErrorInfo kUnorderedErrorInfos[]{
 };
 // clang-format on
 
-// Sort and validate unorderedErrorInfos at compile time.  Should be
-// converted to consteval when get to C++20.
+// Sort and validate unorderedErrorInfos at compile time.
 template 
-constexpr auto
+consteval auto
 sortErrorInfos(ErrorInfo const (&unordered)[N]) -> std::array
 {
     std::array ret = {};
diff --git a/src/libxrpl/protocol/InnerObjectFormats.cpp b/src/libxrpl/protocol/InnerObjectFormats.cpp
index 0bdb217771..5cb7d166e9 100644
--- a/src/libxrpl/protocol/InnerObjectFormats.cpp
+++ b/src/libxrpl/protocol/InnerObjectFormats.cpp
@@ -137,9 +137,9 @@ InnerObjectFormats::InnerObjectFormats()
             {sfCredentialType, SoeRequired},
         });
 
-    add(sfPermission.jsonName.cStr(), sfPermission.getCode(), {{sfPermissionValue, SoeRequired}});
+    add(sfPermission.jsonName, sfPermission.getCode(), {{sfPermissionValue, SoeRequired}});
 
-    add(sfBatchSigner.jsonName.cStr(),
+    add(sfBatchSigner.jsonName,
         sfBatchSigner.getCode(),
         {{sfAccount, SoeRequired},
          {sfSigningPubKey, SoeOptional},
@@ -161,7 +161,7 @@ InnerObjectFormats::InnerObjectFormats()
             {sfSigners, SoeOptional},
         });
 
-    add(sfSponsorSignature.jsonName.cStr(),
+    add(sfSponsorSignature.jsonName,
         sfSponsorSignature.getCode(),
         {
             {sfSigningPubKey, SoeOptional},
diff --git a/src/libxrpl/protocol/QualityFunction.cpp b/src/libxrpl/protocol/QualityFunction.cpp
index e862770406..ffe583b7e1 100644
--- a/src/libxrpl/protocol/QualityFunction.cpp
+++ b/src/libxrpl/protocol/QualityFunction.cpp
@@ -38,7 +38,22 @@ QualityFunction::outFromAvgQ(Quality const& quality)
             return std::nullopt;
         return out;
     }
-    return std::nullopt;
+    // The sole caller (StrandFlow::limitOut) only invokes this on a non-const
+    // quality function, so m_ != 0 here, and a real payment/offer never yields
+    // a zero-rate limit quality (it would divide by zero above). This fallback
+    // is therefore unreachable in practice.
+    return std::nullopt;  // LCOV_EXCL_LINE
+}
+
+bool
+QualityFunction::satisfiesAvgQ(Quality const& quality, Number const& out) const
+{
+    // satisfiesAvgQ is only reached from StrandFlow::limitOut *after*
+    // outFromAvgQ returned a value, which requires a non-zero rate. So a
+    // zero-rate quality never reaches here; this guard is defensive.
+    if (quality.rate() == beast::kZero)
+        return false;  // LCOV_EXCL_LINE
+    return m_ * out + b_ >= 1 / quality.rate();
 }
 
 }  // namespace xrpl
diff --git a/src/libxrpl/protocol/Rules.cpp b/src/libxrpl/protocol/Rules.cpp
index 197139027a..cb71133d8f 100644
--- a/src/libxrpl/protocol/Rules.cpp
+++ b/src/libxrpl/protocol/Rules.cpp
@@ -193,12 +193,6 @@ Rules::operator==(Rules const& other) const
     return *impl_ == *other.impl_;
 }
 
-bool
-Rules::operator!=(Rules const& other) const
-{
-    return !(*this == other);
-}
-
 bool
 isFeatureEnabled(uint256 const& feature, bool resultIfNoRules)
 {
diff --git a/src/libxrpl/protocol/STAmount.cpp b/src/libxrpl/protocol/STAmount.cpp
index 212c34322b..83b2983756 100644
--- a/src/libxrpl/protocol/STAmount.cpp
+++ b/src/libxrpl/protocol/STAmount.cpp
@@ -1445,6 +1445,59 @@ public:
     operator=(DontAffectNumberRoundMode const&) = delete;
 };
 
+Number::RoundingMode
+roundMode(bool const resultNegative, bool const roundUp)
+{
+    using enum Number::RoundingMode;
+    // STAmount roundUp means "away from zero". The legacy scaled-mantissa
+    // multiply and divide paths reach that result with slightly different
+    // mechanics, including a final TowardsZero materialization in multiply.
+    //
+    // The MPT/V2 Number path already performs the operation under the directed
+    // mode below. Use the same mode again when converting back to STAmount so a
+    // fractional integral result stays consistently rounded after Number
+    // arithmetic, independent of whether the operation was multiply or divide.
+    return roundUp ^ resultNegative ? Upward : Downward;
+}
+
+STAmount
+roundNumberResult(
+    Asset const& asset,
+    bool const resultNegative,
+    bool const roundUp,
+    Number const& number)
+{
+    // MPT/V2 Number arithmetic uses directed rounding both for the operation
+    // and for materializing the final integral amount.
+    NumberRoundModeGuard const finalRound(roundMode(resultNegative, roundUp));
+    auto result = STAmount{asset, number};
+    [[maybe_unused]] bool const nonzeroPositiveRoundUp =
+        roundUp && !resultNegative && number != beast::kZero;
+    ALWAYS(
+        !nonzeroPositiveRoundUp || result != beast::kZero,
+        "xrpl::roundNumberResult : positive rounded-up MPT result is representable");
+
+    if (roundUp && !resultNegative && !result)
+    {
+        // Intended to preserve existing mulRound/divRound behavior for a
+        // positive result too small to represent in the target asset.
+        //
+        // Unreachable in practice: when roundUp is set, roundMode() above
+        // selects Upward, and materializing a Number into an STAmount honors
+        // that mode (Number::operator rep()), so any positive value rounds up
+        // to at least the smallest representable unit. Hence, a positive result
+        // is never !result here; the only zero case is a zero operand, which
+        // the mulRound/divRound callers handle before reaching this function.
+        // LCOV_EXCL_START
+        if (asset.integral())
+            return STAmount{asset, 1};
+        return STAmount{asset, STAmount::kMinValue, STAmount::kMinOffset, false};
+        // LCOV_EXCL_STOP
+    }
+
+    return result;
+}
+
 }  // anonymous namespace
 
 // Pass the canonicalizeRound function pointer as a template parameter.
@@ -1486,6 +1539,22 @@ mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool ro
         return STAmount(asset, minV * maxV);
     }
 
+    bool const resultNegative = v1.negative() != v2.negative();
+
+    if (asset.holds() && isFeatureEnabled(featureMPTokensV2, false))
+    {
+        // MPT DEX can combine 63-bit MPT amounts with IOU-shaped transfer
+        // rates. Use Number arithmetic under MPTokensV2 so the rounded
+        // operation is not limited by the legacy uint64_t scaled mantissa.
+        Number result;
+        {
+            NumberRoundModeGuard const operationRound(roundMode(resultNegative, roundUp));
+            result = Number{v1} * Number{v2};
+        }
+
+        return roundNumberResult(asset, resultNegative, roundUp, result);
+    }
+
     std::uint64_t value1 = v1.mantissa(), value2 = v2.mantissa();
     int offset1 = v1.exponent(), offset2 = v2.exponent();
 
@@ -1506,9 +1575,6 @@ mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool ro
             --offset2;
         }
     }
-
-    bool const resultNegative = v1.negative() != v2.negative();
-
     // We multiply the two mantissas (each is between 10^15
     // and 10^16), so their product is in the 10^30 to 10^32
     // range. Dividing their product by 10^14 maintains the
@@ -1575,6 +1641,22 @@ divRoundImpl(STAmount const& num, STAmount const& den, Asset const& asset, bool
     if (num == beast::kZero)
         return {asset};
 
+    bool const resultNegative = (num.negative() != den.negative());
+
+    if (asset.holds() && isFeatureEnabled(featureMPTokensV2, false))
+    {
+        // Match the multiply path above: Number performs the rounded
+        // operation, then STAmount materializes the final MPT amount using the
+        // same final rounding mode as the legacy path below.
+        Number result;
+        {
+            NumberRoundModeGuard const operationRound(roundMode(resultNegative, roundUp));
+            result = Number{num} / Number{den};
+        }
+
+        return roundNumberResult(asset, resultNegative, roundUp, result);
+    }
+
     std::uint64_t numVal = num.mantissa(), denVal = den.mantissa();
     int numOffset = num.exponent(), denOffset = den.exponent();
 
@@ -1596,8 +1678,6 @@ divRoundImpl(STAmount const& num, STAmount const& den, Asset const& asset, bool
         }
     }
 
-    bool const resultNegative = (num.negative() != den.negative());
-
     // We divide the two mantissas (each is between 10^15
     // and 10^16). To maintain precision, we multiply the
     // numerator by 10^17 (the product is in the range of
diff --git a/src/libxrpl/protocol/STBase.cpp b/src/libxrpl/protocol/STBase.cpp
index f029f10e75..1e56897e30 100644
--- a/src/libxrpl/protocol/STBase.cpp
+++ b/src/libxrpl/protocol/STBase.cpp
@@ -38,12 +38,6 @@ STBase::operator==(STBase const& t) const
     return (getSType() == t.getSType()) && isEquivalent(t);
 }
 
-bool
-STBase::operator!=(STBase const& t) const
-{
-    return (getSType() != t.getSType()) || !isEquivalent(t);
-}
-
 STBase*
 STBase::copy(std::size_t n, void* buf) const
 {
diff --git a/src/libxrpl/protocol/STLedgerEntry.cpp b/src/libxrpl/protocol/STLedgerEntry.cpp
index 8c5c5b5eae..9ee8d030ff 100644
--- a/src/libxrpl/protocol/STLedgerEntry.cpp
+++ b/src/libxrpl/protocol/STLedgerEntry.cpp
@@ -18,12 +18,11 @@
 #include 
 #include 
 
-#include 
-
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -111,7 +110,7 @@ STLedgerEntry::getSType() const
 std::string
 STLedgerEntry::getText() const
 {
-    return str(boost::format("{ %s, %s }") % to_string(key_) % STObject::getText());
+    return std::format("{{ {}, {} }}", to_string(key_), STObject::getText());
 }
 
 json::Value
diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp
index 7f1e19ea12..ce672b515d 100644
--- a/src/libxrpl/protocol/STTx.cpp
+++ b/src/libxrpl/protocol/STTx.cpp
@@ -33,13 +33,13 @@
 #include 
 
 #include 
-#include 
 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -399,16 +399,21 @@ STTx::getMetaSQL(
     TxnSql status,
     std::string const& escapedMetaData) const
 {
-    static boost::format const kBfTrans("('%s', '%s', '%s', '%d', '%d', '%c', %s, %s)");
     std::string rTxn = sqlBlobLiteral(rawTxn.peekData());
 
     auto format = TxFormats::getInstance().findByType(txType_);
     XRPL_ASSERT(format, "xrpl::STTx::getMetaSQL : non-null type format");
 
-    return str(
-        boost::format(kBfTrans) % to_string(getTransactionID()) % format->getName() %
-        toBase58(getAccountID(sfAccount)) % getFieldU32(sfSequence) % inLedger %
-        safeCast(status) % rTxn % escapedMetaData);
+    return std::format(
+        "('{}', '{}', '{}', '{}', '{}', '{}', {}, {})",
+        to_string(getTransactionID()),
+        format->getName(),
+        toBase58(getAccountID(sfAccount)),
+        getFieldU32(sfSequence),
+        inLedger,
+        safeCast(status),
+        rTxn,
+        escapedMetaData);
 }
 
 static std::expected
diff --git a/src/libxrpl/protocol/STXChainBridge.cpp b/src/libxrpl/protocol/STXChainBridge.cpp
index 005c9ccbce..f9f1fd1dcc 100644
--- a/src/libxrpl/protocol/STXChainBridge.cpp
+++ b/src/libxrpl/protocol/STXChainBridge.cpp
@@ -11,9 +11,8 @@
 #include 
 #include 
 
-#include 
-
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -141,10 +140,15 @@ STXChainBridge::getJson(JsonOptions jo) const
 std::string
 STXChainBridge::getText() const
 {
-    return str(
-        boost::format("{ %s = %s, %s = %s, %s = %s, %s = %s }") % sfLockingChainDoor.getName() %
-        lockingChainDoor_.getText() % sfLockingChainIssue.getName() % lockingChainIssue_.getText() %
-        sfIssuingChainDoor.getName() % issuingChainDoor_.getText() % sfIssuingChainIssue.getName() %
+    return std::format(
+        "{{ {} = {}, {} = {}, {} = {}, {} = {} }}",
+        sfLockingChainDoor.getName(),
+        lockingChainDoor_.getText(),
+        sfLockingChainIssue.getName(),
+        lockingChainIssue_.getText(),
+        sfIssuingChainDoor.getName(),
+        issuingChainDoor_.getText(),
+        sfIssuingChainIssue.getName(),
         issuingChainIssue_.getText());
 }
 
diff --git a/src/libxrpl/rdb/SociDB.cpp b/src/libxrpl/rdb/SociDB.cpp
index 2c3fb1bde1..84006acbe7 100644
--- a/src/libxrpl/rdb/SociDB.cpp
+++ b/src/libxrpl/rdb/SociDB.cpp
@@ -5,13 +5,11 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -45,8 +43,8 @@ getSociSqliteInit(std::string const& name, std::string const& dir, std::string c
         Throw(
             "Sqlite databases must specify a dir and a name. Name: " + name + " Dir: " + dir);
     }
-    boost::filesystem::path file(dir);
-    if (is_directory(file))
+    std::filesystem::path file(dir);
+    if (std::filesystem::is_directory(file))
         file /= name + ext;
     return file.string();
 }
diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp
index 0760196a3b..c85c8445f0 100644
--- a/src/libxrpl/server/Manifest.cpp
+++ b/src/libxrpl/server/Manifest.cpp
@@ -23,8 +23,6 @@
 #include 
 #include 
 
-#include 
-
 #include 
 #include 
 #include 
@@ -277,7 +275,7 @@ loadValidatorToken(std::vector const& blob, beast::Journal journal)
                 [](std::size_t init, std::string const& s) { return init + s.size(); }));
 
         for (auto const& line : blob)
-            tokenStr += boost::algorithm::trim_copy(line);
+            tokenStr += trimWhitespace(line);
 
         tokenStr = base64Decode(tokenStr);
 
@@ -653,7 +651,7 @@ ManifestCache::load(
                 [](std::size_t init, std::string const& s) { return init + s.size(); }));
 
         for (auto const& line : configRevocation)
-            revocationStr += boost::algorithm::trim_copy(line);
+            revocationStr += trimWhitespace(line);
 
         auto mo = deserializeManifest(base64Decode(revocationStr));
 
diff --git a/src/libxrpl/server/Port.cpp b/src/libxrpl/server/Port.cpp
index 694d4448d5..a7892bc0e8 100644
--- a/src/libxrpl/server/Port.cpp
+++ b/src/libxrpl/server/Port.cpp
@@ -1,5 +1,6 @@
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -9,7 +10,6 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -98,7 +98,7 @@ populate(
 
     while (std::getline(ss, ip, ','))
     {
-        boost::algorithm::trim(ip);
+        ip = trimWhitespace(ip);
         bool v4 = false;
         boost::asio::ip::network_v4 v4Net;
         boost::asio::ip::network_v6 v6Net;
diff --git a/src/libxrpl/server/Vacuum.cpp b/src/libxrpl/server/Vacuum.cpp
index 63d40af156..c952e722b8 100644
--- a/src/libxrpl/server/Vacuum.cpp
+++ b/src/libxrpl/server/Vacuum.cpp
@@ -5,13 +5,10 @@
 #include 
 #include 
 
-#include 
-#include 
-#include   // IWYU pragma: keep
-
 #include 
 
 #include 
+#include 
 #include 
 #include 
 
@@ -20,12 +17,12 @@ namespace xrpl {
 bool
 doVacuumDB(DatabaseCon::Setup const& setup, beast::Journal j)
 {
-    boost::filesystem::path const dbPath = setup.dataDir / kTxDbName;
+    std::filesystem::path const dbPath = setup.dataDir / kTxDbName;
 
-    uintmax_t const dbSize = file_size(dbPath);
+    uintmax_t const dbSize = std::filesystem::file_size(dbPath);
     XRPL_ASSERT(dbSize != static_cast(-1), "xrpl::doVacuumDB : file_size succeeded");
 
-    if (auto available = space(dbPath.parent_path()).available; available < dbSize)
+    if (auto available = std::filesystem::space(dbPath.parent_path()).available; available < dbSize)
     {
         std::cerr << "The database filesystem must have at least as "
                      "much free space as the size of "
@@ -41,7 +38,7 @@ doVacuumDB(DatabaseCon::Setup const& setup, beast::Journal j)
     // Only the most trivial databases will fit in memory on typical
     // (recommended) hardware. Force temp files to be written to disk
     // regardless of the config settings.
-    session << boost::format(kCommonDbPragmaTemp) % "file";
+    session << commonDbPragmaTemp("file");
     session << "PRAGMA page_size;", soci::into(pageSize);
 
     std::cout << "VACUUM beginning. page_size: " << pageSize << std::endl;
diff --git a/src/libxrpl/server/Wallet.cpp b/src/libxrpl/server/Wallet.cpp
index 42ac80ef3f..56d0db67d4 100644
--- a/src/libxrpl/server/Wallet.cpp
+++ b/src/libxrpl/server/Wallet.cpp
@@ -16,7 +16,6 @@
 #include 
 #include 
 
-#include 
 #include   // IWYU pragma: keep
 
 #include   // IWYU pragma: keep
@@ -30,6 +29,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -172,11 +172,10 @@ getNodeIdentity(soci::session& session)
     // If a valid identity wasn't found, we randomly generate a new one:
     auto [newpublicKey, newsecretKey] = randomKeyPair(KeyType::Secp256k1);
 
-    session << str(
-        boost::format(
-            "INSERT INTO NodeIdentity (PublicKey,PrivateKey) "
-            "VALUES ('%s','%s');") %
-        toBase58(TokenType::NodePublic, newpublicKey) %
+    session << std::format(
+        "INSERT INTO NodeIdentity (PublicKey,PrivateKey) "
+        "VALUES ('{}','{}');",
+        toBase58(TokenType::NodePublic, newpublicKey),
         toBase58(TokenType::NodePrivate, newsecretKey));
 
     return {newpublicKey, newsecretKey};
diff --git a/src/libxrpl/shamap/SHAMap.cpp b/src/libxrpl/shamap/SHAMap.cpp
index 2483e6f6e1..3fa8d66be0 100644
--- a/src/libxrpl/shamap/SHAMap.cpp
+++ b/src/libxrpl/shamap/SHAMap.cpp
@@ -116,8 +116,7 @@ SHAMap::dirtyUp(SharedPtrNodeStack& stack, uint256 const& target, SHAMapTreeNode
         stack.pop();
         XRPL_ASSERT(node, "xrpl::SHAMap::dirtyUp : non-null node");
 
-        int const branch = selectBranch(nodeID, target);
-        XRPL_ASSERT(branch >= 0, "xrpl::SHAMap::dirtyUp : valid branch");
+        auto const branch = selectBranch(nodeID, target);
 
         node = unshareNode(std::move(node), nodeID);
         node->setChild(branch, std::move(child));
@@ -278,7 +277,7 @@ SHAMap::fetchNode(SHAMapHash const& hash) const
 }
 
 SHAMapTreeNode*
-SHAMap::descendThrow(SHAMapInnerNode* parent, int branch) const
+SHAMap::descendThrow(SHAMapInnerNode* parent, unsigned int branch) const
 {
     SHAMapTreeNode* ret = descend(parent, branch);  // NOLINT(misc-const-correctness)
 
@@ -289,7 +288,7 @@ SHAMap::descendThrow(SHAMapInnerNode* parent, int branch) const
 }
 
 SHAMapTreeNodePtr
-SHAMap::descendThrow(SHAMapInnerNode& parent, int branch) const
+SHAMap::descendThrow(SHAMapInnerNode& parent, unsigned int branch) const
 {
     SHAMapTreeNodePtr ret = descend(parent, branch);
 
@@ -300,7 +299,7 @@ SHAMap::descendThrow(SHAMapInnerNode& parent, int branch) const
 }
 
 SHAMapTreeNode*
-SHAMap::descend(SHAMapInnerNode* parent, int branch) const
+SHAMap::descend(SHAMapInnerNode* parent, unsigned int branch) const
 {
     SHAMapTreeNode* ret = parent->getChildPointer(branch);  // NOLINT(misc-const-correctness)
     if ((ret != nullptr) || !backed_)
@@ -315,7 +314,7 @@ SHAMap::descend(SHAMapInnerNode* parent, int branch) const
 }
 
 SHAMapTreeNodePtr
-SHAMap::descend(SHAMapInnerNode& parent, int branch) const
+SHAMap::descend(SHAMapInnerNode& parent, unsigned int branch) const
 {
     SHAMapTreeNodePtr node = parent.getChild(branch);
     if (node || !backed_)
@@ -332,7 +331,7 @@ SHAMap::descend(SHAMapInnerNode& parent, int branch) const
 // Gets the node that would be hooked to this branch,
 // but doesn't hook it up.
 SHAMapTreeNodePtr
-SHAMap::descendNoStore(SHAMapInnerNode& parent, int branch) const
+SHAMap::descendNoStore(SHAMapInnerNode& parent, unsigned int branch) const
 {
     SHAMapTreeNodePtr ret = parent.getChild(branch);
     if (!ret && backed_)
@@ -344,12 +343,11 @@ std::pair
 SHAMap::descend(
     SHAMapInnerNode* parent,
     SHAMapNodeID const& parentID,
-    int branch,
+    unsigned int branch,
     SHAMapSyncFilter const* filter) const
 {
     XRPL_ASSERT(parent->isInner(), "xrpl::SHAMap::descend : valid parent input");
-    XRPL_ASSERT(
-        (branch >= 0) && (branch < kBranchFactor), "xrpl::SHAMap::descend : valid branch input");
+    XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMap::descend : valid branch input");
     XRPL_ASSERT(
         !parent->isEmptyBranch(branch), "xrpl::SHAMap::descend : parent branch is non-empty");
 
@@ -373,7 +371,7 @@ SHAMap::descend(
 SHAMapTreeNode*
 SHAMap::descendAsync(
     SHAMapInnerNode* parent,
-    int branch,
+    unsigned int branch,
     SHAMapSyncFilter const* filter,
     bool& pending,
     descendCallback&& callback) const
@@ -433,10 +431,9 @@ SHAMapLeafNode*
 SHAMap::belowHelper(
     SHAMapTreeNodePtr node,
     SharedPtrNodeStack& stack,
-    int branch,
-    std::tuple, std::function> const& loopParams) const
+    unsigned int branch,
+    BelowDirection direction) const
 {
-    auto& [init, cmp, incr] = loopParams;
     if (node->isLeaf())
     {
         auto n = intr_ptr::staticPointerCast(node);
@@ -452,11 +449,16 @@ SHAMap::belowHelper(
     {
         stack.emplace(inner, stack.top().second.getChildNodeID(branch));
     }
-    for (int i = init; cmp(i);)
+    // `scanned` counts how many branches of `inner` we have examined; the branch we look at is
+    // derived from it, so no index ever goes out of range.
+    for (auto scanned = 0u; scanned < kBranchFactor;)
     {
-        if (!inner->isEmptyBranch(i))
+        auto const childBranch =
+            (direction == BelowDirection::Last) ? (kBranchFactor - 1u - scanned) : scanned;
+
+        if (!inner->isEmptyBranch(childBranch))
         {
-            node.adopt(descendThrow(inner.get(), i));
+            node.adopt(descendThrow(inner.get(), childBranch));
             XRPL_ASSERT(!stack.empty(), "xrpl::SHAMap::belowHelper : non-empty stack");
             if (node->isLeaf())
             {
@@ -466,32 +468,24 @@ SHAMap::belowHelper(
             }
             inner = intr_ptr::staticPointerCast(node);
             stack.emplace(inner, stack.top().second.getChildNodeID(branch));
-            i = init;  // descend and reset loop
+            scanned = 0u;  // descend and restart the scan on the new node
         }
         else
         {
-            incr(i);  // scan next branch
+            ++scanned;  // scan next branch
         }
     }
     return nullptr;
 }
 SHAMapLeafNode*
-SHAMap::lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch) const
+SHAMap::lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch) const
 {
-    auto init = kBranchFactor - 1;
-    auto cmp = [](int i) { return i >= 0; };
-    auto incr = [](int& i) { --i; };
-
-    return belowHelper(node, stack, branch, {init, cmp, incr});
+    return belowHelper(node, stack, branch, BelowDirection::Last);
 }
 SHAMapLeafNode*
-SHAMap::firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch) const
+SHAMap::firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch) const
 {
-    auto init = 0;
-    auto cmp = [](int i) { return i <= kBranchFactor; };
-    auto incr = [](int& i) { ++i; };
-
-    return belowHelper(node, stack, branch, {init, cmp, incr});
+    return belowHelper(node, stack, branch, BelowDirection::First);
 }
 static boost::intrusive_ptr const kNoItem;
 
@@ -504,7 +498,7 @@ SHAMap::onlyBelow(SHAMapTreeNode* node) const
     {
         SHAMapTreeNode* nextNode = nullptr;
         auto inner = safeDowncast(node);
-        for (int i = 0; i < kBranchFactor; ++i)
+        for (auto i = 0u; i < kBranchFactor; ++i)
         {
             if (!inner->isEmptyBranch(i))
             {
@@ -650,8 +644,9 @@ SHAMap::lowerBound(uint256 const& id) const
         else
         {
             auto inner = intr_ptr::staticPointerCast(node);
-            for (int branch = selectBranch(nodeID, id) - 1; branch >= 0; --branch)
+            for (auto branch = selectBranch(nodeID, id); branch > 0u;)
             {
+                --branch;
                 if (!inner->isEmptyBranch(branch))
                 {
                     node = descendThrow(*inner, branch);
@@ -715,7 +710,7 @@ SHAMap::delItem(uint256 const& id)
         {
             // we may have made this a node with 1 or 0 children
             // And, if so, we need to remove this branch
-            int const bc = node->getBranchCount();
+            auto const bc = node->getBranchCount();
             if (bc == 0)
             {
                 // no children below this branch
@@ -730,7 +725,7 @@ SHAMap::delItem(uint256 const& id)
 
                 if (item)
                 {
-                    for (int i = 0; i < kBranchFactor; ++i)
+                    for (auto i = 0u; i < kBranchFactor; ++i)
                     {
                         if (!node->isEmptyBranch(i))
                         {
@@ -786,7 +781,7 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr
     {
         // easy case, we end on an inner node
         auto inner = intr_ptr::staticPointerCast(node);
-        int const branch = selectBranch(nodeID, tag);
+        auto const branch = selectBranch(nodeID, tag);
         XRPL_ASSERT(
             inner->isEmptyBranch(branch), "xrpl::SHAMap::addGiveItem : inner branch is empty");
         inner->setChild(branch, makeTypedLeaf(type, std::move(item), cowid_));
@@ -802,7 +797,7 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr
 
         node = intr_ptr::makeShared(node->cowid());
 
-        unsigned int b1 = 0, b2 = 0;
+        auto b1 = 0u, b2 = 0u;
 
         while ((b1 = selectBranch(nodeID, tag)) == (b2 = selectBranch(nodeID, otherItem->key())))
         {
@@ -1012,12 +1007,12 @@ SHAMap::walkSubTree(bool doWrite, NodeObjectType t)
 
     // Stack of {parent,index,child} pointers representing
     // inner nodes we are in the process of flushing
-    using StackEntry = std::pair, int>;
+    using StackEntry = std::pair, unsigned int>;
     std::stack> stack;
 
     node = preFlushNode(std::move(node));
 
-    int pos = 0;
+    auto pos = 0u;
 
     // We can't flush an inner node until we flush its children
     while (true)
@@ -1032,7 +1027,7 @@ SHAMap::walkSubTree(bool doWrite, NodeObjectType t)
             {
                 // No need to do I/O. If the node isn't linked,
                 // it can't need to be flushed
-                int const branch = pos;
+                auto const branch = pos;
                 auto child = node->getChild(pos++);
 
                 if (child && (child->cowid() != 0))
@@ -1126,7 +1121,7 @@ SHAMap::dump(bool hash) const
         if (node->isInner())
         {
             auto inner = safeDowncast(node);
-            for (int i = 0; i < kBranchFactor; ++i)
+            for (auto i = 0u; i < kBranchFactor; ++i)
             {
                 if (!inner->isEmptyBranch(i))
                 {
diff --git a/src/libxrpl/shamap/SHAMapDelta.cpp b/src/libxrpl/shamap/SHAMapDelta.cpp
index 8336ce5481..1306fe6990 100644
--- a/src/libxrpl/shamap/SHAMapDelta.cpp
+++ b/src/libxrpl/shamap/SHAMapDelta.cpp
@@ -54,7 +54,7 @@ SHAMap::walkBranch(
         {
             // This is an inner node, add all non-empty branches
             auto inner = safeDowncast(node);
-            for (int i = 0; i < 16; ++i)
+            for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i)
             {
                 if (!inner->isEmptyBranch(i))
                     nodeStack.push({descendThrow(inner, i)});
@@ -205,7 +205,7 @@ SHAMap::compare(SHAMap const& otherMap, Delta& differences, int maxCount) const
         {
             auto ours = safeDowncast(ourNode);
             auto other = safeDowncast(otherNode);
-            for (int i = 0; i < 16; ++i)
+            for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i)
             {
                 if (ours->getChildHash(i) != other->getChildHash(i))
                 {
@@ -257,7 +257,7 @@ SHAMap::walkMap(std::vector& missingNodes, int maxMissing) co
         intr_ptr::SharedPtr const node = std::move(nodeStack.top());
         nodeStack.pop();
 
-        for (int i = 0; i < 16; ++i)
+        for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i)
         {
             if (!node->isEmptyBranch(i))
             {
@@ -286,27 +286,29 @@ SHAMap::walkMapParallel(std::vector& missingNodes, int maxMis
         return false;
 
     using StackEntry = intr_ptr::SharedPtr;
-    std::array topChildren;
+    std::array topChildren;
     {
         auto const& innerRoot = intr_ptr::staticPointerCast(root_);
-        for (int i = 0; i < 16; ++i)
+        for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i)
         {
             if (!innerRoot->isEmptyBranch(i))
                 topChildren[i] = descendNoStore(*innerRoot, i);
         }
     }
     std::vector workers;
-    workers.reserve(16);
+    workers.reserve(SHAMapInnerNode::kBranchFactor);
     std::vector exceptions;
-    exceptions.reserve(16);
+    exceptions.reserve(SHAMapInnerNode::kBranchFactor);
 
-    std::array>, 16> nodeStacks;
+    std::array>, SHAMapInnerNode::kBranchFactor>
+        nodeStacks;
 
     // This mutex is used inside the worker threads to protect `missingNodes`
     // and `maxMissing` from race conditions
     std::mutex m;
 
-    for (int rootChildIndex = 0; rootChildIndex < 16; ++rootChildIndex)
+    for (auto rootChildIndex = 0u; rootChildIndex < SHAMapInnerNode::kBranchFactor;
+         ++rootChildIndex)
     {
         auto const& child = topChildren[rootChildIndex];
         if (!child || !child->isInner())
@@ -327,7 +329,7 @@ SHAMap::walkMapParallel(std::vector& missingNodes, int maxMis
                         XRPL_ASSERT(node, "xrpl::SHAMap::walkMapParallel : non-null node");
                         nodeStack.pop();
 
-                        for (int i = 0; i < 16; ++i)
+                        for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i)
                         {
                             if (node->isEmptyBranch(i))
                                 continue;
diff --git a/src/libxrpl/shamap/SHAMapInnerNode.cpp b/src/libxrpl/shamap/SHAMapInnerNode.cpp
index 74a0e4515f..bdd89388b2 100644
--- a/src/libxrpl/shamap/SHAMapInnerNode.cpp
+++ b/src/libxrpl/shamap/SHAMapInnerNode.cpp
@@ -63,8 +63,8 @@ SHAMapInnerNode::resizeChildArrays(std::uint8_t toAllocate)
     hashesAndChildren_ = TaggedPointer(std::move(hashesAndChildren_), isBranch_, toAllocate);
 }
 
-std::optional
-SHAMapInnerNode::getChildIndex(int i) const
+std::optional
+SHAMapInnerNode::getChildIndex(unsigned int i) const
 {
     return hashesAndChildren_.getChildIndex(isBranch_, i);
 }
@@ -89,7 +89,7 @@ SHAMapInnerNode::clone(std::uint32_t cowid) const
 
     if (thisIsSparse)
     {
-        int cloneChildIndex = 0;
+        auto cloneChildIndex = 0u;
         iterNonEmptyChildIndexes([&](auto branchNum, auto indexNum) {
             cloneHashes[cloneChildIndex++] = thisHashes[indexNum];
         });
@@ -105,7 +105,7 @@ SHAMapInnerNode::clone(std::uint32_t cowid) const
 
     if (thisIsSparse)
     {
-        int cloneChildIndex = 0;
+        auto cloneChildIndex = 0u;
         iterNonEmptyChildIndexes([&](auto branchNum, auto indexNum) {
             cloneChildren[cloneChildIndex++] = thisChildren[indexNum];
         });
@@ -133,12 +133,12 @@ SHAMapInnerNode::makeFullInner(Slice data, SHAMapHash const& hash, bool hashVali
 
     auto hashes = ret->hashesAndChildren_.getHashes();
 
-    for (int i = 0; i < kBranchFactor; ++i)
+    for (auto i = 0u; i < kBranchFactor; ++i)
     {
         hashes[i].asUInt256() = si.getBitString<256>();
 
         if (hashes[i].isNonZero())
-            ret->isBranch_ |= (1 << i);
+            ret->isBranch_ |= (1u << i);
     }
 
     ret->resizeChildArrays(ret->getBranchCount());
@@ -182,7 +182,7 @@ SHAMapInnerNode::makeCompressedInner(Slice data)
         hashes[pos].asUInt256() = hash;
 
         if (hashes[pos].isNonZero())
-            ret->isBranch_ |= (1 << pos);
+            ret->isBranch_ |= (1u << pos);
     }
 
     ret->resizeChildArrays(ret->getBranchCount());
@@ -267,20 +267,19 @@ SHAMapInnerNode::getString(SHAMapNodeID const& id) const
 
 // We are modifying an inner node
 void
-SHAMapInnerNode::setChild(int m, SHAMapTreeNodePtr child)
+SHAMapInnerNode::setChild(unsigned int branch, SHAMapTreeNodePtr child)
 {
-    XRPL_ASSERT(
-        (m >= 0) && (m < kBranchFactor), "xrpl::SHAMapInnerNode::setChild : valid branch input");
+    XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::setChild : valid branch input");
     XRPL_ASSERT(cowid_, "xrpl::SHAMapInnerNode::setChild : nonzero cowid");
     XRPL_ASSERT(child.get() != this, "xrpl::SHAMapInnerNode::setChild : valid child input");
 
     auto const dstIsBranch = [&] {
         if (child)
         {
-            return isBranch_ | (1u << m);
+            return isBranch_ | (1u << branch);
         }
 
-        return isBranch_ & ~(1u << m);
+        return isBranch_ & ~(1u << branch);
     }();
 
     auto const dstToAllocate = popcnt16(dstIsBranch);
@@ -293,8 +292,8 @@ SHAMapInnerNode::setChild(int m, SHAMapTreeNodePtr child)
 
     if (child)
     {
-        auto const childIndex =
-            *getChildIndex(m);  // NOLINT(bugprone-unchecked-optional-access) isBranch_ set above
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access) isBranch_ set above
+        auto const childIndex = *getChildIndex(branch);
         auto [_, hashes, children] = hashesAndChildren_.getHashesAndChildren();
         hashes[childIndex].zero();
         children[childIndex] = std::move(child);
@@ -309,25 +308,24 @@ SHAMapInnerNode::setChild(int m, SHAMapTreeNodePtr child)
 
 // finished modifying, now make shareable
 void
-SHAMapInnerNode::shareChild(int m, SHAMapTreeNodePtr const& child)
+SHAMapInnerNode::shareChild(unsigned int branch, SHAMapTreeNodePtr const& child)
 {
-    XRPL_ASSERT(
-        (m >= 0) && (m < kBranchFactor), "xrpl::SHAMapInnerNode::shareChild : valid branch input");
+    XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::shareChild : valid branch input");
     XRPL_ASSERT(cowid_, "xrpl::SHAMapInnerNode::shareChild : nonzero cowid");
     XRPL_ASSERT(child, "xrpl::SHAMapInnerNode::shareChild : non-null child input");
     XRPL_ASSERT(child.get() != this, "xrpl::SHAMapInnerNode::shareChild : valid child input");
 
-    XRPL_ASSERT(!isEmptyBranch(m), "xrpl::SHAMapInnerNode::shareChild : non-empty branch input");
+    XRPL_ASSERT(
+        !isEmptyBranch(branch), "xrpl::SHAMapInnerNode::shareChild : non-empty branch input");
     // NOLINTNEXTLINE(bugprone-unchecked-optional-access) assert above
-    hashesAndChildren_.getChildren()[*getChildIndex(m)] = child;
+    hashesAndChildren_.getChildren()[*getChildIndex(branch)] = child;
 }
 
 SHAMapTreeNode*
-SHAMapInnerNode::getChildPointer(int branch)
+SHAMapInnerNode::getChildPointer(unsigned int branch)
 {
     XRPL_ASSERT(
-        branch >= 0 && branch < kBranchFactor,
-        "xrpl::SHAMapInnerNode::getChildPointer : valid branch input");
+        branch < kBranchFactor, "xrpl::SHAMapInnerNode::getChildPointer : valid branch input");
     XRPL_ASSERT(
         !isEmptyBranch(branch), "xrpl::SHAMapInnerNode::getChildPointer : non-empty branch input");
 
@@ -340,11 +338,9 @@ SHAMapInnerNode::getChildPointer(int branch)
 }
 
 SHAMapTreeNodePtr
-SHAMapInnerNode::getChild(int branch)
+SHAMapInnerNode::getChild(unsigned int branch)
 {
-    XRPL_ASSERT(
-        branch >= 0 && branch < kBranchFactor,
-        "xrpl::SHAMapInnerNode::getChild : valid branch input");
+    XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::getChild : valid branch input");
     XRPL_ASSERT(!isEmptyBranch(branch), "xrpl::SHAMapInnerNode::getChild : non-empty branch input");
 
     auto const index =
@@ -356,23 +352,20 @@ SHAMapInnerNode::getChild(int branch)
 }
 
 SHAMapHash const&
-SHAMapInnerNode::getChildHash(int m) const
+SHAMapInnerNode::getChildHash(unsigned int branch) const
 {
-    XRPL_ASSERT(
-        (m >= 0) && (m < kBranchFactor),
-        "xrpl::SHAMapInnerNode::getChildHash : valid branch input");
-    if (auto const i = getChildIndex(m))
+    XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::getChildHash : valid branch input");
+    if (auto const i = getChildIndex(branch))
         return hashesAndChildren_.getHashes()[*i];
 
     return kZeroShaMapHash;
 }
 
 SHAMapTreeNodePtr
-SHAMapInnerNode::canonicalizeChild(int branch, SHAMapTreeNodePtr node)
+SHAMapInnerNode::canonicalizeChild(unsigned int branch, SHAMapTreeNodePtr node)
 {
     XRPL_ASSERT(
-        branch >= 0 && branch < kBranchFactor,
-        "xrpl::SHAMapInnerNode::canonicalizeChild : valid branch input");
+        branch < kBranchFactor, "xrpl::SHAMapInnerNode::canonicalizeChild : valid branch input");
     XRPL_ASSERT(node != nullptr, "xrpl::SHAMapInnerNode::canonicalizeChild : valid node input");
     XRPL_ASSERT(
         !isEmptyBranch(branch),
@@ -410,7 +403,7 @@ SHAMapInnerNode::invariants(bool isRoot) const
     if (numAllocated != kBranchFactor)
     {
         auto const branchCount = getBranchCount();
-        for (int i = 0; i < branchCount; ++i)
+        for (auto i = 0u; i < branchCount; ++i)
         {
             XRPL_ASSERT(
                 hashes[i].isNonZero(),
@@ -422,12 +415,12 @@ SHAMapInnerNode::invariants(bool isRoot) const
     }
     else
     {
-        for (int i = 0; i < kBranchFactor; ++i)
+        for (auto i = 0u; i < kBranchFactor; ++i)
         {
             if (hashes[i].isNonZero())
             {
                 XRPL_ASSERT(
-                    (isBranch_ & (1 << i)),
+                    (isBranch_ & (1u << i)),
                     "xrpl::SHAMapInnerNode::invariants : valid branch when "
                     "nonzero hash");
                 if (children[i] != nullptr)
@@ -437,7 +430,7 @@ SHAMapInnerNode::invariants(bool isRoot) const
             else
             {
                 XRPL_ASSERT(
-                    (isBranch_ & (1 << i)) == 0,
+                    (isBranch_ & (1u << i)) == 0u,
                     "xrpl::SHAMapInnerNode::invariants : valid branch when "
                     "zero hash");
             }
diff --git a/src/libxrpl/shamap/SHAMapNodeID.cpp b/src/libxrpl/shamap/SHAMapNodeID.cpp
index a511fc038c..8fd7afe8fc 100644
--- a/src/libxrpl/shamap/SHAMapNodeID.cpp
+++ b/src/libxrpl/shamap/SHAMapNodeID.cpp
@@ -16,7 +16,7 @@ namespace xrpl {
 static uint256 const&
 depthMask(unsigned int depth)
 {
-    static constexpr auto kMaskSize = 65;
+    static constexpr auto kMaskSize = SHAMap::kLeafDepth + 1;
 
     struct MasksT
     {
@@ -25,7 +25,7 @@ depthMask(unsigned int depth)
         MasksT()
         {
             uint256 selector;
-            for (int i = 0; i < kMaskSize - 1; i += 2)
+            for (auto i = 0u; i < kMaskSize - 1; i += 2)
             {
                 entry[i] = selector;
                 *(selector.begin() + (i / 2)) = 0xF0;
@@ -46,8 +46,7 @@ SHAMapNodeID::SHAMapNodeID(unsigned int depth, uint256 const& hash) : id_(hash),
     XRPL_ASSERT(
         depth <= SHAMap::kLeafDepth, "xrpl::SHAMapNodeID::SHAMapNodeID : maximum depth input");
     XRPL_ASSERT(
-        id_ == (id_ & depthMask(depth)),
-        "xrpl::SHAMapNodeID::SHAMapNodeID : hash and depth inputs do match");
+        isPrefixOf(id_), "xrpl::SHAMapNodeID::SHAMapNodeID : hash and depth inputs do match");
 }
 
 std::string
@@ -60,10 +59,10 @@ SHAMapNodeID::getRawString() const
 }
 
 SHAMapNodeID
-SHAMapNodeID::getChildNodeID(unsigned int m) const
+SHAMapNodeID::getChildNodeID(unsigned int branch) const
 {
     XRPL_ASSERT(
-        m < SHAMap::kBranchFactor, "xrpl::SHAMapNodeID::getChildNodeID : valid branch input");
+        branch < SHAMap::kBranchFactor, "xrpl::SHAMapNodeID::getChildNodeID : valid branch input");
 
     // A SHAMap has exactly 65 levels, so nodes must not exceed that
     // depth; if they do, this breaks the invariant of never allowing
@@ -79,14 +78,20 @@ SHAMapNodeID::getChildNodeID(unsigned int m) const
     if (depth_ >= SHAMap::kLeafDepth)
         Throw("Request for child node ID of " + to_string(*this));
 
-    if (id_ != (id_ & depthMask(depth_)))
+    if (!isPrefixOf(id_))
         Throw("Incorrect mask for " + to_string(*this));
 
     SHAMapNodeID node{depth_ + 1, id_};
-    node.id_.begin()[depth_ / 2] |= ((depth_ & 1) != 0u) ? m : (m << 4);
+    node.id_.begin()[depth_ / 2] |= ((depth_ & 1) != 0u) ? branch : (branch << 4);
     return node;
 }
 
+bool
+SHAMapNodeID::isPrefixOf(uint256 const& key) const
+{
+    return (key & depthMask(depth_)) == id_;
+}
+
 [[nodiscard]] std::optional
 deserializeSHAMapNodeID(void const* data, std::size_t size)
 {
@@ -127,10 +132,9 @@ selectBranch(SHAMapNodeID const& id, uint256 const& hash)
 }
 
 SHAMapNodeID
-SHAMapNodeID::createID(int depth, uint256 const& key)
+SHAMapNodeID::createID(unsigned int depth, uint256 const& key)
 {
-    XRPL_ASSERT(
-        depth >= 0 && depth <= SHAMap::kLeafDepth, "xrpl::SHAMapNodeID::createID : valid depth");
+    XRPL_ASSERT(depth <= SHAMap::kLeafDepth, "xrpl::SHAMapNodeID::createID : valid depth");
     return SHAMapNodeID(depth, key & depthMask(depth));
 }
 
diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp
index cbed6885c9..a12e524a5f 100644
--- a/src/libxrpl/shamap/SHAMapSync.cpp
+++ b/src/libxrpl/shamap/SHAMapSync.cpp
@@ -54,15 +54,15 @@ SHAMap::visitNodes(std::function const& function) const
     if (!root_->isInner())
         return;
 
-    using StackEntry = std::pair>;
+    using StackEntry = std::pair>;
     std::stack> stack;
 
     auto node = intr_ptr::staticPointerCast(root_);
-    int pos = 0;
+    auto pos = 0u;
 
     while (true)
     {
-        while (pos < 16)
+        while (pos < kBranchFactor)
         {
             if (!node->isEmptyBranch(pos))
             {
@@ -77,10 +77,10 @@ SHAMap::visitNodes(std::function const& function) const
                 else
                 {
                     // If there are no more children, don't push this node
-                    while ((pos != 15) && (node->isEmptyBranch(pos + 1)))
+                    while ((pos != kBranchFactor - 1u) && (node->isEmptyBranch(pos + 1)))
                         ++pos;
 
-                    if (pos != 15)
+                    if (pos != kBranchFactor - 1u)
                     {
                         // save next position to resume at
                         stack.emplace(pos + 1, std::move(node));
@@ -144,7 +144,7 @@ SHAMap::visitDifferences(
             return;
 
         // 2) push non-matching child inner nodes
-        for (int i = 0; i < 16; ++i)
+        for (auto i = 0u; i < kBranchFactor; ++i)
         {
             if (!node->isEmptyBranch(i))
             {
@@ -176,13 +176,13 @@ SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se)
 {
     SHAMapInnerNode*& node = std::get<0>(se);
     SHAMapNodeID& nodeID = std::get<1>(se);
-    int& firstChild = std::get<2>(se);
-    int& currentChild = std::get<3>(se);
+    auto& firstChild = std::get<2>(se);
+    auto& currentChild = std::get<3>(se);
     bool& fullBelow = std::get<4>(se);
 
-    while (currentChild < 16)
+    while (currentChild < kBranchFactor)
     {
-        int const branch = (firstChild + currentChild++) % 16;
+        auto const branch = (firstChild + currentChild++) % kBranchFactor;
         if (node->isEmptyBranch(branch))
             continue;
 
@@ -262,7 +262,7 @@ SHAMap::gmnProcessDeferredReads(MissingNodes& mn)
     int complete = 0;
     while (complete != mn.deferred)
     {
-        std::tuple deferredNode;
+        MissingNodes::DeferredNode deferredNode;
         {
             std::unique_lock lock{mn.deferLock};
 
@@ -423,7 +423,7 @@ SHAMap::getNodeFat(
 
     while ((node != nullptr) && node->isInner() && (nodeID.getDepth() < wanted.getDepth()))
     {
-        int const branch = selectBranch(nodeID, wanted.getNodeID());
+        auto const branch = selectBranch(nodeID, wanted.getNodeID());
         auto inner = safeDowncast(node);
         if (inner->isEmptyBranch(branch))
             return false;
@@ -444,7 +444,7 @@ SHAMap::getNodeFat(
         return false;
     }
 
-    std::stack> stack;
+    std::stack> stack;
     stack.emplace(node, nodeID, depth);
 
     Serializer s(8192);
@@ -464,12 +464,12 @@ SHAMap::getNodeFat(
             // We descend inner nodes with only a single child
             // without decrementing the depth
             auto inner = safeDowncast(node);
-            int const bc = inner->getBranchCount();
+            auto const bc = inner->getBranchCount();
 
             if ((depth > 0) || (bc == 1))
             {
                 // We need to process this node's children
-                for (int i = 0; i < 16; ++i)
+                for (auto i = 0u; i < kBranchFactor; ++i)
                 {
                     if (!inner->isEmptyBranch(i))
                     {
@@ -555,10 +555,9 @@ SHAMap::addKnownNode(
 {
     XRPL_ASSERT(!nodeID.isRoot(), "xrpl::SHAMap::addKnownNode : valid node");
     XRPL_ASSERT(treeNode, "xrpl::SHAMap::addKnownNode : non-null tree node");
-    XRPL_ASSERT(
-        !treeNode->isLeaf() ||
-            SHAMapNodeID::createID(nodeID.getDepth(), leafKey(*treeNode)).getNodeID() ==
-                nodeID.getNodeID(),
+    XRPL_ASSERT_IF(
+        treeNode->isLeaf(),
+        nodeID.isPrefixOf(leafKey(*treeNode)),
         "xrpl::SHAMap::addKnownNode : leaf position consistent with node ID");
 
     if (!isSynching())
@@ -575,8 +574,7 @@ SHAMap::addKnownNode(
            !safeDowncast(currNode)->isFullBelow(generation) &&
            (currNodeID.getDepth() < nodeID.getDepth()))
     {
-        int const branch = selectBranch(currNodeID, nodeID.getNodeID());
-        XRPL_ASSERT(branch >= 0, "xrpl::SHAMap::addKnownNode : valid branch");
+        auto const branch = selectBranch(currNodeID, nodeID.getNodeID());
         auto inner = safeDowncast(currNode);
         if (inner->isEmptyBranch(branch))
         {
@@ -686,7 +684,7 @@ SHAMap::deepCompare(SHAMap& other) const
                 return false;
             auto nodeInner = safeDowncast(node);
             auto otherInner = safeDowncast(otherNode);
-            for (int i = 0; i < 16; ++i)
+            for (auto i = 0u; i < kBranchFactor; ++i)
             {
                 if (nodeInner->isEmptyBranch(i))
                 {
@@ -725,7 +723,7 @@ SHAMap::hasInnerNode(SHAMapNodeID const& targetNodeID, SHAMapHash const& targetN
 
     while (node->isInner() && (nodeID.getDepth() < targetNodeID.getDepth()))
     {
-        int const branch = selectBranch(nodeID, targetNodeID.getNodeID());
+        auto const branch = selectBranch(nodeID, targetNodeID.getNodeID());
         auto inner = safeDowncast(node);
         if (inner->isEmptyBranch(branch))
             return false;
@@ -751,7 +749,20 @@ SHAMap::hasLeafNode(uint256 const& tag, SHAMapHash const& targetNodeHash) const
 
     do
     {
-        int const branch = selectBranch(nodeID, tag);
+        // 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.
+        if (nodeID.getDepth() >= kLeafDepth)
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE("xrpl::SHAMap::hasLeafNode : inner node at leaf depth");
+            return false;
+            // LCOV_EXCL_STOP
+        }
+
+        auto const branch = selectBranch(nodeID, tag);
         auto inner = safeDowncast(node);
         if (inner->isEmptyBranch(branch))
             return false;  // Dead end, node must not be here
@@ -803,7 +814,7 @@ SHAMap::getProofPath(uint256 const& key) const
 bool
 SHAMap::verifyProofPath(uint256 const& rootHash, uint256 const& key, std::vector const& path)
 {
-    if (path.empty() || path.size() > 65)
+    if (path.empty() || path.size() > kLeafDepth + 1u)
         return false;
 
     SHAMapHash hash{rootHash};
@@ -819,10 +830,10 @@ SHAMap::verifyProofPath(uint256 const& rootHash, uint256 const& key, std::vector
             if (node->getHash() != hash)
                 return false;
 
-            auto depth = std::distance(path.rbegin(), rit);
+            auto const depth = std::distance(path.rbegin(), rit);
             if (node->isInner())
             {
-                auto nodeId = SHAMapNodeID::createID(depth, key);
+                auto nodeId = SHAMapNodeID::createID(static_cast(depth), key);
                 hash = safeDowncast(node.get())
                            ->getChildHash(selectBranch(nodeId, key));
             }
diff --git a/src/libxrpl/tx/ApplyContext.cpp b/src/libxrpl/tx/ApplyContext.cpp
index 5e5ab90441..50f46fceef 100644
--- a/src/libxrpl/tx/ApplyContext.cpp
+++ b/src/libxrpl/tx/ApplyContext.cpp
@@ -1,27 +1,19 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
-#include 
 
-#include 
-#include 
 #include 
-#include 
 #include 
 #include 
-#include 
-#include 
 
 namespace xrpl {
 
@@ -75,75 +67,4 @@ ApplyContext::visit(
     view_->visit(base_, func);  // NOLINT(bugprone-unchecked-optional-access)
 }
 
-TER
-ApplyContext::failInvariantCheck(TER const result)
-{
-    // If we already failed invariant checks before and we are now attempting to
-    // only charge a fee, and even that fails the invariant checks something is
-    // very wrong. We switch to tefINVARIANT_FAILED, which does NOT get included
-    // in a ledger.
-
-    return (result == tecINVARIANT_FAILED || result == tefINVARIANT_FAILED)
-        ? TER{tefINVARIANT_FAILED}
-        : TER{tecINVARIANT_FAILED};
-}
-
-template 
-TER
-ApplyContext::checkInvariantsHelper(
-    TER const result,
-    XRPAmount const fee,
-    std::index_sequence)
-{
-    try
-    {
-        auto checkers = getInvariantChecks();
-
-        // call each check's per-entry method
-        visit(
-            [&checkers](
-                uint256 const& index, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
-                (..., std::get(checkers).visitEntry(isDelete, before, after));
-            });
-
-        // Note: do not replace this logic with a `...&&` fold expression.
-        // The fold expression will only run until the first check fails (it
-        // short-circuits). While the logic is still correct, the log
-        // message won't be. Every failed invariant should write to the log,
-        // not just the first one.
-        std::array const finalizers{{std::get(checkers).finalize(
-            tx, result, fee, *view_, journal)...}};  // NOLINT(bugprone-unchecked-optional-access)
-
-        // call each check's finalizer to see that it passes
-        if (!std::ranges::all_of(finalizers, [](auto const& b) { return b; }))
-        {
-            JLOG(journal.fatal()) << "Transaction has failed one or more global invariants: "
-                                  << to_string(tx.getJson(JsonOptions::Values::None));
-
-            return failInvariantCheck(result);
-        }
-    }
-    catch (std::exception const& ex)
-    {
-        JLOG(journal.fatal()) << "Transaction caused an exception in a global invariant"
-                              << ", ex: " << ex.what()
-                              << ", tx: " << to_string(tx.getJson(JsonOptions::Values::None));
-
-        return failInvariantCheck(result);
-    }
-
-    return result;
-}
-
-TER
-ApplyContext::checkInvariants(TER const result, XRPAmount const fee)
-{
-    XRPL_ASSERT(
-        isTesSuccess(result) || isTecClaim(result),
-        "xrpl::ApplyContext::checkInvariants : is tesSUCCESS or tecCLAIM");
-
-    return checkInvariantsHelper(
-        result, fee, std::make_index_sequence>{});
-}
-
 }  // namespace xrpl
diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp
index 5fc6942e20..6bf99e567d 100644
--- a/src/libxrpl/tx/Transactor.cpp
+++ b/src/libxrpl/tx/Transactor.cpp
@@ -41,11 +41,12 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
 #include 
-#include 
+#include 
 #include 
 #include 
 #include 
@@ -1539,53 +1540,12 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee)
 }
 
 [[nodiscard]] TER
-Transactor::checkTransactionInvariants(TER result, XRPAmount fee)
+Transactor::checkInvariants(TER result, XRPAmount fee, InvariantScope scope)
 {
-    try
-    {
-        // Phase 1: visit modified entries
-        ctx_.visit(
-            [this](uint256 const&, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
-                this->visitInvariantEntry(isDelete, before, after);
-            });
+    if (scope == InvariantScope::Full)
+        return xrpl::checkInvariants(ctx_, result, fee, *this);
 
-        // Phase 2: finalize
-        if (!this->finalizeInvariants(ctx_.tx, result, fee, ctx_.view(), ctx_.journal))
-        {
-            JLOG(ctx_.journal.fatal()) <<                                             //
-                "Transaction has failed one or more transaction invariants, tx: " <<  //
-                to_string(ctx_.tx.getJson(JsonOptions::Values::None));
-            return tecINVARIANT_FAILED;
-        }
-    }
-    catch (std::exception const& ex)
-    {
-        JLOG(ctx_.journal.fatal()) <<                               //
-            "Exception while checking transaction invariants: " <<  //
-            ex.what() <<                                            //
-            ", tx: " <<                                             //
-            to_string(ctx_.tx.getJson(JsonOptions::Values::None));
-
-        return tecINVARIANT_FAILED;
-    }
-
-    return result;
-}
-
-[[nodiscard]] TER
-Transactor::checkInvariants(TER result, XRPAmount fee)
-{
-    /*
-     * DISABLED for 3.2.0 — Must be re-introduced for 3.3.0
-     *
-     * Transaction invariants are disabled due to a performance regression:
-     * the two-pass design (transaction-specific invariants + protocol invariants)
-     * iterates over modified ledger entries twice per transaction.
-     *
-     * Until resolved, only protocol invariants are checked (delegated to ctx_).
-     * This is safe because all transaction invariants in 3.2.0 are  no-ops.
-     */
-    return ctx_.checkInvariants(result, fee);
+    return xrpl::checkInvariants(ctx_, result, fee);
 }
 
 //------------------------------------------------------------------------------
@@ -1637,85 +1597,97 @@ Transactor::operator()()
     if (auto stream = j_.trace())
         stream << "preclaim result: " << transToken(result);
 
-    bool applied = isTesSuccess(result);
     auto fee = ctx_.tx.getFieldAmount(sfFee).xrp();
+    bool const canApply = std::invoke([&result, &fee, this] {
+        bool canApplyTmp = isTesSuccess(result);
 
-    if (ctx_.size() > kOversizeMetaDataCap)
-        result = tecOVERSIZE;
+        if (ctx_.size() > kOversizeMetaDataCap)
+            result = tecOVERSIZE;
 
-    if (isTecClaim(result) && ((view().flags() & TapFailHard) != 0u))
-    {
-        // If the TapFailHard flag is set, a tec result
-        // must not do anything
-        ctx_.discard();
-        applied = false;
-    }
-    else if (
-        (result == tecOVERSIZE) || (result == tecKILLED) || (result == tecINCOMPLETE) ||
-        (result == tecEXPIRED) || (isTecClaimHardFail(result, view().flags())))
-    {
-        std::tie(result, fee, applied) = processPersistentChanges(result, fee);
-    }
-
-    if (applied)
-    {
-        // Check invariants: if `tecINVARIANT_FAILED` is not returned, we can
-        // proceed to apply the tx
-        result = checkInvariants(result, fee);
-        if (result == tecINVARIANT_FAILED)
+        if (isTecClaim(result) && ((view().flags() & TapFailHard) != 0u))
         {
-            // Reset to fee-claim only
-            auto const resetResult = reset(fee);
-            if (!isTesSuccess(resetResult.first))
-                result = resetResult.first;
-
-            fee = resetResult.second;
-
-            // Check invariants again to ensure the fee claiming doesn't violate
-            // invariants. After reset, only protocol invariants are re-checked.
-            // Transaction invariants are not meaningful here — the transaction's
-            // effects have been rolled back.
-            if (isTesSuccess(result) || isTecClaim(result))
-                result = ctx_.checkInvariants(result, fee);
+            // If the TapFailHard flag is set, a tec result
+            // must not do anything
+            ctx_.discard();
+            canApplyTmp = false;
         }
+        else if (
+            (result == tecOVERSIZE) || (result == tecKILLED) || (result == tecINCOMPLETE) ||
+            (result == tecEXPIRED) || (isTecClaimHardFail(result, view().flags())))
+        {
+            // This is and must remain the only place where `canApplyTmp` can change from false to
+            // true. Changing from true to false is no problem.
+            std::tie(result, fee, canApplyTmp) = processPersistentChanges(result, fee);
+        }
+        return canApplyTmp;
+    });
 
-        // We ran through the invariant checker, which can, in some cases,
-        // return a tef error code. Don't apply the transaction in that case.
-        if (!isTecClaim(result) && !isTesSuccess(result))
-            applied = false;
+    auto const logger = [this](
+                            TER result,
+                            bool canApply,
+                            std::optional&& metadata = std::nullopt) -> ApplyResult {
+        JLOG(j_.trace()) << (canApply ? "applied " : "not applied ") << transToken(result);
+        return {result, canApply, std::move(metadata)};
+    };
+
+    if (!canApply)
+        return logger(result, canApply);
+
+    // First invariant pass: both protocol and transaction-specific
+    // checks run against the transaction's tentative outcome. If it
+    // does not return tecINVARIANT_FAILED, we can proceed to apply the
+    // tx.
+    result = checkInvariants(result, fee, InvariantScope::Full);
+    if (result == tecINVARIANT_FAILED)
+    {
+        // Fee-claim reset: roll the transaction's effects back so that
+        // only the fee deduction remains. This is the reset referenced
+        // by InvariantScope::ProtocolOnly.
+        auto const resetResult = reset(fee);
+        if (!isTesSuccess(resetResult.first))
+            result = resetResult.first;
+
+        fee = resetResult.second;
+
+        // Re-check invariants against the post-reset (fee-claim only)
+        // state. The transaction's effects are gone, so the
+        // transaction-specific invariants no longer apply and only the
+        // protocol invariants are re-run. A failure here escalates to
+        // tefINVARIANT_FAILED and excludes the tx from the ledger.
+        if (isTesSuccess(result) || isTecClaim(result))
+            result = checkInvariants(result, fee, InvariantScope::ProtocolOnly);
     }
 
+    // We ran through the invariant checker, which can, in some cases,
+    // return a tef error code. Don't apply the transaction in that case.
+    if (!isTecClaim(result) && !isTesSuccess(result))
+        return logger(result, false);
+
     std::optional metadata;
-    if (applied)
-    {
-        // Transaction succeeded fully or (retries are not allowed and the
-        // transaction could claim a fee)
 
-        // The transactor and invariant checkers guarantee that this will
-        // *never* trigger but if it, somehow, happens, don't allow a tx
-        // that charges a negative fee.
-        if (fee < beast::kZero)
-            Throw("fee charged is negative!");
+    // Transaction succeeded fully or (retries are not allowed and the
+    // transaction could claim a fee)
 
-        // Charge whatever fee they specified. The fee has already been
-        // deducted from the balance of the account that issued the
-        // transaction. We just need to account for it in the ledger
-        // header.
-        if (!view().open() && fee != beast::kZero)
-            ctx_.destroyXRP(fee);
+    // The transactor and invariant checkers guarantee that this will
+    // *never* trigger but if it, somehow, happens, don't allow a tx
+    // that charges a negative fee.
+    if (fee < beast::kZero)
+        Throw("fee charged is negative!");
 
-        // Once we call apply, we will no longer be able to look at view()
-        metadata = ctx_.apply(result);
-    }
+    // Charge whatever fee they specified. The fee has already been
+    // deducted from the balance of the account that issued the
+    // transaction. We just need to account for it in the ledger
+    // header.
+    if (!view().open() && fee != beast::kZero)
+        ctx_.destroyXRP(fee);
+
+    // Once we call apply, we will no longer be able to look at view()
+    metadata = ctx_.apply(result);
 
     if ((ctx_.flags() & TapDryRun) != 0u)
-    {
-        applied = false;
-    }
+        return logger(result, false, std::move(metadata));
 
-    JLOG(j_.trace()) << (applied ? "applied " : "not applied ") << transToken(result);
-
-    return {result, applied, metadata};
+    return logger(result, canApply, std::move(metadata));
 }
 
 }  // namespace xrpl
diff --git a/src/libxrpl/tx/invariants/FreezeInvariant.cpp b/src/libxrpl/tx/invariants/FreezeInvariant.cpp
index 0a604d4c39..d6039eabd8 100644
--- a/src/libxrpl/tx/invariants/FreezeInvariant.cpp
+++ b/src/libxrpl/tx/invariants/FreezeInvariant.cpp
@@ -4,7 +4,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -17,6 +19,7 @@
 #include 
 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -73,6 +76,21 @@ TransfersNotFrozen::finalize(
      *           view.rules().enabled(fixFreezeExploit);
      */
     [[maybe_unused]] bool const enforce = view.rules().enabled(featureDeepFreeze);
+    bool const fixOverrideFreeze = view.rules().enabled(fixCleanup3_4_0);
+
+    /*
+     * XLS-0066: a broker must be able to default an already-late loan
+     * regardless of the vault asset's freeze state. LoanManage::defaultLoan
+     * moves First-Loss Capital from the broker to the vault pseudo-account via
+     * accountSend, which transits through the issuer in two hops (see
+     * getLoanDefaultFreezeExemptAccounts), so a frozen issuer would otherwise
+     * trip this invariant on either hop. Gated behind fixCleanup3_4_0, and
+     * scoped to exactly the issuer/broker and issuer/vault lines involved for
+     * the vault's own currency, so ledgers without the amendment (or an
+     * unrelated frozen currency/line touched by the same transaction) keep
+     * the current (blocking) behavior.
+     */
+    auto const loanDefaultAccounts = getLoanDefaultFreezeExemptAccounts(view, tx);
 
     return std::ranges::all_of(balanceChanges_, [&](auto const& entry) {
         auto const& [issue, changes] = entry;
@@ -90,7 +108,8 @@ TransfersNotFrozen::finalize(
             return !enforce;
         }
 
-        return validateIssuerChanges(issuerSle, changes, tx, j, enforce);
+        return validateIssuerChanges(
+            issuerSle, changes, tx, j, enforce, fixOverrideFreeze, loanDefaultAccounts);
     });
 }
 
@@ -199,7 +218,9 @@ TransfersNotFrozen::validateIssuerChanges(
     IssuerChanges const& changes,
     STTx const& tx,
     beast::Journal const& j,
-    bool enforce)
+    bool enforce,
+    bool fixOverrideFreeze,
+    std::optional const& loanDefaultAccounts)
 {
     if (!issuer)
     {
@@ -225,7 +246,15 @@ TransfersNotFrozen::validateIssuerChanges(
         {
             bool const high = change.line->at(sfLowLimit).getIssuer() == issuer->at(sfAccount);
 
-            if (!validateFrozenState(change, high, tx, j, enforce, globalFreeze))
+            if (!validateFrozenState(
+                    change,
+                    high,
+                    tx,
+                    j,
+                    enforce,
+                    globalFreeze,
+                    fixOverrideFreeze,
+                    loanDefaultAccounts))
             {
                 return false;
             }
@@ -241,29 +270,60 @@ TransfersNotFrozen::validateFrozenState(
     STTx const& tx,
     beast::Journal const& j,
     bool enforce,
-    bool globalFreeze)
+    bool globalFreeze,
+    bool fixOverrideFreeze,
+    std::optional const& loanDefaultAccounts)
 {
     bool const freeze =
         change.balanceChangeSign < 0 && change.line->isFlag(high ? lsfLowFreeze : lsfHighFreeze);
     bool const deepFreeze = change.line->isFlag(high ? lsfLowDeepFreeze : lsfHighDeepFreeze);
     bool const frozen = globalFreeze || deepFreeze || freeze;
 
-    bool const isAMMLine = change.line->isFlag(lsfAMMNode);
-
     if (!frozen)
     {
         return true;
     }
 
-    // AMMClawbacks are allowed to override some freeze rules
-    if ((!isAMMLine || globalFreeze) && hasPrivilege(tx, OverrideFreeze))
+    // Pre-fixCleanup3_4_0: the isAMMLine check incorrectly blocked clawback on
+    // 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))
     {
         JLOG(j.debug()) << "Invariant check allowing funds to be moved "
                         << (change.balanceChangeSign > 0 ? "to" : "from")
-                        << " a frozen trustline for AMMClawback " << tx.getTransactionID();
+                        << " a frozen trustline for a freeze privileged transaction "
+                        << tx.getTransactionID();
         return true;
     }
 
+    // XLS-0066: LoanManage::defaultLoan's transfer is exempt from freeze (see
+    // finalize()). Since neither the broker nor vault pseudo-account is the
+    // asset's issuer, accountSend routes it as two hops through the issuer
+    // (broker -> issuer, issuer -> vault), so both the issuer/broker and
+    // issuer/vault lines are exempt -- but only for the vault's own currency,
+    // so an unrelated frozen line (a different currency, or one touched by
+    // the same transaction for some other reason) is still caught.
+    if (loanDefaultAccounts && loanDefaultAccounts->asset.holds() &&
+        loanDefaultAccounts->asset.get().currency ==
+            change.line->at(sfBalance).get().currency)
+    {
+        AccountID const lowAcct = change.line->at(sfLowLimit).getIssuer();
+        AccountID const highAcct = change.line->at(sfHighLimit).getIssuer();
+        auto const& accts = *loanDefaultAccounts;
+        auto const isPair = [&](AccountID const& a, AccountID const& b) {
+            return (lowAcct == a && highAcct == b) || (lowAcct == b && highAcct == a);
+        };
+        if (isPair(accts.issuer, accts.broker) || isPair(accts.issuer, accts.vault))
+        {
+            JLOG(j.debug()) << "Invariant check allowing funds to be moved "
+                            << (change.balanceChangeSign > 0 ? "to" : "from")
+                            << " a frozen trustline for LoanManage default "
+                            << tx.getTransactionID();
+            return true;
+        }
+    }
+
     JLOG(j.fatal()) << "Invariant failed: Attempting to move frozen funds for "
                     << tx.getTransactionID();
     // The comment above starting with "assert(enforce)" explains this assert.
diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp
index 9b997e06dd..369206d9e6 100644
--- a/src/libxrpl/tx/invariants/InvariantCheck.cpp
+++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp
@@ -1126,20 +1126,17 @@ NoModifiedUnmodifiableFields::finalize(
         auto const& before = slePair.first;
         auto const& after = slePair.second;
         auto const type = after->getType();
-        bool bad = false;
-        [[maybe_unused]] bool enforce = false;
+        // featureLendingProtocol gates enforcement, not detection: changes are
+        // always logged, but the transaction is only failed once the amendment
+        // is enabled. Type-specific field lists may add their own gates (see
+        // ltVAULT).
+        bool const enforce = view.rules().enabled(featureLendingProtocol);
+        bool bad = kFieldChanged(before, after, sfLedgerEntryType) ||
+            kFieldChanged(before, after, sfLedgerIndex);
         switch (type)
         {
             case ltLOAN_BROKER:
-                /*
-                 * We check this invariant regardless of lending protocol
-                 * amendment status, allowing for detection and logging of
-                 * potential issues even when the amendment is disabled.
-                 */
-                enforce = view.rules().enabled(featureLendingProtocol);
-                bad = kFieldChanged(before, after, sfLedgerEntryType) ||
-                    kFieldChanged(before, after, sfLedgerIndex) ||
-                    kFieldChanged(before, after, sfSequence) ||
+                bad = bad || kFieldChanged(before, after, sfSequence) ||
                     kFieldChanged(before, after, sfOwnerNode) ||
                     kFieldChanged(before, after, sfVaultNode) ||
                     kFieldChanged(before, after, sfVaultID) ||
@@ -1150,15 +1147,7 @@ NoModifiedUnmodifiableFields::finalize(
                     kFieldChanged(before, after, sfCoverRateLiquidation);
                 break;
             case ltLOAN:
-                /*
-                 * We check this invariant regardless of lending protocol
-                 * amendment status, allowing for detection and logging of
-                 * potential issues even when the amendment is disabled.
-                 */
-                enforce = view.rules().enabled(featureLendingProtocol);
-                bad = kFieldChanged(before, after, sfLedgerEntryType) ||
-                    kFieldChanged(before, after, sfLedgerIndex) ||
-                    kFieldChanged(before, after, sfSequence) ||
+                bad = bad || kFieldChanged(before, after, sfSequence) ||
                     kFieldChanged(before, after, sfOwnerNode) ||
                     kFieldChanged(before, after, sfLoanBrokerNode) ||
                     kFieldChanged(before, after, sfLoanBrokerID) ||
@@ -1177,19 +1166,28 @@ NoModifiedUnmodifiableFields::finalize(
                     kFieldChanged(before, after, sfGracePeriod) ||
                     kFieldChanged(before, after, sfLoanScale);
                 break;
-            default:
+            case ltVAULT:
                 /*
-                 * We check this invariant regardless of lending protocol
-                 * amendment status, allowing for detection and logging of
-                 * potential issues even when the amendment is disabled.
-                 *
-                 * We use the lending protocol as a gate, even though
-                 * all transactions are affected because that's when it
-                 * was added.
+                 * sfAccount, sfAsset and sfShareMPTID are already
+                 * captured by VaultInvariant. The additional fields
+                 * below are introduced by featureLendingProtocolV1_1
+                 * and only exist on V1_1 vaults.
                  */
-                enforce = view.rules().enabled(featureLendingProtocol);
-                bad = kFieldChanged(before, after, sfLedgerEntryType) ||
-                    kFieldChanged(before, after, sfLedgerIndex);
+                if (view.rules().enabled(featureLendingProtocolV1_1))
+                {
+                    bad = bad || kFieldChanged(before, after, sfVaultKind) ||
+                        kFieldChanged(before, after, sfSubscriptionDate) ||
+                        kFieldChanged(before, after, sfRedemptionDate) ||
+                        kFieldChanged(before, after, sfSequence) ||
+                        kFieldChanged(before, after, sfOwnerNode) ||
+                        kFieldChanged(before, after, sfOwner) ||
+                        kFieldChanged(before, after, sfWithdrawalPolicy) ||
+                        kFieldChanged(before, after, sfScale) ||
+                        kFieldChanged(before, after, sfLEVersion);
+                }
+                break;
+            default:
+                break;
         }
         XRPL_ASSERT(
             !bad || enforce,
diff --git a/src/libxrpl/tx/invariants/InvariantRunner.cpp b/src/libxrpl/tx/invariants/InvariantRunner.cpp
new file mode 100644
index 0000000000..55bff2d693
--- /dev/null
+++ b/src/libxrpl/tx/invariants/InvariantRunner.cpp
@@ -0,0 +1,110 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include   // IWYU pragma: keep
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+namespace {
+
+TER
+failInvariantCheck(TER const result)
+{
+    return (result == tecINVARIANT_FAILED || result == tefINVARIANT_FAILED)
+        ? TER{tefINVARIANT_FAILED}
+        : TER{tecINVARIANT_FAILED};
+}
+
+template 
+TER
+checkInvariantsHelper(
+    ApplyContext& ctx,
+    TER const result,
+    XRPAmount const fee,
+    std::optional> txCheck,
+    std::index_sequence)
+{
+    bool allOk = true;
+
+    try
+    {
+        auto checkers = getInvariantChecks();
+
+        ctx.visit([&](uint256 const&, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
+            if (txCheck)
+                txCheck->get().visitEntry(isDelete, before, after);
+            (..., std::get(checkers).visitEntry(isDelete, before, after));
+        });
+
+        if (txCheck)
+        {
+            if (!txCheck->get().finalize(ctx.tx, result, fee, ctx.view(), ctx.journal))
+            {
+                JLOG(ctx.journal.fatal())
+                    << "Transaction has failed one or more transaction invariants: "
+                    << to_string(ctx.tx.getJson(JsonOptions::Values::None));
+                allOk = false;
+            }
+        }
+
+        // Note: do not replace this logic with a `...&&` fold expression.
+        // The fold expression will only run until the first check fails (it
+        // short-circuits). While the logic is still correct, the log
+        // message won't be. Every failed invariant should write to the log,
+        // not just the first one.
+        std::array const finalizers{
+            {std::get(checkers).finalize(ctx.tx, result, fee, ctx.view(), ctx.journal)...}};
+
+        if (!std::all_of(finalizers.cbegin(), finalizers.cend(), [](auto const& b) { return b; }))
+        {
+            JLOG(ctx.journal.fatal()) << "Transaction has failed one or more global invariants: "
+                                      << to_string(ctx.tx.getJson(JsonOptions::Values::None));
+            allOk = false;
+        }
+    }
+    catch (std::exception const& ex)
+    {
+        JLOG(ctx.journal.fatal()) << "Transaction caused an exception during invariant checks"
+                                  << ", ex: " << ex.what() << ", tx: "
+                                  << to_string(ctx.tx.getJson(JsonOptions::Values::None));
+        return failInvariantCheck(result);
+    }
+
+    return allOk ? result : failInvariantCheck(result);
+}
+
+}  // namespace
+
+TER
+checkInvariants(
+    ApplyContext& ctx,
+    TER const result,
+    XRPAmount const fee,
+    std::optional> txCheck)
+{
+    XRPL_ASSERT(
+        isTesSuccess(result) || isTecClaim(result),
+        "xrpl::checkInvariants : is tesSUCCESS or tecCLAIM");
+
+    return checkInvariantsHelper(
+        ctx, result, fee, txCheck, std::make_index_sequence>{});
+}
+
+}  // namespace xrpl
diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp
index ce9a7c6e03..7b96790570 100644
--- a/src/libxrpl/tx/invariants/LoanInvariant.cpp
+++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp
@@ -4,7 +4,10 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
@@ -12,6 +15,8 @@
 #include 
 #include 
 
+#include 
+
 namespace xrpl {
 
 void
@@ -26,7 +31,7 @@ ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after
 bool
 ValidLoan::finalize(
     STTx const& tx,
-    TER const,
+    TER const result,
     XRPAmount const,
     ReadView const& view,
     beast::Journal const& j)
@@ -36,6 +41,35 @@ ValidLoan::finalize(
 
     for (auto const& [before, after] : loans_)
     {
+        // A closed-ended vault must not accept a loan whose final scheduled payment falls on or
+        // after the vault's RedemptionDate. This mirrors the LoanSet::preclaim gate and only fires
+        // on loan creation; once the loan exists, its StartDate / PaymentInterval are immutable and
+        // PaymentRemaining only decreases, so the bound is preserved.
+        if (!before && isTesSuccess(result))
+        {
+            auto const broker = view.read(keylet::loanBroker(after->at(sfLoanBrokerID)));
+            if (broker)
+            {
+                auto const vault = view.read(keylet::vault(broker->at(sfVaultID)));
+                // We don't check for LendingProtocolV1_1 amendment because a ClosedEnded Vault will
+                // not exist without the amendment enabled
+                if (vault && getVaultKind(vault) == VaultKind::ClosedEnded)
+                {
+                    std::uint32_t const startDate = after->at(sfStartDate);
+                    std::uint32_t const interval = after->at(sfPaymentInterval);
+                    std::uint32_t const remaining = after->at(sfPaymentRemaining);
+                    std::uint32_t const redemption = vault->at(sfRedemptionDate);
+                    if (std::uint64_t{startDate} + (std::uint64_t{interval} * remaining) >=
+                        redemption)
+                    {
+                        JLOG(j.fatal()) << "Invariant failed: closed-ended loan final payment "
+                                           "must precede RedemptionDate";
+                        return false;
+                    }
+                }
+            }
+        }
+
         // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants
         // If `Loan.PaymentRemaining = 0` then the loan MUST be fully paid off
         if (after->at(sfPaymentRemaining) == 0 &&
diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp
index 77c5ad781e..9a7e96e44f 100644
--- a/src/libxrpl/tx/invariants/MPTInvariant.cpp
+++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp
@@ -7,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -282,12 +283,13 @@ ValidMPTIssuance::finalize(
                                        "but created bad number of mptokens";
                     return false;
                 }
-                //  At most one MPToken may be created on withdraw/clawback since:
+                //  At most two MPToken may be created on withdraw/clawback since:
                 //  - Liquidity Provider must have at least one token in order
-                //    participate in AMM pool liquidity.
+                //    participate in AMM pool liquidity or have LPTokens only.
                 //  - At most two MPTokens may be deleted if AMM pool, which has exactly
                 //    two tokens, is empty after withdraw/clawback.
-                if (mptokensCreated_ > 1 || mptokensDeleted_ > 2)
+                SOMETIMES(mptokensCreated_ == 2, "AMM withdraw/clawback recreated two MPTokens");
+                if (mptokensCreated_ > 2 || mptokensDeleted_ > 2)
                 {
                     JLOG(j.fatal()) << "Invariant failed: MPT authorize  succeeded "
                                        "but created/deleted bad number of mptokens";
@@ -839,6 +841,14 @@ ValidMPTTransfer::finalize(
     if (hasPrivilege(tx, OverrideFreeze))
         return true;
 
+    // XLS-0066: a broker must be able to default an already-late loan
+    // regardless of the vault asset's lock state. Gated behind
+    // fixCleanup3_4_0, and scoped below to exactly the broker/vault
+    // pseudo-accounts and the vault's own MPT issuance -- see
+    // FreezeInvariant.cpp's TransfersNotFrozen::finalize for the IOU-side
+    // equivalent and rationale.
+    auto const loanDefaultAccounts = getLoanDefaultFreezeExemptAccounts(view, tx);
+
     // DEX transactions (AMM[Create,Deposit], cross-currency payments, offer creates) are
     // subject to the MPTCanTrade flag in addition to the standard transfer rules.
     // A payment is only DEX if it is a cross-currency payment.
@@ -880,6 +890,13 @@ ValidMPTTransfer::finalize(
         auto const canTrade = sleIssuance->isFlag(lsfMPTCanTrade);
         auto const reqAuth = sleIssuance->isFlag(lsfMPTRequireAuth);
 
+        // This issuance is the LoanManage default's own vault asset, so the
+        // broker/vault freeze exemption applies to it -- an unrelated MPT
+        // issuance the same accounts happen to hold is still caught.
+        bool const isLoanDefaultAsset = loanDefaultAccounts &&
+            loanDefaultAccounts->asset.holds() &&
+            loanDefaultAccounts->asset.get().getMptID() == mptID;
+
         for (auto const& [account, value] : values)
         {
             // Classify each account as a sender or receiver based on whether their MPTAmount
@@ -898,8 +915,15 @@ ValidMPTTransfer::finalize(
 
                 // Check once: if any involved account is frozen, the whole issuance transfer is
                 // considered frozen. Only need to check for frozen if there is a transfer of funds.
+                //
+                // The LoanManage default exemption only waives the frozen check, and only for
+                // the specific broker/vault pseudo-accounts identified above -- authorization is
+                // still enforced for them, and both checks still apply to every other account.
+                bool const exemptFromFreeze = isLoanDefaultAsset && loanDefaultAccounts &&
+                    (account == loanDefaultAccounts->broker ||
+                     account == loanDefaultAccounts->vault);
                 if (!invalidTransfer &&
-                    (isFrozen(view, account, MPTIssue{mptID}) ||
+                    ((!exemptFromFreeze && isFrozen(view, account, *sleIssuance)) ||
                      !isAuthorized(view, mptID, account, reqAuth)))
                 {
                     invalidTransfer = true;
diff --git a/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp b/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp
index 44f623f284..5c53552a3f 100644
--- a/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp
+++ b/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp
@@ -7,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -18,8 +19,13 @@
 namespace xrpl {
 
 void
-ValidPermissionedDEX::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
+ValidPermissionedDEX::visitEntry(bool isDelete, SLE::const_ref, SLE::const_ref after)
 {
+    // Post-fixCleanup3_4_0: skip when after is null (defensive).
+    // Pre-amendment: original after-only path via the `if (after && ...)` checks below.
+    if (isFeatureEnabled(fixCleanup3_4_0) && !after)
+        return;
+
     auto trackDomain = [this, isDelete](uint256 const& domain) {
         domainsOld_.insert(domain);
         if (!isDelete)
diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp
index c577fdf356..5c25a22987 100644
--- a/src/libxrpl/tx/invariants/VaultInvariant.cpp
+++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -24,11 +25,27 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
 namespace xrpl {
 
+namespace {
+
+/*
+ * True iff the recorded sfVaultKind identifies a closed-ended vault.
+ * Centralizes the presence + enum-value check used by the phase-gate
+ * invariants below.
+ */
+[[nodiscard]] bool
+isClosedEnded(std::optional const& vaultKind)
+{
+    return vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded);
+}
+
+}  // namespace
+
 ValidVault::Vault
 ValidVault::Vault::make(SLE const& from)
 {
@@ -44,6 +61,9 @@ ValidVault::Vault::make(SLE const& from)
     self.assetsAvailable = from.at(sfAssetsAvailable);
     self.assetsMaximum = from.at(sfAssetsMaximum);
     self.lossUnrealized = from.at(sfLossUnrealized);
+    self.vaultKind = from[~sfVaultKind];
+    self.subscriptionDate = from[~sfSubscriptionDate];
+    self.redemptionDate = from[~sfRedemptionDate];
     return self;
 }
 
@@ -254,6 +274,37 @@ ValidVault::isVaultEmpty(Vault const& vault)
     return vault.assetsAvailable == 0 && vault.assetsTotal == 0;
 }
 
+bool
+ValidVault::finalizeLoanSet(ReadView const& view, beast::Journal const& j) const
+{
+    if (afterVault_.empty())
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::ValidVault::finalizeLoanSet : vault exists");
+        return false;
+        // LCOV_EXCL_STOP
+    }
+
+    auto const& afterVault = afterVault_[0];
+
+    // Loan origination against a closed-ended vault is only permitted while the vault is in the
+    // Investment phase - strictly past SubscriptionDate and before RedemptionDate. Open-ended
+    // vaults have NoPhase and are unaffected.
+    auto const phase = getVaultPhase(
+        view, afterVault.vaultKind, afterVault.subscriptionDate, afterVault.redemptionDate);
+    if (phase == VaultPhase::NoPhase)
+        return true;
+
+    if (phase != VaultPhase::Investment)
+    {
+        JLOG(j.fatal()) <<  //
+            "Invariant failed: loan origination only allowed in Investment phase";
+        return false;
+    }
+
+    return true;
+}
+
 std::int32_t
 ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const
 {
@@ -520,6 +571,9 @@ ValidVault::finalize(
         result = false;
     }
 
+    // Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced by
+    // NoModifiedUnmodifiableFields in InvariantCheck.cpp.
+
     auto const beforeShares = [&]() -> std::optional {
         if (beforeVault_.empty())
             return std::nullopt;
@@ -606,6 +660,26 @@ ValidVault::finalize(
                     result = false;
                 }
 
+                if (isClosedEnded(afterVault.vaultKind))
+                {
+                    if (!afterVault.subscriptionDate || !afterVault.redemptionDate)
+                    {
+                        JLOG(j.fatal())  //
+                            << "Invariant failed: closed-ended vault must have SubscriptionDate "
+                               "and RedemptionDate";
+                        result = false;
+                    }
+                    else if (!isValidClosedEndedGap(
+                                 *afterVault.subscriptionDate, *afterVault.redemptionDate))
+                    {
+                        JLOG(j.fatal())  //
+                            << "Invariant failed: closed-ended vault RedemptionDate - "
+                               "SubscriptionDate must be within [MIN_INVESTMENT_PERIOD, "
+                               "MAX_INVESTMENT_PERIOD)";
+                        result = false;
+                    }
+                }
+
                 return result;
             }
             case ttVAULT_SET: {
@@ -666,6 +740,21 @@ ValidVault::finalize(
                     !beforeVault_.empty(), "xrpl::ValidVault::finalize : deposit updated a vault");
                 auto const& beforeVault = beforeVault_[0];
 
+                // Deposit is only allowed while the vault is in NoPhase or
+                // Subscription.
+                auto const depositPhase = getVaultPhase(
+                    view,
+                    afterVault.vaultKind,
+                    afterVault.subscriptionDate,
+                    afterVault.redemptionDate);
+                if (depositPhase != VaultPhase::NoPhase && depositPhase != VaultPhase::Subscription)
+                {
+                    JLOG(j.fatal()) <<  //
+                        "Invariant failed: deposit only allowed in "
+                        "Subscription or NoPhase";
+                    result = false;
+                }
+
                 auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
                 if (!maybeVaultDeltaAssets)
                 {
@@ -804,20 +893,51 @@ ValidVault::finalize(
                     "xrpl::ValidVault::finalize : withdrawal updated a vault");
                 auto const& beforeVault = beforeVault_[0];
 
+                // Withdrawal from a closed-ended vault is not allowed during the Investment phase
+                // (strictly past SubscriptionDate, before RedemptionDate).
+                if (getVaultPhase(
+                        view,
+                        afterVault.vaultKind,
+                        afterVault.subscriptionDate,
+                        afterVault.redemptionDate) == VaultPhase::Investment)
+                {
+                    JLOG(j.fatal()) <<  //
+                        "Invariant failed: withdrawal not allowed during "
+                        "Investment phase";
+                    result = false;
+                }
+
                 auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
-                if (!maybeVaultDeltaAssets)
+
+                // Post-fixCleanup3_4_0: a withdrawal that redeems shares from a
+                // pool with no effective value left to back them (e.g. fully
+                // impaired/insolvent) legitimately moves zero assets on both
+                // sides — VaultWithdraw::doApply does not touch either
+                // balance-holding entry for a zero-value transfer, so no delta
+                // is recorded. VaultWithdraw::doApply separately rejects
+                // (tecPRECISION_LOSS) the case where a *positive* per-share
+                // 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;
+
+                if (!maybeVaultDeltaAssets && !zeroDeltaIsLegitimate)
                 {
                     JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault balance";
                     return false;  // That's all we can do
                 }
 
+                DeltaInfo const vaultDeltaAssets = maybeVaultDeltaAssets.value_or(
+                    DeltaInfo{.delta = kNumZero, .scale = std::nullopt});
+
                 // Get the posterior scale to round calculations to
-                auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
+                auto const minScale = computeVaultMinScale(vaultDeltaAssets, view.rules());
 
                 auto const vaultPseudoDeltaAssets =
-                    roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
+                    roundToAsset(vaultAsset, vaultDeltaAssets.delta, minScale);
 
-                if (vaultPseudoDeltaAssets >= kZero)
+                if (!zeroDeltaIsLegitimate && vaultPseudoDeltaAssets >= kZero)
                 {
                     JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease vault balance";
                     result = false;
@@ -844,63 +964,76 @@ ValidVault::finalize(
 
                     if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value())
                     {
-                        JLOG(j.fatal()) <<  //
-                            "Invariant failed: withdrawal must change one destination balance";
-                        return false;
+                        // Both changed is always a bug. Neither changed is
+                        // consistent only with a legitimate zero-value
+                        // withdrawal, which moves nothing on either side —
+                        // there is nothing left to cross-check.
+                        if (!zeroDeltaIsLegitimate || maybeAccDelta.has_value())
+                        {
+                            JLOG(j.fatal()) <<  //
+                                "Invariant failed: withdrawal must change one destination balance";
+                            return false;
+                        }
                     }
-
-                    auto const destinationDelta =  //
-                        maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta;
-
-                    // the scale of destinationDelta can be coarser than
-                    // minScale, so we take that into account when rounding
-                    auto const destinationScale = computeCoarsestScale({destinationDelta});
-                    auto const localMinScale = std::max(minScale, destinationScale);
-
-                    auto const roundedDestinationDelta =
-                        roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
-
-                    // Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs only.
-                    // If the receiver's trust line sits at a coarser scale, the inflow may
-                    // safely round down to zero.
-                    //
-                    // XRP and MPT remain strict. Because they are integer-exact, a zero
-                    // destination delta indicates a true accounting bug, not a rounding artifact.
-                    bool const tolerateZeroDelta =
-                        view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral();
-                    auto const invalidBalanceChange = tolerateZeroDelta
-                        ? roundedDestinationDelta < kZero
-                        : roundedDestinationDelta <= kZero;
-                    if (invalidBalanceChange)
+                    else
                     {
-                        JLOG(j.fatal()) <<  //
-                            "Invariant failed: withdrawal must increase destination balance";
-                        result = false;
-                    }
+                        // A one-sided change is cross-checked even for a
+                        // legitimate zero vault delta: the destination must
+                        // then have moved by (rounded) zero as well.
+                        auto const destinationDelta =
+                            *maybeAccDelta.or_else([&] { return maybeOtherAccDelta; });
 
-                    auto const localPseudoDeltaAssets =
-                        roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
-                    // For IOU assets near a precision boundary the destination's STAmount
-                    // exponent can shift, making part of the sent value unrepresentable at the
-                    // receiver's new scale — that portion is irreversibly absorbed by the IOU
-                    // rail.  Tolerate the mismatch only when the destroyed amount (vault outflow
-                    // minus destination inflow, in Number space) is itself sub-ULP at the
-                    // destination's scale.  Floor rounding is used so that values exactly at the
-                    // step boundary are not mistakenly dismissed.  Any representable discrepancy
-                    // indicates a real accounting bug and must be caught.
-                    auto const destroyedIsSubUlp = tolerateZeroDelta &&
-                        roundToAsset(
-                            vaultAsset,
-                            maybeVaultDeltaAssets->delta * -1 - destinationDelta.delta,
-                            destinationScale,
-                            Number::RoundingMode::Downward) == kZero;
-                    if (!destroyedIsSubUlp &&
-                        localPseudoDeltaAssets * -1 != roundedDestinationDelta)
-                    {
-                        JLOG(j.fatal()) << "Invariant failed: " <<  //
-                            "withdrawal must change vault and destination balance by equal "
-                            "amount";
-                        result = false;
+                        // the scale of destinationDelta can be coarser than
+                        // minScale, so we take that into account when rounding
+                        auto const destinationScale = computeCoarsestScale({destinationDelta});
+                        auto const localMinScale = std::max(minScale, destinationScale);
+
+                        auto const roundedDestinationDelta =
+                            roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
+
+                        // Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs
+                        // only. If the receiver's trust line sits at a coarser scale, the inflow
+                        // may safely round down to zero.
+                        //
+                        // XRP and MPT remain strict. Because they are integer-exact, a zero
+                        // destination delta indicates a true accounting bug, not a rounding
+                        // artifact.
+                        bool const tolerateZeroDelta =
+                            view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral();
+                        auto const invalidBalanceChange = tolerateZeroDelta
+                            ? roundedDestinationDelta < kZero
+                            : roundedDestinationDelta <= kZero;
+                        if (invalidBalanceChange)
+                        {
+                            JLOG(j.fatal()) <<  //
+                                "Invariant failed: withdrawal must increase destination balance";
+                            result = false;
+                        }
+
+                        auto const localPseudoDeltaAssets =
+                            roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
+                        // For IOU assets near a precision boundary the destination's STAmount
+                        // exponent can shift, making part of the sent value unrepresentable at
+                        // the receiver's new scale — that portion is irreversibly absorbed by the
+                        // IOU rail.  Tolerate the mismatch only when the destroyed amount (vault
+                        // outflow minus destination inflow, in Number space) is itself sub-ULP at
+                        // the destination's scale.  Floor rounding is used so that values exactly
+                        // at the step boundary are not mistakenly dismissed.  Any representable
+                        // discrepancy indicates a real accounting bug and must be caught.
+                        auto const destroyedIsSubUlp = tolerateZeroDelta &&
+                            roundToAsset(
+                                vaultAsset,
+                                vaultDeltaAssets.delta * -1 - destinationDelta.delta,
+                                destinationScale,
+                                Number::RoundingMode::Downward) == kZero;
+                        if (!destroyedIsSubUlp &&
+                            localPseudoDeltaAssets * -1 != roundedDestinationDelta)
+                        {
+                            JLOG(j.fatal()) << "Invariant failed: " <<  //
+                                "withdrawal must change vault and destination balance by equal "
+                                "amount";
+                            result = false;
+                        }
                     }
                 }
 
@@ -1052,6 +1185,7 @@ ValidVault::finalize(
             }
 
             case ttLOAN_SET:
+                return finalizeLoanSet(view, j);
             case ttLOAN_MANAGE:
             case ttLOAN_PAY:
                 return true;
diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp
index e7c2e9ee29..2823627108 100644
--- a/src/libxrpl/tx/paths/BookStep.cpp
+++ b/src/libxrpl/tx/paths/BookStep.cpp
@@ -44,7 +44,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -653,7 +655,15 @@ limitStepIn(
         // under an amendment.
         ofrAmt = offer.limitIn(ofrAmt, inLmt, /* roundUp */ false);
         stpAmt.out = ofrAmt.out;
-        ownerGives = mulRatio(ofrAmt.out, transferRateOut, QUALITY_ONE, /*roundUp*/ false);
+        // Round up for MPT output so the offer owner pays the full
+        // ceil(amount × rate) fee, matching direct Payment semantics.  IOU uses
+        // floating-point arithmetic so the floor/ceil distinction is sub-epsilon
+        // there; preserve the historical false to avoid changing IOU behavior.
+        ownerGives = mulRatio(
+            ofrAmt.out,
+            transferRateOut,
+            QUALITY_ONE,
+            /*roundUp*/ std::is_same_v);
     }
 }
 
@@ -672,7 +682,11 @@ limitStepOut(
     if (limit < stpAmt.out)
     {
         stpAmt.out = limit;
-        ownerGives = mulRatio(stpAmt.out, transferRateOut, QUALITY_ONE, /*roundUp*/ false);
+        ownerGives = mulRatio(
+            stpAmt.out,
+            transferRateOut,
+            QUALITY_ONE,
+            /*roundUp*/ std::is_same_v);
         ofrAmt = offer.limitOut(
             ofrAmt,
             stpAmt.out,
@@ -727,17 +741,20 @@ BookStep::forEachOffer(
         bool const isAssetInMPT = assetIn.holds();
         auto const& owner = offer.owner();
 
-        if (isAssetInMPT)
-        {
-            // Create MPToken for the offer's owner. No need to check
-            // for the reserve since the offer is removed if it is consumed.
-            // Therefore, the owner count remains the same.
-            if (auto const err = checkCreateMPT(sb, assetIn.get(), owner, {}, j_);
-                !isTesSuccess(err))
+        auto removeOffer = [&](std::string_view logMessage = {}) {
+            auto const key = offer.key();
+            if (!logMessage.empty())
             {
-                return true;
+                JLOG(j_.trace()) << logMessage << (key ? " " + to_string(*key) : "");
             }
-        }
+            if (key)
+                offers.permRmOffer(*key);
+            if (!offerAttempted)
+            {
+                // Change quality only if no previous offers were tried.
+                ofrQ = std::nullopt;
+            }
+        };
 
         // It shouldn't matter from auth point of view whether it's sb
         // or afView. Amendment guard this change just in case.
@@ -745,17 +762,15 @@ BookStep::forEachOffer(
         // Make sure offer owner has authorization to own Assets from issuer
         // and MPT assets can be traded/transferred.
         // An account can always own XRP or their own Assets.
-        if (!isTesSuccess(requireAuth(applyView, assetIn, owner)) || !checkMPTDEX(sb, owner))
+        // Missing MPTokens are allowed during offer discovery; they are
+        // created later if the offer is actually consumed.
+        auto const authType = isAssetInMPT ? AuthType::WeakAuth : AuthType::Legacy;
+        if (!isTesSuccess(requireAuth(applyView, assetIn, owner, authType)) ||
+            !checkMPTDEX(sb, owner))
         {
             // Offer owner not authorized to hold IOU/MPT from issuer.
             // Remove this offer even if no crossing occurs.
-            if (auto const key = offer.key())
-                offers.permRmOffer(*key);
-            if (!offerAttempted)
-            {
-                // Change quality only if no previous offers were tried.
-                ofrQ = std::nullopt;
-            }
+            removeOffer();
             // Returning true causes offers.step() to delete the offer.
             return true;
         }
@@ -768,52 +783,88 @@ BookStep::forEachOffer(
             static_cast(this)->getOfrOutRate(prevStep_, owner, strandDst_, trOut));
 
         auto ofrAmt = offer.amount();
-        TAmounts stpAmt{mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true), ofrAmt.out};
-
-        // owner pays the transfer fee.
-        auto ownerGives = mulRatio(ofrAmt.out, ofrOutRate, QUALITY_ONE, /*roundUp*/ false);
-
-        auto const funds = offer.isFunded()
-            ? ownerGives  // Offer owner is issuer; they have unlimited funds
-            : offers.ownerFunds();
-
-        // Only if CLOB offer
-        if (funds < ownerGives)
+        TAmounts stpAmt{ofrAmt.in, ofrAmt.out};
+        auto ownerGives = ofrAmt.out;
+        try
         {
-            // We already know offer.owner()!=offer.issueOut().account
-            ownerGives = funds;
-            stpAmt.out = mulRatio(ownerGives, QUALITY_ONE, ofrOutRate, /*roundUp*/ false);
-
-            // It turns out we can prevent order book blocking by (strictly)
-            // rounding down the ceil_out() result.  This adjustment changes
-            // transaction outcomes, so it must be made under an amendment.
-            ofrAmt = offer.limitOut(ofrAmt, stpAmt.out, /*roundUp*/ false);
-
+            // All arithmetic in this block runs before the offer is consumed.
+            // A crafted MPTokensV2 offer can overflow while transfer rates or
+            // crossing limits are applied; remove that unusable offer instead
+            // of letting it persist as a tecINTERNAL source.
             stpAmt.in = mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true);
-        }
 
-        // Limit offer's input if MPT, BookStep is the first step (an issuer
-        // is making a cross-currency payment), and this offer is not owned
-        // by the issuer. Otherwise, OutstandingAmount may overflow.
-        auto const& issuer = assetIn.getIssuer();
-        if (isAssetInMPT && !prevStep_ && offer.owner() != issuer)
-        {
-            // Funds available to issue
-            auto const available = toAmount(accountFunds(
-                sb,
-                issuer,
-                assetIn,  // STAmount{0}, but the default is not used
-                FreezeHandling::IgnoreFreeze,
-                AuthHandling::IgnoreAuth,
-                j_));
-            if (stpAmt.in > available)
+            // owner pays the transfer fee.
+            ownerGives = mulRatio(
+                ofrAmt.out,
+                ofrOutRate,
+                QUALITY_ONE,
+                /*roundUp*/ std::is_same_v);
+
+            auto const funds = offer.isFunded()
+                ? ownerGives  // Offer owner is issuer; they have unlimited funds
+                : offers.ownerFunds();
+
+            // Only if CLOB offer
+            if (funds < ownerGives)
             {
-                limitStepIn(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate, available);
-            }
-        }
+                // We already know offer.owner()!=offer.issueOut().account
+                ownerGives = funds;
+                stpAmt.out = mulRatio(ownerGives, QUALITY_ONE, ofrOutRate, /*roundUp*/ false);
 
-        offerAttempted = true;
-        return callback(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate);
+                // It turns out we can prevent order book blocking by (strictly)
+                // rounding down the ceil_out() result.  This adjustment changes
+                // transaction outcomes, so it must be made under an amendment.
+                ofrAmt = offer.limitOut(ofrAmt, stpAmt.out, /*roundUp*/ false);
+
+                stpAmt.in = mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true);
+            }
+
+            // Limit offer's input if MPT, BookStep is the first step (an issuer
+            // is making a cross-currency payment), and this offer is not owned
+            // by the issuer. Otherwise, OutstandingAmount may overflow.
+            auto const& issuer = assetIn.getIssuer();
+            if (isAssetInMPT && !prevStep_ && offer.owner() != issuer)
+            {
+                // Funds available to issue
+                auto const available = toAmount(accountFunds(
+                    sb,
+                    issuer,
+                    assetIn,  // STAmount{0}, but the default is not used
+                    FreezeHandling::IgnoreFreeze,
+                    AuthHandling::IgnoreAuth,
+                    j_));
+                if (stpAmt.in > available)
+                {
+                    limitStepIn(
+                        offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate, available);
+                }
+            }
+
+            offerAttempted = true;
+            return callback(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate);
+        }
+        catch (std::overflow_error const&)
+        {
+            if (sb.rules().enabled(featureMPTokensV2))
+            {
+                SOMETIMES(
+                    true,
+                    "BookStep::forEachOffer removed MPT offer after "
+                    "overflow during crossing");
+                removeOffer("Removing offer with overflowing amount calculation");
+                return true;
+            }
+            // An overflow can only be produced by a crafted MPT offer, and MPT
+            // offers require featureMPTokensV2 (enforced at OfferCreate
+            // preflight). So the amendment is always enabled when we get here
+            // and this legacy re-throw is unreachable in practice.
+            // LCOV_EXCL_START
+            XRPL_ASSERT(
+                sb.rules().enabled(featureMPTokensV2),
+                "xrpl::BookStep::forEachOffer : overflow implies MPTokensV2");
+            throw;
+            // LCOV_EXCL_STOP
+        }
     };
 
     // At any payment engine iteration, AMM offer can only be consumed once.
@@ -873,6 +924,22 @@ BookStep::consumeOffer(
     // The offer owner gets the ofrAmt. The difference between ofrAmt and
     // stepAmt is a transfer fee that goes to book_.in.account
     {
+        if constexpr (std::is_same_v)
+        {
+            // If the offer's TakerPays asset is an MPT, the offer owner must
+            // hold an MPToken to receive it. Create one here if it doesn't
+            // already exist.
+            if (auto const err = checkCreateMPT(sb, book_.in.get(), offer.owner(), j_);
+                !isTesSuccess(err))
+            {
+                // checkCreateMPT only fails on tecDIR_FULL (its source line is
+                // itself LCOV-excluded) or a missing offer-owner account, which
+                // cannot happen since that account owns the offer being
+                // consumed. Defensive and unreachable in practice.
+                Throw(err);  // LCOV_EXCL_LINE
+            }
+        }
+
         auto const dr = offer.send(
             sb, book_.in.getIssuer(), offer.owner(), toSTAmount(ofrAmt.in, book_.in), j_);
         if (!isTesSuccess(dr))
@@ -1043,6 +1110,13 @@ BookStep::revImp(
         auto ofrAdjAmt = ofrAmt;
         auto stpAdjAmt = stpAmt;
         auto ownerGivesAdj = ownerGives;
+        // This reduction can overflow via the transfer-rate mulRatio() on a
+        // 63-bit MPT amount (IOU rescales instead of throwing, and XRP stays
+        // under the int64 limit, so only MPT reaches it today), but
+        // savedIns/savedOuts are not updated until after it succeeds. The outer
+        // execOffer() catch can therefore remove the offer under
+        // featureMPTokensV2 (legacy propagate-the-exception behavior otherwise)
+        // without rolling back local state.
         limitStepOut(
             offer,
             ofrAdjAmt,
@@ -1144,12 +1218,25 @@ BookStep::fwdImp(
         auto stpAdjAmt = stpAmt;
         auto ownerGivesAdj = ownerGives;
 
+        // limitStepIn()/limitStepOut() can throw std::overflow_error from the
+        // transfer-rate mulRatio() on a 63-bit MPT amount. (IOUAmount::mulRatio
+        // rescales rather than throwing, and XRP amounts/rates stay under the
+        // int64 limit, so in practice only MPT reaches this today.) execOffer()
+        // catches it: under featureMPTokensV2 the offending offer is removed;
+        // otherwise the legacy behavior (propagate the exception) is preserved.
+        // Keep candidate accumulator changes local until those calls succeed so
+        // the catch path does not observe partially updated state. Re-sum the
+        // staged sets to preserve historical flat_multiset summing behavior.
+        auto savedInsAdj = savedIns;
+        auto savedOutsAdj = savedOuts;
+        auto resultAdj = result;
         typename boost::container::flat_multiset::const_iterator lastOut;
+
         if (stpAmt.in <= remainingIn)
         {
-            savedIns.insert(stpAmt.in);
-            lastOut = savedOuts.insert(stpAmt.out);
-            result = TAmounts(sum(savedIns), sum(savedOuts));
+            savedInsAdj.insert(stpAmt.in);
+            lastOut = savedOutsAdj.insert(stpAmt.out);
+            resultAdj = TAmounts(sum(savedInsAdj), sum(savedOutsAdj));
             // consume the offer even if stepAmt.in == remainingIn
             processMore = true;
         }
@@ -1163,15 +1250,15 @@ BookStep::fwdImp(
                 transferRateIn,
                 transferRateOut,
                 remainingIn);
-            savedIns.insert(remainingIn);
-            lastOut = savedOuts.insert(stpAdjAmt.out);
-            result.out = sum(savedOuts);
-            result.in = in;
+            savedInsAdj.insert(remainingIn);
+            lastOut = savedOutsAdj.insert(stpAdjAmt.out);
+            resultAdj.out = sum(savedOutsAdj);
+            resultAdj.in = in;
 
             processMore = false;
         }
 
-        if (result.out > cache_->out && result.in <= cache_->in)
+        if (resultAdj.out > cache_->out && resultAdj.in <= cache_->in)
         {
             // The step produced more output in the forward pass than the
             // reverse pass while consuming the same input (or less). If we
@@ -1181,8 +1268,8 @@ BookStep::fwdImp(
             // input provided in the forward step and produce the output
             // requested from the reverse step.
             auto const lastOutAmt = *lastOut;
-            savedOuts.erase(lastOut);
-            auto const remainingOut = cache_->out - sum(savedOuts);
+            savedOutsAdj.erase(lastOut);
+            auto const remainingOut = cache_->out - sum(savedOutsAdj);
             auto ofrAdjAmtRev = ofrAmt;
             auto stpAdjAmtRev = stpAmt;
             auto ownerGivesAdjRev = ownerGives;
@@ -1197,13 +1284,13 @@ BookStep::fwdImp(
 
             if (stpAdjAmtRev.in == remainingIn)
             {
-                result.in = in;
-                result.out = cache_->out;
+                resultAdj.in = in;
+                resultAdj.out = cache_->out;
 
-                savedIns.clear();
-                savedIns.insert(result.in);
-                savedOuts.clear();
-                savedOuts.insert(result.out);
+                savedInsAdj.clear();
+                savedInsAdj.insert(resultAdj.in);
+                savedOutsAdj.clear();
+                savedOutsAdj.insert(resultAdj.out);
 
                 ofrAdjAmt = ofrAdjAmtRev;
                 stpAdjAmt.in = remainingIn;
@@ -1214,10 +1301,15 @@ BookStep::fwdImp(
             {
                 // This is (likely) a problem case, and will be caught
                 // with later checks
-                savedOuts.insert(lastOutAmt);
+                savedOutsAdj.insert(lastOutAmt);
             }
         }
 
+        // Commit the staged accounting only after limitStepIn()/limitStepOut()
+        // have succeeded.
+        savedIns = std::move(savedInsAdj);
+        savedOuts = std::move(savedOutsAdj);
+        result = resultAdj;
         remainingIn = in - result.in;
         this->consumeOffer(sb, offer, ofrAdjAmt, stpAdjAmt, ownerGivesAdj);
 
diff --git a/src/libxrpl/tx/paths/DirectStep.cpp b/src/libxrpl/tx/paths/DirectStep.cpp
index f8f12bd421..1854bd3632 100644
--- a/src/libxrpl/tx/paths/DirectStep.cpp
+++ b/src/libxrpl/tx/paths/DirectStep.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -845,8 +846,14 @@ DirectStepI::check(StrandContext const& ctx) const
     // pure issue/redeem can't be frozen
     if (!(ctx.isLast && ctx.isFirst))
     {
-        auto const ter = checkFreeze(ctx.view, src_, dst_, currency_);
-        if (!isTesSuccess(ter))
+        if (auto const ter = checkFreeze(ctx.view, src_, dst_, currency_); !isTesSuccess(ter))
+            return ter;
+
+        // An LPToken redeemed against its AMM (dst_ is the LPToken issuer on
+        // this hop) cannot move if a pool asset is an MPT that forbids
+        // transfers between these accounts. A no-op unless dst_ is an AMM whose
+        // pool holds such an MPT (so it is implicitly gated by featureMPTokensV2).
+        if (auto const ter = canTransferLPToken(ctx.view, src_, dst_, dst_); !isTesSuccess(ter))
             return ter;
     }
 
diff --git a/src/libxrpl/tx/paths/MPTEndpointStep.cpp b/src/libxrpl/tx/paths/MPTEndpointStep.cpp
index 0a0f6a9f27..a47cfa15a5 100644
--- a/src/libxrpl/tx/paths/MPTEndpointStep.cpp
+++ b/src/libxrpl/tx/paths/MPTEndpointStep.cpp
@@ -410,8 +410,7 @@ MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirectio
         // for the reserve since the offer doesn't go on the books
         // if crossed. Insufficient reserve is allowed if the offer
         // crossed. See CreateOffer::applyGuts() for reserve check.
-        if (auto const err = xrpl::checkCreateMPT(view, mptIssue_, dst_, {}, j_);
-            !isTesSuccess(err))
+        if (auto const err = xrpl::checkCreateMPT(view, mptIssue_, dst_, j_); !isTesSuccess(err))
         {
             JLOG(j_.trace()) << "MPTEndpointStep::checkCreateMPT: failed create MPT";
             resetCache(srcDebtDir);
diff --git a/src/libxrpl/tx/paths/OfferStream.cpp b/src/libxrpl/tx/paths/OfferStream.cpp
index ecc8416a2b..6884a113bd 100644
--- a/src/libxrpl/tx/paths/OfferStream.cpp
+++ b/src/libxrpl/tx/paths/OfferStream.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -25,10 +26,14 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 
 #include 
 #include 
+#include 
+#include 
 
 namespace xrpl {
 
@@ -136,17 +141,17 @@ template 
 TOfferStreamBase::shouldRmSmallIncreasedQOffer() const
 {
     // Consider removing the offer if:
-    //  o `TakerPays` is XRP (because of XRP drops granularity) or
+    //  o `TakerPays` is integral (because XRP/MPT have indivisible units) or
     //  o `TakerPays` and `TakerGets` are both IOU and `TakerPays`<`TakerGets`
-    static constexpr bool kInIsXrp = std::is_same_v;
-    static constexpr bool kOutIsXrp = std::is_same_v;
+    constexpr bool const kInIsIntegral = !std::is_same_v;
+    constexpr bool const kOutIsIntegral = !std::is_same_v;
 
-    if constexpr (kOutIsXrp)
+    if constexpr (!kInIsIntegral && kOutIsIntegral)
     {
-        // If `TakerGets` is XRP, the worst this offer's quality can change is
-        // to about 10^-81 `TakerPays` and 1 drop `TakerGets`. This will be
-        // remarkably good quality for any realistic asset, so these offers
-        // don't need this extra check.
+        // If only `TakerGets` is integral, the worst this offer's quality can
+        // change is to about 10^-81 `TakerPays` and 1 unit `TakerGets`. This
+        // will be perfect quality for any realistic asset, so these
+        // offers don't need this extra check.
         return false;
     }
 
@@ -156,7 +161,7 @@ TOfferStreamBase::shouldRmSmallIncreasedQOffer() const
     TAmounts const ofrAmts{
         toAmount(offer_.amount().in), toAmount(offer_.amount().out)};
 
-    if constexpr (!kInIsXrp && !kOutIsXrp)
+    if constexpr (!kInIsIntegral && !kOutIsIntegral)
     {
         if (Number(ofrAmts.in) >= Number(ofrAmts.out))
             return false;
@@ -165,7 +170,12 @@ TOfferStreamBase::shouldRmSmallIncreasedQOffer() const
     TTakerGets const ownerFunds = toAmount(*ownerFunds_);
 
     auto const effectiveAmounts = [&] {
-        if (offer_.owner() != offer_.assetOut().getIssuer() && ownerFunds < ofrAmts.out)
+        // Issuer-owned IOU offers are self-funded without a limit. MPT issuer
+        // offers are bounded by remaining issuance capacity, so they still need
+        // to be clipped by ownerFunds.
+        bool const issuerHasUnlimitedFunds = offer_.owner() == offer_.assetOut().getIssuer() &&
+            offer_.assetOut().template holds();
+        if (!issuerHasUnlimitedFunds && ownerFunds < ofrAmts.out)
         {
             // adjust the amounts by owner funds.
             //
@@ -250,6 +260,23 @@ TOfferStreamBase::step()
             continue;
         }
 
+        // Post-fixCleanup3_4_0 defensive check: an offer indexed in a domain
+        // book must claim that same domain. This can only happen if the book
+        // directory is corrupt (i.e. a separate book indexing bug). An offer
+        // with no sfDomainID at all is just as wrong here: the domain
+        // membership check below is gated on that field being present, so
+        // such an offer would otherwise be consumed from a domain book
+        // without any credential check.
+        if (view_.rules().enabled(fixCleanup3_4_0) && book_.domain.has_value() &&
+            (!entry->isFieldPresent(sfDomainID) ||
+             entry->getFieldH256(sfDomainID) != *book_.domain))
+        {
+            JLOG(j_.error()) << "Offer " << entry->key()
+                             << " domain missing or does not match book domain";
+            Throw(
+                tecINTERNAL, "Offer domain missing or does not match book domain.");
+        }
+
         // Pre-fixCleanup3_3_0: validate domain membership for any book.
         // Post-fixCleanup3_3_0: only validate when walking a domain book.
         // Hybrid offers carry sfDomainID but also participate in the open
@@ -305,7 +332,41 @@ TOfferStreamBase::step()
             continue;
         }
 
-        if (shouldRmSmallIncreasedQOffer())
+        // Partially funded offers can be reduced before BookStep sees them.
+        // If that strict reduction overflows under MPTokensV2, remove the
+        // unusable offer instead of leaving it at the book tip.
+        bool shouldRemoveSmallIncreasedQOffer = false;
+        try
+        {
+            shouldRemoveSmallIncreasedQOffer = shouldRmSmallIncreasedQOffer();
+        }
+        catch (std::overflow_error const&)
+        {
+            if (view_.rules().enabled(featureMPTokensV2))
+            {
+                SOMETIMES(
+                    true,
+                    "OfferStream::step removed MPT offer with overflowing "
+                    "reduced quality");
+                permRmOffer(entry->key());
+                JLOG(j_.warn()) << "Removing offer with overflowing reduced quality "
+                                << entry->key();
+                offer_ = TOffer{};
+                continue;
+            }
+            // The strict reduction only overflows for a crafted MPT offer, and
+            // MPT offers require featureMPTokensV2 (enforced at OfferCreate
+            // preflight). So the amendment is always enabled here and this
+            // legacy re-throw is unreachable in practice.
+            // LCOV_EXCL_START
+            XRPL_ASSERT(
+                view_.rules().enabled(featureMPTokensV2),
+                "xrpl::TOfferStreamBase::step : overflow implies MPTokensV2");
+            throw;
+            // LCOV_EXCL_STOP
+        }
+
+        if (shouldRemoveSmallIncreasedQOffer)
         {
             auto const originalFunds = accountFundsHelper(
                 cancelView_,
diff --git a/src/libxrpl/tx/transactors/account/AccountDelete.cpp b/src/libxrpl/tx/transactors/account/AccountDelete.cpp
index ce027f4cad..0936fe26dc 100644
--- a/src/libxrpl/tx/transactors/account/AccountDelete.cpp
+++ b/src/libxrpl/tx/transactors/account/AccountDelete.cpp
@@ -50,7 +50,7 @@ AccountDelete::preflight(PreflightContext const& ctx)
         return temDST_IS_SRC;
     }
 
-    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
         return err;
 
     return tesSUCCESS;
diff --git a/src/libxrpl/tx/transactors/check/CheckCash.cpp b/src/libxrpl/tx/transactors/check/CheckCash.cpp
index e4d8f192c0..857f759752 100644
--- a/src/libxrpl/tx/transactors/check/CheckCash.cpp
+++ b/src/libxrpl/tx/transactors/check/CheckCash.cpp
@@ -528,7 +528,7 @@ CheckCash::doApply()
                                 return tecINSUFFICIENT_RESERVE;
 
                             if (auto const err =
-                                    checkCreateMPT(psb, mptID, accountID_, *sponsorSle, j_);
+                                    checkCreateMPT(psb, mptID, accountID_, *sponsorSle, 0, j_);
                                 !isTesSuccess(err))
                             {
                                 return err;
diff --git a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
index c1ef9f875e..e690cd7693 100644
--- a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
@@ -227,6 +227,7 @@ AMMClawback::applyGuts(Sandbox& sb)
                 sb,
                 *ammSle,
                 holder,
+                issuer,
                 ammAccount,
                 amountBalance,
                 amount2Balance,
@@ -256,7 +257,7 @@ AMMClawback::applyGuts(Sandbox& sb)
     }
 
     if (!isTesSuccess(result))
-        return result;  // LCOV_EXCL_LINE
+        return result;
 
     if (sb.rules().enabled(fixCleanup3_3_0) && sb.rules().enabled(fixAMMv1_3))
     {
@@ -311,6 +312,14 @@ AMMClawback::equalWithdrawMatchingOneAmount(
     STAmount const& holdLPtokens,
     STAmount const& amount)
 {
+    // The clawback issuer signs for its own asset only. Threaded into the
+    // withdrawal so a recreated MPToken is auto-authorized only for the
+    // clawback issuer's asset, never for a paired asset from another issuer.
+    // preflight guarantees sfAccount is the clawed asset's issuer (it rejects
+    // the tx as temMALFORMED when sfAsset's issuer != sfAccount), so this is
+    // the issuer, not just any signer.
+    AccountID const issuer = ctx_.tx[sfAccount];
+
     auto frac = Number{amount} / amountBalance;
     auto amount2Withdraw = amount2Balance * frac;
 
@@ -324,6 +333,7 @@ AMMClawback::equalWithdrawMatchingOneAmount(
             sb,
             ammSle,
             holder,
+            issuer,
             ammAccount,
             amountBalance,
             amount2Balance,
@@ -353,10 +363,18 @@ AMMClawback::equalWithdrawMatchingOneAmount(
 
         auto amountRounded = getRoundedAsset(rules, amountBalance, frac, IsDeposit::No);
 
+        // The requested clawback amount is likely too small and results in
+        // one-sided pool withdrawal due to round off. Fail so the issuer can
+        // clawback a larger amount.
+        if (rules.enabled(fixCleanup3_4_0) &&
+            (amountRounded == beast::kZero || amount2Rounded == beast::kZero))
+            return {tecAMM_FAILED, STAmount{}, STAmount{}, STAmount{}};
+
         return AMMWithdraw::withdraw(
             sb,
             ammSle,
             ammAccount,
+            issuer,
             holder,
             amountBalance,
             amountRounded,
@@ -377,6 +395,7 @@ AMMClawback::equalWithdrawMatchingOneAmount(
         sb,
         ammSle,
         ammAccount,
+        issuer,
         holder,
         amountBalance,
         amount,
diff --git a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
index 5294dd0c7f..edd2cc2037 100644
--- a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
@@ -19,6 +19,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -516,6 +517,7 @@ AMMWithdraw::withdraw(
         view,
         ammSle,
         ammAccount,
+        std::nullopt,
         accountID_,
         amountBalance,
         amountWithdraw,
@@ -536,6 +538,7 @@ AMMWithdraw::withdraw(
     Sandbox& view,
     SLE const& ammSle,
     AccountID const& ammAccount,
+    std::optional const& clawbackIssuer,
     AccountID const& account,
     STAmount const& amountBalance,
     STAmount const& amountWithdraw,
@@ -703,14 +706,48 @@ AMMWithdraw::withdraw(
         if (mptokenKey && account != asset.getIssuer())
         {
             auto const& mptIssue = asset.get();
+            std::uint32_t createFlags = 0;
             if (auto const err = requireAuth(view, mptIssue, account, AuthType::WeakAuth);
                 !isTesSuccess(err))
-                return err;
+            {
+                if (authHandling != AuthHandling::IgnoreAuth || err != tecNO_AUTH)
+                {
+                    // Unreachable in practice. Normal withdraws (authHandling
+                    // != IgnoreAuth) are rejected for unauthorized holders in
+                    // preclaim, so they never get here. Under clawback
+                    // (IgnoreAuth) requireAuth returns a non-tecNO_AUTH error
+                    // (e.g. tecEXPIRED) only for a domain-authorized MPT, but no
+                    // such MPT can be in an AMM pool: a directly domain-gated
+                    // RequireAuth MPT fails AMMCreate/deposit with tecNO_AUTH,
+                    // and vault shares (whose recursive auth could yield
+                    // tecEXPIRED) are rejected by AMMCreate with tecWRONG_ASSET.
+                    return err;  // LCOV_EXCL_LINE
+                }
 
-            if (auto const err = checkCreateMPT(view, mptIssue, account, {}, journal);
+                // AMMClawback ignores authorization so the issuer can recover
+                // MPT locked in the pool even if the holder deleted their
+                // MPToken. Only auto-authorize the recreated MPToken for the
+                // clawback issuer's own asset: authorization is granted by an
+                // asset's issuer, and the clawback transaction is signed by
+                // that issuer only for its own asset. For a paired asset issued
+                // by a different account, recreate the MPToken *unauthorized* so
+                // the clawback does not grant authorization on behalf of that
+                // issuer (which would bypass its lsfMPTRequireAuth). The holder
+                // still receives the paired asset (accountSend only requires the
+                // MPToken to exist, not to be authorized); the balance remains
+                // gated by its issuer until that issuer authorizes it.
+                if (clawbackIssuer && asset.getIssuer() == *clawbackIssuer)
+                    createFlags = lsfMPTAuthorized;
+            }
+
+            if (auto const err = checkCreateMPT(view, mptIssue, account, {}, createFlags, journal);
                 !isTesSuccess(err))
             {
-                return err;
+                // checkCreateMPT only fails on tecDIR_FULL (its source line is
+                // itself LCOV-excluded) or a missing account, which cannot
+                // happen since `account` is the withdrawing LP. Defensive and
+                // unreachable in practice.
+                return err;  // LCOV_EXCL_LINE
             }
         }
         return tesSUCCESS;
@@ -804,6 +841,7 @@ AMMWithdraw::equalWithdrawTokens(
         view,
         ammSle,
         accountID_,
+        std::nullopt,
         ammAccount,
         amountBalance,
         amount2Balance,
@@ -856,6 +894,7 @@ AMMWithdraw::equalWithdrawTokens(
     Sandbox& view,
     SLE const& ammSle,
     AccountID const account,
+    std::optional const& clawbackIssuer,
     AccountID const& ammAccount,
     STAmount const& amountBalance,
     STAmount const& amount2Balance,
@@ -878,6 +917,7 @@ AMMWithdraw::equalWithdrawTokens(
                 view,
                 ammSle,
                 ammAccount,
+                clawbackIssuer,
                 account,
                 amountBalance,
                 amountBalance,
@@ -913,6 +953,7 @@ AMMWithdraw::equalWithdrawTokens(
             view,
             ammSle,
             ammAccount,
+            clawbackIssuer,
             account,
             amountBalance,
             amountWithdraw,
diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
index 212f9da075..0fe27fb3ba 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
@@ -304,11 +304,11 @@ escrowCreatePreclaimHelper(
         return ter;
 
     // If the issuer has frozen the account, return tecLOCKED
-    if (isFrozen(ctx.view, account, mptIssue))
+    if (isFrozen(ctx.view, account, *sleIssuance))
         return tecLOCKED;
 
     // If the issuer has frozen the destination, return tecLOCKED
-    if (isFrozen(ctx.view, dest, mptIssue))
+    if (isFrozen(ctx.view, dest, *sleIssuance))
         return tecLOCKED;
 
     // If the mpt cannot be transferred, return tecNO_AUTH
diff --git a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
index 5fc0aef853..aa352d5e98 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
@@ -111,7 +111,7 @@ EscrowFinish::preflightSigValidated(PreflightContext const& ctx)
         }
     }
 
-    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
         return err;
 
     return tesSUCCESS;
@@ -186,7 +186,7 @@ escrowFinishPreclaimHelper(
         return ter;
 
     // If the issuer has frozen the destination, return tecLOCKED
-    if (isFrozen(ctx.view, dest, mptIssue))
+    if (isFrozen(ctx.view, dest, *sleIssuance))
         return tecLOCKED;
 
     return tesSUCCESS;
diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp
index b36977d225..433d77806a 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp
@@ -12,7 +12,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -198,8 +197,6 @@ LoanBrokerDelete::doApply()
 
     view().erase(broker);
 
-    associateAsset(*broker, vaultAsset);
-
     return tesSUCCESS;
 }
 
diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp
index 1a77489b4b..bc8e974d10 100644
--- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp
@@ -130,9 +130,6 @@ LoanDelete::doApply()
     // Decrement the borrower's owner count
     decreaseOwnerCountForObject(view, borrowerSle, loanSle, 1, j_);
 
-    // These associations shouldn't do anything, but do them just to be safe
-    associateAsset(*loanSle, vaultAsset);
-    associateAsset(*brokerSle, vaultAsset);
     associateAsset(*vaultSle, vaultAsset);
 
     return tesSUCCESS;
diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
index 4619540295..c5bfd8e9ee 100644
--- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
@@ -2,6 +2,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -9,6 +10,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -33,6 +36,34 @@
 
 namespace xrpl {
 
+namespace {
+// Returns the account's true, unclamped balance in `asset`, for use only in
+// fund-conservation checks. accountHolds(..., SpendableHandling::FullBalance)
+// cannot be used for this: for XRP it always defers to xrpLiquid, which
+// subtracts the account's reserve, so a payee sitting below its own reserve
+// would appear to receive nothing even though its raw ledger balance grew.
+// That mismatch is exactly what a conservation check must not see.
+STAmount
+conservationBalance(ReadView const& view, AccountID const& id, Asset const& asset, beast::Journal j)
+{
+    if (isXRP(asset))
+    {
+        auto const sle = view.read(keylet::account(id));
+        if (!sle)
+            return STAmount{asset};  // LCOV_EXCL_LINE
+        return view.balanceHookIOU(id, xrpAccount(), sle->getFieldAmount(sfBalance));
+    }
+    return accountHolds(
+        view,
+        id,
+        asset,
+        FreezeHandling::IgnoreFreeze,
+        AuthHandling::IgnoreAuth,
+        j,
+        SpendableHandling::FullBalance);
+}
+}  // namespace
+
 bool
 LoanPay::checkExtraFeatures(PreflightContext const& ctx)
 {
@@ -581,34 +612,13 @@ LoanPay::doApply()
     }
 
     // These three values are used to check that funds are conserved after the transfers
-    auto const accountBalanceBefore = accountHolds(
-        view,
-        accountID_,
-        asset,
-        FreezeHandling::IgnoreFreeze,
-        AuthHandling::IgnoreAuth,
-        j_,
-        SpendableHandling::FullBalance);
+    auto const accountBalanceBefore = conservationBalance(view, accountID_, asset, j_);
     auto const vaultBalanceBefore = accountID_ == vaultPseudoAccount
         ? STAmount{asset, 0}
-        : accountHolds(
-              view,
-              vaultPseudoAccount,
-              asset,
-              FreezeHandling::IgnoreFreeze,
-              AuthHandling::IgnoreAuth,
-              j_,
-              SpendableHandling::FullBalance);
+        : conservationBalance(view, vaultPseudoAccount, asset, j_);
     auto const brokerBalanceBefore = accountID_ == brokerPayee
         ? STAmount{asset, 0}
-        : accountHolds(
-              view,
-              brokerPayee,
-              asset,
-              FreezeHandling::IgnoreFreeze,
-              AuthHandling::IgnoreAuth,
-              j_,
-              SpendableHandling::FullBalance);
+        : conservationBalance(view, brokerPayee, asset, j_);
 
     if (totalPaidToVaultRounded != beast::kZero)
     {
@@ -664,33 +674,13 @@ LoanPay::doApply()
 #endif
 
     // Check that funds are conserved
-    auto const accountBalanceAfter = accountHolds(
-        view,
-        accountID_,
-        asset,
-        FreezeHandling::IgnoreFreeze,
-        AuthHandling::IgnoreAuth,
-        j_,
-        SpendableHandling::FullBalance);
+    auto const accountBalanceAfter = conservationBalance(view, accountID_, asset, j_);
     auto const vaultBalanceAfter = accountID_ == vaultPseudoAccount
         ? STAmount{asset, 0}
-        : accountHolds(
-              view,
-              vaultPseudoAccount,
-              asset,
-              FreezeHandling::IgnoreFreeze,
-              AuthHandling::IgnoreAuth,
-              j_,
-              SpendableHandling::FullBalance);
-    auto const brokerBalanceAfter = accountID_ == brokerPayee ? STAmount{asset, 0}
-                                                              : accountHolds(
-                                                                    view,
-                                                                    brokerPayee,
-                                                                    asset,
-                                                                    FreezeHandling::IgnoreFreeze,
-                                                                    AuthHandling::IgnoreAuth,
-                                                                    j_,
-                                                                    SpendableHandling::FullBalance);
+        : conservationBalance(view, vaultPseudoAccount, asset, j_);
+    auto const brokerBalanceAfter = accountID_ == brokerPayee
+        ? STAmount{asset, 0}
+        : conservationBalance(view, brokerPayee, asset, j_);
     auto const balanceScale = [&]() {
         // Find a reasonable scale to use for the balance comparisons.
         //
diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp
index 6533a47916..2def3d2eb2 100644
--- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp
@@ -10,6 +10,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -225,6 +226,8 @@ TER
 LoanSet::preclaim(PreclaimContext const& ctx)
 {
     auto const& tx = ctx.tx;
+    auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval);
+    auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal);
 
     {
         // Check for numeric overflow of the schedule before we load any
@@ -238,9 +241,6 @@ LoanSet::preclaim(PreclaimContext const& ctx)
         static_assert(kMaxTime == 4'294'967'295);
 
         auto const timeAvailable = kMaxTime - getStartDate(ctx.view);
-
-        auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval);
-        auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal);
         auto const grace = ctx.tx.at(~sfGracePeriod).value_or(kDefaultGracePeriod);
 
         // The grace period can't be larger than the interval. Check it first,
@@ -310,6 +310,32 @@ LoanSet::preclaim(PreclaimContext const& ctx)
         return tefBAD_LEDGER;  // LCOV_EXCL_LINE
     }
 
+    if (ctx.view.rules().enabled(featureLendingProtocolV1_1))
+    {
+        auto const phase = getVaultPhase(ctx.view, vault);
+        if (phase == VaultPhase::Subscription)
+        {
+            JLOG(ctx.j.warn()) << "Vault is still in the subscription phase.";
+            return tecTOO_SOON;
+        }
+        if (phase == VaultPhase::Redemption)
+        {
+            JLOG(ctx.j.warn()) << "Vault has entered the redemption phase.";
+            return tecEXPIRED;
+        }
+        if (phase == VaultPhase::Investment)
+        {
+            auto const finalPayment =
+                std::uint64_t{getStartDate(ctx.view)} + (std::uint64_t{interval} * total);
+            if (finalPayment >= vault->at(sfRedemptionDate))
+            {
+                JLOG(ctx.j.warn()) << "Final loan payment date is on or after "
+                                      "the vault's redemption date.";
+                return tecNO_PERMISSION;
+            }
+        }
+    }
+
     if (vault->at(sfAssetsMaximum) != 0 && vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum))
     {
         JLOG(ctx.j.warn()) << "Vault at maximum assets limit. Can't add another loan.";
diff --git a/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp b/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
index 41bb051768..0cf7af1463 100644
--- a/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
+++ b/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
@@ -8,12 +8,14 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -46,6 +48,13 @@ NFTokenAcceptOffer::preflight(PreflightContext const& ctx)
 
         if (*bf <= beast::kZero)
             return temMALFORMED;
+
+        if (ctx.rules.enabled(fixCleanup3_4_0))
+        {
+            // We don't allow a non-native currency to use the currency code XRP.
+            if (badAsset() == bf->asset())
+                return temBAD_CURRENCY;
+        }
     }
 
     return tesSUCCESS;
diff --git a/src/libxrpl/tx/transactors/payment/Payment.cpp b/src/libxrpl/tx/transactors/payment/Payment.cpp
index 17c96a1919..c8b00f0193 100644
--- a/src/libxrpl/tx/transactors/payment/Payment.cpp
+++ b/src/libxrpl/tx/transactors/payment/Payment.cpp
@@ -281,7 +281,7 @@ Payment::preflight(PreflightContext const& ctx)
         }
     }
 
-    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
         return err;
 
     return tesSUCCESS;
diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp
index b8118bc49f..9143a675f6 100644
--- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp
+++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp
@@ -87,7 +87,7 @@ PaymentChannelClaim::preflight(PreflightContext const& ctx)
             return temBAD_SIGNATURE;
     }
 
-    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
         return err;
 
     return tesSUCCESS;
diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp
index 0e036649fd..c3131714f8 100644
--- a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp
+++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp
@@ -8,6 +8,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -412,9 +413,24 @@ SponsorshipTransfer::doApply()
             if (!oldSponsorSle)
                 return tefINTERNAL;  // LCOV_EXCL_LINE
 
-            // The owner reclaims the reserve burden when the object is no longer sponsored.
-            // We do not check the sponsee's reserve here (via `checkReserve`) so that a sponsor can
-            // always end a sponsorship, even if the sponsee lacks sufficient reserve.
+            // The owner reclaims the reserve burden when the object is no longer
+            // sponsored, so it must be able to hold that reserve on its own once the
+            // sponsorship is removed. This mirrors the account-level End check below,
+            // keeping the behavior consistent across accounts and objects: a
+            // sponsorship can only be ended if the sponsee self-funds, another sponsor
+            // steps in (Reassign), or the object/account is deleted.
+            if (view().rules().enabled(fixCleanup3_4_0))
+            {
+                if (auto const ter = checkReserve(
+                        ctx_.getApplyViewContext(),
+                        sponseeSle,
+                        balanceBeforeFee(sponseeSle),
+                        SLE::pointer(),
+                        {.ownerCountDelta = ownerCountDelta},
+                        ctx_.journal);
+                    !isTesSuccess(ter))
+                    return ter;
+            }
 
             // Decrement sponsored count
             if (auto const ter = decrementSponsorCount(
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp
index 6366e99105..19ec99702a 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp
@@ -1,6 +1,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -70,7 +71,14 @@ ConfidentialMPTClawback::preclaim(PreclaimContext const& ctx)
 
     // Sanity check: account must be the same as issuer
     if (sleIssuance->getAccountID(sfIssuer) != account)
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTClawback::preclaim : preflight already validated the "
+            "submitter is the issuer");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // Check if issuance has issuer ElGamal public key
     if (!sleIssuance->isFieldPresent(sfIssuerEncryptionKey))
@@ -127,7 +135,14 @@ ConfidentialMPTClawback::doApply()
     auto sleHolderMPToken = view().peek(keylet::mptoken(mptIssuanceID, holder));
 
     if (!sleIssuance || !sleHolderMPToken)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTClawback::doApply : preclaim already validated these "
+            "objects exist");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const clawAmount = ctx_.tx[sfMPTAmount];
 
@@ -137,11 +152,25 @@ ConfidentialMPTClawback::doApply()
     // After clawback, the balance should be encrypted zero.
     auto const encZeroForHolder = encryptCanonicalZeroAmount(holderPubKey, holder, mptIssuanceID);
     if (!encZeroForHolder)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot fail "
+            "for an already-valid holder public key");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto encZeroForIssuer = encryptCanonicalZeroAmount(issuerPubKey, holder, mptIssuanceID);
     if (!encZeroForIssuer)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot fail "
+            "for an already-valid issuer public key");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // Set holder's confidential balances to encrypted zero
     (*sleHolderMPToken)[sfConfidentialBalanceInbox] = *encZeroForHolder;
@@ -154,14 +183,28 @@ ConfidentialMPTClawback::doApply()
         // Sanity check: the issuance must have an auditor public key if
         // auditing is enabled.
         if (!sleIssuance->isFieldPresent(sfAuditorEncryptionKey))
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::ConfidentialMPTClawback::doApply : the holder's auditor balance implies "
+                "the issuance has an auditor public key");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         auto const auditorPubKey = (*sleIssuance)[sfAuditorEncryptionKey];
 
         auto encZeroForAuditor = encryptCanonicalZeroAmount(auditorPubKey, holder, mptIssuanceID);
 
         if (!encZeroForAuditor)
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot "
+                "fail for an already-valid auditor public key");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         (*sleHolderMPToken)[sfAuditorEncryptedBalance] = std::move(*encZeroForAuditor);
     }
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp
index 454eb39ead..5be3892151 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp
@@ -3,6 +3,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -89,7 +90,14 @@ ConfidentialMPTConvert::preclaim(PreclaimContext const& ctx)
     // already checked in preflight, but should also check that issuer on the
     // issuance isn't the account either
     if (sleIssuance->getAccountID(sfIssuer) == account)
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvert::preclaim : issuer derived from the MPT ID must "
+            "match the ledger's stored issuer");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount);
     bool const requiresAuditor = sleIssuance->isFieldPresent(sfAuditorEncryptionKey);
@@ -207,11 +215,25 @@ ConfidentialMPTConvert::doApply()
 
     auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_));
     if (!sleMptoken)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvert::doApply : preclaim already validated the MPToken "
+            "exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleIssuance)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvert::doApply : preclaim already validated the issuance "
+            "exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const amtToConvert = ctx_.tx[sfMPTAmount];
     auto const amt = (*sleMptoken)[~sfMPTAmount].valueOr(0);
@@ -273,7 +295,14 @@ ConfidentialMPTConvert::doApply()
         if (auditorEc)
         {
             if (!sleMptoken->isFieldPresent(sfAuditorEncryptedBalance))
-                return tecINTERNAL;  // LCOV_EXCL_LINE
+            {
+                // LCOV_EXCL_START
+                UNREACHABLE(
+                    "xrpl::ConfidentialMPTConvert::doApply : issuance-level auditing implies "
+                    "the MPToken already carries an auditor balance");
+                return tecINTERNAL;
+                // LCOV_EXCL_STOP
+            }
 
             auto sum = homomorphicAdd(*auditorEc, (*sleMptoken)[sfAuditorEncryptedBalance]);
             if (!sum)
@@ -308,7 +337,14 @@ ConfidentialMPTConvert::doApply()
             (*sleMptoken)[sfHolderEncryptionKey], accountID_, mptIssuanceID);
 
         if (!zeroBalance)
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::ConfidentialMPTConvert::doApply : canonical zero encryption cannot fail "
+                "for an already-valid holder public key");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         (*sleMptoken)[sfConfidentialBalanceSpending] = std::move(*zeroBalance);
     }
@@ -316,7 +352,12 @@ ConfidentialMPTConvert::doApply()
     {
         // both sfIssuerEncryptedBalance and sfConfidentialBalanceInbox should
         // exist together
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvert::doApply : confidential balance fields must be all "
+            "present or all absent");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     view().update(sleIssuance);
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp
index 87f9e476d6..1e3617ffbd 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp
@@ -2,6 +2,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -72,7 +73,14 @@ verifyProofs(
     std::shared_ptr const& mptoken)
 {
     if (!mptoken->isFieldPresent(sfHolderEncryptionKey))
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifyProofs : preclaim already validated the holder encryption key is "
+            "present");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const mptIssuanceID = tx[sfMPTokenIssuanceID];
     auto const account = tx[sfAccount];
@@ -169,7 +177,14 @@ ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx)
     // already checked in preflight, but should also check that issuer on
     // the issuance isn't the account either
     if (sleIssuance->getAccountID(sfIssuer) == account)
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvertBack::preclaim : issuer derived from the MPT ID must "
+            "match the ledger's stored issuer");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, account));
     if (!sleMptoken)
@@ -185,7 +200,14 @@ ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx)
     // Sanity check: holder's MPToken must have auditor balance field if auditing
     // is enabled
     if (requiresAuditor && !sleMptoken->isFieldPresent(sfAuditorEncryptedBalance))
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvertBack::preclaim : issuance-level auditing implies the "
+            "MPToken already carries an auditor balance");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // if the total circulating confidential balance is smaller than what the
     // holder is trying to convert back, we know for sure this txn should
@@ -215,11 +237,25 @@ ConfidentialMPTConvertBack::doApply()
 
     auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_));
     if (!sleMptoken)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvertBack::doApply : preclaim already validated the "
+            "MPToken exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleIssuance)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvertBack::doApply : preclaim already validated the "
+            "issuance exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const amtToConvertBack = ctx_.tx[sfMPTAmount];
     auto const amt = (*sleMptoken)[~sfMPTAmount].valueOr(0);
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp
index 0b98382a61..6485578cb4 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp
@@ -2,6 +2,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -49,7 +50,14 @@ ConfidentialMPTMergeInbox::preclaim(PreclaimContext const& ctx)
     // already checked in preflight, but should also check that issuer on the
     // issuance isn't the account either
     if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount])
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTMergeInbox::preclaim : issuer derived from the MPT ID must "
+            "match the ledger's stored issuer");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const sleMptoken =
         ctx.view.read(keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], ctx.tx[sfAccount]));
@@ -82,14 +90,26 @@ ConfidentialMPTMergeInbox::doApply()
     auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID];
     auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_));
     if (!sleMptoken)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTMergeInbox::doApply : preclaim already validated the "
+            "MPToken exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // sanity check
     if (!sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) ||
         !sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) ||
         !sleMptoken->isFieldPresent(sfHolderEncryptionKey))
     {
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTMergeInbox::doApply : preclaim already validated these "
+            "fields are present");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     // Merge inbox into spending: spending = spending + inbox
@@ -114,7 +134,14 @@ ConfidentialMPTMergeInbox::doApply()
         encryptCanonicalZeroAmount((*sleMptoken)[sfHolderEncryptionKey], accountID_, mptIssuanceID);
 
     if (!zeroEncryption)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTMergeInbox::doApply : canonical zero encryption cannot fail "
+            "for an already-valid holder public key");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     (*sleMptoken)[sfConfidentialBalanceInbox] = std::move(*zeroEncryption);
 
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp
index d121ec2634..e713ae5029 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp
@@ -2,6 +2,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -82,7 +83,7 @@ ConfidentialMPTSend::preflight(PreflightContext const& ctx)
     if (hasAuditor && !isValidCiphertext(ctx.tx[sfAuditorEncryptedAmount]))
         return temBAD_CIPHERTEXT;
 
-    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
         return err;
 
     return tesSUCCESS;
@@ -105,7 +106,14 @@ verifySendProofs(
 {
     // Sanity check
     if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::detail::verifySendProofs : caller must pre-validate sender/destination/"
+            "issuance existence");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount);
 
@@ -204,7 +212,14 @@ ConfidentialMPTSend::preclaim(PreclaimContext const& ctx)
 
     // Sanity check: issuer isn't the sender
     if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount])
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTSend::preclaim : issuer derived from the MPT ID must match "
+            "the ledger's stored issuer");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // Check sender's MPToken existence
     auto const sleSenderMPToken = ctx.view.read(keylet::mptoken(mptIssuanceID, account));
@@ -238,7 +253,12 @@ ConfidentialMPTSend::preclaim(PreclaimContext const& ctx)
         (!sleSenderMPToken->isFieldPresent(sfAuditorEncryptedBalance) ||
          !sleDestinationMPToken->isFieldPresent(sfAuditorEncryptedBalance)))
     {
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTSend::preclaim : issuance-level auditing implies both "
+            "MPTokens already carry an auditor balance");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     // Check lock
@@ -283,7 +303,14 @@ ConfidentialMPTSend::doApply()
     auto const sleDestAcct = view().read(keylet::account(destination));
 
     if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance || !sleDestAcct)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTSend::doApply : preclaim already validated these objects "
+            "exist");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // Deposit preauth authorization was already verified in preclaim.
     // Remove any expired credentials.
@@ -353,7 +380,13 @@ ConfidentialMPTSend::doApply()
         auto rerandomizedDestEc = rerandomizeCiphertext(
             destEc, (*sleDestinationMPToken)[sfHolderEncryptionKey], sendChallenge);
         if (!rerandomizedDestEc)
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed to rerandomize destination inbox ciphertext.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         auto const curInbox = (*sleDestinationMPToken)[sfConfidentialBalanceInbox];
         auto newInbox = homomorphicAdd(curInbox, *rerandomizedDestEc);
@@ -374,7 +407,13 @@ ConfidentialMPTSend::doApply()
         auto rerandomizedIssuerEc =
             rerandomizeCiphertext(issuerEc, (*sleIssuance)[sfIssuerEncryptionKey], sendChallenge);
         if (!rerandomizedIssuerEc)
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed to rerandomize destination issuer ciphertext.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         auto const curIssuerEnc = (*sleDestinationMPToken)[sfIssuerEncryptedBalance];
         auto newIssuerEnc = homomorphicAdd(curIssuerEnc, *rerandomizedIssuerEc);
@@ -396,7 +435,13 @@ ConfidentialMPTSend::doApply()
         auto rerandomizedAuditorEc = rerandomizeCiphertext(
             *auditorEc, (*sleIssuance)[sfAuditorEncryptionKey], sendChallenge);
         if (!rerandomizedAuditorEc)
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed to rerandomize destination auditor ciphertext.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         auto const curAuditorEnc = (*sleDestinationMPToken)[sfAuditorEncryptedBalance];
         auto newAuditorEnc = homomorphicAdd(curAuditorEnc, *rerandomizedAuditorEc);
diff --git a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
index 0aeb6f33d1..c19b8f64d7 100644
--- a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
+++ b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
@@ -37,6 +37,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
 {
     auto const accountID = ctx.tx[sfAccount];
     auto const holderID = ctx.tx[~sfHolder];
+    auto const sleMptIssuance = ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
 
     // if non-issuer account submits this tx, then they are trying either:
     // 1. Unauthorize/delete MPToken
@@ -51,9 +52,8 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
 
         // There is an edge case where all holders have zero balance, issuance
         // is legally destroyed, then outstanding MPT(s) are deleted afterwards.
-        // Thus, there is no need to check for the existence of the issuance if
-        // the MPT is being deleted with a zero balance. Check for unauthorize
-        // before fetching the MPTIssuance object.
+        // Thus, the unauthorize/delete path below does not require the issuance
+        // to exist when the MPT is being deleted with a zero balance.
 
         // if holder wants to delete/unauthorize a mpt
         if (ctx.tx.isFlag(tfMPTUnauthorize))
@@ -63,8 +63,6 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
 
             if ((*sleMpt)[sfMPTAmount] != 0)
             {
-                auto const sleMptIssuance =
-                    ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
                 if (!sleMptIssuance)
                     return tefINTERNAL;  // LCOV_EXCL_LINE
 
@@ -73,21 +71,24 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
 
             if ((*sleMpt)[~sfLockedAmount].value_or(0) != 0)
             {
-                auto const sleMptIssuance =
-                    ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
                 if (!sleMptIssuance)
                     return tefINTERNAL;  // LCOV_EXCL_LINE
 
                 return tecHAS_OBLIGATIONS;
             }
-            if (ctx.view.rules().enabled(featureSingleAssetVault) && sleMpt->isFlag(lsfMPTLocked))
+            if (ctx.view.rules().enabled(fixCleanup3_4_0))
+            {
+                if (sleMptIssuance && sleMpt->isFlag(lsfMPTLocked))
+                    return tecNO_PERMISSION;
+            }
+            else if (
+                ctx.view.rules().enabled(featureSingleAssetVault) && sleMpt->isFlag(lsfMPTLocked))
+            {
                 return tecNO_PERMISSION;
+            }
 
             if (ctx.view.rules().enabled(featureConfidentialTransfer))
             {
-                auto const sleMptIssuance =
-                    ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
-
                 // if there still existing encrypted balances of MPT in
                 // circulation
                 if (sleMptIssuance &&
@@ -106,9 +107,6 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
         }
 
         // Now test when the holder wants to hold/create/authorize a new MPT
-        auto const sleMptIssuance =
-            ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
-
         if (!sleMptIssuance)
             return tecOBJECT_NOT_FOUND;
 
@@ -126,7 +124,6 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
     if (!sleHolder)
         return tecNO_DST;
 
-    auto const sleMptIssuance = ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
     if (!sleMptIssuance)
         return tecOBJECT_NOT_FOUND;
 
diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
index d77286b667..d0eeaed071 100644
--- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
@@ -383,6 +383,20 @@ VaultClawback::doApply()
     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)))
+    {
+        JLOG(j_.debug()) << "VaultClawback: clawback amount too small to change stored vault"
+                            " balance";
+        return tecPRECISION_LOSS;
+    }
+
     assetsTotal -= assetsRecovered;
     assetsAvailable -= assetsRecovered;
     view().update(vault);
diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp
index f74a27c39b..7ade4ed5ab 100644
--- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp
@@ -8,6 +8,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -43,6 +44,11 @@ VaultCreate::checkExtraFeatures(PreflightContext const& ctx)
     if (ctx.tx.isFieldPresent(sfDomainID) && !ctx.rules.enabled(featurePermissionedDomains))
         return false;
 
+    if (!ctx.rules.enabled(featureLendingProtocolV1_1) &&
+        (ctx.tx.isFieldPresent(sfVaultKind) || ctx.tx.isFieldPresent(sfSubscriptionDate) ||
+         ctx.tx.isFieldPresent(sfRedemptionDate)))
+        return false;
+
     return true;
 }
 
@@ -99,6 +105,22 @@ VaultCreate::preflight(PreflightContext const& ctx)
             return temMALFORMED;
     }
 
+    if (!isValidVaultKind(ctx.tx))
+        return temMALFORMED;
+    auto const kind = getVaultKind(ctx.tx);
+    auto const hasSubscription = ctx.tx.isFieldPresent(sfSubscriptionDate);
+    auto const hasRedemption = ctx.tx.isFieldPresent(sfRedemptionDate);
+    auto const isClosedEnded = kind == VaultKind::ClosedEnded;
+    if (!isClosedEnded && (hasSubscription || hasRedemption))
+        return temMALFORMED;
+    if (isClosedEnded)
+    {
+        if (!hasSubscription || !hasRedemption)
+            return temMALFORMED;
+        if (!isValidClosedEndedGap(ctx.tx[sfSubscriptionDate], ctx.tx[sfRedemptionDate]))
+            return temMALFORMED;
+    }
+
     return tesSUCCESS;
 }
 
@@ -136,6 +158,16 @@ VaultCreate::preclaim(PreclaimContext const& ctx)
         accountId == beast::kZero)
         return terADDRESS_COLLISION;
 
+    // preflight enforces red >= sub + kMinInvestmentPeriod for closed-ended
+    // vaults, so a past RedemptionDate always implies a strictly-earlier,
+    // equally-past SubscriptionDate. The RedemptionDate arm below is therefore
+    // defensive: it cannot be the sole cause of tecEXPIRED. It is kept to
+    // preserve the invariant locally in case the preflight gap check is ever
+    // weakened.
+    if (hasExpired(ctx.view, ctx.tx[~sfSubscriptionDate]) ||
+        hasExpired(ctx.view, ctx.tx[~sfRedemptionDate]))
+        return tecEXPIRED;
+
     return tesSUCCESS;
 }
 
@@ -242,7 +274,17 @@ VaultCreate::doApply()
     if (scale != 0u)
         vault->at(sfScale) = scale;
     if (view().rules().enabled(featureLendingProtocolV1_1))
+    {
         vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis);
+
+        auto const kind = getVaultKind(tx);
+        vault->at(sfVaultKind) = std::to_underlying(kind);
+        if (kind == VaultKind::ClosedEnded)
+        {
+            vault->at(sfSubscriptionDate) = tx[sfSubscriptionDate];
+            vault->at(sfRedemptionDate) = tx[sfRedemptionDate];
+        }
+    }
     view().insert(vault);
 
     // Explicitly create MPToken for the vault owner
diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
index aa9cfc8537..a3c0a94eb5 100644
--- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
@@ -13,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -71,6 +72,17 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
     if (!vault)
         return tecNO_ENTRY;
 
+    if (ctx.view.rules().enabled(featureLendingProtocolV1_1))
+    {
+        auto const phase = getVaultPhase(ctx.view, vault);
+        if (phase == VaultPhase::Investment || phase == VaultPhase::Redemption)
+        {
+            JLOG(ctx.j.debug()) << "VaultDeposit: vault deposit is not allowed in the investment "
+                                   "or redemption phase.";
+            return tecEXPIRED;
+        }
+    }
+
     auto const& account = ctx.tx[sfAccount];
     auto const amount = ctx.tx[sfAmount];
     auto const vaultAsset = vault->at(sfAsset);
diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
index 353b72c30d..7e32e720d6 100644
--- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
@@ -73,6 +73,16 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
     if (!vault)
         return tecNO_ENTRY;
 
+    if (ctx.view.rules().enabled(featureLendingProtocolV1_1))
+    {
+        if (getVaultPhase(ctx.view, vault) == VaultPhase::Investment)
+        {
+            JLOG(ctx.j.debug())
+                << "VaultWithdraw: vault withdrawal is not allowed in the investment phase.";
+            return tecTOO_SOON;
+        }
+    }
+
     auto const amount = ctx.tx[sfAmount];
     auto const vaultAsset = vault->at(sfAsset);
     auto const vaultShare = vault->at(sfShareMPTID);
@@ -273,6 +283,44 @@ VaultWithdraw::doApply()
         return tecPATH_DRY;
     }
 
+    // The "final withdrawal" rule below handles its own zero-value case using
+    // sfAssetsAvailable directly, so it is exempt from the checks below.
+    bool const isFinalWithdrawal =
+        sharesRedeemed == STAmount{share, sleIssuance->at(sfOutstandingAmount)};
+
+    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)
+    {
+        // 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.
+        if (amount.asset() == share && assetsWithdrawn == beast::kZero &&
+            assetsTotalForWithdrawal(vault, waiveUnrealizedLoss) != beast::kZero)
+        {
+            JLOG(j_.debug()) << "VaultWithdraw: fixed-share withdrawal rounds to zero assets";
+            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))
+        {
+            JLOG(j_.debug()) << "VaultWithdraw: withdrawal amount too small to change stored"
+                                " vault balance";
+            return tecPRECISION_LOSS;
+        }
+    }
+
     // Post-fixCleanup3_3_0: preclaim already validated all freeze conditions
     // (checkWithdrawFreeze), so IgnoreFreeze avoids a redundant check that
     // would incorrectly return zero for vault pseudo-accounts whose shares
@@ -287,13 +335,6 @@ VaultWithdraw::doApply()
         return tecINSUFFICIENT_FUNDS;
     }
 
-    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");
-
     // The vault must have enough assets on hand.
     if (*assetsAvailable < assetsWithdrawn)
     {
@@ -309,8 +350,6 @@ VaultWithdraw::doApply()
     // 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.
-    bool const isFinalWithdrawal =
-        sharesRedeemed == STAmount{share, sleIssuance->at(sfOutstandingAmount)};
     if (view().rules().enabled(fixCleanup3_2_0) && isFinalWithdrawal)
     {
         // Unreachable: a final withdrawal with lossUnrealized > 0 has
diff --git a/src/test/app/AMMCalc_test.cpp b/src/test/app/AMMCalc_test.cpp
index 74080e669c..23f251d57a 100644
--- a/src/test/app/AMMCalc_test.cpp
+++ b/src/test/app/AMMCalc_test.cpp
@@ -20,6 +20,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -188,8 +189,7 @@ class AMMCalc_test : public beast::unit_test::Suite
     static std::string
     toString(STAmount const& a)
     {
-        return (boost::format("%s/%s") % a.getText() % ::xrpl::to_string(a.get().currency))
-            .str();
+        return std::format("{}/{}", a.getText(), ::xrpl::to_string(a.get().currency));
     }
 
     static STAmount
diff --git a/src/test/app/AMMClawbackMPT_test.cpp b/src/test/app/AMMClawbackMPT_test.cpp
index 6facafde4a..1d75c4db22 100644
--- a/src/test/app/AMMClawbackMPT_test.cpp
+++ b/src/test/app/AMMClawbackMPT_test.cpp
@@ -16,6 +16,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -137,7 +139,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             AMM amm(env, gw, btc(100), usd(100));
             env.close();
             amm.deposit(alice, 1'000);
-            env.close();
 
             // can not clawback when tfMPTCanClawback is not enabled
             env(amm::ammClawback(gw, alice, btc, usd, std::nullopt), Ter(tecNO_PERMISSION));
@@ -503,6 +504,150 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testAMMClawbackAmountRoundsToZero(FeatureBitset features)
+    {
+        // Ensure a clawback that rounds down to zero MPT fails with
+        // tecAMM_FAILED instead of silently burning the holder's LP.
+        testcase("test AMMClawback amount that rounds down to zero");
+        using namespace jtx;
+
+        Env env(*this, features);
+        Account const gw{"gateway"};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        env.fund(XRP(10'000'000), gw, alice, bob);
+        env.close();
+
+        env(fset(gw, asfAllowTrustLineClawback));
+        env.close();
+
+        // The clawed asset (amountRounded) rounds to zero while its XRP
+        // counterpart is always large.
+        {
+            MPTTester const mptBtc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, bob},
+                 .pay = 1'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+            MPT const btc = mptBtc;
+
+            AMM amm(env, alice, btc(3), XRP(333'000));
+            amm.deposit(bob, btc(3), XRP(333'000));
+
+            [[maybe_unused]] auto const [poolBtcBefore, poolXrpBefore, lptBefore] = amm.balances();
+            BEAST_EXPECT(poolBtcBefore == btc(6));
+
+            auto const issuerOABefore = mptBtc.getBalance(gw);
+            auto const aliceLpBefore = amm.getLPTokensBalance(alice.id());
+            auto const bobLpBefore = amm.getLPTokensBalance(bob.id());
+
+            // Attempt to clawback 1/6th of the BTC pool. When the zero-rounding
+            // guard is active (gated by fixCleanup3_4_0) the rounded amount
+            // drops to 0 and should trigger tecAMM_FAILED.
+            env(amm::ammClawback(gw, alice, btc, XRP, btc(1)),
+                Ter(features[fixCleanup3_4_0] ? TER{tecAMM_FAILED} : TER{tesSUCCESS}));
+            env.close();
+
+            [[maybe_unused]] auto const [poolBtcAfter, poolXrpAfter, lptAfter] = amm.balances();
+            auto const issuerOAAfter = mptBtc.getBalance(gw);
+            auto const aliceLpAfter = amm.getLPTokensBalance(alice.id());
+            auto const bobLpAfter = amm.getLPTokensBalance(bob.id());
+
+            if (features[fixCleanup3_4_0])
+            {
+                // Post-fixCleanup3_4_0: Clawback fails because the BTC balance
+                // would round to zero. All balances must remain untouched.
+                BEAST_EXPECT(poolBtcAfter == poolBtcBefore);
+                BEAST_EXPECT(poolXrpAfter == poolXrpBefore);
+                BEAST_EXPECT(issuerOAAfter == issuerOABefore);
+                BEAST_EXPECT(aliceLpAfter == aliceLpBefore);
+                BEAST_EXPECT(bobLpAfter == bobLpBefore);
+            }
+            else
+            {
+                // Pre-fixCleanup3_4_0: BTC rounds to zero and the clawback
+                // silently burns alice's LP without clawing back any BTC.
+                BEAST_EXPECT(poolBtcAfter == poolBtcBefore);
+                BEAST_EXPECT(poolXrpAfter < poolXrpBefore);
+                BEAST_EXPECT(issuerOAAfter == issuerOABefore);
+                BEAST_EXPECT(aliceLpAfter < aliceLpBefore);
+                BEAST_EXPECT(bobLpAfter == bobLpBefore);
+            }
+        }
+
+        // The pool above only ever rounds the clawed asset (amountRounded) to
+        // zero; its XRP counterpart is always large. Exercise the other operand
+        // of the guard (amount2Rounded == 0) with an MPT/MPT pool where the
+        // *paired* asset is the tiny integer that floors to zero while the
+        // clawed asset still rounds non-zero.
+        {
+            Account const carol{"carol"};
+            Account const dan{"dan"};
+            env.fund(XRP(10'000'000), carol, dan);
+            env.close();
+
+            MPTTester const mptBtc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {carol, dan},
+                 .pay = 100'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+            MPT const btc = mptBtc;
+
+            MPTTester const mptEth(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {carol, dan},
+                 .pay = 1'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+            MPT const eth = mptEth;
+
+            // btc pool dwarfs the eth pool, so a ~1/12th claw withdraws a
+            // non-zero btc amount while the eth counterpart rounds to zero.
+            AMM amm(env, carol, btc(3'000), eth(3));
+            amm.deposit(dan, btc(3'000), eth(3));
+
+            [[maybe_unused]] auto const [poolBtcBefore, poolEthBefore, lptBefore] = amm.balances();
+            BEAST_EXPECT(poolBtcBefore == btc(6'000));
+            BEAST_EXPECT(poolEthBefore == eth(6));
+
+            auto const carolLpBefore = amm.getLPTokensBalance(carol.id());
+            auto const danLpBefore = amm.getLPTokensBalance(dan.id());
+
+            env(amm::ammClawback(gw, carol, btc, eth, btc(500)),
+                Ter(features[fixCleanup3_4_0] ? TER{tecAMM_FAILED} : TER{tesSUCCESS}));
+            env.close();
+
+            [[maybe_unused]] auto const [poolBtcAfter, poolEthAfter, lptAfter] = amm.balances();
+            auto const carolLpAfter = amm.getLPTokensBalance(carol.id());
+            auto const danLpAfter = amm.getLPTokensBalance(dan.id());
+
+            if (features[fixCleanup3_4_0])
+            {
+                // Post-fixCleanup3_4_0: clawback fails because the ETH (Asset2)
+                // balance would round to zero (guard fires via
+                // amount2Rounded == 0). All balances must remain untouched.
+                BEAST_EXPECT(poolBtcAfter == poolBtcBefore);
+                BEAST_EXPECT(poolEthAfter == poolEthBefore);
+                BEAST_EXPECT(carolLpAfter == carolLpBefore);
+                BEAST_EXPECT(danLpAfter == danLpBefore);
+            }
+            else
+            {
+                // Pre-fixCleanup3_4_0: the asymmetric round-off goes through.
+                // btc is clawed (non-zero) but eth rounds to zero, so the eth
+                // pool is untouched while carol's LP is burned. This asymmetry
+                // proves amount2Rounded == 0 is the trigger.
+                BEAST_EXPECT(poolBtcAfter < poolBtcBefore);
+                BEAST_EXPECT(poolEthAfter == poolEthBefore);
+                BEAST_EXPECT(carolLpAfter < carolLpBefore);
+                BEAST_EXPECT(danLpAfter == danLpBefore);
+            }
+        }
+    }
+
     void
     testAMMClawbackAll(FeatureBitset features)
     {
@@ -543,7 +688,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
 
             // gw clawback all BTC from alice
             amm.deposit(bob, btc(1'000'000000), usd(2000));
-            env.close();
             BEAST_EXPECT(amm.expectBalances(btc(3'000'000000), usd(3000), IOUAmount(3000000)));
 
             auto aliceBTC = env.balance(alice, btc);
@@ -921,7 +1065,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             BEAST_EXPECT(amm.expectBalances(btc(2'000'000000), usd(8'000), IOUAmount(4'000'000)));
 
             amm.deposit(bob, btc(1'000'000000), usd(4'000));
-            env.close();
             BEAST_EXPECT(amm.expectBalances(btc(3'000'000000), usd(12'000), IOUAmount(6'000'000)));
 
             auto aliceBTC = env.balance(alice, btc);
@@ -1335,6 +1478,60 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testClawbackCreatesMissingMPToken(FeatureBitset features)
+    {
+        testcase("test AMMClawback creates missing MPToken");
+        using namespace jtx;
+
+        auto test = [&](std::optional const clawAmount) {
+            Env env{*this, features};
+            Account const gw{"gateway"};
+            Account const alice{"alice"};
+            env.fund(XRP(1'000'000), gw, alice);
+            env.close();
+
+            MPTTester token(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice},
+                 .pay = 1'000,
+                 .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags,
+                 .authHolder = true});
+
+            AMM ammAlice(env, alice, token(1'000), XRP(1'000));
+            env.close();
+            BEAST_EXPECT(env.balance(alice, token) == token(0));
+
+            // The holder can delete the zero-balance MPToken while still
+            // holding LP tokens. A regular AMMWithdraw remains subject to
+            // RequireAuth and cannot recreate the missing token.
+            token.authorize({.account = alice, .flags = tfMPTUnauthorize});
+            env.close();
+            BEAST_EXPECT(!env.le(keylet::mptoken(token.issuanceID(), alice.id())));
+            ammAlice.withdrawAll(alice, std::nullopt, Ter(tecNO_AUTH));
+            env.close();
+            BEAST_EXPECT(!env.le(keylet::mptoken(token.issuanceID(), alice.id())));
+
+            // AMMClawback ignores authorization and must be able to recreate
+            // the holder MPToken so the issuer can recover MPT from the pool.
+            std::optional amount;
+            if (clawAmount)
+                amount = token(*clawAmount);
+            env(amm::ammClawback(gw, alice, token, XRP, amount));
+            env.close();
+
+            auto const sleMpt = env.le(keylet::mptoken(token.issuanceID(), alice.id()));
+            BEAST_EXPECT(sleMpt && sleMpt->isFlag(lsfMPTAuthorized));
+            env.require(Balance(alice, token(0)));
+
+            BEAST_EXPECT(clawAmount ? ammAlice.ammExists() : !ammAlice.ammExists());
+        };
+
+        test(std::nullopt);
+        test(400);
+    }
+
     void
     testSingleDepositAndClawback(FeatureBitset features)
     {
@@ -1361,7 +1558,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             env.close();
             BEAST_EXPECT(amm.expectBalances(XRP(100), btc(400), IOUAmount(200000)));
             amm.deposit(alice, btc(400));
-            env.close();
             BEAST_EXPECT(amm.expectBalances(XRP(100), btc(800), IOUAmount{282842'712474619, -9}));
 
             auto aliceBTC = env.balance(alice, MPT(btc));
@@ -1407,7 +1603,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             env.close();
             BEAST_EXPECT(amm.expectBalances(usd(100), btc(400), IOUAmount(200)));
             amm.deposit(alice, btc(400));
-            env.close();
             BEAST_EXPECT(amm.expectBalances(usd(100), btc(800), IOUAmount{282'842712474619, -12}));
 
             auto aliceBTC = env.balance(alice, MPT(btc));
@@ -1462,7 +1657,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             env.close();
             BEAST_EXPECT(amm.expectBalances(usd(100), btc(400), IOUAmount(200)));
             amm.deposit(alice, btc(400));
-            env.close();
             BEAST_EXPECT(amm.expectBalances(usd(100), btc(800), IOUAmount{282'842712474619, -12}));
 
             auto aliceBTC = env.balance(alice, MPT(btc));
@@ -1669,7 +1863,7 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             env(amm::ammClawback(gw, alice, btc, usd, std::nullopt), Ter(tecNO_PERMISSION));
 
             // Although USD is clawable with asfAllowTrustLineClawback.
-            // When tfClawTwoAssets is set, we will claw Asser2 as well.
+            // When tfClawTwoAssets is set, we will claw Asset2 as well.
             // But Asset2 is not clawable. tfMPTCanClawback was not set for BTC.
             env(amm::ammClawback(gw, alice, usd, btc, std::nullopt),
                 Txflags(tfClawTwoAssets),
@@ -1811,6 +2005,199 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
         }
     }
 
+    // Test that AMMClawback succeeds when the LP has previously deleted both
+    // zero-balance MPToken objects in an MPT/MPT pool.  The fix changes the
+    // ValidMPTIssuance invariant threshold from > 1 to > 2 so that the two
+    // MPToken creations triggered by the internal AMMWithdraw are permitted.
+    void
+    testClawbackAfterDeletingMPTokens(FeatureBitset features)
+    {
+        testcase("test AMMClawback after holder deletes zero-balance MPTokens");
+        using namespace jtx;
+
+        // Partial clawback (one asset): verify both MPTokens are recreated and
+        // the non-claw asset is returned to alice.
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const alice{"alice"};
+            env.fund(XRP(100'000), gw, alice);
+            env.close();
+
+            MPTTester btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice},
+                 .pay = 10'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+
+            MPTTester eth(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice},
+                 .pay = 10'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+
+            // Alice deposits everything into the MPT/MPT pool; her MPT
+            // balances drop to zero.
+            AMM const amm(env, alice, btc(10'000), eth(10'000));
+            env.close();
+            BEAST_EXPECT(amm.expectBalances(btc(10'000), eth(10'000), IOUAmount{10'000}));
+
+            auto aliceBTC = env.balance(alice, btc);
+            auto aliceETH = env.balance(alice, eth);
+            BEAST_EXPECT(aliceBTC == btc(0));
+            BEAST_EXPECT(aliceETH == eth(0));
+
+            // Alice deletes both zero-balance MPTokens to reclaim reserves.
+            btc.authorize({.account = alice, .flags = tfMPTUnauthorize});
+            eth.authorize({.account = alice, .flags = tfMPTUnauthorize});
+            BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id())));
+            BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id())));
+
+            // gw claws back some BTC from alice's share in the pool.
+            // AMMWithdraw internally creates both missing MPTokens
+            // (mptokensCreated_ == 2); the invariant (> 2) allows this.
+            env(amm::ammClawback(gw, alice, btc, eth, btc(1'000)));
+            env.close();
+
+            // Both MPToken objects must have been recreated.
+            BEAST_EXPECT(env.le(keylet::mptoken(btc.issuanceID(), alice.id())));
+            BEAST_EXPECT(env.le(keylet::mptoken(eth.issuanceID(), alice.id())));
+
+            // The non-claw asset (eth) was returned to alice.
+            BEAST_EXPECT(env.balance(alice, eth) > aliceETH);
+            // The claw asset (btc) was burned; alice's btc balance stays 0.
+            env.require(Balance(alice, aliceBTC));
+            BEAST_EXPECT(amm.ammExists());
+        }
+
+        // Full clawback (two assets, tfClawTwoAssets): verify both MPTokens
+        // are recreated and the AMM is deleted when fully drained.
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const alice{"alice"};
+            env.fund(XRP(100'000), gw, alice);
+            env.close();
+
+            MPTTester btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice},
+                 .pay = 10'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+
+            MPTTester eth(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice},
+                 .pay = 10'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+
+            AMM const amm(env, alice, btc(10'000), eth(10'000));
+            env.close();
+
+            auto aliceBTC = env.balance(alice, btc);
+            auto aliceETH = env.balance(alice, eth);
+
+            btc.authorize({.account = alice, .flags = tfMPTUnauthorize});
+            eth.authorize({.account = alice, .flags = tfMPTUnauthorize});
+            BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id())));
+            BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id())));
+
+            // Full two-asset clawback: both assets are clawed and alice
+            // receives nothing back.  The AMM should be empty and deleted.
+            env(amm::ammClawback(gw, alice, btc, eth, std::nullopt), Txflags(tfClawTwoAssets));
+            env.close();
+
+            BEAST_EXPECT(!amm.ammExists());
+            // Both assets were clawed; alice's balances remain at zero.
+            env.require(Balance(alice, aliceBTC));
+            env.require(Balance(alice, aliceETH));
+        }
+    }
+
+    void
+    testClawbackCrossIssuerPairedAssetAuth(FeatureBitset features)
+    {
+        testcase("test AMMClawback recreates paired-issuer MPToken unauthorized");
+        using namespace jtx;
+
+        // Cross-issuer MPT/MPT pool: btc is issued by gw, eth by gw2, and both
+        // require authorization. Alice deposits her entire balance of both and
+        // deletes the resulting zero-balance MPTokens. When gw claws back its
+        // own asset (btc), the two-asset withdrawal must recreate both of
+        // Alice's MPTokens so the pool can pay her the paired asset. The
+        // recreated MPToken may only be auto-authorized for the clawback
+        // issuer's own asset (btc); the paired asset's issuer (gw2) never
+        // consented, so eth must be recreated *unauthorized*, leaving gw2 in
+        // control of its own token and preserving its RequireAuth guarantee.
+        Env env(*this, features);
+        Account const gw{"gateway"};
+        Account const gw2{"gateway2"};
+        Account const alice{"alice"};
+        env.fund(XRP(100'000), gw, gw2, alice);
+        env.close();
+
+        MPTTester btc(
+            {.env = env,
+             .issuer = gw,
+             .holders = {alice},
+             .pay = 10'000,
+             .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags,
+             .authHolder = true});
+
+        MPTTester eth(
+            {.env = env,
+             .issuer = gw2,
+             .holders = {alice},
+             .pay = 10'000,
+             .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags,
+             .authHolder = true});
+
+        // Alice deposits everything into the pool; her MPT balances drop to 0.
+        AMM const amm(env, alice, btc(10'000), eth(10'000));
+        env.close();
+        BEAST_EXPECT(amm.expectBalances(btc(10'000), eth(10'000), IOUAmount{10'000}));
+        BEAST_EXPECT(env.balance(alice, btc) == btc(0));
+        BEAST_EXPECT(env.balance(alice, eth) == eth(0));
+
+        // Alice deletes both zero-balance MPTokens to reclaim reserves.
+        btc.authorize({.account = alice, .flags = tfMPTUnauthorize});
+        eth.authorize({.account = alice, .flags = tfMPTUnauthorize});
+        BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id())));
+        BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id())));
+
+        // gw (issuer of btc) claws back part of Alice's btc. This is a
+        // cross-issuer pool, so tfClawTwoAssets is not permitted: only btc is
+        // clawed back, while the paired eth is returned to Alice.
+        env(amm::ammClawback(gw, alice, btc, eth, btc(1'000)));
+        env.close();
+
+        // Both MPTokens were recreated so the withdrawal could pay Alice.
+        auto const sleBtc = env.le(keylet::mptoken(btc.issuanceID(), alice.id()));
+        auto const sleEth = env.le(keylet::mptoken(eth.issuanceID(), alice.id()));
+        BEAST_EXPECT(sleBtc);
+        BEAST_EXPECT(sleEth);
+
+        // The clawback issuer's own asset (btc) may be recreated authorized:
+        // gw has authority over its own token.
+        BEAST_EXPECT(sleBtc && sleBtc->isFlag(lsfMPTAuthorized));
+
+        // The paired asset (eth) is issued by gw2, who did not sign this
+        // transaction. It must be recreated *unauthorized* so gw2's RequireAuth
+        // is not bypassed. This is the core assertion for the cross-issuer fix.
+        BEAST_EXPECT(sleEth && !sleEth->isFlag(lsfMPTAuthorized));
+
+        // The clawback still completed: btc was clawed back (Alice keeps a zero
+        // btc balance) and the paired eth was delivered into Alice's now
+        // unauthorized, gw2-gated MPToken (non-zero raw balance).
+        BEAST_EXPECT(sleBtc && sleBtc->getFieldU64(sfMPTAmount) == 0);
+        BEAST_EXPECT(sleEth && sleEth->getFieldU64(sfMPTAmount) > 0);
+        BEAST_EXPECT(amm.ammExists());
+    }
+
     void
     run() override
     {
@@ -1819,11 +2206,17 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
         testInvalidRequest(all);
         testFeatureDisabled(all);
         testAMMClawbackAmount(all);
+        testAMMClawbackAmount(all - fixCleanup3_4_0);
+        testAMMClawbackAmountRoundsToZero(all);
+        testAMMClawbackAmountRoundsToZero(all - fixCleanup3_4_0);
         testAMMClawbackAll(all);
         testAMMClawbackAmountSameIssuer(all);
         testAMMClawbackAllSameIssuer(all);
         testAMMClawbackIssuesEachOther(all);
         testAssetFrozenOrLocked(all);
+        testClawbackCreatesMissingMPToken(all);
+        testClawbackAfterDeletingMPTokens(all);
+        testClawbackCrossIssuerPairedAssetAuth(all);
         testSingleDepositAndClawback(all);
         testLastHolderLPTokenBalance(all);
         testLastHolderLPTokenBalance(all - fixAMMv1_3 - fixAMMClawbackRounding);
diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp
index ba416d8192..90bface1fb 100644
--- a/src/test/app/AMMClawback_test.cpp
+++ b/src/test/app/AMMClawback_test.cpp
@@ -2155,6 +2155,209 @@ class AMMClawback_test : public beast::unit_test::Suite
             }
             BEAST_EXPECT(env.balance(carol, eur) == eur(7750));
         }
+
+        // gw (USD issuer) individually freezes the AMM-USD trust line.
+        // AMMClawback must still succeed because the freeze invariant
+        // short-circuits before reaching the AMM line check (no receivers in
+        // the USD issuer's change set). Behavior is identical with or without
+        // fixCleanup3_4_0.
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const gw2{"gateway2"};
+            Account const alice{"alice"};
+            env.fund(XRP(1000000), gw, gw2, alice);
+            env.close();
+
+            env(fset(gw, asfAllowTrustLineClawback));
+            env.close();
+            env.require(Flags(gw, asfAllowTrustLineClawback));
+
+            auto const usd = gw["USD"];
+            env.trust(usd(100000), alice);
+            env(pay(gw, alice, usd(3000)));
+            env.close();
+
+            auto const eur = gw2["EUR"];
+            env.trust(eur(100000), alice);
+            env(pay(gw2, alice, eur(3000)));
+            env.close();
+
+            AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(
+                amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12}));
+
+            // gw individually freezes the AMM-USD trust line (AMM pseudo-account
+            // <-> gw), not alice's trust line.
+            env(trust(gw, STAmount{Issue{usd.currency, amm.ammAccount()}, 0}, tfSetFreeze));
+            env.close();
+
+            env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS));
+            env.close();
+
+            env.require(Balance(alice, usd(1000)));
+            env.require(Balance(alice, eur(2500)));
+            BEAST_EXPECT(amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13}));
+            BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13}));
+        }
+
+        // gw2 (EUR issuer) individually freezes the AMM-EUR trust line.
+        // The EUR flow (AMM → alice) is a genuine P2P transfer checked by the
+        // freeze invariant. Pre-fixCleanup3_4_0 the isAMMNode guard incorrectly
+        // blocked AMMClawback's overrideFreeze privilege on that trust line.
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const gw2{"gateway2"};
+            Account const alice{"alice"};
+            env.fund(XRP(1000000), gw, gw2, alice);
+            env.close();
+
+            env(fset(gw, asfAllowTrustLineClawback));
+            env.close();
+            env.require(Flags(gw, asfAllowTrustLineClawback));
+
+            auto const usd = gw["USD"];
+            env.trust(usd(100000), alice);
+            env(pay(gw, alice, usd(3000)));
+            env.close();
+
+            auto const eur = gw2["EUR"];
+            env.trust(eur(100000), alice);
+            env(pay(gw2, alice, eur(3000)));
+            env.close();
+
+            AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(
+                amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12}));
+
+            // gw2 individually freezes the AMM-EUR trust line.
+            env(trust(gw2, STAmount{Issue{eur.currency, amm.ammAccount()}, 0}, tfSetFreeze));
+            env.close();
+
+            if (features[fixCleanup3_4_0])
+            {
+                // Post-fixCleanup3_4_0: overrideFreeze privilege applies to
+                // all freeze types on AMM trust lines.
+                env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS));
+                env.close();
+
+                env.require(Balance(alice, usd(1000)));
+                env.require(Balance(alice, eur(2500)));
+                BEAST_EXPECT(
+                    amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13}));
+                BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13}));
+            }
+            else
+            {
+                // Pre-fixCleanup3_4_0: the isAMMNode guard prevents the
+                // overrideFreeze privilege from applying to individually-frozen
+                // AMM trust lines, so the invariant blocks the clawback.
+                env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tecINVARIANT_FAILED));
+            }
+        }
+
+        // gw2 (EUR issuer) globally freezes its issued assets. AMMClawback
+        // must still be able to return EUR from the AMM to alice.
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const gw2{"gateway2"};
+            Account const alice{"alice"};
+            env.fund(XRP(1000000), gw, gw2, alice);
+            env.close();
+
+            env(fset(gw, asfAllowTrustLineClawback));
+            env.close();
+            env.require(Flags(gw, asfAllowTrustLineClawback));
+
+            auto const usd = gw["USD"];
+            env.trust(usd(100000), alice);
+            env(pay(gw, alice, usd(3000)));
+            env.close();
+
+            auto const eur = gw2["EUR"];
+            env.trust(eur(100000), alice);
+            env(pay(gw2, alice, eur(3000)));
+            env.close();
+
+            AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(
+                amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12}));
+
+            env(fset(gw2, asfGlobalFreeze));
+            env.close();
+
+            env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS));
+            env.close();
+
+            env.require(Balance(alice, usd(1000)));
+            env.require(Balance(alice, eur(2500)));
+            BEAST_EXPECT(amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13}));
+            BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13}));
+        }
+
+        // Same as above but gw2 deep-freezes the AMM-EUR trust line.
+        if (features[featureDeepFreeze])
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const gw2{"gateway2"};
+            Account const alice{"alice"};
+            env.fund(XRP(1000000), gw, gw2, alice);
+            env.close();
+
+            env(fset(gw, asfAllowTrustLineClawback));
+            env.close();
+            env.require(Flags(gw, asfAllowTrustLineClawback));
+
+            auto const usd = gw["USD"];
+            env.trust(usd(100000), alice);
+            env(pay(gw, alice, usd(3000)));
+            env.close();
+
+            auto const eur = gw2["EUR"];
+            env.trust(eur(100000), alice);
+            env(pay(gw2, alice, eur(3000)));
+            env.close();
+
+            AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(
+                amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12}));
+
+            // gw2 deep-freezes the AMM-EUR trust line.
+            env(trust(
+                gw2,
+                STAmount{Issue{eur.currency, amm.ammAccount()}, 0},
+                tfSetFreeze | tfSetDeepFreeze));
+            env.close();
+
+            if (features[fixCleanup3_4_0])
+            {
+                env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS));
+                env.close();
+
+                env.require(Balance(alice, usd(1000)));
+                env.require(Balance(alice, eur(2500)));
+                BEAST_EXPECT(
+                    amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13}));
+                BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13}));
+            }
+            else
+            {
+                // Pre-fixCleanup3_4_0: same isAMMNode guard issue blocks the
+                // clawback on deep-frozen AMM trust lines.
+                env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tecINVARIANT_FAILED));
+            }
+        }
     }
 
     void
@@ -2530,6 +2733,7 @@ class AMMClawback_test : public beast::unit_test::Suite
               // precision loss caught in transaction layer -> tecPRECISION_LOSS
               all - fixAMMClawbackRounding - featureMPTokensV2,
               all - featureMPTokensV2,
+              all - fixCleanup3_4_0,
               all})
         {
             testAMMClawbackSpecificAmount(features);
diff --git a/src/test/app/AMMExtendedMPT_test.cpp b/src/test/app/AMMExtendedMPT_test.cpp
index f04ea39f2b..5059128d4b 100644
--- a/src/test/app/AMMExtendedMPT_test.cpp
+++ b/src/test/app/AMMExtendedMPT_test.cpp
@@ -188,20 +188,28 @@ private:
             {features});
 
         // tfPassive -- place the offer without crossing it.
-        testAMM(
-            [&](AMM& ammAlice, Env& env) {
-                // Carol creates a passive offer that could cross AMM.
-                // Carol's offer should stay in the ledger.
-                auto const& btc = MPT(ammAlice[1]);
-                env(offer(carol_, XRP(100), btc(100), tfPassive));
-                env.close();
-                BEAST_EXPECT(ammAlice.expectBalances(XRP(10'100), btc(10'000), ammAlice.tokens()));
-                BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), btc(100)}}}));
-            },
-            {{XRP(10'100), gAmmmpt(10'000)}},
-            0,
-            std::nullopt,
-            {features});
+        {
+            Env env{*this, features};
+            fund(env, gw_, {alice_, carol_}, XRP(30'000'000));
+
+            MPTTester const btc(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_, carol_},
+                 .pay = 30'000'000,
+                 .flags = kMptDexFlags});
+
+            AMM const ammAlice(env, alice_, XRP(10'100'000), btc(10'000'000));
+
+            // Scale the exact-quality fixture up so the visual relationship
+            // stays clear: the passive CLOB offer has the same 1:1 quality as
+            // the generated AMM offer, so it should not cross.
+            env(offer(carol_, XRP(100'000), btc(100'000), tfPassive));
+            env.close();
+            BEAST_EXPECT(
+                ammAlice.expectBalances(XRP(10'100'000), btc(10'000'000), ammAlice.tokens()));
+            BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100'000), btc(100'000)}}}));
+        }
 
         // tfPassive -- cross only offers of better quality.
         testAMM(
@@ -1084,9 +1092,9 @@ private:
 
         // AMM is consumed up to the first cam Offer quality
         BEAST_EXPECT(ammCarol.expectBalances(
-            aBux(3'093'541'659'651'604), bBux(3'200'215'509'984'418), ammCarol.tokens()));
+            aBux(3'093'541'659'651'603), bBux(3'200'215'509'984'419), ammCarol.tokens()));
         BEAST_EXPECT(expectOffers(
-            env, cam, 1, {{Amounts{bBux(200'215'509'984'418), aBux(200'215'509'984'419)}}}));
+            env, cam, 1, {{Amounts{bBux(200'215'509'984'419), aBux(200'215'509'984'419)}}}));
     }
 
     void
@@ -1241,7 +1249,7 @@ private:
         BEAST_EXPECT(sa == XRP(100'000'000));
         // Bob gets ~99.99e12ETH. This is the amount Bob
         // can get out of AMM for 100,000,000XRP.
-        BEAST_EXPECT(equal(da, eth(99'999'900'000'100)));
+        BEAST_EXPECT(equal(da, eth(99'999'900'000'099)));
     }
 
     // carol holds ETH, sells ETH for XRP
@@ -1505,6 +1513,96 @@ private:
         }
     }
 
+    void
+    pathFindMPTAMMExecutableSourceAmount()
+    {
+        testcase("Path Find: MPT AMM source amount is executable");
+        using namespace jtx;
+
+        auto const checkQuote = [&](std::int64_t usdPool,
+                                    std::int64_t eurPool,
+                                    std::int64_t deliverAmount,
+                                    std::int64_t expectedSourceAmount) {
+            Env env = pathTestEnv();
+            env.fund(XRP(30'000), gw_, alice_, bob_, carol_);
+            env.close();
+
+            MPTTester const usd(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_, bob_, carol_},
+                 .pay = usdPool,
+                 .flags = kMptDexFlags});
+
+            MPTTester const eur(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_, bob_, carol_},
+                 .pay = eurPool,
+                 .flags = kMptDexFlags});
+
+            AMM const ammCarol(env, carol_, usd(usdPool), eur(eurPool));
+            env.close();
+
+            STPathSet st;
+            STAmount sa, da;
+            auto const deliver = eur(deliverAmount);
+            std::tie(st, sa, da) = findPaths(
+                env,
+                alice_,
+                bob_,
+                deliver,
+                std::nullopt,
+                usd.issuanceID(),
+                std::nullopt,
+                std::nullopt);
+
+            // Each quote must execute when used as an exact-output SendMax.
+            BEAST_EXPECT(equal(da, deliver));
+            BEAST_EXPECT(equal(sa, usd(expectedSourceAmount)));
+            BEAST_EXPECT(!st.empty());
+
+            auto const before = eur.getBalance(bob_);
+            env(pay(alice_, bob_, deliver),
+                Json(jss::Paths, st.getJson(JsonOptions::Values::None)),
+                Sendmax(sa),
+                Txflags(tfNoRippleDirect));
+            BEAST_EXPECT(eur.getBalance(bob_) == before + deliverAmount);
+        };
+
+        struct TestCase
+        {
+            std::int64_t usdPool;
+            std::int64_t eurPool;
+            std::int64_t deliverAmount;
+            std::int64_t expectedSourceAmount;
+        };
+
+        // Cover the original 2:1 pool and the same pool scaled down by 1000.
+        // clang-format off
+        TestCase const testCases[] = {
+            {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 1,     .expectedSourceAmount = 3},
+            {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 2,     .expectedSourceAmount = 5},
+            {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 10,    .expectedSourceAmount = 21},
+            {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 100,   .expectedSourceAmount = 201},
+            {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 1'000, .expectedSourceAmount = 2'003},
+            {.usdPool = 2'000,     .eurPool = 1'000,     .deliverAmount = 1,     .expectedSourceAmount = 3},
+            {.usdPool = 2'000,     .eurPool = 1'000,     .deliverAmount = 2,     .expectedSourceAmount = 5},
+            {.usdPool = 2'000,     .eurPool = 1'000,     .deliverAmount = 10,    .expectedSourceAmount = 21},
+            {.usdPool = 2'000,     .eurPool = 1'000,     .deliverAmount = 100,   .expectedSourceAmount = 223},
+        };
+        // clang-format on
+
+        for (auto const& testCase : testCases)
+        {
+            checkQuote(
+                testCase.usdPool,
+                testCase.eurPool,
+                testCase.deliverAmount,
+                testCase.expectedSourceAmount);
+        }
+    }
+
     void
     testFalseDry(FeatureBitset features)
     {
@@ -3583,6 +3681,7 @@ private:
         pathFind01();
         pathFind02();
         pathFind06();
+        pathFindMPTAMMExecutableSourceAmount();
     }
 
     void
diff --git a/src/test/app/AMMExtended_test.cpp b/src/test/app/AMMExtended_test.cpp
index bb532b361a..83c848b7c4 100644
--- a/src/test/app/AMMExtended_test.cpp
+++ b/src/test/app/AMMExtended_test.cpp
@@ -267,20 +267,39 @@ private:
             {features});
 
         // tfPassive -- place the offer without crossing it.
-        testAMM(
-            [&](AMM& ammAlice, Env& env) {
-                // Carol creates a passive offer that could cross AMM.
-                // Carol's offer should stay in the ledger.
-                env(offer(carol_, XRP(100), USD(100), tfPassive));
-                env.close();
-                BEAST_EXPECT(
-                    ammAlice.expectBalances(XRP(10'100), STAmount{USD, 10'000}, ammAlice.tokens()));
-                BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), STAmount{USD, 100}}}}));
-            },
-            {{XRP(10'100), USD(10'000)}},
-            0,
-            std::nullopt,
-            {features});
+        if (features[featureMPTokensV2])
+        {
+            Env env{*this, features};
+            fund(env, gw_, {alice_, carol_}, XRP(30'000'000), {USD(30'000'000)});
+
+            AMM const ammAlice(env, alice_, XRP(10'100'000), USD(10'000'000));
+
+            // Scale the exact-quality fixture up so the visual relationship
+            // stays clear: the passive CLOB offer has the same 1:1 quality as
+            // the generated AMM offer, so it should not cross.
+            env(offer(carol_, XRP(100'000), USD(100'000), tfPassive));
+            env.close();
+            BEAST_EXPECT(
+                ammAlice.expectBalances(XRP(10'100'000), USD(10'000'000), ammAlice.tokens()));
+            BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100'000), USD(100'000)}}}));
+        }
+        else
+        {
+            testAMM(
+                [&](AMM& ammAlice, Env& env) {
+                    // Carol creates a passive offer that could cross AMM.
+                    // Carol's offer should stay in the ledger.
+                    env(offer(carol_, XRP(100), USD(100), tfPassive));
+                    env.close();
+                    BEAST_EXPECT(ammAlice.expectBalances(
+                        XRP(10'100), STAmount{USD, 10'000}, ammAlice.tokens()));
+                    BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), STAmount{USD, 100}}}}));
+                },
+                {{XRP(10'100), USD(10'000)}},
+                0,
+                std::nullopt,
+                {features});
+        }
 
         // tfPassive -- cross only offers of better quality.
         testAMM(
@@ -1359,6 +1378,7 @@ private:
         testRmFundedOffer(all_ - fixAMMv1_1 - fixAMMv1_3);
         testEnforceNoRipple(all_);
         testFillModes(all_);
+        testFillModes(all_ - featureMPTokensV2);
         testOfferCrossWithXRP(all_);
         testOfferCrossWithLimitOverride(all_);
         testCurrencyConversionEntire(all_);
diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp
index 7078ea6769..bfd2d529b5 100644
--- a/src/test/app/AMMMPT_test.cpp
+++ b/src/test/app/AMMMPT_test.cpp
@@ -27,19 +27,24 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -3269,6 +3274,48 @@ private:
                     ammAlice.expectBalances(MPT(ammAlice[1])(1), XRP(10'000), IOUAmount{100000}));
             },
             {{XRP(10'000), gAmmmpt(10'000)}});
+
+        // MPT/MPT equal withdrawal after LP deletes both zero-balance MPTokens.
+        // AMMWithdraw must recreate both missing MPTokens; the invariant allows
+        // up to two MPToken creations per AMMWithdraw/AMMClawback (threshold > 2).
+        {
+            Env env{*this};
+            env.fund(XRP(30'000), gw_, alice_);
+            env.close();
+            MPTTester btc(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_},
+                 .pay = 10'000,
+                 .flags = kMptDexFlags});
+            MPTTester eth(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_},
+                 .pay = 10'000,
+                 .flags = kMptDexFlags});
+
+            // Alice deposits everything into the MPT/MPT pool; her MPT
+            // balances drop to zero.
+            AMM ammAlice(env, alice_, btc(10'000), eth(10'000));
+            BEAST_EXPECT(expectMPT(env, alice_, btc(0)));
+            BEAST_EXPECT(expectMPT(env, alice_, eth(0)));
+
+            // Alice deletes both zero-balance MPTokens to reclaim reserve.
+            btc.authorize({.account = alice_, .flags = tfMPTUnauthorize});
+            eth.authorize({.account = alice_, .flags = tfMPTUnauthorize});
+            BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice_.id())));
+            BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice_.id())));
+
+            // Equal withdrawal succeeds: both missing MPTokens are recreated
+            // (mptokensCreated_ == 2, which satisfies the > 2 invariant check).
+            ammAlice.withdrawAll(alice_);
+            BEAST_EXPECT(env.le(keylet::mptoken(btc.issuanceID(), alice_.id())));
+            BEAST_EXPECT(env.le(keylet::mptoken(eth.issuanceID(), alice_.id())));
+            BEAST_EXPECT(expectMPT(env, alice_, btc(10'000)));
+            BEAST_EXPECT(expectMPT(env, alice_, eth(10'000)));
+            BEAST_EXPECT(!ammAlice.ammExists());
+        }
     }
 
     void
@@ -4041,9 +4088,9 @@ private:
             {
                 auto jtx = env.jt(tx, Seq(1), Fee(10));
                 env.app().config().features.erase(featureMPTokensV2);
-                PreflightContext const pfctx(
+                PreflightContext const ctx(
                     env.app(), *jtx.stx, env.current()->rules(), TapNone, env.journal);
-                auto pf = AMMBid::checkExtraFeatures(pfctx);
+                auto pf = AMMBid::checkExtraFeatures(ctx);
                 BEAST_EXPECT(pf == false);
                 env.app().config().features.insert(featureMPTokensV2);
             }
@@ -4053,9 +4100,9 @@ private:
                 jtx.jv["Asset2"]["currency"] = "XRP";
                 jtx.jv["Asset2"].removeMember("mpt_issuance_id");
                 jtx.stx = env.ust(jtx);
-                PreflightContext const pfctx(
+                PreflightContext const ctx(
                     env.app(), *jtx.stx, env.current()->rules(), TapNone, env.journal);
-                auto pf = AMMBid::preflight(pfctx);
+                auto pf = AMMBid::preflight(ctx);
                 BEAST_EXPECT(pf == temBAD_AMM_TOKENS);
             }
         }
@@ -4901,7 +4948,7 @@ private:
                 XRP(10'100), MPT(ammAlice[1])(10'000'000000000001), ammAlice.tokens()));
             env.require(Balance(carol_, MPT(ammAlice[1])(30'199'999999999999)));
 
-            // Initial 30,000 - 10000(AMM pool LP) - 100(AMMoffer) -
+            // Initial 30,000 - 10000(AMM pool LP) - 100(AMM offer) -
             // - 100(offer) - 10(tx fee) - 10(tx fee of MPTTester init as
             // holder) - one reserve
             BEAST_EXPECT(expectLedgerEntryRoot(
@@ -5010,12 +5057,12 @@ private:
             env.close();
 
             BEAST_EXPECT(
-                amm.expectBalances(XRPAmount(909'090'909), btc(550'000000055001), amm.tokens()));
-            // Offer ~91XRP/49.99e12BTC
+                amm.expectBalances(XRPAmount(909'090'910), btc(549'999999450001), amm.tokens()));
+            // Offer ~91XRP/50e12BTC
             BEAST_EXPECT(expectOffers(
-                env, carol_, 1, {{Amounts{XRPAmount{9'090'909}, btc(4'999999950000)}}}));
-            // Carol pays 0.1% fee on 50'000000055000BTC = 50'000000055BTC
-            env.require(Balance(carol_, btc(29'949'949'999'944'943)));
+                env, carol_, 1, {{Amounts{XRPAmount{9'090'910}, btc(5'000000500000)}}}));
+            // Carol pays 0.1% fee on 49'999999450001BTC.
+            env.require(Balance(carol_, btc(29'949'950'000'550'548)));
         }
 
         {
@@ -5065,15 +5112,15 @@ private:
             env.close();
 
             BEAST_EXPECT(ammAlice.expectBalances(
-                btc(1'060'6848287928033), eth(1'037'0658372213574), ammAlice.tokens()));
+                btc(1'060'6848287928025), eth(1'037'0658372213582), ammAlice.tokens()));
             // Consumed offer ~72.93e13ETH/72.93e13BTC
             BEAST_EXPECT(expectOffers(
-                env, carol_, 1, {Amounts{eth(27'0658372213574), btc(27'0658372213575)}}));
+                env, carol_, 1, {Amounts{eth(27'0658372213582), btc(27'0658372213582)}}));
             BEAST_EXPECT(expectOffers(env, bob_, 0));
             BEAST_EXPECT(expectOffers(env, ed, 0));
 
-            env.require(Balance(carol_, btc(19'116'439'640'089'955)));
-            env.require(Balance(carol_, eth(20'729'341'627'786'426)));
+            env.require(Balance(carol_, btc(19'116'439'640'089'965)));
+            env.require(Balance(carol_, eth(20'729'341'627'786'418)));
             env.require(Balance(bob_, btc(20'100'000'000'000'000)));
             env.require(Balance(ed, eth(19'875'000'000'000'000)));
         }
@@ -5672,6 +5719,87 @@ private:
             });
     }
 
+    void
+    testAMMOfferGenerationPolicy(FeatureBitset features)
+    {
+        testcase("AMM payment offer generation picks economically coarser integral side");
+
+        using namespace jtx;
+
+        enum class GeneratedFirst { TakerPays, TakerGets };
+
+        auto const check = [&](std::uint64_t mptUnitsPerXRP, GeneratedFirst generatedFirst) {
+            TAmounts const pool{
+                XRPAmount{1'000'000}, MPTAmount{1'000'000'125}};
+            TAmounts const clobOffer{
+                kDropsPerXrp, MPTAmount{static_cast(mptUnitsPerXRP)}};
+            Quality const clobQuality{clobOffer};
+
+            auto const expectedAmounts = generatedFirst == GeneratedFirst::TakerGets
+                ? getAMMOfferStartWithTakerGets(pool, clobQuality, 0)
+                : getAMMOfferStartWithTakerPays(pool, clobQuality, 0);
+            auto const otherAmounts = generatedFirst == GeneratedFirst::TakerGets
+                ? getAMMOfferStartWithTakerPays(pool, clobQuality, 0)
+                : getAMMOfferStartWithTakerGets(pool, clobQuality, 0);
+            BEAST_EXPECT(expectedAmounts);
+            BEAST_EXPECT(otherAmounts);
+            if (!expectedAmounts || !otherAmounts)
+                return;
+
+            // Make the tested branch observable: these cases are chosen so the
+            // payment consumes different AMM amounts depending on which side
+            // is generated first.
+            BEAST_EXPECT(*expectedAmounts != *otherAmounts);
+
+            Env env(*this, features);
+            auto const gw = Account("gw");
+            auto const lp = Account("lp");
+            auto const maker = Account("maker");
+            auto const taker = Account("taker");
+            auto const dst = Account("dst");
+
+            env.fund(XRP(10'000), gw, lp, maker, taker, dst);
+            env.close();
+
+            MPTTester const token(
+                {.env = env, .issuer = gw, .holders = {lp, maker, dst}, .flags = kMptDexFlags});
+            env(pay(gw, lp, token(pool.out.value())));
+            env(pay(gw, maker, token(10'000'000)));
+            env.close();
+
+            AMM const amm(env, lp, drops(pool.in), token(pool.out.value()));
+            auto const makerOfferSeq = env.seq(maker);
+            env(offer(maker, XRP(1), token(mptUnitsPerXRP)), Txflags(tfPassive));
+            env.close();
+
+            env(pay(taker, dst, token(expectedAmounts->out.value())),
+                Sendmax(drops(expectedAmounts->in)));
+            env.close();
+
+            BEAST_EXPECT(amm.expectBalances(
+                drops(pool.in + expectedAmounts->in),
+                token((pool.out - expectedAmounts->out).value()),
+                amm.tokens()));
+            env.require(Balance(dst, token(expectedAmounts->out.value())));
+            BEAST_EXPECT(env.le(keylet::offer(maker.id(), SeqProxy::rawSequence(makerOfferSeq))));
+        };
+
+        // CLOB price: 10'000'000 MPT per 1 XRP, so one raw MPT unit is worth
+        // 0.1 drops. One drop is the economically coarser unit and the AMM
+        // offer is generated from takerPays.
+        check(10 * kDropsPerXrp.drops(), GeneratedFirst::TakerPays);
+
+        // CLOB price: 1'000'000 MPT per 1 XRP, so one raw MPT unit is worth
+        // one drop. Ties use takerGets to preserve the historical XRP-output
+        // behavior.
+        check(kDropsPerXrp.drops(), GeneratedFirst::TakerGets);
+
+        // CLOB price: 100'000 MPT per 1 XRP, so one raw MPT unit is worth
+        // 10 drops. MPT is the economically coarser unit and the AMM offer is
+        // generated from takerGets.
+        check(kDropsPerXrp.drops() / 10, GeneratedFirst::TakerGets);
+    }
+
     void
     testTradingFee(FeatureBitset features)
     {
@@ -7242,7 +7370,7 @@ private:
         // overflow. Deposit has no such bound, which is why only the deposit
         // path was exposed.
         //
-        // These mirror the deposit repros: the same oversized two-asset
+        // These mirror the deposit tests: the same oversized two-asset
         // request is rejected cleanly. If the preclaim bound is ever weakened,
         // equalWithdrawLimit would be reached with a huge frac and
         // Number::operator rep() would escape as tefEXCEPTION, failing this.
@@ -7295,6 +7423,57 @@ private:
         }
     }
 
+    void
+    testDanglingAMMMPTokenFreezeCheck()
+    {
+        testcase("Dangling AMM MPToken freeze check");
+
+        using namespace jtx;
+        FeatureBitset const all{testableAmendments()};
+
+        Env env(*this, all);
+
+        env.fund(XRP(1'000), gw_, alice_);
+        MPTTester usd({.env = env, .issuer = gw_});
+        MPTTester const btc({.env = env, .issuer = gw_});
+
+        AMM amm(env, gw_, usd(10'000), btc(10'000));
+        for (auto i = 0; i < kMaxDeletableAmmTrustLines + 10; ++i)
+        {
+            Account const a{std::to_string(i)};
+            env.fund(XRP(1'000), a);
+            env(trust(a, STAmount{amm.lptIssue(), 10'000}));
+            env.close();
+        }
+
+        // With too many LP-token trust lines to delete in one pass, the AMM
+        // remains in an empty state with zero-balance MPToken objects.
+        amm.withdrawAll(gw_);
+        BEAST_EXPECT(amm.ammExists());
+        BEAST_EXPECT(amm.expectBalances(usd(0), btc(0), IOUAmount{0}));
+
+        auto const ammToken = env.le(keylet::mptoken(usd.issuanceID(), amm.ammAccount()));
+        if (!BEAST_EXPECT(ammToken))
+            return;
+        BEAST_EXPECT((*ammToken)[sfMPTAmount] == 0);
+
+        usd.destroy();
+        BEAST_EXPECT(env.le(keylet::mptokenIssuance(usd.issuanceID())) == nullptr);
+        BEAST_EXPECT(!isFrozen(*env.current(), amm.ammAccount(), *ammToken));
+        // A Payment cannot cross this empty AMM because BookStep skips AMMs
+        // with zero LPTokenBalance. Probe the same ZeroIfFrozen balance read
+        // used by AMM accounting.
+        auto const balance = accountHolds(
+            *env.current(),
+            amm.ammAccount(),
+            MPTIssue{usd.issuanceID()},
+            FreezeHandling::ZeroIfFrozen,
+            AuthHandling::IgnoreAuth,
+            env.journal);
+
+        BEAST_EXPECT(balance == usd(0));
+    }
+
     void
     run() override
     {
@@ -7318,6 +7497,7 @@ private:
         testAMMTokens();
         testAmendment();
         testAMMAndCLOB(all);
+        testAMMOfferGenerationPolicy(all);
         testTradingFee(all);
         testTradingFee(all - fixAMMv1_3);
         testAdjustedTokens(all);
@@ -7334,6 +7514,7 @@ private:
         testDepositIntegralOverflowMPT(all);
         testDepositIntegralOverflowMPT(all - fixCleanup3_4_0);
         testWithdrawIntegralNoOverflowMPT();
+        testDanglingAMMMPTokenFreezeCheck();
     }
 };
 
diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp
index 8f8079c34a..e1732aaf0e 100644
--- a/src/test/app/AMM_test.cpp
+++ b/src/test/app/AMM_test.cpp
@@ -3778,6 +3778,21 @@ private:
                     BEAST_EXPECT(amm.expectBalances(XRP(1'000), USD(500), amm.tokens()));
                     BEAST_EXPECT(expectOffers(env, carol_, 1, {{Amounts{XRP(100), USD(55)}}}));
                 }
+                else if (!features[featureMPTokensV2])
+                {
+                    BEAST_EXPECT(amm.expectBalances(
+                        XRPAmount(909'090'909),
+                        STAmount{USD, UINT64_C(550'000000055), -9},
+                        amm.tokens()));
+                    BEAST_EXPECT(expectOffers(
+                        env,
+                        carol_,
+                        1,
+                        {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}}));
+                    BEAST_EXPECT(
+                        env.balance(carol_, USD) ==
+                        STAmount(USD, UINT64_C(29'949'94999999494), -11));
+                }
                 else
                 {
                     // Post-amendment the transfer fee is taken into account
@@ -3788,19 +3803,19 @@ private:
                     // quality.
                     // AMM offer ~50USD/91XRP
                     BEAST_EXPECT(amm.expectBalances(
-                        XRPAmount(909'090'909),
-                        STAmount{USD, UINT64_C(550'000000055), -9},
+                        XRPAmount(909'090'910),
+                        STAmount{USD, UINT64_C(549'99999945), -8},
                         amm.tokens()));
-                    // Offer ~91XRP/49.99USD
+                    // Offer ~91XRP/50USD
                     BEAST_EXPECT(expectOffers(
                         env,
                         carol_,
                         1,
-                        {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}}));
+                        {{Amounts{XRPAmount{9'090'910}, STAmount{USD, 5'0000005, -7}}}}));
                     // Carol pays 0.1% fee on ~50USD =~ 0.05USD
                     BEAST_EXPECT(
                         env.balance(carol_, USD) ==
-                        STAmount(USD, UINT64_C(29'949'94999999494), -11));
+                        STAmount(USD, UINT64_C(29'949'95000060055), -11));
                 }
             },
             {{XRP(1'000), USD(500)}},
@@ -6451,7 +6466,7 @@ private:
                 BEAST_EXPECT(expectOffers(env, bob_, 1, {{Amounts{USD(1), XRPAmount(500)}}}));
                 BEAST_EXPECT(expectOffers(env, carol_, 1, {{Amounts{XRP(100), USD(55)}}}));
             }
-            else
+            else if (!features[featureMPTokensV2])
             {
                 BEAST_EXPECT(amm.expectBalances(
                     XRPAmount(909'090'909),
@@ -6464,6 +6479,19 @@ private:
                     {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}}));
                 BEAST_EXPECT(expectOffers(env, bob_, 1, {{Amounts{USD(1), XRPAmount(500)}}}));
             }
+            else
+            {
+                BEAST_EXPECT(amm.expectBalances(
+                    XRPAmount(909'090'910),
+                    STAmount{USD, UINT64_C(549'99999945), -8},
+                    amm.tokens()));
+                BEAST_EXPECT(expectOffers(
+                    env,
+                    carol_,
+                    1,
+                    {{Amounts{XRPAmount{9'090'910}, STAmount{USD, 5'0000005, -7}}}}));
+                BEAST_EXPECT(expectOffers(env, bob_, 1, {{Amounts{USD(1), XRPAmount(500)}}}));
+            }
         }
 
         // There is no blocking offer, the same AMM liquidity is consumed
@@ -6475,10 +6503,30 @@ private:
             AMM const amm(env, alice_, XRP(1'000), USD(500));
             env(offer(carol_, XRP(100), USD(55)));
             env.close();
-            BEAST_EXPECT(amm.expectBalances(
-                XRPAmount(909'090'909), STAmount{USD, UINT64_C(550'000000055), -9}, amm.tokens()));
-            BEAST_EXPECT(expectOffers(
-                env, carol_, 1, {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}}));
+            if (!features[featureMPTokensV2])
+            {
+                BEAST_EXPECT(amm.expectBalances(
+                    XRPAmount(909'090'909),
+                    STAmount{USD, UINT64_C(550'000000055), -9},
+                    amm.tokens()));
+                BEAST_EXPECT(expectOffers(
+                    env,
+                    carol_,
+                    1,
+                    {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}}));
+            }
+            else
+            {
+                BEAST_EXPECT(amm.expectBalances(
+                    XRPAmount(909'090'910),
+                    STAmount{USD, UINT64_C(549'99999945), -8},
+                    amm.tokens()));
+                BEAST_EXPECT(expectOffers(
+                    env,
+                    carol_,
+                    1,
+                    {{Amounts{XRPAmount{9'090'910}, STAmount{USD, 5'0000005, -7}}}}));
+            }
         }
     }
 
@@ -7400,6 +7448,7 @@ private:
         testFlags();
         testRippling();
         testAMMAndCLOB(all);
+        testAMMAndCLOB(all - featureMPTokensV2);
         testAMMAndCLOB(all - fixAMMv1_1 - fixAMMv1_3);
         testTradingFee(all);
         testTradingFee(all - fixAMMv1_3);
@@ -7419,8 +7468,10 @@ private:
         testOverflowOffer(all - fixAMMv1_1 - fixAMMv1_3);
         testSwapRounding();
         testFixChangeSpotPriceQuality(all);
+        testFixChangeSpotPriceQuality(all - featureMPTokensV2);
         testFixChangeSpotPriceQuality(all - fixAMMv1_1 - fixAMMv1_3);
         testFixAMMOfferBlockedByLOB(all);
+        testFixAMMOfferBlockedByLOB(all - featureMPTokensV2);
         testFixAMMOfferBlockedByLOB(all - fixAMMv1_1 - fixAMMv1_3);
         testLPTokenBalance(all);
         testLPTokenBalance(all - fixAMMv1_3);
diff --git a/src/test/app/DepositAuth_test.cpp b/src/test/app/DepositAuth_test.cpp
index 881441e0f9..c987e603be 100644
--- a/src/test/app/DepositAuth_test.cpp
+++ b/src/test/app/DepositAuth_test.cpp
@@ -934,6 +934,46 @@ struct DepositPreauth_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testZeroCredentialID(FeatureBitset features)
+    {
+        testcase("Zero credential ID");
+
+        using namespace jtx;
+
+        char const credType[] = "abcde";
+        Account const issuer{"issuer"};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+
+        Env env(*this, features);
+
+        env.fund(XRP(5000), issuer, alice, bob);
+        env.close();
+
+        env(credentials::create(alice, issuer, credType));
+        env.close();
+        env(credentials::accept(alice, issuer, credType));
+        env.close();
+
+        auto const jv = credentials::ledgerEntry(env, alice, issuer, credType);
+        std::string const credIdx = jv[jss::result][jss::index].asString();
+
+        std::string const zeroIdx(64, '0');
+
+        // post-fixCleanup3_4_0: a zero ID is rejected by checkFields in
+        // preflight; pre-fixCleanup3_4_0, it will trigger assertion, so it is not testable.
+        env(pay(alice, bob, XRP(100)), credentials::Ids({zeroIdx}), Ter(temMALFORMED));
+        env.close();
+
+        env(pay(alice, bob, XRP(100)), credentials::Ids({credIdx, zeroIdx}), Ter(temMALFORMED));
+        env.close();
+
+        // A valid credential succeeds
+        env(pay(alice, bob, XRP(100)), credentials::Ids({credIdx}));
+        env.close();
+    }
+
     void
     testCredentialsCreation()
     {
@@ -1446,6 +1486,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite
         testPayment(supported - featureCredentials);
         testPayment(supported);
         testCredentialsPayment();
+        testZeroCredentialID(supported);
         testCredentialsCreation();
         testExpiredCreds();
         testSortingCredentials();
diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp
index 7e7509c3b7..72db63bd3f 100644
--- a/src/test/app/EscrowToken_test.cpp
+++ b/src/test/app/EscrowToken_test.cpp
@@ -3749,6 +3749,186 @@ struct EscrowToken_test : public beast::unit_test::Suite
         BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0);
     }
 
+    void
+    testMPTLargeLockedRate(FeatureBitset features)
+    {
+        testcase("MPT large locked rate");
+        using namespace test::jtx;
+        using namespace std::literals;
+
+        auto constexpr escrowAmount = 200'000'000'000'000'000LL;
+        auto constexpr noOverflowEscrowAmount = 186'000'000'000'000'000LL;
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const gw = Account("gw");
+
+        for (auto const testFeatures :
+             {features - featureMPTokensV2 - fixCleanup3_4_0,
+              features - featureMPTokensV2,
+              (features | featureMPTokensV2) - fixCleanup3_4_0,
+              features | featureMPTokensV2})
+        {
+            bool const mptV2 = testFeatures[featureMPTokensV2];
+            bool const tokenEscrowV1 = testFeatures[fixTokenEscrowV1];
+            // The transfer-fee split in EscrowFinish only overflows on the
+            // legacy divideRound(amount, lockedRate, ...) path, which runs when
+            // fixCleanup3_4_0 is disabled. With fixCleanup3_4_0 the split uses
+            // mulRatio (128-bit intermediate), which cannot overflow. Without
+            // it, this large amount overflows unless the MPTokensV2 Number path
+            // is active. So the finish succeeds when either amendment is enabled.
+            bool const cleanup340 = testFeatures[fixCleanup3_4_0];
+            bool const noOverflow = cleanup340 || mptV2;
+            auto const expectedErr = noOverflow ? Ter(tesSUCCESS) : Ter(tefEXCEPTION);
+
+            // Finish with a large MPT amount and non-zero transfer fee. When the
+            // computation overflows (legacy divideRound path, no MPTokensV2) the
+            // finish fails with tefEXCEPTION and the escrow is untouched;
+            // otherwise it unlocks the escrow.
+            {
+                Env env{*this, testFeatures};
+                env.fund(XRP(1'000), alice, bob, gw);
+                auto const baseFee = env.current()->fees().base;
+
+                MPTTester const mpt(
+                    {.env = env,
+                     .issuer = gw,
+                     .holders = {alice, bob},
+                     .transferFee = 1'000,
+                     .flags = tfMPTCanEscrow | tfMPTCanTransfer});
+                env(pay(gw, alice, mpt(escrowAmount)));
+                env.close();
+
+                auto const preAlice = env.balance(alice, mpt);
+                auto const preBob = env.balance(bob, mpt);
+                auto const seq = env.seq(alice);
+                env(escrow::create(alice, bob, mpt(escrowAmount)),
+                    escrow::kCondition(escrow::kCb1),
+                    escrow::kFinishTime(env.now() + 1s),
+                    escrow::kCancelTime(env.now() + 500s),
+                    Fee(baseFee * 150));
+                env.close();
+
+                BEAST_EXPECT(mptEscrowed(env, alice, mpt) == escrowAmount);
+                BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == escrowAmount);
+
+                env(escrow::finish(bob, alice, seq),
+                    escrow::kCondition(escrow::kCb1),
+                    escrow::kFulfillment(escrow::kFb1),
+                    Fee(baseFee * 150),
+                    expectedErr);
+                env.close();
+
+                if (noOverflow)
+                {
+                    BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq))));
+                    BEAST_EXPECT(env.balance(alice, mpt) == preAlice - mpt(escrowAmount));
+                    auto const postBob = env.balance(bob, mpt);
+                    BEAST_EXPECT(postBob.value() > preBob.value());
+                    BEAST_EXPECT(postBob.value() < (preBob + mpt(escrowAmount)).value());
+                    auto const xferFee = escrowAmount - (postBob.value() - preBob.value());
+                    auto const expectedEscrow = tokenEscrowV1 ? 0 : xferFee;
+                    BEAST_EXPECT(mptEscrowed(env, alice, mpt) == expectedEscrow);
+                    BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == expectedEscrow);
+                }
+                else
+                {
+                    BEAST_EXPECT(env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq))));
+                    BEAST_EXPECT(env.balance(alice, mpt) == preAlice - mpt(escrowAmount));
+                    BEAST_EXPECT(env.balance(bob, mpt) == preBob);
+                    BEAST_EXPECT(mptEscrowed(env, alice, mpt) == escrowAmount);
+                    BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == escrowAmount);
+                }
+            }
+
+            // Control: a still-large amount below the legacy overflow boundary
+            // finishes successfully in both feature modes.
+            {
+                Env env{*this, testFeatures};
+                env.fund(XRP(1'000), alice, bob, gw);
+                auto const baseFee = env.current()->fees().base;
+
+                MPTTester const mpt(
+                    {.env = env,
+                     .issuer = gw,
+                     .holders = {alice, bob},
+                     .transferFee = 1'000,
+                     .flags = tfMPTCanEscrow | tfMPTCanTransfer});
+                env(pay(gw, alice, mpt(noOverflowEscrowAmount)));
+                env.close();
+
+                auto const preAlice = env.balance(alice, mpt);
+                auto const preBob = env.balance(bob, mpt);
+                auto const seq = env.seq(alice);
+                env(escrow::create(alice, bob, mpt(noOverflowEscrowAmount)),
+                    escrow::kCondition(escrow::kCb1),
+                    escrow::kFinishTime(env.now() + 1s),
+                    escrow::kCancelTime(env.now() + 500s),
+                    Fee(baseFee * 150));
+                env.close();
+
+                BEAST_EXPECT(mptEscrowed(env, alice, mpt) == noOverflowEscrowAmount);
+                BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == noOverflowEscrowAmount);
+
+                env(escrow::finish(bob, alice, seq),
+                    escrow::kCondition(escrow::kCb1),
+                    escrow::kFulfillment(escrow::kFb1),
+                    Fee(baseFee * 150),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq))));
+                BEAST_EXPECT(env.balance(alice, mpt) == preAlice - mpt(noOverflowEscrowAmount));
+                auto const postBob = env.balance(bob, mpt);
+                BEAST_EXPECT(postBob.value() > preBob.value());
+                BEAST_EXPECT(postBob.value() < (preBob + mpt(noOverflowEscrowAmount)).value());
+                auto const xferFee = noOverflowEscrowAmount - (postBob.value() - preBob.value());
+                auto const expectedEscrow = tokenEscrowV1 ? 0 : xferFee;
+                BEAST_EXPECT(mptEscrowed(env, alice, mpt) == expectedEscrow);
+                BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == expectedEscrow);
+            }
+
+            // Cancel returns the escrow to the owner using parity rate, so it
+            // does not hit the transfer-rate division in either feature mode.
+            {
+                Env env{*this, testFeatures};
+                env.fund(XRP(1'000), alice, bob, gw);
+                auto const baseFee = env.current()->fees().base;
+
+                MPTTester const mpt(
+                    {.env = env,
+                     .issuer = gw,
+                     .holders = {alice, bob},
+                     .transferFee = 1'000,
+                     .flags = tfMPTCanEscrow | tfMPTCanTransfer});
+                env(pay(gw, alice, mpt(escrowAmount)));
+                env.close();
+
+                auto const preAlice = env.balance(alice, mpt);
+                auto const preBob = env.balance(bob, mpt);
+                auto const seq = env.seq(alice);
+                env(escrow::create(alice, bob, mpt(escrowAmount)),
+                    escrow::kCondition(escrow::kCb1),
+                    escrow::kFinishTime(env.now() + 1s),
+                    escrow::kCancelTime(env.now() + 3s),
+                    Fee(baseFee * 150));
+                env.close();
+
+                BEAST_EXPECT(mptEscrowed(env, alice, mpt) == escrowAmount);
+                BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == escrowAmount);
+
+                env(escrow::cancel(alice, alice, seq), Fee(baseFee), Ter(tesSUCCESS));
+                env.close();
+
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq))));
+                BEAST_EXPECT(env.balance(alice, mpt) == preAlice);
+                BEAST_EXPECT(env.balance(bob, mpt) == preBob);
+                BEAST_EXPECT(env.balance(gw, mpt) == -mpt(escrowAmount));
+                BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 0);
+                BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0);
+            }
+        }
+    }
+
     void
     testMPTRequireAuth(FeatureBitset features)
     {
@@ -4047,6 +4227,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
         testMPTMetaAndOwnership(features);
         testMPTGateway(features);
         testMPTLockedRate(features);
+        testMPTLargeLockedRate(features);
         testMPTRequireAuth(features);
         testMPTLock(features);
         testMPTCanTransfer(features);
diff --git a/src/test/app/FlowMPT_test.cpp b/src/test/app/FlowMPT_test.cpp
index a94834eb28..49e3f9be94 100644
--- a/src/test/app/FlowMPT_test.cpp
+++ b/src/test/app/FlowMPT_test.cpp
@@ -26,6 +26,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -742,6 +743,164 @@ struct FlowMPT_test : public beast::unit_test::Suite
         return result;
     }
 
+    void
+    testOfferOwnerMPTCreation(FeatureBitset features)
+    {
+        using namespace jtx;
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const gw("gw");
+
+        {
+            testcase("Reserve-edge offer owner cannot create another object");
+
+            Env env(*this, features);
+
+            auto const baseFee = env.current()->fees().base;
+            auto const ownerIncrement = reserve(env, 1) - reserve(env, 0);
+            auto const xrpOffer = ownerIncrement - drops(1);
+            auto const bobStart = reserve(env, 2) - drops(1) + baseFee;
+
+            env.fund(XRP(10'000), alice, gw);
+            env.fund(bobStart, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .maxAmt = 10});
+
+            env(offer(bob, usd(1), xrpOffer));
+            env.close();
+
+            env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1));
+
+            // This mirrors the full-crossing setup below. Bob has enough XRP
+            // for the resting offer, but not enough to pay a fee and add
+            // another owner-count object while the offer remains on ledger.
+            env(check::create(bob, alice, drops(1)), Ter(tecINSUFFICIENT_RESERVE));
+            env.close();
+
+            env.require(Owners(bob, 1));
+            BEAST_EXPECT(offersOnAccount(env, bob).size() == 1);
+        }
+
+        {
+            testcase("Reserve-edge offer owner creates MPToken during consume");
+
+            Env env(*this, features);
+
+            auto const baseFee = env.current()->fees().base;
+            auto const ownerIncrement = reserve(env, 1) - reserve(env, 0);
+            auto const xrpOffer = ownerIncrement - drops(1);
+            auto const bobStart = reserve(env, 2) - drops(1) + baseFee;
+
+            env.fund(XRP(10'000), alice, carol, gw);
+            env.fund(bobStart, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10});
+
+            env(pay(gw, alice, usd(1)));
+            env(offer(bob, usd(1), xrpOffer));
+            env.close();
+
+            env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1));
+            BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
+            auto const carolXRP = env.balance(carol);
+
+            // Bob has enough XRP for the resting offer but is close to
+            // reserve. The payment should not create Bob's USD MPToken until
+            // the offer is actually consumed, otherwise the temporary owner
+            // count increase can make the offer look underfunded during path
+            // execution.
+            env(pay(alice, carol, xrpOffer),
+                Path(~XRP),
+                Sendmax(usd(1)),
+                Txflags(tfNoRippleDirect));
+            env.close();
+
+            env.require(Balance(carol, carolXRP + xrpOffer));
+            env.require(Balance(bob, usd(1)));
+            env.require(Balance(bob, reserve(env, 1)), Owners(bob, 1));
+            BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
+            BEAST_EXPECT(offersOnAccount(env, bob).empty());
+        }
+
+        {
+            testcase("Partial offer owner creates MPToken during consume");
+
+            Env env(*this, features);
+
+            auto const baseFee = env.current()->fees().base;
+            auto const ownerIncrement = reserve(env, 1) - reserve(env, 0);
+            auto const bobStart = reserve(env, 3) + baseFee;
+
+            env.fund(XRP(10'000), alice, carol, gw);
+            env.fund(bobStart, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10});
+
+            env(pay(gw, alice, usd(1)));
+            env(offer(bob, usd(2), drops(2 * ownerIncrement)));
+            env.close();
+
+            env.require(Balance(bob, reserve(env, 3)), Owners(bob, 1));
+            BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
+            auto const carolXRP = env.balance(carol);
+
+            // Partial consumption leaves Bob's offer on the ledger, so he ends
+            // up owning both the remaining offer and a newly created MPToken.
+            // The MPToken is created regardless of reserve; this setup simply
+            // funds Bob enough that he still meets reserve(2) afterward (the
+            // under-reserved case is covered in OfferMPT_test's no-reserve-check
+            // testcase).
+            env(pay(alice, carol, drops(ownerIncrement)),
+                Path(~XRP),
+                Sendmax(usd(1)),
+                Txflags(tfNoRippleDirect));
+            env.close();
+
+            env.require(Balance(carol, carolXRP + drops(ownerIncrement)));
+            env.require(Balance(bob, usd(1)));
+            env.require(Balance(bob, reserve(env, 2)), Owners(bob, 2));
+            BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
+            BEAST_EXPECT(offersOnAccount(env, bob).size() == 1);
+            BEAST_EXPECT(isOffer(env, bob, usd(1), drops(ownerIncrement)));
+        }
+
+        {
+            testcase("Issuer-owned offer does not create issuer MPToken");
+
+            Env env(*this, features);
+
+            env.fund(XRP(10'000), alice, carol, gw);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10});
+
+            env(pay(gw, alice, usd(1)));
+            env(offer(gw, usd(1), drops(1'000)));
+            env.close();
+
+            BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id())));
+            auto const carolXRP = env.balance(carol);
+
+            // The issuer can own an offer that receives its own MPT without an
+            // MPToken. Consuming that offer should keep the issuer side
+            // tokenless.
+            env(pay(alice, carol, drops(1'000)),
+                Path(~XRP),
+                Sendmax(usd(1)),
+                Txflags(tfNoRippleDirect));
+            env.close();
+
+            env.require(Balance(alice, usd(0)));
+            env.require(Balance(carol, carolXRP + drops(1'000)));
+            BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id())));
+            BEAST_EXPECT(offersOnAccount(env, gw).empty());
+        }
+    }
+
     void
     testSelfPayment1(FeatureBitset features)
     {
@@ -2121,6 +2280,7 @@ struct FlowMPT_test : public beast::unit_test::Suite
         testFalseDry(features);
         testDirectStep(features);
         testBookStep(features);
+        testOfferOwnerMPTCreation(features);
         testTransferRate(features);
         testSelfPayment1(features);
         testSelfPayment2(features);
diff --git a/src/test/app/GRPCServerTLS_test.cpp b/src/test/app/GRPCServerTLS_test.cpp
index a48986d004..58ccf33959 100644
--- a/src/test/app/GRPCServerTLS_test.cpp
+++ b/src/test/app/GRPCServerTLS_test.cpp
@@ -1,13 +1,12 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
 #include 
 
-#include 
-
 #include 
 #include 
 #include 
@@ -17,6 +16,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -254,10 +254,8 @@ public:
 
     TemporaryTLSCertificates()
     {
-        auto tmpDir = std::filesystem::temp_directory_path();
-        auto uniqueDirName =
-            boost::filesystem::unique_path(std::string(kCertsDirPrefix) + "%%%%%%%%");
-        tempDir_ = tmpDir / uniqueDirName.string();
+        tempDir_ = xrpl::uniqueRandomPath(
+            std::filesystem::temp_directory_path(), std::string(kCertsDirPrefix));
         std::filesystem::create_directories(tempDir_);
 
         writeFile(tempDir_ / kCaCertFilename, kCaCertContent);
diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp
index ffdfe6bc83..ced2dea9bb 100644
--- a/src/test/app/Invariants_test.cpp
+++ b/src/test/app/Invariants_test.cpp
@@ -16,11 +16,13 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -55,6 +57,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 
 #include 
@@ -65,6 +69,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -135,7 +141,8 @@ class Invariants_test : public beast::unit_test::Suite
         STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
         std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
         Preclose const& preclose = {},
-        TxAccount setTxAccount = TxAccount::None)
+        TxAccount setTxAccount = TxAccount::None,
+        std::source_location const& loc = std::source_location::current())
     {
         doInvariantCheck(
             makeEnv(defaultAmendments()),
@@ -145,7 +152,8 @@ class Invariants_test : public beast::unit_test::Suite
             tx,
             ters,
             preclose,
-            setTxAccount);
+            setTxAccount,
+            loc);
     }
 
     void
@@ -157,7 +165,8 @@ class Invariants_test : public beast::unit_test::Suite
         STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
         std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
         Preclose const& preclose = {},
-        TxAccount setTxAccount = TxAccount::None)
+        TxAccount setTxAccount = TxAccount::None,
+        std::source_location const& loc = std::source_location::current())
     {
         using namespace test::jtx;
 
@@ -171,7 +180,7 @@ 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);
+        doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc);
     }
 
     void
@@ -184,7 +193,8 @@ class Invariants_test : public beast::unit_test::Suite
         Precheck const& precheck,
         XRPAmount fee = XRPAmount{},
         STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
-        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED})
+        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+        std::source_location const& loc = std::source_location::current())
     {
         using namespace test::jtx;
 
@@ -210,24 +220,29 @@ class Invariants_test : public beast::unit_test::Suite
         TER terActual = tesSUCCESS;
         for (TER const& terExpect : ters)
         {
-            terActual = transactor->checkInvariants(terActual, fee);
-            BEAST_EXPECTS(
+            terActual =
+                transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full);
+            expect(
                 terExpect == terActual,
-                "expected: " + transToken(terExpect) + " got: " + transToken(terActual));
+                "expected: " + transToken(terExpect) + " got: " + transToken(terActual),
+                loc.file_name(),
+                loc.line());
             auto const messages = sink.messages().str();
 
             if (!isTesSuccess(terActual))
             {
-                BEAST_EXPECTS(
+                expect(
                     messages.starts_with("Invariant failed:") ||
                         messages.starts_with("Transaction caused an exception"),
-                    messages);
+                    messages,
+                    loc.file_name(),
+                    loc.line());
             }
 
             // std::cerr << messages << '\n';
             for (auto const& m : expectLogs)
             {
-                BEAST_EXPECTS(messages.contains(m), m);
+                expect(messages.contains(m), m, loc.file_name(), loc.line());
             }
         }
     }
@@ -437,16 +452,10 @@ class Invariants_test : public beast::unit_test::Suite
             XRPAmount{},
             STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
 
-        for (auto const& keyletInfo : kDirectAccountKeylets)
+        for (auto const& [keyletfunc, type, includeInTests] : kDirectAccountKeylets)
         {
-            // TODO: Use structured binding once LLVM 16 is the minimum
-            // supported version. See also:
-            // https://github.com/llvm/llvm-project/issues/48582
-            // https://github.com/llvm/llvm-project/commit/127bf44385424891eb04cff8e52d3f157fc2cb7c
-            if (!keyletInfo.includeInTests)
+            if (!includeInTests)
                 continue;
-            auto const& keyletfunc = keyletInfo.function;
-            auto const& type = keyletInfo.expectedLEName;
 
             using namespace std::string_literals;
 
@@ -2243,6 +2252,90 @@ class Invariants_test : public beast::unit_test::Suite
         }
     }
 
+    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()
     {
@@ -2481,6 +2574,54 @@ class Invariants_test : public beast::unit_test::Suite
 
         // 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; },
@@ -4373,6 +4514,286 @@ class Invariants_test : public beast::unit_test::Suite
                 }},
             {tecINVARIANT_FAILED, tecINVARIANT_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, tecINVARIANT_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, tecINVARIANT_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
@@ -5962,12 +6383,137 @@ class Invariants_test : public beast::unit_test::Suite
             auto transactor = makeTransactor(ac);
             if (!BEAST_EXPECT(transactor))
                 return;
-            TER const result = transactor->checkInvariants(tesSUCCESS, XRPAmount{});
+            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()
     {
@@ -6239,6 +6785,7 @@ public:
         testPermissionedDomainInvariants(defaultAmendments() - fixCleanup3_1_3);
         testPermissionedDEX(defaultAmendments() | fixCleanup3_1_3);
         testPermissionedDEX(defaultAmendments() - fixCleanup3_1_3);
+        testPermissionedDEXDeletedOfferFallback();
         testBookDirectoryExchangeRate();
         testNoModifiedUnmodifiableFields();
         testValidPseudoAccounts();
@@ -6252,6 +6799,8 @@ public:
         testAMM();
         testObjectHasPseudoAccount();
         testSponsorship();
+        testTxCheckException();
+        testTxCheckFinalizeFalse();
     }
 };
 
diff --git a/src/test/app/LPTokenTransfer_test.cpp b/src/test/app/LPTokenTransfer_test.cpp
index e30e37ed98..3e72094eb3 100644
--- a/src/test/app/LPTokenTransfer_test.cpp
+++ b/src/test/app/LPTokenTransfer_test.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include   // IWYU pragma: keep
 #include 
@@ -21,6 +22,8 @@
 #include 
 #include 
 
+#include 
+
 namespace xrpl::test {
 
 class LPTokenTransfer_test : public jtx::AMMTest
@@ -433,6 +436,136 @@ class LPTokenTransfer_test : public jtx::AMMTest
         }
     }
 
+    void
+    testMPTCanTransferDirectStep(FeatureBitset features)
+    {
+        testcase("MPT CanTransfer DirectStep");
+
+        using namespace jtx;
+
+        // An MPT can only be an AMM pool asset once featureMPTokensV2 is
+        // enabled, so this behavior is only meaningful when V2 is present, and
+        // is independent of fixFrozenLPTokenTransfer.
+        if (!features[featureMPTokensV2])
+            return;
+
+        // gw issues an MPT used as one of the AMM pool assets. gw (the MPT
+        // issuer) seeds the pool and hands LP tokens to alice. Transferring LP
+        // tokens between two non-issuer holders is only permitted when the
+        // pool MPT allows transfers (lsfMPTCanTransfer); issuer-involving
+        // transfers are always permitted. The check fires on the redeem step
+        // against the AMM account via canTransferLPToken().
+        auto testLPTokenTransfer = [&](std::uint32_t mptFlags, bool poolXrpToBtc) {
+            Env env{*this, features};
+            env.fund(XRP(30'000), gw_, alice_, bob_);
+            env.close();
+
+            // gw is the MPT issuer, so it may seed the pool regardless of
+            // whether the MPT permits third-party transfers.
+            MPT const btc = MPTTester(
+                {.env = env, .issuer = gw_, .holders = {alice_}, .pay = 1'000, .flags = mptFlags});
+
+            auto const asset1 = poolXrpToBtc ? XRP(10'000) : btc(10'000);
+            auto const asset2 = poolXrpToBtc ? btc(10'000) : XRP(10'000);
+            AMM const amm(env, gw_, asset1, asset2);
+            auto const lpIssue = amm.lptIssue();
+
+            env.trust(STAmount{lpIssue, 100'000}, alice_);
+            env.trust(STAmount{lpIssue, 100'000}, bob_);
+            env.close();
+
+            // Issuer-involving LP token transfer is always allowed (gw is the
+            // pool MPT's issuer), even when the MPT lacks CanTransfer.
+            env(pay(gw_, alice_, STAmount{lpIssue, 1'000}));
+            env.close();
+
+            // Transfer between two non-issuer holders is allowed only if the
+            // pool MPT has CanTransfer set; otherwise the redeem step against
+            // the AMM account blocks it with tecNO_AUTH.
+            if ((mptFlags & tfMPTCanTransfer) != 0u)
+            {
+                env(pay(alice_, bob_, STAmount{lpIssue, 100}));
+            }
+            else
+            {
+                env(pay(alice_, bob_, STAmount{lpIssue, 100}), Ter(tecNO_AUTH));
+            }
+            env.close();
+        };
+
+        // Pool MPT without CanTransfer blocks third-party LP token transfers.
+        testLPTokenTransfer(tfMPTCanTrade, true);
+        testLPTokenTransfer(tfMPTCanTrade, false);
+
+        // Pool MPT with CanTransfer allows them.
+        testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, true);
+        testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, false);
+    }
+
+    void
+    testMPTCanTransferOffer(FeatureBitset features)
+    {
+        testcase("MPT CanTransfer Offer");
+
+        using namespace jtx;
+
+        if (!features[featureMPTokensV2])
+            return;
+
+        // Parity with frozen LP tokens for the order book: a non-transferable
+        // pool MPT makes the LP token un-spendable (canTransferLPToken zeroes
+        // the spendable balance in accountHolds, just as isLPTokenFrozen does),
+        // so an offer to sell it cannot be funded - the same tecUNFUNDED_OFFER
+        // outcome as freezing a pool asset (see testOfferCreation).
+        auto testLPTokenTransfer = [&](std::uint32_t mptFlags, bool poolXrpToBtc) {
+            Env env{*this, features};
+            env.fund(XRP(30'000), gw_, carol_);
+            env.close();
+
+            MPT const btc = MPTTester(
+                {.env = env, .issuer = gw_, .holders = {carol_}, .pay = 1'000, .flags = mptFlags});
+
+            auto const asset1 = poolXrpToBtc ? XRP(10'000) : btc(10'000);
+            auto const asset2 = poolXrpToBtc ? btc(10'000) : XRP(10'000);
+            AMM const amm(env, gw_, asset1, asset2);
+            auto const lpIssue = amm.lptIssue();
+
+            env.trust(STAmount{lpIssue, 100'000}, carol_);
+            env.close();
+
+            // gw (the pool MPT issuer) seeds carol_ with LP tokens; issuer
+            // involving transfers are always allowed.
+            env(pay(gw_, carol_, STAmount{lpIssue, 1'000}));
+            env.close();
+
+            // carol_ tries to create an offer to sell the LP token.
+            if ((mptFlags & tfMPTCanTransfer) != 0u)
+            {
+                env(offer(carol_, XRP(10), STAmount{lpIssue, 10}), Txflags(tfPassive));
+                env.close();
+                BEAST_EXPECT(expectOffers(env, carol_, 1));
+            }
+            else
+            {
+                // Non-transferable pool MPT => LP token un-spendable => the
+                // sell offer is unfunded, just as if a pool asset were frozen.
+                env(offer(carol_, XRP(10), STAmount{lpIssue, 10}),
+                    Txflags(tfPassive),
+                    Ter(tecUNFUNDED_OFFER));
+                env.close();
+                BEAST_EXPECT(expectOffers(env, carol_, 0));
+            }
+        };
+
+        // Pool MPT without CanTransfer: LP token sell offer is unfunded.
+        testLPTokenTransfer(tfMPTCanTrade, true);
+        testLPTokenTransfer(tfMPTCanTrade, false);
+
+        // Pool MPT with CanTransfer: LP token sell offer is created.
+        testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, true);
+        testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, false);
+    }
+
 public:
     void
     run() override
@@ -447,6 +580,8 @@ public:
             testOfferCrossing(features);
             testCheck(features);
             testNFTOffers(features);
+            testMPTCanTransferDirectStep(features);
+            testMPTCanTransferOffer(features);
         }
     }
 };
diff --git a/src/test/app/LedgerLoad_test.cpp b/src/test/app/LedgerLoad_test.cpp
index ee3bfe5192..8fb10c1088 100644
--- a/src/test/app/LedgerLoad_test.cpp
+++ b/src/test/app/LedgerLoad_test.cpp
@@ -7,10 +7,10 @@
 
 #include 
 
+#include 
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -18,16 +18,16 @@
 #include 
 
 #include 
-#include 
-#include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -61,7 +61,7 @@ class LedgerLoad_test : public beast::unit_test::Suite
     };
 
     SetupData
-    setupLedger(beast::TempDir const& td)
+    setupLedger(TempDir const& td)
     {
         using namespace test::jtx;
         SetupData retval = {.dbPath = td.path()};
@@ -139,7 +139,7 @@ class LedgerLoad_test : public beast::unit_test::Suite
     {
         testcase("Load ledger: Bad Files");
         using namespace test::jtx;
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
 
         // empty path
         except([&] {
@@ -161,8 +161,8 @@ class LedgerLoad_test : public beast::unit_test::Suite
         });
 
         // make a corrupted version of the ledger file (last 10 bytes removed).
-        boost::system::error_code ec;
-        auto ledgerFileCorrupt = boost::filesystem::path{sd.dbPath} / "ledgerdata_bad.json";
+        std::error_code ec;
+        auto ledgerFileCorrupt = std::filesystem::path{sd.dbPath} / "ledgerdata_bad.json";
         copy_file(sd.ledgerFile, ledgerFileCorrupt, copy_options::overwrite_existing, ec);
         if (!BEAST_EXPECTS(!ec, ec.message()))
             return;
@@ -330,7 +330,7 @@ public:
     void
     run() override
     {
-        beast::TempDir const td;
+        TempDir const td;
         auto sd = setupLedger(td);
 
         // test cases
diff --git a/src/test/app/LedgerReplay_test.cpp b/src/test/app/LedgerReplay_test.cpp
index 2e2c80d6f8..0853affab7 100644
--- a/src/test/app/LedgerReplay_test.cpp
+++ b/src/test/app/LedgerReplay_test.cpp
@@ -28,7 +28,6 @@
 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -53,6 +52,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -402,7 +402,7 @@ public:
 
 enum class PeerSetBehavior {
     Good,
-    Drop50,
+    DropAlternate,
     DropAll,
     DropSkipListReply,
     DropLedgerDeltaReply,
@@ -445,17 +445,13 @@ struct TestPeerSet : public PeerSet
         protocol::MessageType type,
         std::shared_ptr const& peer) override
     {
-        int dropRate = 0;
-        if (behavior == PeerSetBehavior::Drop50)
-        {
-            dropRate = 50;
-        }
-        else if (behavior == PeerSetBehavior::DropAll)
-        {
-            dropRate = 100;
-        }
+        if (behavior == PeerSetBehavior::DropAll)
+            return;
 
-        if (randInt(1, 100) <= dropRate)
+        // Drop every other message deterministically. Alternating drops
+        // still exercise the timeout/retry path while guaranteeing every
+        // subtask eventually gets a reply.
+        if (behavior == PeerSetBehavior::DropAlternate && sendCount++ % 2 == 0)
             return;
 
         switch (type)
@@ -500,6 +496,7 @@ struct TestPeerSet : public PeerSet
     LedgerReplayMsgHandler& remote;
     std::shared_ptr dummyPeer;
     PeerSetBehavior behavior;
+    std::atomic sendCount{0};
 };
 
 /**
@@ -1397,7 +1394,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
             case PeerSetBehavior::Good:
                 testcase("good network");
                 break;
-            case PeerSetBehavior::Drop50:
+            case PeerSetBehavior::DropAlternate:
                 testcase("network drops 50% messages");
                 break;
             case PeerSetBehavior::Repeat:
@@ -1613,7 +1610,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
         testAllInboundLedgers(4);
         testPeerSetBehavior(PeerSetBehavior::Good, 1);
         testPeerSetBehavior(PeerSetBehavior::Good);
-        testPeerSetBehavior(PeerSetBehavior::Drop50);
+        testPeerSetBehavior(PeerSetBehavior::DropAlternate);
         testPeerSetBehavior(PeerSetBehavior::Repeat);
         testStop();
         testSkipListBadReply();
diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp
index b392dca758..7086adf743 100644
--- a/src/test/app/MPToken_test.cpp
+++ b/src/test/app/MPToken_test.cpp
@@ -789,7 +789,7 @@ class MPToken_test : public beast::unit_test::Suite
 
             // locks up bob's mptoken again
             mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock});
-            if (!features[featureSingleAssetVault])
+            if (!features[featureSingleAssetVault] && !features[fixCleanup3_4_0])
             {
                 // Delete bob's mptoken even though it is locked
                 mptAlice.authorize({.account = bob, .flags = tfMPTUnauthorize});
@@ -7657,6 +7657,56 @@ class MPToken_test : public beast::unit_test::Suite
             0, tecNO_PERMISSION, tecNO_PERMISSION, tecNO_PERMISSION, tecNO_PERMISSION);
     }
 
+    void
+    testLockedMPTokenDestroyedIssuance(FeatureBitset features)
+    {
+        testcase("Locked MPToken with destroyed issuance");
+
+        using namespace test::jtx;
+        Account const alice("alice");  // issuer
+        Account const bob("bob");      // holder
+
+        Env env{*this, features};
+        env.fund(XRP(1'000), alice, bob);
+        env.close();
+        MPTTester mptAlice(
+            {.env = env, .issuer = alice, .holders = {bob}, .flags = kMptDexFlags | tfMPTCanLock});
+
+        // alice locks bob's mptoken individually
+        mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock});
+
+        // alice destroys her issuance. This succeeds: MPTokenIssuanceDestroy
+        // only requires that the issuance has no outstanding balance; it does
+        // not require that all holder MPTokens have been deleted first.
+        mptAlice.destroy({.ownerCount = 0});
+
+        if (!features[featureSingleAssetVault] || features[fixCleanup3_4_0])
+        {
+            // pre SAV or post Cleanup340 amendment: bob deletes the dangling locked MPToken
+            mptAlice.authorize({.account = bob, .holderCount = 0, .flags = tfMPTUnauthorize});
+            BEAST_EXPECT(ownerCount(env, bob) == 0);
+        }
+        else
+        {
+            // bob cannot delete his locked MPToken, even though the issuance
+            // no longer exists.
+            mptAlice.authorize(
+                {.account = bob, .flags = tfMPTUnauthorize, .err = tecNO_PERMISSION});
+
+            // and the lock can never be cleared, because unlocking
+            // requires the (destroyed) issuance
+            mptAlice.set(
+                {.account = alice,
+                 .holder = bob,
+                 .flags = tfMPTUnlock,
+                 .err = tecOBJECT_NOT_FOUND});
+
+            // the dangling locked MPToken survives
+            BEAST_EXPECT(env.current()->exists(keylet::mptoken(mptAlice.issuanceID(), bob.id())));
+            BEAST_EXPECT(ownerCount(env, bob) == 1);
+        }
+    }
+
 public:
     void
     run() override
@@ -7703,7 +7753,9 @@ public:
         testSetValidation(all - featurePermissionedDomains);
         testSetValidation(all);
 
+        testSetEnabled(all - featureSingleAssetVault - fixCleanup3_4_0);
         testSetEnabled(all - featureSingleAssetVault);
+        testSetEnabled(all - fixCleanup3_4_0);
         testSetEnabled(all);
 
         // MPT clawback
@@ -7770,6 +7822,10 @@ public:
 
         // Fixes
         testFixDoubleOwnerCount(all);
+        testLockedMPTokenDestroyedIssuance(all);
+        testLockedMPTokenDestroyedIssuance(all - fixCleanup3_4_0);
+        testLockedMPTokenDestroyedIssuance(all - featureSingleAssetVault);
+        testLockedMPTokenDestroyedIssuance(all - featureSingleAssetVault - fixCleanup3_4_0);
     }
 };
 
diff --git a/src/test/app/Manifest_test.cpp b/src/test/app/Manifest_test.cpp
index ef2043a22c..14d176b45f 100644
--- a/src/test/app/Manifest_test.cpp
+++ b/src/test/app/Manifest_test.cpp
@@ -22,14 +22,12 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -56,18 +54,18 @@ private:
     }
 
     static void
-    cleanupDatabaseDir(boost::filesystem::path const& dbPath)
+    cleanupDatabaseDir(std::filesystem::path const& dbPath)
     {
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
         if (!exists(dbPath) || !is_directory(dbPath) || !is_empty(dbPath))
             return;
         remove(dbPath);
     }
 
     static void
-    setupDatabaseDir(boost::filesystem::path const& dbPath)
+    setupDatabaseDir(std::filesystem::path const& dbPath)
     {
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
         if (!exists(dbPath))
         {
             create_directory(dbPath);
@@ -80,10 +78,10 @@ private:
             Throw("Cannot create directory: " + dbPath.string());
         }
     }
-    static boost::filesystem::path
+    static std::filesystem::path
     getDatabasePath()
     {
-        return boost::filesystem::current_path() / "manifest_test_databases";
+        return std::filesystem::current_path() / "manifest_test_databases";
     }
 
 public:
@@ -351,7 +349,7 @@ public:
                 BEAST_EXPECT(loaded.revoked(pk));
             }
         }
-        boost::filesystem::remove(getDatabasePath() / boost::filesystem::path(dbName));
+        std::filesystem::remove(getDatabasePath() / std::filesystem::path(dbName));
     }
 
     void
diff --git a/src/test/app/NFTokenBurn_test.cpp b/src/test/app/NFTokenBurn_test.cpp
index 52565432a9..ae1d557bb9 100644
--- a/src/test/app/NFTokenBurn_test.cpp
+++ b/src/test/app/NFTokenBurn_test.cpp
@@ -32,6 +32,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -794,7 +795,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 TER terActual = tesSUCCESS;
                 for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
                 {
-                    terActual = ac.checkInvariants(terActual, XRPAmount{});
+                    terActual = xrpl::checkInvariants(ac, terActual, XRPAmount{});
                     BEAST_EXPECT(terExpect == terActual);
                     BEAST_EXPECT(sink.messages().str().starts_with("Invariant failed:"));
                     // uncomment to log the invariant failure message
@@ -830,7 +831,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 TER terActual = tesSUCCESS;
                 for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
                 {
-                    terActual = ac.checkInvariants(terActual, XRPAmount{});
+                    terActual = xrpl::checkInvariants(ac, terActual, XRPAmount{});
                     BEAST_EXPECT(terExpect == terActual);
                     BEAST_EXPECT(sink.messages().str().starts_with("Invariant failed:"));
                     // uncomment to log the invariant failure message
diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp
index a7437eea7f..08c12e94d1 100644
--- a/src/test/app/NFToken_test.cpp
+++ b/src/test/app/NFToken_test.cpp
@@ -36,6 +36,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -4790,6 +4791,87 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         checkOffers("nft_buy_offers", 501, 2, __LINE__);
     }
 
+    void
+    testNftXxxOffersMarkerWrongSide(FeatureBitset features)
+    {
+        // A pagination marker passed to nft_buy_offers / nft_sell_offers must
+        // reference an offer on the same side (buy vs. sell) as the directory
+        // being enumerated.  A wrong-side marker is rejected with invalidParams.
+        //
+        // Note: the pre-fix code also returned invalidParams for a wrong-side
+        // marker, but only after scanning the entire target directory (an
+        // O(directory size) walk usable to burn CPU).  The fix short-circuits
+        // that scan.  The scan-avoidance is not observable from the RPC
+        // response, so this test locks the rejection contract (wrong-side ->
+        // error, same-side -> success) rather than the performance property.
+        testcase("nft_buy_offers and nft_sell_offers wrong-side marker");
+
+        using namespace test::jtx;
+
+        Env env{*this, features};
+
+        Account const issuer{"issuer"};
+        Account const buyer{"buyer"};
+
+        env.fund(XRP(10000), issuer, buyer);
+        env.close();
+
+        // Mint a transferable NFT.
+        uint256 const nftID{token::getNextID(env, issuer, 0u, tfTransferable)};
+        env(token::mint(issuer, 0), Txflags(tfTransferable));
+        env.close();
+
+        // Create one sell offer (from the issuer, who owns the NFT) and one
+        // buy offer (from the buyer) for the same NFT.
+        env(token::createOffer(issuer, nftID, XRP(100)), Txflags(tfSellNFToken));
+        env(token::createOffer(buyer, nftID, XRP(50)), token::Owner(issuer));
+        env.close();
+
+        // Grab the index of the single offer on each side from the RPC
+        // response so we can use it as a marker.
+        auto firstOfferIndex = [this, &env, &nftID](char const* request) {
+            json::Value params;
+            params[jss::nft_id] = to_string(nftID);
+            json::Value const result = env.rpc("json", request, to_string(params))[jss::result];
+            BEAST_EXPECT(result.isMember(jss::offers) && result[jss::offers].size() == 1);
+            return result[jss::offers][0u][jss::nft_offer_index].asString();
+        };
+
+        std::string const sellOfferIndex = firstOfferIndex("nft_sell_offers");
+        std::string const buyOfferIndex = firstOfferIndex("nft_buy_offers");
+
+        auto queryWithMarker = [&env, &nftID](char const* request, std::string const& marker) {
+            json::Value params;
+            params[jss::nft_id] = to_string(nftID);
+            params[jss::marker] = marker;
+            return env.rpc("json", request, to_string(params))[jss::result];
+        };
+
+        // A marker referencing an offer on the wrong side is rejected with
+        // invalidParams.
+        {
+            // Sell-side marker passed to nft_buy_offers.
+            json::Value const result = queryWithMarker("nft_buy_offers", sellOfferIndex);
+            BEAST_EXPECT(result[jss::error].asString() == "invalidParams");
+        }
+        {
+            // Buy-side marker passed to nft_sell_offers.
+            json::Value const result = queryWithMarker("nft_sell_offers", buyOfferIndex);
+            BEAST_EXPECT(result[jss::error].asString() == "invalidParams");
+        }
+
+        // A same-side marker is still accepted.  With a single offer on each
+        // side, resuming after it simply yields no further offers.
+        {
+            json::Value const result = queryWithMarker("nft_buy_offers", buyOfferIndex);
+            BEAST_EXPECT(!result.isMember(jss::error));
+        }
+        {
+            json::Value const result = queryWithMarker("nft_sell_offers", sellOfferIndex);
+            BEAST_EXPECT(!result.isMember(jss::error));
+        }
+    }
+
     void
     testNFTokenNegOffer(FeatureBitset features)
     {
@@ -7274,6 +7356,127 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testCreateOfferInvalidAmount(FeatureBitset features)
+    {
+        testcase("Invalid NFT offer create amount");
+
+        using namespace test::jtx;
+
+        // Before fixCleanup3_4_0, a fake-XRP offer amount (an IOU using the
+        // "XRP" currency code) is not rejected in preflight. With the amendment
+        // enabled, preflight rejects it with temBAD_CURRENCY.
+        for (bool const withFix : {false, true})
+        {
+            Env env{*this, withFix ? features | fixCleanup3_4_0 : features - fixCleanup3_4_0};
+
+            Account const alice{"alice"};
+            Account const gw{"gw"};
+
+            env.fund(XRP(1000), alice, gw);
+            env.close();
+
+            uint256 const nftID = token::getNextID(env, alice, 0, tfTransferable);
+            env(token::mint(alice, 0u), Txflags(tfTransferable));
+            env.close();
+
+            // Fake XRP (an IOU using the "XRP" currency code) sell offer
+            // amount.
+            auto const bad = IOU(gw, badCurrency());
+            env(token::createOffer(alice, nftID, bad(1)),
+                Txflags(tfSellNFToken),
+                Ter(withFix ? TER{temBAD_CURRENCY} : TER{tesSUCCESS}));
+            env.close();
+        }
+    }
+
+    void
+    testAcceptOfferInvalidBrokerFee(FeatureBitset features)
+    {
+        testcase("Invalid NFT offer accept broker fee");
+
+        using namespace test::jtx;
+
+        // Before fixCleanup3_4_0, a fake-XRP broker fee (an IOU using the "XRP"
+        // currency code) is not rejected in preflight and reaches later offer
+        // validation instead. With the amendment enabled, preflight rejects it
+        // with temBAD_CURRENCY.
+        for (bool const withFix : {false, true})
+        {
+            Env env{*this, withFix ? features | fixCleanup3_4_0 : features - fixCleanup3_4_0};
+
+            Account const alice{"alice"};
+            Account const buyer{"buyer"};
+            Account const broker{"broker"};
+            Account const gw{"gw"};
+
+            env.fund(XRP(1000), alice, buyer, broker, gw);
+            env.close();
+
+            uint256 const nftID = token::getNextID(env, alice, 0, tfTransferable);
+            env(token::mint(alice, 0u), Txflags(tfTransferable));
+            env.close();
+
+            uint256 const sellOfferIndex =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
+            env(token::createOffer(alice, nftID, XRP(10)), Txflags(tfSellNFToken));
+            env.close();
+
+            uint256 const buyOfferIndex =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
+            env(token::createOffer(buyer, nftID, XRP(40)), token::Owner(alice));
+            env.close();
+
+            // Fake XRP (an IOU using the "XRP" currency code) broker fee.
+            auto const bad = IOU(gw, badCurrency());
+            env(token::brokerOffers(broker, buyOfferIndex, sellOfferIndex),
+                token::BrokerFee(bad(1)),
+                Ter(withFix ? TER{temBAD_CURRENCY} : TER{tecNFTOKEN_BUY_SELL_MISMATCH}));
+            env.close();
+        }
+    }
+
+    void
+    testCreateOfferIouIssuerGlobalFreeze(FeatureBitset features)
+    {
+        testcase("Create NFT offer by IOU issuer under global freeze");
+
+        using namespace test::jtx;
+
+        // Before fixCleanup3_4_0, an IOU issuer that has set a global freeze on
+        // their own currency cannot create an NFToken offer denominated in that
+        // currency; the offer is rejected with tecFROZEN.  With the amendment
+        // enabled, the issuer is not subject to their own global freeze when the
+        // offer is denominated in their own IOU (e.g. to receive their own
+        // transfer fees), so the offer succeeds.
+        for (bool const withFix : {false, true})
+        {
+            Env env{*this, withFix ? features | fixCleanup3_4_0 : features - fixCleanup3_4_0};
+
+            Account const issuer{"issuer"};
+            IOU const isISU(issuer["ISU"]);
+
+            env.fund(XRP(1000), issuer);
+            env.close();
+
+            // issuer mints a transferable NFToken.
+            uint256 const nftID = token::getNextID(env, issuer, 0, tfTransferable);
+            env(token::mint(issuer, 0u), Txflags(tfTransferable));
+            env.close();
+
+            // issuer sets a global freeze on their own IOU.
+            env(fset(issuer, asfGlobalFreeze));
+            env.close();
+
+            // issuer creates a sell offer for the NFToken denominated in their
+            // own (globally frozen) IOU.
+            env(token::createOffer(issuer, nftID, isISU(100)),
+                Txflags(tfSellNFToken),
+                Ter(withFix ? TER{tesSUCCESS} : TER{tecFROZEN}));
+            env.close();
+        }
+    }
+
 protected:
     FeatureBitset const allFeatures_{test::jtx::testableAmendments()};
 
@@ -7305,6 +7508,7 @@ protected:
         testNFTokenWithTickets(features);
         testNFTokenDeleteAccount(features);
         testNftXxxOffers(features);
+        testNftXxxOffersMarkerWrongSide(features);
         testNFTokenNegOffer(features);
         testIOUWithTransferFee(features);
         testBrokeredSaleToSelf(features);
@@ -7315,6 +7519,9 @@ protected:
         testUnaskedForAutoTrustline(features);
         testNFTIssuerIsIOUIssuer(features);
         testNFTokenModify(features);
+        testCreateOfferInvalidAmount(features);
+        testAcceptOfferInvalidBrokerFee(features);
+        testCreateOfferIouIssuerGlobalFreeze(features);
     }
 
 public:
diff --git a/src/test/app/OfferMPT_test.cpp b/src/test/app/OfferMPT_test.cpp
index d03b1b8e93..e262954fdf 100644
--- a/src/test/app/OfferMPT_test.cpp
+++ b/src/test/app/OfferMPT_test.cpp
@@ -1,3 +1,5 @@
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -5,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -22,6 +25,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -35,6 +39,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -46,6 +51,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -609,6 +615,267 @@ public:
         testHelper2TokensMix(test);
     }
 
+    void
+    testMPTIssuerOfferUsesRemainingCapacity(FeatureBitset features)
+    {
+        testcase("MPT issuer offer dust removal uses remaining issuance capacity");
+
+        using namespace jtx;
+
+        Account const issuer{"issuer"};
+        Account const carol{"carol"};
+        Account const bob{"bob"};
+
+        Env env{*this, features};
+        env.fund(XRP(10'000), issuer, carol, bob);
+        env.close();
+
+        MPTTester const musd(
+            {.env = env, .issuer = issuer, .holders = {carol, bob}, .maxAmt = 101});
+
+        // The issuer offer is fully fundable when placed. Later issuance leaves
+        // only one MPT of remaining capacity, so this issuer-owned MPT offer
+        // must be clipped by owner funds just like a holder-funded offer.
+        auto const issuerOfferSeq = env.seq(issuer);
+        env(offer(issuer, drops(1), musd(100)));
+        env.close();
+
+        env(pay(issuer, carol, musd(100)));
+        env.close();
+        BEAST_EXPECT(env.balance(issuer, musd) == musd(-100));
+        BEAST_EXPECT(env.balance(carol, musd) == musd(100));
+
+        // Carol's same-quality offer provides the legitimately funded side of
+        // the crossing. Without the issuer-cap dust-removal check, Bob would
+        // receive Carol's 100 MPT plus one free self-issued MPT from issuer's
+        // stale offer while paying only Carol's one drop.
+        auto const carolOfferSeq = env.seq(carol);
+        env(offer(carol, drops(1), musd(100)));
+        env.close();
+
+        auto const issuerOffer = keylet::offer(issuer.id(), SeqProxy::rawSequence(issuerOfferSeq));
+        auto const carolOffer = keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq));
+        BEAST_EXPECT(env.le(issuerOffer) != nullptr);
+        BEAST_EXPECT(env.le(carolOffer) != nullptr);
+
+        env(offer(bob, musd(101), drops(2), tfImmediateOrCancel));
+        env.close();
+
+        BEAST_EXPECT(env.le(issuerOffer) == nullptr);
+        BEAST_EXPECT(env.le(carolOffer) == nullptr);
+        env.require(offers(issuer, 0), offers(carol, 0), offers(bob, 0));
+        BEAST_EXPECT(env.balance(issuer, musd) == musd(-100));
+        BEAST_EXPECT(env.balance(carol, musd) == musd(0));
+        BEAST_EXPECT(env.balance(bob, musd) == musd(100));
+    }
+
+    void
+    testPartiallyFundedMPTInputOfferZeroInput(FeatureBitset features)
+    {
+        using namespace jtx;
+        auto const alice = Account{"alice"};
+        auto const bob = Account{"bob"};
+
+        {
+            testcase("Partially funded MPT/XRP input offer cannot be consumed for free");
+
+            Env env{*this, features};
+            auto const gw = Account{"gw"};
+
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}});
+
+            auto const aliceOfferSeq = env.seq(alice);
+            env(offer(alice, usd(1), drops(1'000'000)));
+            env.close();
+
+            auto const targetBalance = reserve(env, 2) + drops(999'999);
+            auto const drain = env.balance(alice).value().xrp() - targetBalance.value().xrp() -
+                env.current()->fees().base;
+            env(pay(alice, gw, drops(drain)));
+            env.close();
+
+            auto const aliceXRPBefore = env.balance(alice);
+            auto const bobXRPBefore = env.balance(bob);
+
+            env(pay(gw, bob, drops(1'000'000)),
+                Sendmax(usd(1)),
+                Path(~XRP),
+                Txflags(tfNoRippleDirect | tfPartialPayment),
+                Ter(tecPATH_DRY));
+            env.close();
+
+            // alice's offer sells 1,000,000 drops for usd(1) but she can fund
+            // only 999,999. Filling the clipped remainder would require a
+            // fractional usd (MPT) input that rounds down to zero, so without
+            // the fix the taker could take the funded drops for free.
+            // shouldRmSmallIncreasedQOffer() now treats the MPT input as
+            // integral (like XRP) and removes the degraded offer, so the
+            // payment goes dry. The removal happens only inside the crossing:
+            // tecPATH_DRY discards everything but the fee, so the offer itself
+            // stays in the ledger, unconsumed.
+            BEAST_EXPECT(
+                env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr);
+            BEAST_EXPECT(env.balance(alice) == aliceXRPBefore);
+            BEAST_EXPECT(env.balance(bob) == bobXRPBefore);
+        }
+
+        {
+            testcase("Partially funded MPT/IOU input offer cannot be consumed for free");
+
+            Env env{*this, features};
+            auto const mptIssuer = Account{"mptIssuer"};
+            auto const iouIssuer = Account{"iouIssuer"};
+
+            env.fund(XRP(10'000), mptIssuer, iouIssuer, alice, bob);
+            env.close();
+
+            auto const eur = iouIssuer["EUR"];
+            env.trust(eur(100), alice, bob);
+            env(pay(iouIssuer, alice, eur(0.5)));
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = mptIssuer, .holders = {alice}});
+
+            auto const aliceOfferSeq = env.seq(alice);
+            env(offer(alice, usd(1), eur(1)));
+            env.close();
+
+            auto const aliceEURBefore = env.balance(alice, eur);
+            auto const bobEURBefore = env.balance(bob, eur);
+
+            env(pay(mptIssuer, bob, eur(1)),
+                Sendmax(usd(1)),
+                Path(~eur),
+                Txflags(tfNoRippleDirect | tfPartialPayment),
+                Ter(tecPATH_DRY));
+            env.close();
+
+            // Same zero-input regression as the MPT/XRP case above, but with
+            // an IOU (eur) output leg: the fractional usd (MPT) input rounds
+            // to zero. The degraded offer is removed during crossing, the
+            // payment goes dry, and tecPATH_DRY leaves the offer in the ledger.
+            BEAST_EXPECT(
+                env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr);
+            BEAST_EXPECT(env.balance(alice, eur) == aliceEURBefore);
+            BEAST_EXPECT(env.balance(bob, eur) == bobEURBefore);
+        }
+
+        {
+            testcase("Partially funded MPT/MPT input offer cannot be consumed for free");
+
+            Env env{*this, features};
+            auto const issuerA = Account{"issuerA"};
+            auto const issuerB = Account{"issuerB"};
+
+            env.fund(XRP(10'000), issuerA, issuerB, alice, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = issuerA, .holders = {alice}});
+            MPTTester const eur({.env = env, .issuer = issuerB, .holders = {alice, bob}});
+
+            env(pay(issuerB, alice, eur(999'999)));
+            env.close();
+
+            auto const aliceOfferSeq = env.seq(alice);
+            env(offer(alice, usd(1), eur(1'000'000)));
+            env.close();
+
+            auto const aliceEURBefore = eur.getBalance(alice);
+            auto const bobEURBefore = eur.getBalance(bob);
+
+            env(pay(issuerA, bob, eur(1'000'000)),
+                Sendmax(usd(1)),
+                Path(~eur),
+                Txflags(tfNoRippleDirect | tfPartialPayment),
+                Ter(tecPATH_DRY));
+            env.close();
+
+            // Same zero-input regression as above, but with both legs MPT: the
+            // fractional usd (MPT) input rounds to zero. The degraded offer is
+            // removed during crossing, the payment goes dry, and tecPATH_DRY
+            // leaves the offer in the ledger.
+            BEAST_EXPECT(
+                env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr);
+            BEAST_EXPECT(env.balance(alice, eur) == eur(aliceEURBefore));
+            BEAST_EXPECT(env.balance(bob, eur) == eur(bobEURBefore));
+        }
+
+        {
+            // The dry cases above never observe the degraded offer actually
+            // being removed, because tecPATH_DRY rolls the removal back. Here a
+            // second, fully funded offer lets the crossing succeed, so the
+            // removal persists: alice's degraded offer is deleted from the
+            // book (not taken for free) while carol's good offer fills.
+            testcase(
+                "Partially funded MPT input offer is removed, not consumed, "
+                "when a funded offer crosses");
+
+            Env env{*this, features};
+            auto const gw = Account{"gw"};
+            auto const carol = Account{"carol"};
+
+            env.fund(XRP(10'000), gw, alice, carol, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .holders = {alice, carol, bob}});
+
+            // alice's offer sells 1,000,000 drops for usd(1) but, as in the
+            // dry cases above, she can fund only 999,999 drops, so filling the
+            // clipped remainder would require a fractional usd (MPT) input that
+            // rounds down to zero.
+            auto const aliceOfferSeq = env.seq(alice);
+            env(offer(alice, usd(1), drops(1'000'000)));
+            env.close();
+
+            auto const targetBalance = reserve(env, 2) + drops(999'999);
+            auto const drain = env.balance(alice).value().xrp() - targetBalance.value().xrp() -
+                env.current()->fees().base;
+            env(pay(alice, gw, drops(drain)));
+            env.close();
+
+            // carol's same-quality offer is fully funded and provides the
+            // legitimate side of the crossing.
+            auto const carolOfferSeq = env.seq(carol);
+            env(offer(carol, usd(1), drops(1'000'000)));
+            env.close();
+
+            // bob needs usd to buy drops.
+            env(pay(gw, bob, usd(2)));
+            env.close();
+
+            auto const aliceOffer = keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq));
+            auto const carolOffer = keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq));
+            BEAST_EXPECT(env.le(aliceOffer) != nullptr);
+            BEAST_EXPECT(env.le(carolOffer) != nullptr);
+
+            auto const aliceXRPBefore = env.balance(alice);
+            auto const bobXRPBefore = env.balance(bob);
+
+            // bob buys drops with usd, wanting more than carol alone supplies so
+            // the crossing also reaches alice's offer. carol's offer fills;
+            // alice's degraded offer is removed rather than taken for free, so
+            // bob receives only carol's 1,000,000 drops and pays only usd(1).
+            env(offer(bob, drops(2'000'000), usd(2), tfImmediateOrCancel));
+            env.close();
+
+            BEAST_EXPECT(env.le(aliceOffer) == nullptr);
+            BEAST_EXPECT(env.le(carolOffer) == nullptr);
+            env.require(offers(alice, 0), offers(carol, 0), offers(bob, 0));
+
+            // alice's offer was removed, not consumed: her balances are
+            // unchanged and none of her funded 999'999 drops leaked to bob.
+            BEAST_EXPECT(env.balance(alice) == aliceXRPBefore);
+            BEAST_EXPECT(env.balance(alice, usd) == usd(0));
+            BEAST_EXPECT(env.balance(carol, usd) == usd(1));
+            BEAST_EXPECT(env.balance(bob, usd) == usd(1));
+            BEAST_EXPECT(
+                env.balance(bob) == bobXRPBefore + drops(1'000'000) - env.current()->fees().base);
+        }
+    }
+
     void
     testInsufficientReserve(FeatureBitset features)
     {
@@ -947,6 +1214,161 @@ public:
         }
     }
 
+    void
+    testMPTAMMLimitQualityRounding(FeatureBitset features)
+    {
+        testcase("MPT AMM limitQuality checks rounded integral output");
+
+        using namespace jtx;
+
+        Account const gw{"gateway"};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+
+        // IOC used to reject the AMM strand with tecKILLED.  The continuous
+        // limitQuality target is about 32.88 MPT; rounding to nearest requested
+        // 33 MPT and made the realized AMM quality miss Bob's limit.  The
+        // discrete fallback takes the largest satisfying integer output: 32.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPTTester const btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, bob},
+                 .pay = 100'000,
+                 .flags = kMptDexFlags});
+            AMM const amm(env, alice, XRP(100), btc(1'000));
+
+            auto const bobBTCBefore = btc.getBalance(bob);
+            auto const [xrpBefore, btcBefore, lpBefore] = amm.balances();
+
+            env(offer(bob, btc(100), drops(10'340'000)), Txflags(tfImmediateOrCancel));
+            env.close();
+
+            auto const [xrpAfter, btcAfter, lpAfter] = amm.balances();
+            BEAST_EXPECT(btc.getBalance(bob) == bobBTCBefore + 32);
+            BEAST_EXPECT(xrpAfter > xrpBefore);
+            BEAST_EXPECT(btcAfter < btcBefore);
+            BEAST_EXPECT(lpAfter == lpBefore);
+            BEAST_EXPECT(expectOffers(env, bob, 0));
+        }
+
+        // A standard OfferCreate at the same limit used to bypass the AMM and
+        // rest unchanged on the book.  It should now take the largest
+        // satisfying 32-MPT AMM fill first, then leave only the remainder on
+        // the book.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPTTester const btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, bob},
+                 .pay = 100'000,
+                 .flags = kMptDexFlags});
+            AMM const amm(env, alice, XRP(100), btc(1'000));
+
+            auto const bobBTCBefore = btc.getBalance(bob);
+            auto const [xrpBefore, btcBefore, lpBefore] = amm.balances();
+
+            env(offer(bob, btc(100), drops(10'340'000)));
+            env.close();
+
+            auto const [xrpAfter, btcAfter, lpAfter] = amm.balances();
+            BEAST_EXPECT(btc.getBalance(bob) == bobBTCBefore + 32);
+            BEAST_EXPECT(xrpAfter > xrpBefore);
+            BEAST_EXPECT(btcAfter < btcBefore);
+            BEAST_EXPECT(lpAfter == lpBefore);
+            BEAST_EXPECT(expectOffers(env, bob, 1));
+
+            auto const bobOffers = offersOnAccount(env, bob);
+            if (BEAST_EXPECT(bobOffers.size() == 1))
+            {
+                BEAST_EXPECT((*bobOffers[0])[sfTakerPays] != btc(100));
+                BEAST_EXPECT((*bobOffers[0])[sfTakerGets] != drops(10'340'000));
+            }
+        }
+
+        // Mirror the IOC case with the integral output flipped from MPT units
+        // to XRP drops.  The same continuous target (~32.88) used to round up
+        // to 33 drops and miss limitQuality; the discrete fallback allows the
+        // largest satisfying 32-drop AMM fill.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPTTester const btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, bob},
+                 .pay = 200'000'000,
+                 .flags = kMptDexFlags});
+            AMM const amm(env, alice, drops(1'000), btc(100'000'000));
+
+            auto const bobXRPBefore = env.balance(bob, XRP);
+            auto const baseFee = env.current()->fees().base;
+            auto const [xrpBefore, btcBefore, lpBefore] = amm.balances();
+
+            env(offer(bob, drops(100), btc(10'340'000)), Txflags(tfImmediateOrCancel));
+            env.close();
+
+            auto const [xrpAfter, btcAfter, lpAfter] = amm.balances();
+            env.require(Balance(bob, bobXRPBefore + drops(32) - baseFee));
+            BEAST_EXPECT(xrpAfter < xrpBefore);
+            BEAST_EXPECT(btcAfter > btcBefore);
+            BEAST_EXPECT(lpAfter == lpBefore);
+            BEAST_EXPECT(expectOffers(env, bob, 0));
+        }
+
+        // Mirror the standard OfferCreate case as well.  It should consume the
+        // largest satisfying 32-drop AMM fill before leaving only the remainder
+        // on the book.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPTTester const btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, bob},
+                 .pay = 200'000'000,
+                 .flags = kMptDexFlags});
+            AMM const amm(env, alice, drops(1'000), btc(100'000'000));
+
+            auto const bobXRPBefore = env.balance(bob, XRP);
+            auto const baseFee = env.current()->fees().base;
+            auto const [xrpBefore, btcBefore, lpBefore] = amm.balances();
+
+            env(offer(bob, drops(100), btc(10'340'000)));
+            env.close();
+
+            auto const [xrpAfter, btcAfter, lpAfter] = amm.balances();
+            env.require(Balance(bob, bobXRPBefore + drops(32) - baseFee));
+            BEAST_EXPECT(xrpAfter < xrpBefore);
+            BEAST_EXPECT(btcAfter > btcBefore);
+            BEAST_EXPECT(lpAfter == lpBefore);
+            BEAST_EXPECT(expectOffers(env, bob, 1));
+
+            auto const bobOffers = offersOnAccount(env, bob);
+            if (BEAST_EXPECT(bobOffers.size() == 1))
+            {
+                BEAST_EXPECT((*bobOffers[0])[sfTakerPays] != drops(100));
+                BEAST_EXPECT((*bobOffers[0])[sfTakerGets] != btc(10'340'000));
+            }
+        }
+    }
+
     void
     testMalformed(FeatureBitset features)
     {
@@ -2727,6 +3149,50 @@ public:
         using namespace jtx;
         auto const gw1 = Account("gateway1");
 
+        {
+            auto const issuer = Account("issuer");
+            auto const sender = Account("sender");
+            auto const receiver = Account("receiver");
+            auto const seller = Account("seller");
+            auto const buyer = Account("buyer");
+
+            Env env{*this, features};
+            env.fund(XRP(10'000), issuer, sender, receiver, seller, buyer);
+            env.close();
+
+            MPTTester mpt{
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {sender, receiver, seller, buyer},
+                 .transferFee = 100}};
+            MPT const token = mpt;
+
+            mpt.pay(issuer, sender, 2'000);
+            mpt.pay(issuer, seller, 2'000);
+
+            // A direct holder-to-holder payment of 999 MPT at a 0.1% fee
+            // requires 1000 from the sender and burns one MPT.
+            env(pay(sender, receiver, token(999)), Ter(tecPATH_PARTIAL));
+            env.close();
+            env(pay(sender, receiver, token(999)), Sendmax(token(1'000)));
+            env.close();
+
+            BEAST_EXPECT(mpt.getBalance(sender) == 1'000);
+            BEAST_EXPECT(mpt.getBalance(receiver) == 999);
+            BEAST_EXPECT(mpt.getBalance(issuer) == 3'999);
+
+            // CLOB crossing should apply the same fee quantum.  The offer
+            // owner pays ceil(999 * 1.001) = 1000, not floor(...) = 999.
+            env(offer(seller, XRP(999), token(999)));
+            env.close();
+            env(offer(buyer, token(999), XRP(999)));
+            env.close();
+
+            BEAST_EXPECT(mpt.getBalance(seller) == 1'000);
+            BEAST_EXPECT(mpt.getBalance(buyer) == 999);
+            BEAST_EXPECT(mpt.getBalance(issuer) == 3'998);
+        }
+
         auto test = [&](auto&& issue1, auto&& issue2) {
             Env env{*this, features};
 
@@ -3102,6 +3568,247 @@ public:
         }
     }
 
+    void
+    testTransferRateOverflowOffer(FeatureBitset features)
+    {
+        testcase("Transfer Rate Overflow Offer");
+
+        using namespace jtx;
+
+        auto const issuer = Account("issuer");
+        auto const taker = Account("taker");
+
+        {
+            Env env{*this, features};
+            env.fund(XRP(10'000), issuer, taker);
+            env.close();
+
+            auto constexpr takerFunds = 2'000'000'000'000'000'000LL;
+            MPTTester const token{
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {taker},
+                 .transferFee = 50'000,
+                 .pay = takerFunds,
+                 .maxAmt = kMaxMpTokenAmount}};
+
+            // Covers OfferCreate::flowCross() sendMax calculation. A large
+            // non-issuer MPT offer with a transfer fee used to overflow in
+            // multiplyRound() before the offer could be placed.
+            auto constexpr offerAmount = 1'230'000'000'000'000'000LL;
+            auto const takerSeq = env.seq(taker);
+            env(offer(taker, XRP(1), token(offerAmount)));
+            env.close();
+
+            BEAST_EXPECT(
+                env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr);
+            BEAST_EXPECT(env.balance(taker, token) == token(takerFunds));
+        }
+
+        // Each scenario below targets a BookStep/OfferStream overflow path.
+        // The expected behavior is the same in all cases: remove the unusable
+        // book tip offer and let the taker's crossing offer remain rather than
+        // returning tecINTERNAL with the poison offer still on-ledger.
+        {
+            Env env{*this, features};
+            env.fund(XRP(10'000), issuer, taker);
+            env.close();
+
+            MPTTester const token{
+                {.env = env, .issuer = issuer, .holders = {taker}, .transferFee = 10'000}};
+
+            // Covers BookStep::forEachOffer() offer preparation, where
+            // ownerGives = mulRatio(ofrAmt.out, transferRateOut) overflowed
+            // for an oversized MPT output with a transfer fee.
+            std::int64_t const poisonAmount = 8'500'000'000'000'000'000LL;
+            auto const poisonSeq = env.seq(issuer);
+            env(offer(issuer, XRP(1), token(poisonAmount)));
+            env.close();
+
+            auto const poisonKeylet = keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq));
+            BEAST_EXPECT(env.le(poisonKeylet) != nullptr);
+
+            auto const takerSeq = env.seq(taker);
+            env(offer(taker, token(100), XRP(100)));
+            env.close();
+
+            BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
+            BEAST_EXPECT(
+                env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr);
+        }
+
+        {
+            auto const gwA = Account("gatewayA");
+            auto const gwB = Account("gatewayB");
+            auto const alice = Account("alice");
+            auto const mallory = Account("mallory");
+
+            Env env{*this, features};
+            env.fund(XRP(10'000), gwA, gwB, alice, mallory);
+            env.close();
+
+            MPTTester const tokenA{
+                {.env = env, .issuer = gwA, .holders = {alice, mallory}, .transferFee = 50'000}};
+
+            MPTTester const tokenB{{.env = env, .issuer = gwB, .holders = {alice, mallory}}};
+
+            env(pay(gwA, alice, tokenA(1'000)));
+
+            // Covers BookStep::forEachOffer() offer preparation, where
+            // stpAmt.in = mulRatio(ofrAmt.in, transferRateIn) overflowed.
+            // The MPT/MPT amounts keep the offer quality reachable while
+            // applying tokenA's transfer rate overflows the input side.
+            std::int64_t const poisonPays = 6'148'914'691'236'517'205LL;
+            std::int64_t const poisonGets = 34'000'000'000'000'000LL;
+            env(pay(gwB, mallory, tokenB(poisonGets)));
+
+            auto const poisonSeq = env.seq(mallory);
+            env(offer(mallory, tokenA(poisonPays), tokenB(poisonGets)));
+            env.close();
+
+            auto const poisonKeylet = keylet::offer(mallory.id(), SeqProxy::rawSequence(poisonSeq));
+            BEAST_EXPECT(env.le(poisonKeylet) != nullptr);
+
+            auto const aliceSeq = env.seq(alice);
+            env(offer(alice, tokenB(1), tokenA(100)));
+            env.close();
+
+            BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
+            BEAST_EXPECT(
+                env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceSeq))) != nullptr);
+        }
+
+        {
+            Env env{*this, features};
+            env.fund(XRP(10'000), issuer, taker);
+            env.close();
+
+            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;
+
+            auto const poisonSeq = env.seq(issuer);
+            env(offer(issuer, XRP(1), token(offerOut)));
+            env.close();
+
+            auto const poisonKeylet = keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq));
+            BEAST_EXPECT(env.le(poisonKeylet) != nullptr);
+
+            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 takerSeq = env.seq(taker);
+            env(offer(taker, token(funded), XRP(1)));
+            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.
+            BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
+            BEAST_EXPECT(
+                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);
+        }
+
+        {
+            auto const poisonMaker = Account("poisonMaker");
+
+            Env env{*this, features};
+            env.fund(XRP(10'000), issuer, poisonMaker, taker);
+            env.close();
+
+            MPTTester const token{
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {poisonMaker, taker},
+                 .maxAmt = kMaxMpTokenAmount}};
+
+            // Covers OfferStream::step() filtering. The offer is mostly
+            // funded, but reducing it to the actual owner funds inside
+            // shouldRmSmallIncreasedQOffer() used to overflow before BookStep
+            // saw the offer.
+            auto const funded = 1'844'674'407'370'955'162LL;
+            auto const offerOut = funded + 1;
+            env(pay(issuer, poisonMaker, token(funded)));
+
+            auto const poisonSeq = env.seq(poisonMaker);
+            env(offer(poisonMaker, XRP(1), token(offerOut)));
+            env.close();
+
+            auto const poisonKeylet =
+                keylet::offer(poisonMaker.id(), SeqProxy::rawSequence(poisonSeq));
+            BEAST_EXPECT(env.le(poisonKeylet) != nullptr);
+
+            auto const takerSeq = env.seq(taker);
+            env(offer(taker, token(1), XRP(1)));
+            env.close();
+
+            BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
+            BEAST_EXPECT(
+                env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr);
+            BEAST_EXPECT(env.balance(poisonMaker, token) == token(funded));
+            BEAST_EXPECT(env.balance(taker, token) == token(0));
+        }
+
+        {
+            // Same overflow scenario as the ownerGives case above, but run with
+            // trace-level logging so BookStep::forEachOffer's removeOffer()
+            // emits its "Removing offer with overflowing amount calculation"
+            // trace line. This exercises the JLOG body inside removeOffer,
+            // which is skipped when logging is above trace severity.
+            std::string logs;
+            {
+                Env env{
+                    *this,
+                    envconfig(),
+                    features,
+                    std::make_unique(&logs),
+                    beast::Severity::Trace};
+                env.fund(XRP(10'000), issuer, taker);
+                env.close();
+
+                MPTTester const token{
+                    {.env = env, .issuer = issuer, .holders = {taker}, .transferFee = 10'000}};
+
+                std::int64_t const poisonAmount = 8'500'000'000'000'000'000LL;
+                auto const poisonSeq = env.seq(issuer);
+                env(offer(issuer, XRP(1), token(poisonAmount)));
+                env.close();
+
+                auto const poisonKeylet =
+                    keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq));
+                BEAST_EXPECT(env.le(poisonKeylet) != nullptr);
+
+                auto const takerSeq = env.seq(taker);
+                env(offer(taker, token(100), XRP(100)));
+                env.close();
+
+                BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
+                BEAST_EXPECT(
+                    env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr);
+            }
+            BEAST_EXPECT(logs.contains("Removing offer with overflowing amount calculation"));
+        }
+    }
+
     void
     testSelfCrossOffer1(FeatureBitset features)
     {
@@ -4920,6 +5627,7 @@ public:
         testSellOffer(features);
         testSellWithFillOrKill(features);
         testTransferRateOffer(features);
+        testTransferRateOverflowOffer(features);
         testSelfCrossOffer(features);
         testSelfIssueOffer(features);
         testDirectToDirectPath(features);
@@ -4934,8 +5642,11 @@ public:
         testDeletedOfferIssuer(features);
         testTicketOffer(features);
         testTicketCancelOffer(features);
+        testMPTAMMLimitQualityRounding(features);
         testRmSmallIncreasedQOffersXRP(features);
         testRmSmallIncreasedQOffersMPT(features);
+        testMPTIssuerOfferUsesRemainingCapacity(features);
+        testPartiallyFundedMPTInputOfferZeroInput(features);
         testFillOrKill(features);
         testTickSize(features);
         testAutoCreateReserve(features);
diff --git a/src/test/app/PermissionedDEX_test.cpp b/src/test/app/PermissionedDEX_test.cpp
index a7e4cd7615..ddb56a1480 100644
--- a/src/test/app/PermissionedDEX_test.cpp
+++ b/src/test/app/PermissionedDEX_test.cpp
@@ -2008,6 +2008,143 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testDomainOfferInWrongBook(FeatureBitset features)
+    {
+        bool const fixEnabled = features[fixCleanup3_4_0];
+
+        testcase << "Domain offer indexed in the wrong domain book"
+                 << (fixEnabled ? " (fixCleanup3_4_0 enabled)" : " (fixCleanup3_4_0 disabled)");
+
+        // Bob (a member of domains A and B) places an offer in domain A's
+        // book, which we then corrupt to claim domain B while it stays in
+        // domain A's book. A payment routed through domain A meets this offer.
+        //
+        // - With fixCleanup3_4_0: OfferStream sees the offer's domain (B)
+        //   mismatch the book (A) and errors out -> tecPATH_PARTIAL.
+        // - Without it: OfferStream only checks the offer's own domain (B,
+        //   which Bob is in), so it is used; the invariant then catches the
+        //   mismatch -> tecINVARIANT_FAILED.
+        //
+        // Either way the payment fails and the offer is left untouched.
+
+        Env env(*this, features);
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
+            PermissionedDEX(env);
+
+        // A second domain that Bob also belongs to.
+        Account const bobAcct = bob;
+        auto const domainID2 =
+            setupDomain(env, {bobAcct}, Account("permdex-domainOwner2"), "permdex-cred2");
+
+        // Bob places a domain offer in domain A's book.
+        auto const bobOfferSeq{env.seq(bob)};
+        env(offer(bob, XRP(10), USD(10)), Domain(domainID));
+        env.close();
+        BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
+
+        // Corrupt the offer: point its sfDomainID at domain B while it stays
+        // indexed in domain A's book directory.
+        auto const offerKey = keylet::offer(bob.id(), SeqProxy::rawSequence(bobOfferSeq));
+        env.app().getOpenLedger().modify([&offerKey, &domainID2](OpenView& view, beast::Journal) {
+            auto const sle = view.read(offerKey);
+            if (!sle)
+                return false;
+            auto replacement = std::make_shared(*sle, sle->key());
+            replacement->setFieldH256(sfDomainID, domainID2);
+            view.rawReplace(replacement);
+            return true;
+        });
+
+        if (fixEnabled)
+        {
+            // With the fix: OfferStream rejects the mismatched offer.
+            env(pay(alice, carol, USD(10)),
+                Path(~USD),
+                Sendmax(XRP(10)),
+                Domain(domainID),
+                Ter(tecPATH_PARTIAL));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+        }
+        else
+        {
+            // Without the fix: the offer is used, then the invariant
+            // rejects the whole transaction.
+            env(pay(alice, carol, USD(10)),
+                Path(~USD),
+                Sendmax(XRP(10)),
+                Domain(domainID),
+                Ter(tecINVARIANT_FAILED));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+        }
+    }
+
+    void
+    testDomainBookOfferMissingDomain(FeatureBitset features)
+    {
+        bool const fixEnabled = features[fixCleanup3_4_0];
+
+        testcase << "Offer without a domain indexed in a domain book"
+                 << (fixEnabled ? " (fixCleanup3_4_0 enabled)" : " (fixCleanup3_4_0 disabled)");
+
+        // Same corruption as testDomainOfferInWrongBook, except the offer
+        // loses sfDomainID entirely instead of pointing at another domain
+        // while it stays indexed in domain A's book.
+        //
+        // - With fixCleanup3_4_0: OfferStream sees an offer that claims no
+        //   domain in a domain book and errors out -> tecPATH_PARTIAL.
+        // - Without it: neither the domain mismatch check nor the domain
+        //   membership check fires (both are gated on sfDomainID being
+        //   present), and the invariant does not catch it either because the
+        //   offer is fully consumed and deleted. The payment succeeds using an
+        //   offer that was never credential checked.
+
+        Env env(*this, features);
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
+            PermissionedDEX(env);
+
+        // Bob places a domain offer in domain A's book.
+        auto const bobOfferSeq{env.seq(bob)};
+        env(offer(bob, XRP(10), USD(10)), Domain(domainID));
+        env.close();
+        BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
+
+        // Corrupt the offer: drop sfDomainID while it stays indexed in domain
+        // A's book directory.
+        auto const offerKey = keylet::offer(bob.id(), SeqProxy::rawSequence(bobOfferSeq));
+        env.app().getOpenLedger().modify([&offerKey](OpenView& view, beast::Journal) {
+            auto const sle = view.read(offerKey);
+            if (!sle)
+                return false;
+            auto replacement = std::make_shared(*sle, sle->key());
+            replacement->makeFieldAbsent(sfDomainID);
+            view.rawReplace(replacement);
+            return true;
+        });
+
+        auto const carolBefore = env.balance(carol, USD);
+
+        if (fixEnabled)
+        {
+            // With the fix: OfferStream rejects the domainless offer.
+            env(pay(alice, carol, USD(10)),
+                Path(~USD),
+                Sendmax(XRP(10)),
+                Domain(domainID),
+                Ter(tecPATH_PARTIAL));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+            BEAST_EXPECT(env.balance(carol, USD) - carolBefore == USD(0));
+        }
+        else
+        {
+            // Without the fix: the offer is silently usable in the domain
+            // book, and the payment goes through.
+            env(pay(alice, carol, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
+            BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
+            BEAST_EXPECT(env.balance(carol, USD) - carolBefore == USD(10));
+        }
+    }
+
     void
     testReplaceDomainOfferWithOtherDomainOffer(FeatureBitset features)
     {
@@ -2100,6 +2237,14 @@ public:
         // only after fixCleanup3_2_0.
         testCancelRegularOfferWithDomainCreate(all);
         testCancelRegularOfferWithDomainCreate(all - fixCleanup3_2_0);
+
+        // A domain offer indexed in the wrong domain book is caught only
+        // after fixCleanup3_4_0. (Not an existing bug, but defensive testing)
+        testDomainOfferInWrongBook(all);
+        testDomainOfferInWrongBook(all - fixCleanup3_4_0);
+        testDomainBookOfferMissingDomain(all);
+        testDomainBookOfferMissingDomain(all - fixCleanup3_4_0);
+
         testReplaceDomainOfferWithOtherDomainOffer(all);
         testReplaceDomainOfferWithOtherDomainOffer(all - fixCleanup3_4_0);
     }
diff --git a/src/test/app/SHAMapStore_test.cpp b/src/test/app/SHAMapStore_test.cpp
index 6ee7442d23..82019affba 100644
--- a/src/test/app/SHAMapStore_test.cpp
+++ b/src/test/app/SHAMapStore_test.cpp
@@ -23,10 +23,9 @@
 #include 
 #include 
 
-#include 
-
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -493,7 +492,7 @@ public:
     makeBackendRotating(jtx::Env& env, NodeStoreScheduler& scheduler, std::string path)
     {
         Section section{env.app().config().section(Sections::kNodeDatabase)};
-        boost::filesystem::path newPath;
+        std::filesystem::path newPath;
 
         if (!BEAST_EXPECT(path.size()))
             return {};
diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp
index bcd31bc6a0..a1a9f80a11 100644
--- a/src/test/app/Sponsor_test.cpp
+++ b/src/test/app/Sponsor_test.cpp
@@ -1073,14 +1073,17 @@ public:
     }
 
     void
-    testTransferSponsor()
+    testTransferSponsor(FeatureBitset features)
     {
-        testcase("Transfer Sponsor");
+        testcase(
+            std::string("Transfer Sponsor ") +
+            (features[fixCleanup3_4_0] ? "(fixCleanup3_4_0 enabled)"
+                                       : "(fixCleanup3_4_0 disabled)"));
         using namespace test::jtx;
 
         // Verify preflight checks
         {
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor("sponsor");
@@ -1164,7 +1167,7 @@ public:
 
         {
             // Invalid SponsorshipEnd permission (sponsor object/sponsor account)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const charlie("charlie");
@@ -1209,7 +1212,7 @@ public:
 
         {
             // sponsor account
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor1("sponsor1");
@@ -1340,7 +1343,7 @@ public:
         }
         {
             // dissolve account sponsorship from sponsor
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor("sponsor");
@@ -1364,7 +1367,7 @@ public:
 
         {
             // sponsor object (co-signing)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor1("sponsor1");
@@ -1473,10 +1476,20 @@ public:
             BEAST_EXPECT(sle2->isFieldPresent(sfSponsor));
             BEAST_EXPECT(sle2->getAccountID(sfSponsor) == sponsor2.id());
 
-            // dissolve sponsor: ending an object sponsorship succeeds even
-            // when the sponsee lacks sufficient reserve to reclaim the object.
+            // dissolve sponsor: ending an object sponsorship now (fixCleanup3_4_0) requires the
+            // sponsee to be able to self-fund the object's reserve.
             adjustAccountXRPBalance(env, alice, reserve(env, 1) - drops(1));
 
+            if (features[fixCleanup3_4_0])
+            {
+                // Under-funded: End is rejected until alice can self-fund.
+                env(sponsor::transfer(alice, tfSponsorshipEnd, checkId),
+                    Ter(tecINSUFFICIENT_RESERVE));
+                env.close();
+
+                adjustAccountXRPBalance(env, alice, reserve(env, 1));
+            }
+
             env(sponsor::transfer(alice, tfSponsorshipEnd, checkId));
             env.close();
 
@@ -1509,7 +1522,7 @@ public:
         }
         {
             // sponsor object (pre-funded + no ltSponsorship entry)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor1("sponsor1");
@@ -1543,7 +1556,7 @@ public:
         }
         {
             // sponsor object (pre-funded)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor1("sponsor1");
@@ -1646,7 +1659,7 @@ public:
 
         {
             // Dissolve object sponsorship from sponsor(no-ltSponsorship)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor("sponsor");
@@ -1686,7 +1699,7 @@ public:
 
         {
             // Dissolve object sponsorship from sponsor (with ltSponsorship)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor("sponsor");
@@ -1744,7 +1757,7 @@ public:
 
             for (bool const isIssuerHigh : {false, true})
             {
-                Env env{*this, testableAmendments()};
+                Env env{*this, features};
                 env.fund(XRP(10000), alice, bob, sponsor);
                 env.close();
 
@@ -1788,7 +1801,7 @@ public:
 
         {
             // invalid transfer
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor("sponsor");
@@ -1825,7 +1838,7 @@ public:
         {
             // existing owner objects that are outside the v1 SponsorshipTransfer
             // object allow-list
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const sponsor("sponsor");
             env.fund(XRP(10000), alice, sponsor);
@@ -5671,7 +5684,8 @@ protected:
         testPreFundAndCosign();
         testSponsoredFreeTierReserve();
 
-        testTransferSponsor();
+        testTransferSponsor(jtx::testableAmendments());
+        testTransferSponsor(jtx::testableAmendments() - fixCleanup3_4_0);
         testLegacySignerListReserve();
         testSponsorFee();
         testSponsorAccount();
diff --git a/src/test/app/ValidatorList_test.cpp b/src/test/app/ValidatorList_test.cpp
index 323c77c780..d2e6cb24aa 100644
--- a/src/test/app/ValidatorList_test.cpp
+++ b/src/test/app/ValidatorList_test.cpp
@@ -2253,8 +2253,7 @@ private:
     {
         testcase("Sha512 hashing");
         // Tests that ValidatorList hash_append helpers with a single blob
-        // returns the same result as xrpl::Sha512Half used by the
-        // TMValidatorList protocol message handler
+        // return the same result as xrpl::Sha512Half
         std::string const manifest = "This is not really a manifest";
         std::string const blob = "This is not really a blob";
         std::string const signature = "This is not really a signature";
@@ -2275,17 +2274,6 @@ private:
             BEAST_EXPECT(global != sha512Half(blob, blobMap, version));
         }
 
-        {
-            protocol::TMValidatorList msg1;
-            msg1.set_manifest(manifest);
-            msg1.set_blob(blob);
-            msg1.set_signature(signature);
-            msg1.set_version(version);
-            BEAST_EXPECT(global == sha512Half(msg1));
-            msg1.set_signature(blob);
-            BEAST_EXPECT(global != sha512Half(msg1));
-        }
-
         {
             protocol::TMValidatorListCollection msg2;
             msg2.set_manifest(manifest);
@@ -2323,19 +2311,7 @@ private:
             BEAST_EXPECT(!ec);
             return std::make_pair(header, buffers);
         };
-        auto extractProtocolMessage1 = [this, &extractHeader](Message& message) {
-            auto [header, buffers] = extractHeader(message);
-            if (BEAST_EXPECT(header) &&
-                BEAST_EXPECT(header->messageType == protocol::mtVALIDATOR_LIST))
-            {
-                auto const msg =
-                    detail::parseMessageContent(*header, buffers.data());
-                BEAST_EXPECT(msg);
-                return msg;
-            }
-            return std::shared_ptr();
-        };
-        auto extractProtocolMessage2 = [this, &extractHeader](Message& message) {
+        auto extractProtocolMessage = [this, &extractHeader](Message& message) {
             auto [header, buffers] = extractHeader(message);
             if (BEAST_EXPECT(header) &&
                 BEAST_EXPECT(header->messageType == protocol::mtVALIDATOR_LIST_COLLECTION))
@@ -2347,92 +2323,55 @@ private:
             }
             return std::shared_ptr();
         };
-        auto verifyMessage =
-            [this, manifestCutoff, &extractProtocolMessage1, &extractProtocolMessage2](
-                auto const version,
-                auto const& manifest,
-                auto const& blobInfos,
-                auto const& messages,
-                std::vector>> expectedInfo) {
-                BEAST_EXPECT(messages.size() == expectedInfo.size());
-                auto msgIter = expectedInfo.begin();
-                for (auto const& messageWithHash : messages)
+        auto verifyMessage = [this, manifestCutoff, &extractProtocolMessage](
+                                 auto const version,
+                                 auto const& manifest,
+                                 auto const& blobInfos,
+                                 auto const& messages,
+                                 std::vector> expectedInfo) {
+            BEAST_EXPECT(messages.size() == expectedInfo.size());
+            auto msgIter = expectedInfo.begin();
+            for (auto const& messageWithHash : messages)
+            {
+                if (!BEAST_EXPECT(msgIter != expectedInfo.end()))
+                    break;
+                if (!BEAST_EXPECT(messageWithHash.message))
+                    continue;
+                auto const& expectedSeqs = *msgIter;
+                auto seqIter = expectedSeqs.begin();
                 {
-                    if (!BEAST_EXPECT(msgIter != expectedInfo.end()))
-                        break;
-                    if (!BEAST_EXPECT(messageWithHash.message))
-                        continue;
-                    auto const& expectedSeqs = msgIter->second;
-                    auto seqIter = expectedSeqs.begin();
-                    auto const size =
-                        messageWithHash.message->getBuffer(compression::Compressed::Off).size();
-                    // This size is arbitrary, but shouldn't change
-                    BEAST_EXPECT(size == msgIter->first);
-                    if (expectedSeqs.size() == 1)
+                    std::vector hashingBlobs;
+                    hashingBlobs.reserve(expectedSeqs.size());
+
+                    auto const msg = extractProtocolMessage(*messageWithHash.message);
+                    if (BEAST_EXPECT(msg))
                     {
-                        auto const msg = extractProtocolMessage1(*messageWithHash.message);
-                        auto const expectedVersion = 1;
-                        if (BEAST_EXPECT(msg))
+                        BEAST_EXPECT(msg->version() == version);
+                        BEAST_EXPECT(msg->manifest() == manifest);
+                        for (auto const& blobInfo : msg->blobs())
                         {
-                            BEAST_EXPECT(msg->version() == expectedVersion);
                             if (!BEAST_EXPECT(seqIter != expectedSeqs.end()))
-                                continue;
+                                break;
                             auto const& expectedBlob = blobInfos.at(*seqIter);
-                            BEAST_EXPECT((*seqIter < manifestCutoff) == !!expectedBlob.manifest);
-                            auto const expectedManifest =
-                                *seqIter < manifestCutoff && expectedBlob.manifest
-                                ? *expectedBlob.manifest
-                                : manifest;
-                            BEAST_EXPECT(msg->manifest() == expectedManifest);
-                            BEAST_EXPECT(msg->blob() == expectedBlob.blob);
-                            BEAST_EXPECT(msg->signature() == expectedBlob.signature);
+                            hashingBlobs.push_back(expectedBlob);
+                            BEAST_EXPECT(blobInfo.has_manifest() == !!expectedBlob.manifest);
+                            BEAST_EXPECT(blobInfo.has_manifest() == (*seqIter < manifestCutoff));
+
+                            if (*seqIter < manifestCutoff)
+                                BEAST_EXPECT(blobInfo.manifest() == *expectedBlob.manifest);
+                            BEAST_EXPECT(blobInfo.blob() == expectedBlob.blob);
+                            BEAST_EXPECT(blobInfo.signature() == expectedBlob.signature);
                             ++seqIter;
-                            BEAST_EXPECT(seqIter == expectedSeqs.end());
-
-                            BEAST_EXPECT(
-                                messageWithHash.hash ==
-                                sha512Half(
-                                    expectedManifest,
-                                    expectedBlob.blob,
-                                    expectedBlob.signature,
-                                    expectedVersion));
                         }
+                        BEAST_EXPECT(seqIter == expectedSeqs.end());
                     }
-                    else
-                    {
-                        std::vector hashingBlobs;
-                        hashingBlobs.reserve(msgIter->second.size());
-
-                        auto const msg = extractProtocolMessage2(*messageWithHash.message);
-                        if (BEAST_EXPECT(msg))
-                        {
-                            BEAST_EXPECT(msg->version() == version);
-                            BEAST_EXPECT(msg->manifest() == manifest);
-                            for (auto const& blobInfo : msg->blobs())
-                            {
-                                if (!BEAST_EXPECT(seqIter != expectedSeqs.end()))
-                                    break;
-                                auto const& expectedBlob = blobInfos.at(*seqIter);
-                                hashingBlobs.push_back(expectedBlob);
-                                BEAST_EXPECT(blobInfo.has_manifest() == !!expectedBlob.manifest);
-                                BEAST_EXPECT(
-                                    blobInfo.has_manifest() == (*seqIter < manifestCutoff));
-
-                                if (*seqIter < manifestCutoff)
-                                    BEAST_EXPECT(blobInfo.manifest() == *expectedBlob.manifest);
-                                BEAST_EXPECT(blobInfo.blob() == expectedBlob.blob);
-                                BEAST_EXPECT(blobInfo.signature() == expectedBlob.signature);
-                                ++seqIter;
-                            }
-                            BEAST_EXPECT(seqIter == expectedSeqs.end());
-                        }
-                        BEAST_EXPECT(
-                            messageWithHash.hash == sha512Half(manifest, hashingBlobs, version));
-                    }
-                    ++msgIter;
+                    BEAST_EXPECT(
+                        messageWithHash.hash == sha512Half(manifest, hashingBlobs, version));
                 }
-                BEAST_EXPECT(msgIter == expectedInfo.end());
-            };
+                ++msgIter;
+            }
+            BEAST_EXPECT(msgIter == expectedInfo.end());
+        };
         auto verifyBuildMessages = [this](
                                        std::pair const& result,
                                        std::size_t expectedSequence,
@@ -2471,66 +2410,10 @@ private:
 
         std::vector messages;
 
-        // Version 1
-
-        // This peer has a VL ahead of our "current"
-        verifyBuildMessages(
-            ValidatorList::buildValidatorListMessages(
-                1, 8, maxSequence, version, manifest, blobInfos, messages),
-            0,
-            0);
-        BEAST_EXPECT(messages.empty());
-
-        // Don't repeat the work if messages is populated, even though the
-        // peerSequence provided indicates it should. Note that this
-        // situation is contrived for this test and should never happen in
-        // real code.
-        messages.emplace_back();
-        verifyBuildMessages(
-            ValidatorList::buildValidatorListMessages(
-                1, 3, maxSequence, version, manifest, blobInfos, messages),
-            5,
-            0);
-        BEAST_EXPECT(messages.size() == 1 && !messages.front().message);
-
-        // Generate a version 1 message
-        messages.clear();
-        verifyBuildMessages(
-            ValidatorList::buildValidatorListMessages(
-                1, 3, maxSequence, version, manifest, blobInfos, messages),
-            5,
-            1);
-        if (BEAST_EXPECT(messages.size() == 1) && BEAST_EXPECT(messages.front().message))
-        {
-            auto const& messageWithHash = messages.front();
-            auto const msg = extractProtocolMessage1(*messageWithHash.message);
-            auto const size =
-                messageWithHash.message->getBuffer(compression::Compressed::Off).size();
-            // This size is arbitrary, but shouldn't change
-            BEAST_EXPECT(size == 108);
-            auto const& expected = blobInfos.at(5);
-            if (BEAST_EXPECT(msg))
-            {
-                BEAST_EXPECT(msg->version() == 1);
-                // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-                BEAST_EXPECT(msg->manifest() == *expected.manifest);
-                BEAST_EXPECT(msg->blob() == expected.blob);
-                BEAST_EXPECT(msg->signature() == expected.signature);
-            }
-            BEAST_EXPECT(
-                messageWithHash.hash ==
-                // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-                sha512Half(*expected.manifest, expected.blob, expected.signature, 1));
-        }
-
-        // Version 2
-
-        messages.clear();
-
         // This peer has a VL ahead of us.
         verifyBuildMessages(
             ValidatorList::buildValidatorListMessages(
-                2, maxSequence * 2, maxSequence, version, manifest, blobInfos, messages),
+                maxSequence * 2, maxSequence, version, manifest, blobInfos, messages),
             0,
             0);
         BEAST_EXPECT(messages.empty());
@@ -2542,19 +2425,19 @@ private:
         messages.emplace_back();
         verifyBuildMessages(
             ValidatorList::buildValidatorListMessages(
-                2, 3, maxSequence, version, manifest, blobInfos, messages),
+                3, maxSequence, version, manifest, blobInfos, messages),
             maxSequence,
             0);
         BEAST_EXPECT(messages.size() == 1 && !messages.front().message);
 
-        // Generate a version 2 message. Don't send the current
+        // Generate a message. Don't send the current
         messages.clear();
         verifyBuildMessages(
             ValidatorList::buildValidatorListMessages(
-                2, 5, maxSequence, version, manifest, blobInfos, messages),
+                5, maxSequence, version, manifest, blobInfos, messages),
             maxSequence,
             4);
-        verifyMessage(version, manifest, blobInfos, messages, {{372, {6, 7, 10, 12}}});
+        verifyMessage(version, manifest, blobInfos, messages, {{6, 7, 10, 12}});
 
         // Test message splitting on size limits.
 
@@ -2562,50 +2445,39 @@ private:
         messages.clear();
         verifyBuildMessages(
             ValidatorList::buildValidatorListMessages(
-                2, 5, maxSequence, version, manifest, blobInfos, messages, 300),
+                5, maxSequence, version, manifest, blobInfos, messages, 300),
             maxSequence,
             4);
-        verifyMessage(version, manifest, blobInfos, messages, {{212, {6, 7}}, {192, {10, 12}}});
+        verifyMessage(version, manifest, blobInfos, messages, {{6, 7}, {10, 12}});
 
         // Set a limit between the size of the two earlier messages so one
         // will split and the other won't
         messages.clear();
         verifyBuildMessages(
             ValidatorList::buildValidatorListMessages(
-                2, 5, maxSequence, version, manifest, blobInfos, messages, 200),
+                5, maxSequence, version, manifest, blobInfos, messages, 200),
             maxSequence,
             4);
-        verifyMessage(
-            version, manifest, blobInfos, messages, {{108, {6}}, {108, {7}}, {192, {10, 12}}});
+        verifyMessage(version, manifest, blobInfos, messages, {{6}, {7}, {10, 12}});
 
         // Set a limit so that all the VLs are sent individually
         messages.clear();
         verifyBuildMessages(
             ValidatorList::buildValidatorListMessages(
-                2, 5, maxSequence, version, manifest, blobInfos, messages, 150),
+                5, maxSequence, version, manifest, blobInfos, messages, 150),
             maxSequence,
             4);
-        verifyMessage(
-            version,
-            manifest,
-            blobInfos,
-            messages,
-            {{108, {6}}, {108, {7}}, {110, {10}}, {110, {12}}});
+        verifyMessage(version, manifest, blobInfos, messages, {{6}, {7}, {10}, {12}});
 
         // Set a limit smaller than some of the messages. Because single
         // messages send regardless, they will all still be sent
         messages.clear();
         verifyBuildMessages(
             ValidatorList::buildValidatorListMessages(
-                2, 5, maxSequence, version, manifest, blobInfos, messages, 108),
+                5, maxSequence, version, manifest, blobInfos, messages, 108),
             maxSequence,
             4);
-        verifyMessage(
-            version,
-            manifest,
-            blobInfos,
-            messages,
-            {{108, {6}}, {108, {7}}, {110, {10}}, {110, {12}}});
+        verifyMessage(version, manifest, blobInfos, messages, {{6}, {7}, {10}, {12}});
     }
 
     void
diff --git a/src/test/app/ValidatorSite_test.cpp b/src/test/app/ValidatorSite_test.cpp
index 8400f2d794..8373efe85b 100644
--- a/src/test/app/ValidatorSite_test.cpp
+++ b/src/test/app/ValidatorSite_test.cpp
@@ -15,13 +15,12 @@
 #include 
 
 #include 
-#include 
-#include 
 #include 
 
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -704,7 +703,7 @@ public:
                   .effectiveOverlap = detail::kDefaultEffectiveOverlap,
                   .expectedRefreshMin = 60 * 24}});  // max of 24 hours
         }
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
         for (auto const& file : directory_iterator(good.subdir()))
         {
             remove_all(file);
diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp
deleted file mode 100644
index 70527f570d..0000000000
--- a/src/test/app/Vault_test.cpp
+++ /dev/null
@@ -1,8436 +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 
-
-namespace xrpl {
-
-class Vault_test : public beast::unit_test::Suite
-{
-    using PrettyAsset = xrpl::test::jtx::PrettyAsset;
-    using PrettyAmount = xrpl::test::jtx::PrettyAmount;
-
-    static constexpr auto kNegativeAmount = [](PrettyAsset const& asset) -> PrettyAmount {
-        return {STAmount{asset.raw(), 1ul, 0, true, STAmount::Unchecked{}}, ""};
-    };
-
-    void
-    testSequences()
-    {
-        using namespace test::jtx;
-        Account const issuer{"issuer"};
-        Account const owner{"owner"};
-        Account const depositor{"depositor"};
-        Account const charlie{"charlie"};  // authorized 3rd party
-        Account const dave{"dave"};
-
-        auto const testSequence = [&, this](
-                                      std::string const& prefix,
-                                      Env& env,
-                                      Vault& vault,
-                                      PrettyAsset const& asset) {
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            tx[sfData] = "AFEED00E";
-            tx[sfAssetsMaximum] = asset(100).number();
-            env(tx);
-            env.close();
-            BEAST_EXPECT(env.le(keylet));
-            std::uint64_t const scale = asset.raw().holds() ? 1 : 1e6;
-
-            auto const [share, vaultAccount] =
-                [&env, keylet = keylet, asset, this]() -> std::tuple {
-                auto const vault = env.le(keylet);
-                BEAST_EXPECT(vault != nullptr);
-                if (!asset.integral())
-                {
-                    BEAST_EXPECT(vault->at(sfScale) == 6);
-                }
-                else
-                {
-                    BEAST_EXPECT(vault->at(sfScale) == 0);
-                }
-                auto const shares = env.le(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
-                BEAST_EXPECT(shares != nullptr);
-                if (!asset.integral())
-                {
-                    BEAST_EXPECT(shares->at(sfAssetScale) == 6);
-                }
-                else
-                {
-                    BEAST_EXPECT(shares->at(sfAssetScale) == 0);
-                }
-                return {MPTIssue(vault->at(sfShareMPTID)), Account("vault", vault->at(sfAccount))};
-            }();
-            auto const shares = share.raw().get();
-            env.memoize(vaultAccount);
-
-            // Several 3rd party accounts which cannot receive funds
-            Account const alice{"alice"};
-            Account const erin{"erin"};  // not authorized by issuer
-            env.fund(XRP(1000), alice, erin);
-            env(fset(alice, asfDepositAuth));
-            env.close();
-
-            {
-                testcase(prefix + " fail to deposit more than assets held");
-                auto tx = vault.deposit(
-                    {.depositor = depositor, .id = keylet.key, .amount = asset(10000)});
-                env(tx, Ter(tecINSUFFICIENT_FUNDS));
-                env.close();
-            }
-
-            {
-                testcase(prefix + " deposit non-zero amount");
-                auto tx =
-                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale));
-            }
-
-            {
-                testcase(prefix + " deposit non-zero amount again");
-                auto tx =
-                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale));
-            }
-
-            {
-                testcase(prefix + " fail to delete non-empty vault");
-                auto tx = vault.del({.owner = owner, .id = keylet.key});
-                env(tx, Ter(tecHAS_OBLIGATIONS));
-                env.close();
-            }
-
-            {
-                testcase(prefix + " fail to update because wrong owner");
-                auto tx = vault.set({.owner = issuer, .id = keylet.key});
-                tx[sfAssetsMaximum] = asset(50).number();
-                env(tx, Ter(tecNO_PERMISSION));
-                env.close();
-            }
-
-            {
-                testcase(prefix + " fail to set maximum lower than current amount");
-                auto tx = vault.set({.owner = owner, .id = keylet.key});
-                tx[sfAssetsMaximum] = asset(50).number();
-                env(tx, Ter(tecLIMIT_EXCEEDED));
-                env.close();
-            }
-
-            {
-                testcase(prefix + " set maximum higher than current amount");
-                auto tx = vault.set({.owner = owner, .id = keylet.key});
-                tx[sfAssetsMaximum] = asset(150).number();
-                env(tx);
-                env.close();
-            }
-
-            {
-                testcase(prefix + " set maximum is idempotent, set it again");
-                auto tx = vault.set({.owner = owner, .id = keylet.key});
-                tx[sfAssetsMaximum] = asset(150).number();
-                env(tx);
-                env.close();
-            }
-
-            {
-                testcase(prefix + " set data");
-                auto tx = vault.set({.owner = owner, .id = keylet.key});
-                tx[sfData] = "0";
-                env(tx);
-                env.close();
-            }
-
-            {
-                testcase(prefix + " fail to set domain on public vault");
-                auto tx = vault.set({.owner = owner, .id = keylet.key});
-                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
-                env(tx, Ter{tecNO_PERMISSION});
-                env.close();
-            }
-
-            {
-                testcase(prefix + " fail to deposit more than maximum");
-                auto tx =
-                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-                env(tx, Ter(tecLIMIT_EXCEEDED));
-                env.close();
-            }
-
-            {
-                testcase(prefix + " reset maximum to zero i.e. not enforced");
-                auto tx = vault.set({.owner = owner, .id = keylet.key});
-                tx[sfAssetsMaximum] = asset(0).number();
-                env(tx);
-                env.close();
-            }
-
-            {
-                testcase(prefix + " fail to withdraw more than assets held");
-                auto tx = vault.withdraw(
-                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
-                env(tx, Ter(tecINSUFFICIENT_FUNDS));
-                env.close();
-            }
-
-            {
-                testcase(prefix + " deposit some more");
-                auto tx =
-                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale));
-            }
-
-            {
-                testcase(prefix + " clawback some");
-                auto code = asset.raw().native() ? Ter(temMALFORMED) : Ter(tesSUCCESS);
-                auto tx = vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(10)});
-                env(tx, code);
-                env.close();
-                if (!asset.raw().native())
-                {
-                    BEAST_EXPECT(env.balance(depositor, shares) == share(190 * scale));
-                }
-            }
-
-            {
-                testcase(prefix + " clawback all");
-                auto code = asset.raw().native() ? Ter(tecNO_PERMISSION) : Ter(tesSUCCESS);
-                auto tx = vault.clawback({.issuer = issuer, .id = keylet.key, .holder = depositor});
-                env(tx, code);
-                env.close();
-                if (!asset.raw().native())
-                {
-                    BEAST_EXPECT(env.balance(depositor, shares) == share(0));
-
-                    {
-                        auto tx = vault.clawback(
-                            {.issuer = issuer,
-                             .id = keylet.key,
-                             .holder = depositor,
-                             .amount = asset(10)});
-                        env(tx, Ter{tecPRECISION_LOSS});
-                        env.close();
-                    }
-
-                    {
-                        auto tx = vault.withdraw(
-                            {.depositor = depositor, .id = keylet.key, .amount = asset(10)});
-                        env(tx, Ter{tecPRECISION_LOSS});
-                        env.close();
-                    }
-                }
-            }
-
-            if (!asset.raw().native())
-            {
-                testcase(prefix + " deposit again");
-                auto tx =
-                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(200)});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale));
-            }
-            else
-            {
-                testcase(prefix + " deposit/withdrawal same or less than fee");
-                auto const amount = env.current()->fees().base;
-
-                auto tx =
-                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount});
-                env(tx);
-                env.close();
-
-                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount});
-                env(tx);
-                env.close();
-
-                tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount});
-                env(tx);
-                env.close();
-
-                // Withdraw to 3rd party
-                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount});
-                tx[sfDestination] = charlie.human();
-                env(tx);
-                env.close();
-
-                tx =
-                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount - 1});
-                env(tx);
-                env.close();
-
-                tx = vault.withdraw(
-                    {.depositor = depositor, .id = keylet.key, .amount = amount - 1});
-                env(tx);
-                env.close();
-            }
-
-            {
-                testcase(prefix + " fail to withdraw to 3rd party lsfDepositAuth");
-                auto tx = vault.withdraw(
-                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-                tx[sfDestination] = alice.human();
-                env(tx, Ter{tecNO_PERMISSION});
-                env.close();
-            }
-
-            {
-                testcase(prefix + " fail to withdraw to zero destination");
-                auto tx = vault.withdraw(
-                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
-                tx[sfDestination] = "0";
-                env(tx, Ter(temMALFORMED));
-                env.close();
-            }
-
-            if (!asset.raw().native())
-            {
-                testcase(prefix + " fail to withdraw to 3rd party no authorization");
-                auto tx = vault.withdraw(
-                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-                tx[sfDestination] = erin.human();
-                env(tx, Ter{asset.raw().holds() ? tecNO_LINE : tecNO_AUTH});
-                env.close();
-            }
-
-            {
-                testcase(prefix + " fail to withdraw to 3rd party lsfRequireDestTag");
-                auto tx = vault.withdraw(
-                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-                tx[sfDestination] = dave.human();
-                env(tx, Ter{tecDST_TAG_NEEDED});
-                env.close();
-            }
-
-            {
-                testcase(prefix + " withdraw to 3rd party lsfRequireDestTag");
-                auto tx =
-                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-                tx[sfDestination] = dave.human();
-                tx[sfDestinationTag] = "0";
-                env(tx);
-                env.close();
-            }
-
-            {
-                testcase(prefix + " deposit again");
-                auto tx = vault.deposit({.depositor = dave, .id = keylet.key, .amount = asset(50)});
-                env(tx);
-                env.close();
-            }
-
-            {
-                testcase(prefix + " fail to withdraw lsfRequireDestTag");
-                auto tx =
-                    vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)});
-                env(tx, Ter{tecDST_TAG_NEEDED});
-                env.close();
-            }
-
-            {
-                testcase(prefix + " withdraw with tag");
-                auto tx =
-                    vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)});
-                tx[sfDestinationTag] = "0";
-                env(tx);
-                env.close();
-            }
-
-            {
-                testcase(prefix + " withdraw to authorized 3rd party");
-                auto tx =
-                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-                tx[sfDestination] = charlie.human();
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale));
-            }
-
-            {
-                testcase(prefix + " withdraw to issuer");
-                auto tx =
-                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-                tx[sfDestination] = issuer.human();
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale));
-            }
-
-            if (!asset.raw().native())
-            {
-                testcase(prefix + " issuer deposits");
-                auto tx =
-                    vault.deposit({.depositor = issuer, .id = keylet.key, .amount = asset(10)});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(issuer, shares) == share(10 * scale));
-
-                testcase(prefix + " issuer withdraws");
-                tx = vault.withdraw(
-                    {.depositor = issuer, .id = keylet.key, .amount = share(10 * scale)});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(issuer, shares) == share(0 * scale));
-            }
-
-            {
-                testcase(prefix + " withdraw remaining assets");
-                auto tx =
-                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(depositor, shares) == share(0));
-
-                if (!asset.raw().native())
-                {
-                    auto tx = vault.clawback(
-                        {.issuer = issuer,
-                         .id = keylet.key,
-                         .holder = depositor,
-                         .amount = asset(0)});
-                    env(tx, Ter{tecPRECISION_LOSS});
-                    env.close();
-                }
-
-                {
-                    auto tx = vault.withdraw(
-                        {.depositor = depositor, .id = keylet.key, .amount = share(10)});
-                    env(tx, Ter{tecINSUFFICIENT_FUNDS});
-                    env.close();
-                }
-            }
-
-            if (!asset.integral())
-            {
-                testcase(prefix + " temporary authorization for 3rd party");
-                env(trust(erin, asset(1000)));
-                env(trust(issuer, asset(0), erin, tfSetfAuth));
-                env(pay(issuer, erin, asset(10)));
-
-                // Erin deposits all in vault, then sends shares to depositor
-                auto tx = vault.deposit({.depositor = erin, .id = keylet.key, .amount = asset(10)});
-                env(tx);
-                env.close();
-                {
-                    auto tx = pay(erin, depositor, share(10 * scale));
-
-                    // depositor no longer has MPToken for shares
-                    env(tx, Ter{tecNO_AUTH});
-                    env.close();
-
-                    // depositor will gain MPToken for shares again
-                    env(vault.deposit(
-                        {.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
-                    env.close();
-
-                    env(tx);
-                    env.close();
-                }
-
-                testcase(prefix + " withdraw to authorized 3rd party");
-                // Depositor withdraws assets, destined to Erin
-                tx =
-                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
-                tx[sfDestination] = erin.human();
-                env(tx);
-                env.close();
-
-                // Erin returns assets to issuer
-                env(pay(erin, issuer, asset(10)));
-                env.close();
-
-                testcase(prefix + " fail to pay to unauthorized 3rd party");
-                env(trust(erin, asset(0)));
-                env.close();
-
-                // Erin has MPToken but is no longer authorized to hold assets
-                env(pay(depositor, erin, share(1)), Ter{tecNO_LINE});
-                env.close();
-
-                // Depositor withdraws remaining single asset
-                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
-                env(tx);
-                env.close();
-            }
-
-            {
-                testcase(prefix + " fail to delete because wrong owner");
-                auto tx = vault.del({.owner = issuer, .id = keylet.key});
-                env(tx, Ter(tecNO_PERMISSION));
-                env.close();
-            }
-
-            {
-                testcase(prefix + " delete empty vault");
-                auto tx = vault.del({.owner = owner, .id = keylet.key});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(!env.le(keylet));
-            }
-        };
-
-        auto testCases = [&, this](
-                             std::string prefix, std::function setup) {
-            Env env{*this, testableAmendments()};
-
-            Vault vault{env};
-            env.fund(XRP(1000), issuer, owner, depositor, charlie, dave);
-            env.close();
-            env(fset(issuer, asfAllowTrustLineClawback));
-            env(fset(issuer, asfRequireAuth));
-            env(fset(dave, asfRequireDest));
-            env.close();
-            env.require(Flags(issuer, asfAllowTrustLineClawback));
-            env.require(Flags(issuer, asfRequireAuth));
-
-            PrettyAsset const asset = setup(env);
-            testSequence(prefix, env, vault, asset);
-        };
-
-        testCases("XRP", [&](Env& env) -> PrettyAsset { return {xrpIssue(), 1'000'000}; });
-
-        testCases("IOU", [&](Env& env) -> Asset {
-            PrettyAsset const asset = issuer["IOU"];
-            env(trust(owner, asset(1000)));
-            env(trust(depositor, asset(1000)));
-            env(trust(charlie, asset(1000)));
-            env(trust(dave, asset(1000)));
-            env(trust(issuer, asset(0), owner, tfSetfAuth));
-            env(trust(issuer, asset(0), depositor, tfSetfAuth));
-            env(trust(issuer, asset(0), charlie, tfSetfAuth));
-            env(trust(issuer, asset(0), dave, tfSetfAuth));
-            env(pay(issuer, depositor, asset(1000)));
-            env.close();
-            return asset;
-        });
-
-        testCases("MPT", [&](Env& env) -> Asset {
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
-            PrettyAsset const asset = mptt.issuanceID();
-            mptt.authorize({.account = depositor});
-            mptt.authorize({.account = charlie});
-            mptt.authorize({.account = dave});
-            env(pay(issuer, depositor, asset(1000)));
-            env.close();
-            return asset;
-        });
-    }
-
-    void
-    testPreflight()
-    {
-        using namespace test::jtx;
-
-        struct CaseArgs
-        {
-            FeatureBitset features = testableAmendments();
-        };
-
-        auto testCase = [&, this](
-                            std::function test,
-                            CaseArgs args = {}) {
-            Env env{*this, args.features};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            Vault vault{env};
-            env.fund(XRP(1000), issuer, owner);
-            env.close();
-
-            env(fset(issuer, asfAllowTrustLineClawback));
-            env(fset(issuer, asfRequireAuth));
-            env.close();
-
-            PrettyAsset const asset = issuer["IOU"];
-            env(trust(owner, asset(1000)));
-            env(trust(issuer, asset(0), owner, tfSetfAuth));
-            env(pay(issuer, owner, asset(1000)));
-            env.close();
-
-            test(env, issuer, owner, asset, vault);
-        };
-
-        auto testDisabled = [&](TER resultAfterCreate = temDISABLED) {
-            return [&, resultAfterCreate](
-                       Env& env,
-                       Account const& issuer,
-                       Account const& owner,
-                       Asset const& asset,
-                       Vault& vault) {
-                testcase("disabled single asset vault");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                env(tx, Ter{temDISABLED});
-
-                {
-                    auto tx = vault.set({.owner = owner, .id = keylet.key});
-                    env(tx, kData("test"), Ter{resultAfterCreate});
-                }
-
-                {
-                    auto tx =
-                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                    env(tx, Ter{resultAfterCreate});
-                }
-
-                {
-                    auto tx =
-                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                    env(tx, Ter{resultAfterCreate});
-                }
-
-                {
-                    auto tx = vault.clawback(
-                        {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
-                    env(tx, Ter{resultAfterCreate});
-                }
-
-                {
-                    auto tx = vault.del({.owner = owner, .id = keylet.key});
-                    env(tx, Ter{resultAfterCreate});
-                }
-            };
-        };
-
-        testCase(testDisabled(), {.features = testableAmendments() - featureSingleAssetVault});
-
-        testCase(testDisabled(tecNO_ENTRY), {.features = testableAmendments() - featureMPTokensV1});
-
-        testCase(
-            [&](Env& env,
-                Account const& issuer,
-                Account const& owner,
-                Asset const& asset,
-                Vault& vault) {
-                testcase("disabled permissioned domains");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                env(tx);
-
-                tx[sfFlags] = tx[sfFlags].asUInt() | tfVaultPrivate;
-                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
-                env(tx, Ter{temDISABLED});
-
-                {
-                    auto tx = vault.set({.owner = owner, .id = keylet.key});
-                    env(tx, kData("Test"));
-
-                    tx[sfDomainID] = to_string(BaseUInt<256>(13ul));
-                    env(tx, Ter{temDISABLED});
-                }
-            },
-            {.features = testableAmendments() - featurePermissionedDomains});
-
-        testCase([&](Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Asset const& asset,
-                     Vault& vault) {
-            testcase("invalid flags");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            tx[sfFlags] = tfClearDeepFreeze;
-            env(tx, Ter{temINVALID_FLAG});
-
-            {
-                auto tx = vault.set({.owner = owner, .id = keylet.key});
-                tx[sfFlags] = tfClearDeepFreeze;
-                env(tx, Ter{temINVALID_FLAG});
-            }
-
-            {
-                auto tx =
-                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                tx[sfFlags] = tfClearDeepFreeze;
-                env(tx, Ter{temINVALID_FLAG});
-            }
-
-            {
-                auto tx =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                tx[sfFlags] = tfClearDeepFreeze;
-                env(tx, Ter{temINVALID_FLAG});
-            }
-
-            {
-                auto tx = vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
-                tx[sfFlags] = tfClearDeepFreeze;
-                env(tx, Ter{temINVALID_FLAG});
-            }
-
-            {
-                auto tx = vault.del({.owner = owner, .id = keylet.key});
-                tx[sfFlags] = tfClearDeepFreeze;
-                env(tx, Ter{temINVALID_FLAG});
-            }
-        });
-
-        testCase([&](Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Asset const& asset,
-                     Vault& vault) {
-            testcase("invalid fee");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            tx[jss::Fee] = "-1";
-            env(tx, Ter{temBAD_FEE});
-
-            {
-                auto tx = vault.set({.owner = owner, .id = keylet.key});
-                tx[jss::Fee] = "-1";
-                env(tx, Ter{temBAD_FEE});
-            }
-
-            {
-                auto tx =
-                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                tx[jss::Fee] = "-1";
-                env(tx, Ter{temBAD_FEE});
-            }
-
-            {
-                auto tx =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                tx[jss::Fee] = "-1";
-                env(tx, Ter{temBAD_FEE});
-            }
-
-            {
-                auto tx = vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
-                tx[jss::Fee] = "-1";
-                env(tx, Ter{temBAD_FEE});
-            }
-
-            {
-                auto tx = vault.del({.owner = owner, .id = keylet.key});
-                tx[jss::Fee] = "-1";
-                env(tx, Ter{temBAD_FEE});
-            }
-        });
-
-        testCase(
-            [&](Env& env, Account const&, Account const& owner, Asset const&, Vault& vault) {
-                testcase("disabled permissioned domain");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
-                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
-                env(tx, Ter{temDISABLED});
-
-                {
-                    auto tx = vault.set({.owner = owner, .id = keylet.key});
-                    tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
-                    env(tx, Ter{temDISABLED});
-                }
-
-                {
-                    auto tx = vault.set({.owner = owner, .id = keylet.key});
-                    tx[sfDomainID] = "0";
-                    env(tx, Ter{temDISABLED});
-                }
-            },
-            {.features = (testableAmendments()) - featurePermissionedDomains});
-
-        testCase([&](Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Asset const& asset,
-                     Vault& vault) {
-            testcase("use zero vault");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
-
-            {
-                auto tx = vault.set({
-                    .owner = owner,
-                    .id = beast::kZero,
-                });
-                env(tx, Ter{temMALFORMED});
-            }
-
-            {
-                auto tx =
-                    vault.deposit({.depositor = owner, .id = beast::kZero, .amount = asset(10)});
-                env(tx, Ter(temMALFORMED));
-            }
-
-            {
-                auto tx =
-                    vault.withdraw({.depositor = owner, .id = beast::kZero, .amount = asset(10)});
-                env(tx, Ter{temMALFORMED});
-            }
-
-            {
-                auto tx = vault.clawback(
-                    {.issuer = issuer, .id = beast::kZero, .holder = owner, .amount = asset(10)});
-                env(tx, Ter{temMALFORMED});
-            }
-
-            {
-                auto tx = vault.del({
-                    .owner = owner,
-                    .id = beast::kZero,
-                });
-                env(tx, Ter{temMALFORMED});
-            }
-        });
-
-        testCase(
-            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
-                testcase("withdraw to bad destination");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-
-                {
-                    auto tx =
-                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                    tx[jss::Destination] = "0";
-                    env(tx, Ter{temMALFORMED});
-                }
-            });
-
-        testCase(
-            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
-                testcase("create with Scale");
-
-                {
-                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                    tx[sfScale] = 255;
-                    env(tx, Ter(temMALFORMED));
-                }
-
-                {
-                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                    tx[sfScale] = 19;
-                    env(tx, Ter(temMALFORMED));
-                }
-
-                // accepted range from 0 to 18
-                {
-                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                    tx[sfScale] = 18;
-                    env(tx);
-                    env.close();
-                    auto const sleVault = env.le(keylet);
-                    BEAST_EXPECT(sleVault);
-                    BEAST_EXPECT((*sleVault)[sfScale] == 18);
-                }
-
-                {
-                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                    tx[sfScale] = 0;
-                    env(tx);
-                    env.close();
-                    auto const sleVault = env.le(keylet);
-                    BEAST_EXPECT(sleVault);
-                    BEAST_EXPECT((*sleVault)[sfScale] == 0);
-                }
-
-                {
-                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                    env(tx);
-                    env.close();
-                    auto const sleVault = env.le(keylet);
-                    BEAST_EXPECT(sleVault);
-                    BEAST_EXPECT((*sleVault)[sfScale] == 6);
-                }
-            });
-
-        testCase(
-            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
-                testcase("create or set invalid data");
-
-                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
-
-                {
-                    auto tx = tx1;
-                    tx[sfData] = "";
-                    env(tx, Ter(temMALFORMED));
-                }
-
-                {
-                    auto tx = tx1;
-                    // A hexadecimal string of 257 bytes.
-                    tx[sfData] = std::string(514, 'A');
-                    env(tx, Ter(temMALFORMED));
-                }
-
-                {
-                    auto tx = vault.set({.owner = owner, .id = keylet.key});
-                    tx[sfData] = "";
-                    env(tx, Ter{temMALFORMED});
-                }
-
-                {
-                    auto tx = vault.set({.owner = owner, .id = keylet.key});
-                    // A hexadecimal string of 257 bytes.
-                    tx[sfData] = std::string(514, 'A');
-                    env(tx, Ter{temMALFORMED});
-                }
-            });
-
-        testCase(
-            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
-                testcase("set nothing updated");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-
-                {
-                    auto tx = vault.set({.owner = owner, .id = keylet.key});
-                    env(tx, Ter{temMALFORMED});
-                }
-            });
-
-        testCase(
-            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
-                testcase("create with invalid metadata");
-
-                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
-
-                {
-                    auto tx = tx1;
-                    tx[sfMPTokenMetadata] = "";
-                    env(tx, Ter(temMALFORMED));
-                }
-
-                {
-                    auto tx = tx1;
-                    // This metadata is for the share token.
-                    // A hexadecimal string of 1025 bytes.
-                    tx[sfMPTokenMetadata] = std::string(2050, 'B');
-                    env(tx, Ter(temMALFORMED));
-                }
-            });
-
-        testCase(
-            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
-                testcase("set negative maximum");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-
-                {
-                    auto tx = vault.set({.owner = owner, .id = keylet.key});
-                    tx[sfAssetsMaximum] = kNegativeAmount(asset).number();
-                    env(tx, Ter{temMALFORMED});
-                }
-            });
-
-        testCase(
-            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
-                testcase("invalid deposit amount");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-
-                {
-                    auto tx = vault.deposit(
-                        {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)});
-                    env(tx, Ter(temBAD_AMOUNT));
-                }
-
-                {
-                    auto tx =
-                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(0)});
-                    env(tx, Ter(temBAD_AMOUNT));
-                }
-            });
-
-        testCase(
-            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
-                testcase("invalid set immutable flag");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-
-                {
-                    auto tx = vault.set({.owner = owner, .id = keylet.key});
-                    tx[sfFlags] = tfVaultPrivate;
-                    env(tx, Ter(temINVALID_FLAG));
-                }
-            });
-
-        testCase(
-            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
-                testcase("invalid withdraw amount");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-
-                {
-                    auto tx = vault.withdraw(
-                        {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)});
-                    env(tx, Ter(temBAD_AMOUNT));
-                }
-
-                {
-                    auto tx =
-                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(0)});
-                    env(tx, Ter(temBAD_AMOUNT));
-                }
-            });
-
-        testCase([&](Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Asset const& asset,
-                     Vault& vault) {
-            testcase("invalid clawback");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-
-            // Preclaim only checks for native assets.
-            if (asset.native())
-            {
-                auto tx = vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
-                env(tx, Ter(temMALFORMED));
-            }
-
-            {
-                auto tx = vault.clawback(
-                    {.issuer = issuer,
-                     .id = keylet.key,
-                     .holder = owner,
-                     .amount = kNegativeAmount(asset)});
-                env(tx, Ter(temBAD_AMOUNT));
-            }
-        });
-
-        testCase(
-            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
-                testcase("invalid create");
-
-                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
-
-                {
-                    auto tx = tx1;
-                    tx[sfWithdrawalPolicy] = 0;
-                    env(tx, Ter(temMALFORMED));
-                }
-
-                {
-                    auto tx = tx1;
-                    tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
-                    env(tx, Ter{temMALFORMED});
-                }
-
-                {
-                    auto tx = tx1;
-                    tx[sfAssetsMaximum] = kNegativeAmount(asset).number();
-                    env(tx, Ter{temMALFORMED});
-                }
-
-                {
-                    auto tx = tx1;
-                    tx[sfFlags] = tfVaultPrivate;
-                    tx[sfDomainID] = "0";
-                    env(tx, Ter{temMALFORMED});
-                }
-            });
-    }
-
-    // Test for non-asset specific behaviors.
-    void
-    testCreateFailXRP()
-    {
-        using namespace test::jtx;
-
-        auto testCase = [this](
-                            std::function test) {
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            Account const depositor{"depositor"};
-
-            env.fund(XRP(1000), issuer, owner, depositor);
-            env.close();
-            Vault vault{env};
-            Asset const asset = xrpIssue();
-
-            test(env, issuer, owner, depositor, asset, vault);
-        };
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     PrettyAsset const& asset,
-                     Vault& vault) {
-            testcase("nothing to set");
-            auto tx = vault.set({.owner = owner, .id = keylet::skip().key});
-            tx[sfAssetsMaximum] = asset(0).number();
-            env(tx, Ter(tecNO_ENTRY));
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     PrettyAsset const& asset,
-                     Vault& vault) {
-            testcase("nothing to deposit to");
-            auto tx = vault.deposit(
-                {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)});
-            env(tx, Ter(tecNO_ENTRY));
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     PrettyAsset const& asset,
-                     Vault& vault) {
-            testcase("nothing to withdraw from");
-            auto tx = vault.withdraw(
-                {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)});
-            env(tx, Ter(tecNO_ENTRY));
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault) {
-            testcase("nothing to delete");
-            auto tx = vault.del({.owner = owner, .id = keylet::skip().key});
-            env(tx, Ter(tecNO_ENTRY));
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault) {
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            testcase("transaction is good");
-            env(tx);
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault) {
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            tx[sfWithdrawalPolicy] = 1;
-            testcase("explicitly select withdrawal policy");
-            env(tx);
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault) {
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            testcase("insufficient fee");
-            env(tx, Fee(env.current()->fees().base - 1), Ter(telINSUF_FEE_P));
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault) {
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            testcase("insufficient reserve");
-            // It is possible to construct a complicated mathematical
-            // expression for this amount, but it is sadly not easy.
-            env(pay(owner, issuer, XRP(775)));
-            env.close();
-            env(tx, Ter(tecINSUFFICIENT_RESERVE));
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault) {
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            tx[sfFlags] = tfVaultPrivate;
-            tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
-            testcase("non-existing domain");
-            env(tx, Ter{tecOBJECT_NOT_FOUND});
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault) {
-            testcase("cannot set Scale=0");
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            tx[sfScale] = 0;
-            env(tx, Ter{temMALFORMED});
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault) {
-            testcase("cannot set Scale=1");
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            tx[sfScale] = 1;
-            env(tx, Ter{temMALFORMED});
-        });
-    }
-
-    void
-    testCreateFailIOU()
-    {
-        using namespace test::jtx;
-        {
-            {
-                testcase("IOU fail because MPT is disabled");
-                Env env{*this, (testableAmendments() - featureMPTokensV1)};
-                Account const issuer{"issuer"};
-                Account const owner{"owner"};
-                env.fund(XRP(1000), issuer, owner);
-                env.close();
-
-                Vault const vault{env};
-                Asset const asset = issuer["IOU"].asset();
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-
-                env(tx, Ter(temDISABLED));
-                env.close();
-            }
-
-            {
-                testcase("IOU fail create frozen");
-                Env env{*this, testableAmendments()};
-                Account const issuer{"issuer"};
-                Account const owner{"owner"};
-                env.fund(XRP(1000), issuer, owner);
-                env.close();
-                env(fset(issuer, asfGlobalFreeze));
-                env.close();
-
-                Vault const vault{env};
-                Asset const asset = issuer["IOU"].asset();
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-
-                env(tx, Ter(tecFROZEN));
-                env.close();
-            }
-
-            {
-                testcase("IOU fail create no ripling");
-                Env env{*this, testableAmendments()};
-                Account const issuer{"issuer"};
-                Account const owner{"owner"};
-                env.fund(XRP(1000), issuer, owner);
-                env.close();
-                env(fclear(issuer, asfDefaultRipple));
-                env.close();
-
-                Vault const vault{env};
-                Asset const asset = issuer["IOU"].asset();
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                env(tx, Ter(terNO_RIPPLE));
-                env.close();
-            }
-
-            {
-                testcase("IOU no issuer");
-                Env env{*this, testableAmendments()};
-                Account const issuer{"issuer"};
-                Account const owner{"owner"};
-                env.fund(XRP(1000), owner);
-                env.close();
-
-                Vault const vault{env};
-                Asset const asset = issuer["IOU"].asset();
-                {
-                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                    env(tx, Ter(terNO_ACCOUNT));
-                    env.close();
-                }
-            }
-        }
-
-        {
-            testcase("IOU fail create vault for AMM LPToken");
-            Env env{*this, testableAmendments()};
-            Account const gw("gateway");
-            Account const alice("alice");
-            Account const carol("carol");
-            IOU const usd = gw["USD"];
-
-            auto const [asset1, asset2] = std::pair(XRP(10000), usd(10000));
-            auto toFund = [&](STAmount const& a) -> STAmount {
-                if (a.native())
-                {
-                    auto const defXRP = XRP(30000);
-                    if (a <= defXRP)
-                        return defXRP;
-                    return a + XRP(1000);
-                }
-                auto defIOU = STAmount{a.asset(), 30000};
-                if (a <= defIOU)
-                    return defIOU;
-                return a + STAmount{a.asset(), 1000};
-            };
-            auto const toFund1 = toFund(asset1);
-            auto const toFund2 = toFund(asset2);
-            BEAST_EXPECT(asset1 <= toFund1 && asset2 <= toFund2);
-
-            if (!asset1.native() && !asset2.native())
-            {
-                fund(env, gw, {alice, carol}, {toFund1, toFund2}, Fund::All);
-            }
-            else if (asset1.native())
-            {
-                fund(env, gw, {alice, carol}, toFund1, {toFund2}, Fund::All);
-            }
-            else if (asset2.native())
-            {
-                fund(env, gw, {alice, carol}, toFund2, {toFund1}, Fund::All);
-            }
-
-            AMM const ammAlice(env, alice, asset1, asset2, CreateArg{.log = false, .tfee = 0});
-
-            Account const owner{"owner"};
-            env.fund(XRP(1000000), owner);
-
-            Vault const vault{env};
-            auto [tx, k] = vault.create({.owner = owner, .asset = ammAlice.lptIssue()});
-            env(tx, Ter{tecWRONG_ASSET});
-            env.close();
-        }
-    }
-
-    void
-    testCreateFailMPT()
-    {
-        using namespace test::jtx;
-
-        auto testCase = [this](
-                            std::function test) {
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            Account const depositor{"depositor"};
-            env.fund(XRP(1000), issuer, owner, depositor);
-            env.close();
-            Vault vault{env};
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            // Locked because that is the default flag.
-            mptt.create();
-            Asset const asset = mptt.issuanceID();
-
-            test(env, issuer, owner, depositor, asset, vault);
-        };
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault) {
-            testcase("MPT no authorization");
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx, Ter(tecNO_AUTH));
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault) {
-            testcase("MPT cannot set Scale=0");
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            tx[sfScale] = 0;
-            env(tx, Ter{temMALFORMED});
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault) {
-            testcase("MPT cannot set Scale=1");
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            tx[sfScale] = 1;
-            env(tx, Ter{temMALFORMED});
-        });
-    }
-
-    void
-    testNonTransferableShares()
-    {
-        using namespace test::jtx;
-
-        Env env{*this, testableAmendments()};
-        Account const issuer{"issuer"};
-        Account const owner{"owner"};
-        Account const depositor{"depositor"};
-        env.fund(XRP(1000), issuer, owner, depositor);
-        env.close();
-
-        Vault const vault{env};
-        PrettyAsset const asset = issuer["IOU"];
-        env.trust(asset(1000), owner);
-        env(pay(issuer, owner, asset(100)));
-        env.trust(asset(1000), depositor);
-        env(pay(issuer, depositor, asset(100)));
-        env.close();
-
-        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-        tx[sfFlags] = tfVaultShareNonTransferable;
-        env(tx);
-        env.close();
-
-        {
-            testcase("nontransferable deposits");
-            auto tx1 =
-                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(40)});
-            env(tx1);
-
-            auto tx2 = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(60)});
-            env(tx2);
-            env.close();
-        }
-
-        auto const vaultAccount =  //
-            [&env, key = keylet.key, this]() -> AccountID {
-            auto jvVault = env.rpc("vault_info", strHex(key));
-
-            BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "100");
-            BEAST_EXPECT(
-                jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "100000000");
-
-            // Vault pseudo-account
-            return parseBase58(jvVault[jss::result][jss::vault][jss::Account].asString())
-                .value();
-        }();
-
-        auto const mptId = makeMptID(1, vaultAccount);
-        Asset const shares = mptId;
-
-        {
-            testcase("nontransferable shares cannot be moved");
-            env(pay(owner, depositor, shares(10)), Ter{tecNO_AUTH});
-            env(pay(depositor, owner, shares(10)), Ter{tecNO_AUTH});
-        }
-
-        {
-            testcase("nontransferable shares can be used to withdraw");
-            auto tx1 =
-                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
-            env(tx1);
-
-            auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
-            env(tx2);
-            env.close();
-        }
-
-        {
-            testcase("nontransferable shares balance check");
-            auto jvVault = env.rpc("vault_info", strHex(keylet.key));
-            BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "50");
-            BEAST_EXPECT(
-                jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "50000000");
-        }
-
-        {
-            testcase("nontransferable shares withdraw rest");
-            auto tx1 =
-                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
-            env(tx1);
-
-            auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
-            env(tx2);
-            env.close();
-        }
-
-        {
-            testcase("nontransferable shares delete empty vault");
-            auto tx = vault.del({.owner = owner, .id = keylet.key});
-            env(tx);
-            BEAST_EXPECT(!env.le(keylet));
-        }
-    }
-
-    void
-    testWithMPT()
-    {
-        using namespace test::jtx;
-
-        struct CaseArgs
-        {
-            bool enableClawback = true;
-            bool requireAuth = true;
-            int initialXRP = 1000;
-            FeatureBitset features = testableAmendments();
-        };
-
-        auto testCase = [this](
-                            std::function test,
-                            CaseArgs args = {}) {
-            Env env{*this, args.features};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            Account const depositor{"depositor"};
-            env.fund(XRP(args.initialXRP), issuer, owner, depositor);
-            env.close();
-            Vault vault{env};
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            auto const kNone = LedgerSpecificFlags(0);
-            mptt.create(
-                {.flags = tfMPTCanTransfer | tfMPTCanLock |
-                     (args.enableClawback ? tfMPTCanClawback : kNone) |
-                     (args.requireAuth ? tfMPTRequireAuth : kNone)});
-            PrettyAsset const asset = mptt.issuanceID();
-            mptt.authorize({.account = owner});
-            mptt.authorize({.account = depositor});
-            if (args.requireAuth)
-            {
-                mptt.authorize({.account = issuer, .holder = owner});
-                mptt.authorize({.account = issuer, .holder = depositor});
-            }
-
-            env(pay(issuer, depositor, asset(1000)));
-            env.close();
-
-            test(env, issuer, owner, depositor, asset, vault, mptt);
-        };
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     PrettyAsset const& asset,
-                     Vault& vault,
-                     MPTTester& mptt) {
-            testcase("MPT nothing to clawback from");
-            auto tx = vault.clawback(
-                {.issuer = issuer,
-                 .id = keylet::skip().key,
-                 .holder = depositor,
-                 .amount = asset(10)});
-            env(tx, Ter(tecNO_ENTRY));
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault,
-                     MPTTester& mptt) {
-            testcase("MPT global lock blocks create");
-            mptt.set({.account = issuer, .flags = tfMPTLock});
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx, Ter(tecLOCKED));
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     PrettyAsset const& asset,
-                     Vault& vault,
-                     MPTTester& mptt) {
-            testcase("MPT only issuer can clawback");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-            env(tx);
-            env.close();
-
-            {
-                auto tx = vault.clawback({
-                    .issuer = depositor,
-                    .id = keylet.key,
-                    .holder = depositor,
-                });
-                env(tx, Ter(tecNO_PERMISSION));
-            }
-
-            {
-                auto tx = vault.clawback({
-                    .issuer = owner,
-                    .id = keylet.key,
-                    .holder = depositor,
-                });
-                env(tx, Ter(tecNO_PERMISSION));
-            }
-        });
-
-        testCase(
-            [this](
-                Env& env,
-                Account const& issuer,
-                Account const& owner,
-                Account const& depositor,
-                PrettyAsset const& asset,
-                Vault& vault,
-                MPTTester& mptt) {
-                testcase("MPT depositor without MPToken, auth required");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                env(tx);
-                env.close();
-
-                tx = vault.deposit(
-                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
-                env(tx);
-                env.close();
-
-                {
-                    // Remove depositor MPToken and it will not be re-created
-                    mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize});
-                    env.close();
-
-                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor);
-                    auto const sleMPT1 = env.le(mptoken);
-                    BEAST_EXPECT(sleMPT1 == nullptr);
-
-                    tx = vault.withdraw(
-                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-                    env(tx, Ter{tecNO_AUTH});
-                    env.close();
-
-                    auto const sleMPT2 = env.le(mptoken);
-                    BEAST_EXPECT(sleMPT2 == nullptr);
-                }
-
-                {
-                    // Set destination to 3rd party without MPToken
-                    Account const charlie{"charlie"};
-                    env.fund(XRP(1000), charlie);
-                    env.close();
-
-                    tx = vault.withdraw(
-                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-                    tx[sfDestination] = charlie.human();
-                    env(tx, Ter(tecNO_AUTH));
-                }
-            },
-            {.requireAuth = true});
-
-        testCase(
-            [this](
-                Env& env,
-                Account const& issuer,
-                Account const& owner,
-                Account const& depositor,
-                PrettyAsset const& asset,
-                Vault& vault,
-                MPTTester& mptt) {
-                testcase("MPT depositor without MPToken, no auth required");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                env(tx);
-                env.close();
-                auto v = env.le(keylet);
-                BEAST_EXPECT(v);
-
-                tx = vault.deposit(
-                    {.depositor = depositor,
-                     .id = keylet.key,
-                     .amount = asset(1000)});  // all assets held by depositor
-                env(tx);
-                env.close();
-
-                {
-                    // Remove depositor's MPToken and it will be re-created
-                    mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize});
-                    env.close();
-
-                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor);
-                    auto const sleMPT1 = env.le(mptoken);
-                    BEAST_EXPECT(sleMPT1 == nullptr);
-
-                    tx = vault.withdraw(
-                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-                    env(tx);
-                    env.close();
-
-                    auto const sleMPT2 = env.le(mptoken);
-                    BEAST_EXPECT(sleMPT2 != nullptr);
-                    BEAST_EXPECT(sleMPT2->at(sfMPTAmount) == 100);
-                }
-
-                {
-                    // Remove 3rd party MPToken and it will not be re-created
-                    mptt.authorize({.account = owner, .flags = tfMPTUnauthorize});
-                    env.close();
-
-                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner);
-                    auto const sleMPT1 = env.le(mptoken);
-                    BEAST_EXPECT(sleMPT1 == nullptr);
-
-                    tx = vault.withdraw(
-                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-                    tx[sfDestination] = owner.human();
-                    env(tx, Ter(tecNO_AUTH));
-                    env.close();
-
-                    auto const sleMPT2 = env.le(mptoken);
-                    BEAST_EXPECT(sleMPT2 == nullptr);
-                }
-            },
-            {.requireAuth = false});
-
-        auto const [acctReserve, incReserve] = [this]() -> std::pair {
-            Env const env{*this, testableAmendments()};
-            return {
-                env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(),
-                env.current()->fees().increment.drops() / kDropsPerXrp.drops()};
-        }();
-
-        testCase(
-            [&, this](
-                Env& env,
-                Account const& issuer,
-                Account const& owner,
-                Account const& depositor,
-                PrettyAsset const& asset,
-                Vault& vault,
-                MPTTester& mptt) {
-                testcase("MPT fail reserve to re-create MPToken");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                env(tx);
-                env.close();
-                auto v = env.le(keylet);
-                BEAST_EXPECT(v);
-
-                env(pay(depositor, owner, asset(1000)));
-                env.close();
-
-                tx = vault.deposit(
-                    {.depositor = owner,
-                     .id = keylet.key,
-                     .amount = asset(1000)});  // all assets held by owner
-                env(tx);
-                env.close();
-
-                {
-                    // Remove owners's MPToken and it will not be re-created
-                    mptt.authorize({.account = owner, .flags = tfMPTUnauthorize});
-                    env.close();
-
-                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner);
-                    auto const sleMPT = env.le(mptoken);
-                    BEAST_EXPECT(sleMPT == nullptr);
-
-                    // Use one reserve so the next transaction fails
-                    env(ticket::create(owner, 1));
-                    env.close();
-
-                    // No reserve to create MPToken for asset in VaultWithdraw
-                    tx = vault.withdraw(
-                        {.depositor = owner, .id = keylet.key, .amount = asset(100)});
-                    env(tx, Ter{tecINSUFFICIENT_RESERVE});
-                    env.close();
-
-                    env(pay(depositor, owner, XRP(incReserve)));
-                    env.close();
-
-                    // Withdraw can now create asset MPToken, tx will succeed
-                    env(tx);
-                    env.close();
-                }
-            },
-            {.requireAuth = false, .initialXRP = acctReserve + (incReserve * 4) + 1});
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     PrettyAsset const& asset,
-                     Vault& vault,
-                     MPTTester& mptt) {
-            testcase("MPT issuance deleted");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
-            env(tx);
-            env.close();
-
-            {
-                auto tx = vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
-                env(tx);
-            }
-
-            mptt.destroy({.issuer = issuer, .id = mptt.issuanceID()});
-            env.close();
-
-            {
-                auto [tx, keylet] = vault.create({.owner = depositor, .asset = asset});
-                env(tx, Ter{tecOBJECT_NOT_FOUND});
-            }
-
-            {
-                auto tx =
-                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
-                env(tx, Ter{tecOBJECT_NOT_FOUND});
-            }
-
-            {
-                auto tx =
-                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
-                env(tx, Ter{tecOBJECT_NOT_FOUND});
-            }
-
-            {
-                auto tx = vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
-                env(tx, Ter{tecOBJECT_NOT_FOUND});
-            }
-
-            env(vault.del({.owner = owner, .id = keylet.key}));
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     PrettyAsset const& asset,
-                     Vault& vault,
-                     MPTTester& mptt) {
-            testcase("MPT vault owner can receive shares unless unauthorized");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
-            env(tx);
-            env.close();
-
-            auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID {
-                auto const vault = env.le(keylet);
-                return vault->at(sfShareMPTID);
-            }(keylet);
-            PrettyAsset const shares = MPTIssue(issuanceId);
-
-            {
-                // owner has MPToken for shares they did not explicitly create
-                env(pay(depositor, owner, shares(1)));
-                env.close();
-
-                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = shares(1)});
-                env(tx);
-                env.close();
-
-                // owner's MPToken for vault shares not destroyed by withdraw
-                env(pay(depositor, owner, shares(1)));
-                env.close();
-
-                tx = vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)});
-                env(tx);
-                env.close();
-
-                // owner's MPToken for vault shares not destroyed by clawback
-                env(pay(depositor, owner, shares(1)));
-                env.close();
-
-                // pay back, so we can destroy owner's MPToken now
-                env(pay(owner, depositor, shares(1)));
-                env.close();
-
-                {
-                    // explicitly destroy vault owners MPToken with zero balance
-                    json::Value jv;
-                    jv[sfAccount] = owner.human();
-                    jv[sfMPTokenIssuanceID] = to_string(issuanceId);
-                    jv[sfFlags] = tfMPTUnauthorize;
-                    jv[sfTransactionType] = jss::MPTokenAuthorize;
-                    env(jv);
-                    env.close();
-                }
-
-                // owner no longer has MPToken for vault shares
-                tx = pay(depositor, owner, shares(1));
-                env(tx, Ter{tecNO_AUTH});
-                env.close();
-
-                // destroy all remaining shares, so we can delete vault
-                tx = vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
-                env(tx);
-                env.close();
-
-                // will soft fail destroying MPToken for vault owner
-                env(vault.del({.owner = owner, .id = keylet.key}));
-                env.close();
-            }
-        });
-
-        testCase(
-            [this](
-                Env& env,
-                Account const& issuer,
-                Account const& owner,
-                Account const& depositor,
-                PrettyAsset const& asset,
-                Vault& vault,
-                MPTTester& mptt) {
-                testcase("MPT clawback disabled");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                env(tx);
-                env.close();
-
-                tx = vault.deposit(
-                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
-                env(tx);
-                env.close();
-
-                {
-                    auto tx = vault.clawback(
-                        {.issuer = issuer,
-                         .id = keylet.key,
-                         .holder = depositor,
-                         .amount = asset(0)});
-                    env(tx, Ter{tecNO_PERMISSION});
-                }
-            },
-            {.enableClawback = false});
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault,
-                     MPTTester& mptt) {
-            testcase("MPT un-authorization");
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
-            env(tx);
-            env.close();
-
-            mptt.authorize({.account = issuer, .holder = depositor, .flags = tfMPTUnauthorize});
-            env.close();
-
-            {
-                auto tx = vault.withdraw(
-                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-                env(tx, Ter(tecNO_AUTH));
-
-                // Withdrawal to other (authorized) accounts works
-                tx[sfDestination] = issuer.human();
-                env(tx);
-                env.close();
-
-                tx[sfDestination] = owner.human();
-                env(tx);
-                env.close();
-            }
-
-            {
-                // Cannot deposit some more
-                auto tx =
-                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-                env(tx, Ter(tecNO_AUTH));
-            }
-
-            {
-                // Cannot clawback if issuer is the holder
-                tx = vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = issuer, .amount = asset(800)});
-                env(tx, Ter(tecNO_PERMISSION));
-            }
-            // Clawback works
-            tx = vault.clawback(
-                {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(800)});
-            env(tx);
-            env.close();
-
-            env(vault.del({.owner = owner, .id = keylet.key}));
-        });
-
-        {
-            testcase("MPT shares to a vault");
-
-            Env env{*this, testableAmendments()};
-            Account const owner{"owner"};
-            Account const issuer{"issuer"};
-            env.fund(XRP(1000000), owner, issuer);
-            env.close();
-            Vault const vault{env};
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create(
-                {.flags = tfMPTCanTransfer | tfMPTCanLock | lsfMPTCanClawback | tfMPTRequireAuth});
-            mptt.authorize({.account = owner});
-            mptt.authorize({.account = issuer, .holder = owner});
-            PrettyAsset const asset = mptt.issuanceID();
-            env(pay(issuer, owner, asset(100)));
-            auto [tx1, k1] = vault.create({.owner = owner, .asset = asset});
-            env(tx1);
-            env.close();
-
-            auto const shares = [&env, keylet = k1, this]() -> Asset {
-                auto const vault = env.le(keylet);
-                BEAST_EXPECT(vault != nullptr);
-                return MPTIssue(vault->at(sfShareMPTID));
-            }();
-
-            auto [tx2, k2] = vault.create({.owner = owner, .asset = shares});
-            env(tx2, Ter{tecWRONG_ASSET});
-            env.close();
-        }
-
-        {
-            testcase("MPT locked: vault shares inherit underlying lock");
-
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            Account const alice{"alice"};
-            Account const bob{"bob"};
-            Account const carol{"carol"};
-            env.fund(XRP(10'000), issuer, owner, alice, bob, carol);
-            env.close();
-            Vault const vault{env};
-
-            MPTTester asset{
-                {.env = env,
-                 .issuer = issuer,
-                 .holders = {owner, alice, bob, carol},
-                 .flags = tfMPTCanTransfer | tfMPTCanTrade | tfMPTCanLock}};
-            env(pay(issuer, alice, asset(1'000)));
-            env(pay(issuer, bob, asset(1'000)));
-            env.close();
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(500)}));
-            // Bob also deposits so he has a share MPToken to receive into.
-            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(500)}));
-            env.close();
-
-            auto const shares = [&]() -> PrettyAsset {
-                auto const sle = env.le(keylet);
-                BEAST_EXPECT(sle != nullptr);
-                return MPTIssue(sle->at(sfShareMPTID));
-            }();
-            auto const shareMptID = shares.raw().get().getMptID();
-            auto const shareBalance = [&](Account const& account) {
-                auto const sle = env.le(keylet::mptoken(shareMptID, account));
-                return sle ? sle->at(sfMPTAmount) : 0;
-            };
-
-            // Sanity: before the underlying lock, peer-to-peer share
-            // transfers are allowed.
-            env(pay(alice, bob, shares(1)));
-            env.close();
-
-            // Create the offer while shares are spendable, then lock the
-            // underlying to test whether a stale offer can still be crossed.
-            env(offer(alice, XRP(1), shares(1)));
-            env.close();
-
-            // Lock the underlying after the vault and share balances exist.
-            asset.set({.account = issuer, .flags = tfMPTLock});
-            env.close();
-
-            // Direct vault share payment inherits the underlying lock via
-            // sfReferenceHolding.
-            BEAST_EXPECT(shareBalance(alice) == 499);
-            BEAST_EXPECT(shareBalance(bob) == 501);
-            env(pay(alice, bob, shares(1)), Ter{tecLOCKED});
-            env.close();
-            BEAST_EXPECT(shareBalance(alice) == 499);
-            BEAST_EXPECT(shareBalance(bob) == 501);
-
-            // The same inherited lock must also block DEX payment paths that
-            // would consume an offer selling vault shares.
-            env(pay(carol, bob, shares(1)),
-                Sendmax(XRP(1)),
-                Path(BookSpec{shares.raw()}),
-                Ter{tecPATH_PARTIAL});
-            env.close();
-            BEAST_EXPECT(shareBalance(alice) == 499);
-            BEAST_EXPECT(shareBalance(bob) == 501);
-            BEAST_EXPECT(expectOffers(env, alice, 1));
-        }
-
-        {
-            testcase("MPT CanTrade governance: share inherits underlying on DEX and AMM");
-
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            Account const alice{"alice"};
-            Account const bob{"bob"};
-            env.fund(XRP(100'000), issuer, owner, alice, bob);
-            env.close();
-            Vault const vault{env};
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
-            PrettyAsset const asset = mptt.issuanceID();
-            mptt.authorize({.account = owner});
-            mptt.authorize({.account = alice});
-            mptt.authorize({.account = bob});
-            env(pay(issuer, alice, asset(10'000)));
-            env(pay(issuer, bob, asset(10'000)));
-            env.close();
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            // Seed shares so we can later place them on trading venues.
-            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(5'000)}));
-            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(5'000)}));
-            env.close();
-
-            auto const shares = [&]() -> PrettyAsset {
-                auto const sle = env.le(keylet);
-                BEAST_EXPECT(sle != nullptr);
-                return MPTIssue(sle->at(sfShareMPTID));
-            }();
-
-            // CanTrade is not set on the underlying, both the asset and
-            // the vault share are blocked on the DEX.
-            env(offer(alice, XRP(1), asset(10)), Ter{tecNO_PERMISSION});
-            env(offer(alice, XRP(1), shares(1)), Ter{tecNO_PERMISSION});
-            env.close();
-
-            // Deposit still works before enabling CanTrade.
-            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
-            env.close();
-
-            // Peer-to-peer share transfers still work (CanTransfer is set on
-            // both layers).
-            env(pay(alice, bob, shares(1)));
-            env.close();
-
-            // Withdraw still works before enabling CanTrade.
-            env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
-            env.close();
-
-            // Enable CanTrade on the underlying.
-            mptt.set({.flags = tfMPTSetCanTrade});
-            env.close();
-
-            env(offer(alice, XRP(1), asset(10)));
-            env(offer(alice, XRP(1), shares(1)));
-            env.close();
-
-            AMM const ammUnderlying(env, alice, XRP(1'000), asset(1'000));
-        }
-
-        {
-            testcase("MPT OutstandingAmount > MaximumAmount");
-
-            Env env{*this, testableAmendments() | featureSingleAssetVault};
-            Account const alice{"alice"};
-            Account const issuer{"issuer"};
-            env.fund(XRP(1'000), alice, issuer);
-            env.close();
-            Vault const vault{env};
-
-            MPTTester const btc({.env = env, .issuer = issuer, .holders = {alice}, .maxAmt = 100});
-
-            auto [tx, k] = vault.create({.owner = issuer, .asset = btc});
-            env(tx);
-            env.close();
-
-            tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(110)});
-            // accountHolds is the first check and the issuer has only BTC(100)
-            // available
-            env(tx, Ter{tecINSUFFICIENT_FUNDS});
-            env.close();
-
-            // OutstandingAmount == MaximumAmount
-            env(pay(issuer, alice, btc(100)));
-            env.close();
-
-            tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(100)});
-            // the issuer has BTC(0) available
-            env(tx, Ter{tecINSUFFICIENT_FUNDS});
-            env.close();
-
-            tx = vault.deposit({.depositor = alice, .id = k.key, .amount = btc(100)});
-            // alice transfers BTC(100), OutstandingAmount is 100
-            env(tx);
-            env.close();
-        }
-    }
-
-    void
-    testWithIOU()
-    {
-        using namespace test::jtx;
-
-        struct CaseArgs
-        {
-            int initialXRP = 1000;
-            Number initialIOU = 200;
-            double transferRate = 1.0;
-            bool charlieRipple = true;
-            FeatureBitset features = testableAmendments();
-        };
-
-        auto testCase = [&, this](
-                            std::function vaultAccount,
-                                Vault& vault,
-                                PrettyAsset const& asset,
-                                std::function issuanceId)> test,
-                            CaseArgs args = {}) {
-            Env env{*this, args.features};
-            Account const owner{"owner"};
-            Account const issuer{"issuer"};
-            Account const charlie{"charlie"};
-            Vault vault{env};
-            env.fund(XRP(args.initialXRP), issuer, owner, charlie);
-            env(fset(issuer, asfAllowTrustLineClawback));
-            env.close();
-
-            PrettyAsset const asset = issuer["IOU"];
-            env.trust(asset(1000), owner);
-            env(pay(issuer, owner, asset(args.initialIOU)));
-            env.close();
-            if (!args.charlieRipple)
-            {
-                env(fset(issuer, 0, asfDefaultRipple));
-                env.close();
-                env.trust(asset(1000), charlie);
-                env.close();
-                env(pay(issuer, charlie, asset(args.initialIOU)));
-                env.close();
-                env(fset(issuer, asfDefaultRipple));
-            }
-            else
-            {
-                env.trust(asset(1000), charlie);
-            }
-            env.close();
-            env(rate(issuer, args.transferRate));
-            env.close();
-
-            auto const vaultAccount = [&env](xrpl::Keylet keylet) -> Account {
-                return Account("vault", env.le(keylet)->at(sfAccount));
-            };
-            auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID {
-                return env.le(keylet)->at(sfShareMPTID);
-            };
-
-            test(env, owner, issuer, charlie, vaultAccount, vault, asset, issuanceId);
-        };
-
-        testCase([&, this](
-                     Env& env,
-                     Account const& owner,
-                     Account const& issuer,
-                     Account const&,
-                     auto vaultAccount,
-                     Vault& vault,
-                     PrettyAsset const& asset,
-                     auto&&...) {
-            testcase("IOU cannot use different asset");
-            PrettyAsset const foo = issuer["FOO"];
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            {
-                // Cannot create new trustline to a vault
-                auto tx = [&, account = vaultAccount(keylet)]() {
-                    json::Value jv;
-                    jv[jss::Account] = issuer.human();
-                    {
-                        auto& ja = jv[jss::LimitAmount] =
-                            foo(0).value().getJson(JsonOptions::Values::None);
-                        ja[jss::issuer] = toBase58(account);
-                    }
-                    jv[jss::TransactionType] = jss::TrustSet;
-                    jv[jss::Flags] = tfSetFreeze;
-                    return jv;
-                }();
-                env(tx, Ter{tecNO_PERMISSION});
-                env.close();
-            }
-
-            {
-                auto tx = vault.deposit({.depositor = issuer, .id = keylet.key, .amount = foo(20)});
-                env(tx, Ter{tecWRONG_ASSET});
-                env.close();
-            }
-
-            {
-                auto tx =
-                    vault.withdraw({.depositor = issuer, .id = keylet.key, .amount = foo(20)});
-                env(tx, Ter{tecWRONG_ASSET});
-                env.close();
-            }
-
-            env(vault.del({.owner = owner, .id = keylet.key}));
-            env.close();
-        });
-
-        testCase(
-            [&, this](
-                Env& env,
-                Account const& owner,
-                Account const& issuer,
-                Account const& charlie,
-                auto vaultAccount,
-                Vault& vault,
-                PrettyAsset const& asset,
-                auto issuanceId) {
-                testcase("IOU transfer fees not applied");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                env(tx);
-                env.close();
-
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
-                env.close();
-
-                auto const issue = asset.raw().get();
-                Asset const share = Asset(issuanceId(keylet));
-
-                // transfer fees ignored on deposit
-                BEAST_EXPECT(env.balance(owner, issue) == asset(100));
-                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(100));
-
-                {
-                    auto tx = vault.clawback(
-                        {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
-                    env(tx);
-                    env.close();
-                }
-
-                // transfer fees ignored on clawback
-                BEAST_EXPECT(env.balance(owner, issue) == asset(100));
-                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(50));
-
-                env(vault.withdraw(
-                    {.depositor = owner, .id = keylet.key, .amount = share(20'000'000)}));
-
-                // transfer fees ignored on withdraw
-                BEAST_EXPECT(env.balance(owner, issue) == asset(120));
-                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(30));
-
-                {
-                    auto tx = vault.withdraw(
-                        {.depositor = owner, .id = keylet.key, .amount = share(30'000'000)});
-                    tx[sfDestination] = charlie.human();
-                    env(tx);
-                }
-
-                // transfer fees ignored on withdraw to 3rd party
-                BEAST_EXPECT(env.balance(owner, issue) == asset(120));
-                BEAST_EXPECT(env.balance(charlie, issue) == asset(30));
-                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(0));
-
-                env(vault.del({.owner = owner, .id = keylet.key}));
-                env.close();
-            },
-            CaseArgs{.transferRate = 1.25});
-
-        testCase([&, this](
-                     Env& env,
-                     Account const& owner,
-                     Account const& issuer,
-                     Account const& charlie,
-                     auto,
-                     Vault& vault,
-                     PrettyAsset const& asset,
-                     auto&&...) {
-            testcase("IOU no trust line to 3rd party");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
-            env.close();
-
-            Account const erin{"erin"};
-            env.fund(XRP(1000), erin);
-            env.close();
-
-            // Withdraw to 3rd party without trust line
-            auto const tx1 = [&](xrpl::Keylet keylet) {
-                auto tx =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                tx[sfDestination] = erin.human();
-                return tx;
-            }(keylet);
-            env(tx1, Ter{tecNO_LINE});
-        });
-
-        testCase([&, this](
-                     Env& env,
-                     Account const& owner,
-                     Account const& issuer,
-                     Account const& charlie,
-                     auto,
-                     Vault& vault,
-                     PrettyAsset const& asset,
-                     auto&&...) {
-            testcase("IOU no trust line to depositor");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            // reset limit, so deposit of all funds will delete the trust line
-            env.trust(asset(0), owner);
-            env.close();
-
-            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
-            env.close();
-
-            auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
-            BEAST_EXPECT(trustline == nullptr);
-
-            // Withdraw without trust line, will succeed
-            auto const tx1 = [&](xrpl::Keylet keylet) {
-                auto tx =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                return tx;
-            }(keylet);
-            env(tx1);
-        });
-
-        testCase(
-            [&, this](
-                Env& env,
-                Account const& owner,
-                Account const& issuer,
-                Account const& charlie,
-                auto vaultAccount,
-                Vault& vault,
-                PrettyAsset const& asset,
-                std::function issuanceId) {
-                testcase("IOU non-transferable");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                tx[sfScale] = 0;
-                env(tx);
-                env.close();
-
-                // Turn on noripple on the pseudo account's trust line.
-                // Charlie's is already set.
-                env(trust(issuer, vaultAccount(keylet)["IOU"], tfSetNoRipple));
-
-                {
-                    // Charlie cannot deposit
-                    auto tx = vault.deposit(
-                        {.depositor = charlie, .id = keylet.key, .amount = asset(100)});
-                    env(tx, Ter{terNO_RIPPLE});
-                    env.close();
-                }
-
-                {
-                    PrettyAsset const shares = issuanceId(keylet);
-                    auto tx1 =
-                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)});
-                    env(tx1);
-                    env.close();
-
-                    // Charlie cannot receive funds
-                    auto tx2 = vault.withdraw(
-                        {.depositor = owner, .id = keylet.key, .amount = shares(100)});
-                    tx2[sfDestination] = charlie.human();
-                    env(tx2, Ter{terNO_RIPPLE});
-                    env.close();
-
-                    {
-                        // Create MPToken for shares held by Charlie
-                        json::Value tx{json::ValueType::Object};
-                        tx[sfAccount] = charlie.human();
-                        tx[sfMPTokenIssuanceID] =
-                            to_string(shares.raw().get().getMptID());
-                        tx[sfTransactionType] = jss::MPTokenAuthorize;
-                        env(tx);
-                        env.close();
-                    }
-                    // Behavioral shift introduced by share inheritance:
-                    // before fixCleanup3_2_0 this share Payment succeeded
-                    // and the underlying IOU's NoRipple restriction surfaced
-                    // only later on Charlie's withdrawal (terNO_RIPPLE).
-                    // Post-amendment, canTransfer reads the share's
-                    // sfReferenceHolding and dispatches to the underlying IOU;
-                    // rippling is disabled between owner and charlie so the
-                    // share payment itself is now blocked. tecPATH_DRY is
-                    // the path-find layer's translation of the underlying
-                    // terNO_RIPPLE under featureMPTokensV2.
-                    env(pay(owner, charlie, shares(100)), Ter{tecPATH_DRY});
-                    env.close();
-                }
-
-                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(100)});
-                env(tx);
-                env.close();
-
-                // Delete vault with zero balance
-                env(vault.del({.owner = owner, .id = keylet.key}));
-            },
-            {.charlieRipple = false});
-
-        testCase(
-            [&, this](
-                Env& env,
-                Account const& owner,
-                Account const& issuer,
-                Account const& charlie,
-                auto const& vaultAccount,
-                Vault& vault,
-                PrettyAsset const& asset,
-                auto&&...) {
-                testcase("IOU calculation rounding");
-
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                tx[sfScale] = 1;
-                env(tx);
-                env.close();
-
-                auto const startingOwnerBalance = env.balance(owner, asset);
-                BEAST_EXPECT((startingOwnerBalance.value() == STAmount{asset, 11875, -2}));
-
-                // This operation (first deposit 100, then 3.75 x 5) is known to
-                // have triggered calculation rounding errors in Number
-                // (addition and division), causing the last deposit to be
-                // blocked by Vault invariants.
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
-
-                auto const tx1 = vault.deposit(
-                    {.depositor = owner, .id = keylet.key, .amount = asset(Number(375, -2))});
-                for (auto i = 0; i < 5; ++i)
-                {
-                    env(tx1);
-                }
-                env.close();
-
-                {
-                    STAmount const xfer{asset, 1185, -1};
-                    BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value() - xfer);
-                    BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == xfer);
-
-                    auto const vault = env.le(keylet);
-                    BEAST_EXPECT(vault->at(sfAssetsAvailable) == xfer);
-                    BEAST_EXPECT(vault->at(sfAssetsTotal) == xfer);
-                }
-
-                // Total vault balance should be 118.5 IOU. Withdraw and delete
-                // the vault to verify this exact amount was deposited and the
-                // owner has matching shares
-                env(vault.withdraw(
-                    {.depositor = owner,
-                     .id = keylet.key,
-                     .amount = asset(Number(1000 + (37 * 5), -1))}));
-
-                {
-                    BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value());
-                    BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == beast::kZero);
-                    auto const vault = env.le(keylet);
-                    BEAST_EXPECT(vault->at(sfAssetsAvailable) == beast::kZero);
-                    BEAST_EXPECT(vault->at(sfAssetsTotal) == beast::kZero);
-                }
-
-                env(vault.del({.owner = owner, .id = keylet.key}));
-                env.close();
-            },
-            {.initialIOU = Number(11875, -2)});
-
-        auto const [acctReserve, incReserve] = [this]() -> std::pair {
-            Env const env{*this, testableAmendments()};
-            return {
-                env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(),
-                env.current()->fees().increment.drops() / kDropsPerXrp.drops()};
-        }();
-
-        testCase(
-            [&, this](
-                Env& env,
-                Account const& owner,
-                Account const& issuer,
-                Account const& charlie,
-                auto,
-                Vault& vault,
-                PrettyAsset const& asset,
-                auto&&...) {
-                testcase("IOU no trust line to depositor no reserve");
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                env(tx);
-                env.close();
-
-                // reset limit, so deposit of all funds will delete the trust
-                // line
-                env.trust(asset(0), owner);
-                env.close();
-
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
-                env.close();
-
-                auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
-                BEAST_EXPECT(trustline == nullptr);
-
-                env(ticket::create(owner, 1));
-                env.close();
-
-                // Fail because not enough reserve to create trust line
-                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                env(tx, Ter{tecNO_LINE_INSUF_RESERVE});
-                env.close();
-
-                env(pay(charlie, owner, XRP(incReserve)));
-                env.close();
-
-                // Withdraw can now create trust line, will succeed
-                env(tx);
-                env.close();
-            },
-            CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1});
-
-        testCase(
-            [&, this](
-                Env& env,
-                Account const& owner,
-                Account const& issuer,
-                Account const& charlie,
-                auto,
-                Vault& vault,
-                PrettyAsset const& asset,
-                auto&&...) {
-                testcase("IOU no reserve for share MPToken");
-                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-                env(tx);
-                env.close();
-
-                env(pay(owner, charlie, asset(100)));
-                env.close();
-
-                env(ticket::create(charlie, 3));
-                env.close();
-
-                // Fail because not enough reserve to create MPToken for shares
-                tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(100)});
-                env(tx, Ter{tecINSUFFICIENT_RESERVE});
-                env.close();
-
-                env(pay(issuer, charlie, XRP(incReserve)));
-                env.close();
-
-                // Deposit can now create MPToken, will succeed
-                env(tx);
-                env.close();
-            },
-            CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1});
-    }
-
-    void
-    testWithDomainCheck()
-    {
-        using namespace test::jtx;
-
-        testcase("private vault");
-
-        Env env{*this, testableAmendments()};
-        Account const issuer{"issuer"};
-        Account const owner{"owner"};
-        Account const depositor{"depositor"};
-        Account const charlie{"charlie"};
-        Account const pdOwner{"pdOwner"};
-        Account const credIssuer1{"credIssuer1"};
-        Account const credIssuer2{"credIssuer2"};
-        std::string const credType = "credential";
-        Vault const vault{env};
-        env.fund(XRP(1000), issuer, owner, depositor, charlie, pdOwner, credIssuer1, credIssuer2);
-        env.close();
-        env(fset(issuer, asfAllowTrustLineClawback));
-        env.close();
-        env.require(Flags(issuer, asfAllowTrustLineClawback));
-
-        PrettyAsset const asset = issuer["IOU"];
-        env.trust(asset(1000), owner);
-        env(pay(issuer, owner, asset(500)));
-        env.trust(asset(1000), depositor);
-        env(pay(issuer, depositor, asset(500)));
-        env.trust(asset(1000), charlie);
-        env(pay(issuer, charlie, asset(5)));
-        env.close();
-
-        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
-        env(tx);
-        env.close();
-        BEAST_EXPECT(env.le(keylet));
-
-        {
-            testcase("private vault owner can deposit");
-            auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
-            env(tx);
-        }
-
-        {
-            testcase("private vault depositor not authorized yet");
-            auto tx =
-                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-            env(tx, Ter{tecNO_AUTH});
-        }
-
-        {
-            testcase("private vault cannot set non-existing domain");
-            auto tx = vault.set({.owner = owner, .id = keylet.key});
-            tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
-            env(tx, Ter{tecOBJECT_NOT_FOUND});
-        }
-
-        {
-            testcase("private vault set domainId");
-
-            {
-                pdomain::Credentials const credentials1{
-                    {.issuer = credIssuer1, .credType = credType}};
-
-                env(pdomain::setTx(pdOwner, credentials1));
-                auto const domainId1 = [&]() {
-                    auto tx = env.tx()->getJson(JsonOptions::Values::None);
-                    return pdomain::getNewDomain(env.meta());
-                }();
-
-                auto tx = vault.set({.owner = owner, .id = keylet.key});
-                tx[sfDomainID] = to_string(domainId1);
-                env(tx);
-                env.close();
-
-                // Update domain second time, should be harmless
-                env(tx);
-                env.close();
-            }
-
-            {
-                pdomain::Credentials const credentials{
-                    {.issuer = credIssuer1, .credType = credType},
-                    {.issuer = credIssuer2, .credType = credType}};
-
-                env(pdomain::setTx(pdOwner, credentials));
-                auto const domainId = [&]() {
-                    auto tx = env.tx()->getJson(JsonOptions::Values::None);
-                    return pdomain::getNewDomain(env.meta());
-                }();
-
-                auto tx = vault.set({.owner = owner, .id = keylet.key});
-                tx[sfDomainID] = to_string(domainId);
-                env(tx);
-                env.close();
-
-                // Should be idempotent
-                tx = vault.set({.owner = owner, .id = keylet.key});
-                tx[sfDomainID] = to_string(domainId);
-                env(tx);
-                env.close();
-            }
-        }
-
-        {
-            testcase("private vault depositor still not authorized");
-            auto tx =
-                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-            env(tx, Ter{tecNO_AUTH});
-            env.close();
-        }
-
-        auto const credKeylet = credentials::keylet(depositor, credIssuer1, credType);
-        {
-            testcase("private vault depositor now authorized");
-            env(credentials::create(depositor, credIssuer1, credType));
-            env(credentials::accept(depositor, credIssuer1, credType));
-            env(credentials::create(charlie, credIssuer1, credType));
-            // charlie's credential not accepted
-            env.close();
-            auto credSle = env.le(credKeylet);
-            BEAST_EXPECT(credSle != nullptr);
-
-            auto tx =
-                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-            env(tx);
-            env.close();
-
-            tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(50)});
-            env(tx, Ter{tecNO_AUTH});
-            env.close();
-        }
-
-        {
-            testcase("private vault depositor lost authorization");
-            env(credentials::deleteCred(credIssuer1, depositor, credIssuer1, credType));
-            env(credentials::deleteCred(credIssuer1, charlie, credIssuer1, credType));
-            env.close();
-            auto credSle = env.le(credKeylet);
-            BEAST_EXPECT(credSle == nullptr);
-
-            auto tx =
-                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-            env(tx, Ter{tecNO_AUTH});
-            env.close();
-        }
-
-        auto const shares = [&env, keylet = keylet, this]() -> Asset {
-            auto const vault = env.le(keylet);
-            BEAST_EXPECT(vault != nullptr);
-            return MPTIssue(vault->at(sfShareMPTID));
-        }();
-
-        {
-            testcase("private vault expired authorization");
-            uint32_t const closeTime =
-                env.current()->header().parentCloseTime.time_since_epoch().count();
-            {
-                auto tx0 = credentials::create(depositor, credIssuer2, credType);
-                tx0[sfExpiration] = closeTime + 20;
-                env(tx0);
-                tx0 = credentials::create(charlie, credIssuer2, credType);
-                tx0[sfExpiration] = closeTime + 20;
-                env(tx0);
-                env.close();
-
-                env(credentials::accept(depositor, credIssuer2, credType));
-                env(credentials::accept(charlie, credIssuer2, credType));
-                env.close();
-            }
-
-            {
-                auto tx1 =
-                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-                env(tx1);
-                env.close();
-
-                auto const tokenKeylet =
-                    keylet::mptoken(shares.get().getMptID(), depositor.id());
-                BEAST_EXPECT(env.le(tokenKeylet) != nullptr);
-            }
-
-            {
-                // time advance
-                env.close();
-                env.close();
-                env.close();
-
-                auto const credsKeylet = credentials::keylet(depositor, credIssuer2, credType);
-                BEAST_EXPECT(env.le(credsKeylet) != nullptr);
-
-                auto tx2 =
-                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
-                env(tx2, Ter{tecEXPIRED});
-                env.close();
-
-                BEAST_EXPECT(env.le(credsKeylet) == nullptr);
-            }
-
-            {
-                auto const credsKeylet = credentials::keylet(charlie, credIssuer2, credType);
-                BEAST_EXPECT(env.le(credsKeylet) != nullptr);
-                auto const tokenKeylet =
-                    keylet::mptoken(shares.get().getMptID(), charlie.id());
-                BEAST_EXPECT(env.le(tokenKeylet) == nullptr);
-
-                auto tx3 =
-                    vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(2)});
-                env(tx3, Ter{tecEXPIRED});
-
-                env.close();
-                BEAST_EXPECT(env.le(credsKeylet) == nullptr);
-                BEAST_EXPECT(env.le(tokenKeylet) == nullptr);
-            }
-        }
-
-        {
-            testcase("private vault reset domainId");
-            auto tx = vault.set({.owner = owner, .id = keylet.key});
-            tx[sfDomainID] = "0";
-            env(tx);
-            env.close();
-
-            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-            env(tx, Ter{tecNO_AUTH});
-            env.close();
-
-            tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-            env(tx);
-            env.close();
-
-            tx = vault.clawback(
-                {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
-            env(tx);
-
-            tx = vault.clawback(
-                {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)});
-            env(tx);
-            env.close();
-
-            tx = vault.del({
-                .owner = owner,
-                .id = keylet.key,
-            });
-            env(tx);
-        }
-    }
-
-    void
-    testWithDomainChecXRP()
-    {
-        using namespace test::jtx;
-
-        testcase("private XRP vault");
-
-        Env env{*this, testableAmendments()};
-        Account const owner{"owner"};
-        Account const depositor{"depositor"};
-        Account const alice{"charlie"};
-        std::string const credType = "credential";
-        Vault const vault{env};
-        env.fund(XRP(100000), owner, depositor, alice);
-        env.close();
-
-        PrettyAsset const asset = xrpIssue();
-        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
-        env(tx);
-        env.close();
-
-        auto const [vaultAccount, issuanceId] =
-            [&env, keylet = keylet, this]() -> std::tuple {
-            auto const vault = env.le(keylet);
-            BEAST_EXPECT(vault != nullptr);
-            return {vault->at(sfAccount), vault->at(sfShareMPTID)};
-        }();
-        BEAST_EXPECT(env.le(keylet::account(vaultAccount)));
-        BEAST_EXPECT(env.le(keylet::mptokenIssuance(issuanceId)));
-        PrettyAsset const shares{issuanceId};
-
-        {
-            testcase("private XRP vault owner can deposit");
-            auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
-            env(tx);
-            env.close();
-        }
-
-        {
-            testcase("private XRP vault cannot pay shares to depositor yet");
-            env(pay(owner, depositor, shares(1)), Ter{tecNO_AUTH});
-        }
-
-        {
-            testcase("private XRP vault depositor not authorized yet");
-            auto tx =
-                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-            env(tx, Ter{tecNO_AUTH});
-        }
-
-        {
-            testcase("private XRP vault set DomainID");
-            pdomain::Credentials const credentials{{.issuer = owner, .credType = credType}};
-
-            env(pdomain::setTx(owner, credentials));
-            auto const domainId = [&]() {
-                auto tx = env.tx()->getJson(JsonOptions::Values::None);
-                return pdomain::getNewDomain(env.meta());
-            }();
-
-            auto tx = vault.set({.owner = owner, .id = keylet.key});
-            tx[sfDomainID] = to_string(domainId);
-            env(tx);
-            env.close();
-        }
-
-        auto const credKeylet = credentials::keylet(depositor, owner, credType);
-        {
-            testcase("private XRP vault depositor now authorized");
-            env(credentials::create(depositor, owner, credType));
-            env(credentials::accept(depositor, owner, credType));
-            env.close();
-
-            BEAST_EXPECT(env.le(credKeylet));
-            auto tx =
-                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
-            env(tx);
-            env.close();
-        }
-
-        {
-            testcase("private XRP vault can pay shares to depositor");
-            env(pay(owner, depositor, shares(1)));
-        }
-
-        {
-            testcase("private XRP vault cannot pay shares to 3rd party");
-            json::Value jv;
-            jv[sfAccount] = alice.human();
-            jv[sfTransactionType] = jss::MPTokenAuthorize;
-            jv[sfMPTokenIssuanceID] = to_string(issuanceId);
-            env(jv);
-            env.close();
-
-            env(pay(owner, alice, shares(1)), Ter{tecNO_AUTH});
-        }
-    }
-
-    void
-    testFailedPseudoAccount()
-    {
-        using namespace test::jtx;
-
-        testcase("fail pseudo-account allocation");
-        Env env{*this, testableAmendments()};
-        Account const owner{"owner"};
-        Vault const vault{env};
-        env.fund(XRP(1000), owner);
-
-        auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-        for (int i = 0; i < 256; ++i)
-        {
-            AccountID const accountId = xrpl::pseudoAccountAddress(*env.current(), keylet.key);
-
-            env(pay(env.master.id(), accountId, XRP(1000)),
-                Seq(kAutofill),
-                Fee(kAutofill),
-                Sig(kAutofill));
-        }
-
-        auto [tx, keylet1] = vault.create({.owner = owner, .asset = xrpIssue()});
-        BEAST_EXPECT(keylet.key == keylet1.key);
-        env(tx, Ter{terADDRESS_COLLISION});
-    }
-
-    void
-    testScaleIOU()
-    {
-        using namespace test::jtx;
-
-        struct Data
-        {
-            Account const& owner;
-            Account const& issuer;
-            Account const& depositor;
-            Account const& vaultAccount;
-            MPTIssue shares;
-            PrettyAsset const& share;
-            Vault& vault;
-            xrpl::Keylet keylet;
-            Issue assets;
-            PrettyAsset const& asset;
-            std::function)> peek;
-        };
-
-        auto testCase = [&, this](
-                            std::uint8_t scale, std::function test) {
-            Env env{*this, testableAmendments()};
-            Account const owner{"owner"};
-            Account const issuer{"issuer"};
-            Account const depositor{"depositor"};
-            Vault vault{env};
-            env.fund(XRP(1000), issuer, owner, depositor);
-            env(fset(issuer, asfAllowTrustLineClawback));
-            env.close();
-
-            PrettyAsset const asset = issuer["IOU"];
-            env.trust(asset(1000), owner);
-            env.trust(asset(1000), depositor);
-            env(pay(issuer, owner, asset(200)));
-            env(pay(issuer, depositor, asset(200)));
-            env.close();
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            tx[sfScale] = scale;
-            env(tx);
-
-            auto const [vaultAccount, issuanceId] =
-                [&env](xrpl::Keylet keylet) -> std::tuple {
-                auto const vault = env.le(keylet);
-                return {Account("vault", vault->at(sfAccount)), vault->at(sfShareMPTID)};
-            }(keylet);
-            MPTIssue const shares(issuanceId);
-            env.memoize(vaultAccount);
-
-            auto const peek = [keylet, &env, this](std::function fn) -> bool {
-                return env.app().getOpenLedger().modify(
-                    [&](OpenView& view, beast::Journal j) -> bool {
-                        Sandbox sb(&view, TapNone);
-                        auto vault = sb.peek(keylet::vault(keylet.key));
-                        if (!BEAST_EXPECT(vault))
-                            return false;
-                        auto shares = sb.peek(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
-                        if (!BEAST_EXPECT(shares))
-                            return false;
-                        if (fn(*vault, *shares))
-                        {
-                            sb.update(vault);
-                            sb.update(shares);
-                            sb.apply(view);
-                            return true;
-                        }
-                        return false;
-                    });
-            };
-
-            test(
-                env,
-                {.owner = owner,
-                 .issuer = issuer,
-                 .depositor = depositor,
-                 .vaultAccount = vaultAccount,
-                 .shares = shares,
-                 .share = PrettyAsset(shares),
-                 .vault = vault,
-                 .keylet = keylet,
-                 .assets = asset.raw().get(),
-                 .asset = asset,
-                 .peek = peek});
-        };
-
-        testCase(18, [&, this](Env& env, Data d) {
-            testcase("Scale deposit overflow on first deposit");
-            auto tx = d.vault.deposit(
-                {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)});
-            env(tx, Ter{tecPATH_DRY});
-            env.close();
-        });
-
-        testCase(18, [&, this](Env& env, Data d) {
-            testcase("Scale deposit overflow on second deposit");
-
-            {
-                auto tx = d.vault.deposit(
-                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
-                env(tx);
-                env.close();
-            }
-
-            {
-                auto tx = d.vault.deposit(
-                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)});
-                env(tx, Ter{tecPATH_DRY});
-                env.close();
-            }
-        });
-
-        testCase(18, [&, this](Env& env, Data d) {
-            testcase("Scale deposit overflow on total shares");
-
-            {
-                auto tx = d.vault.deposit(
-                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
-                env(tx);
-                env.close();
-            }
-
-            {
-                auto tx = d.vault.deposit(
-                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
-                env(tx, Ter{tecPATH_DRY});
-                env.close();
-            }
-        });
-
-        testCase(1, [&, this](Env& env, Data d) {
-            testcase("Scale deposit exact");
-
-            auto const start = env.balance(d.depositor, d.assets).number();
-            auto tx = d.vault.deposit(
-                {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(1)});
-            env(tx);
-            env.close();
-            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(10));
-            BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start - 1));
-        });
-
-        testCase(1, [&, this](Env& env, Data d) {
-            testcase("Scale deposit insignificant amount");
-
-            auto tx = d.vault.deposit(
-                {.depositor = d.depositor,
-                 .id = d.keylet.key,
-                 .amount = STAmount(d.asset, Number(9, -2))});
-            env(tx, Ter{tecPRECISION_LOSS});
-        });
-
-        testCase(1, [&, this](Env& env, Data d) {
-            testcase("Scale deposit exact, using full precision");
-
-            auto const start = env.balance(d.depositor, d.assets).number();
-            auto tx = d.vault.deposit(
-                {.depositor = d.depositor,
-                 .id = d.keylet.key,
-                 .amount = STAmount(d.asset, Number(15, -1))});
-            env(tx);
-            env.close();
-            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(15));
-            BEAST_EXPECT(
-                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(15, -1)));
-        });
-
-        testCase(1, [&, this](Env& env, Data d) {
-            testcase("Scale deposit exact, truncating from .5");
-
-            auto const start = env.balance(d.depositor, d.assets).number();
-            // Each of the cases below will transfer exactly 1.2 IOU to the
-            // vault and receive 12 shares in exchange
-            {
-                auto tx = d.vault.deposit(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.asset, Number(125, -2))});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
-                BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) ==
-                    STAmount(d.asset, start - Number(12, -1)));
-            }
-
-            {
-                auto tx = d.vault.deposit(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.asset, Number(1201, -3))});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(24));
-                BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) ==
-                    STAmount(d.asset, start - Number(24, -1)));
-            }
-
-            {
-                auto tx = d.vault.deposit(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.asset, Number(1299, -3))});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(36));
-                BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) ==
-                    STAmount(d.asset, start - Number(36, -1)));
-            }
-        });
-
-        testCase(1, [&, this](Env& env, Data d) {
-            testcase("Scale deposit exact, truncating from .01");
-
-            auto const start = env.balance(d.depositor, d.assets).number();
-            // round to 12
-            auto tx = d.vault.deposit(
-                {.depositor = d.depositor,
-                 .id = d.keylet.key,
-                 .amount = STAmount(d.asset, Number(1201, -3))});
-            env(tx);
-            env.close();
-            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
-            BEAST_EXPECT(
-                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1)));
-
-            {
-                // round to 6
-                auto tx = d.vault.deposit(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.asset, Number(69, -2))});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18));
-                BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) ==
-                    STAmount(d.asset, start - Number(18, -1)));
-            }
-        });
-
-        testCase(1, [&, this](Env& env, Data d) {
-            testcase("Scale deposit exact, truncating from .99");
-
-            auto const start = env.balance(d.depositor, d.assets).number();
-            // round to 12
-            auto tx = d.vault.deposit(
-                {.depositor = d.depositor,
-                 .id = d.keylet.key,
-                 .amount = STAmount(d.asset, Number(1299, -3))});
-            env(tx);
-            env.close();
-            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
-            BEAST_EXPECT(
-                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1)));
-
-            {
-                // round to 6
-                auto tx = d.vault.deposit(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.asset, Number(62, -2))});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18));
-                BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) ==
-                    STAmount(d.asset, start - Number(18, -1)));
-            }
-        });
-
-        testCase(1, [&, this](Env& env, Data d) {
-            // initial setup: deposit 100 IOU, receive 1000 shares
-            auto const start = env.balance(d.depositor, d.assets).number();
-            auto tx = d.vault.deposit(
-                {.depositor = d.depositor,
-                 .id = d.keylet.key,
-                 .amount = STAmount(d.asset, Number(100, 0))});
-            env(tx);
-            env.close();
-            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
-            BEAST_EXPECT(
-                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
-            BEAST_EXPECT(
-                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
-            BEAST_EXPECT(
-                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0)));
-
-            {
-                testcase("Scale redeem exact");
-                // sharesToAssetsWithdraw:
-                //  assets = assetsTotal * (shares / sharesTotal)
-                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
-
-                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.share, Number(100, 0))});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
-                BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0)));
-            }
-
-            {
-                testcase("Scale redeem with rounding");
-                // sharesToAssetsWithdraw:
-                //  assets = assetsTotal * (shares / sharesTotal)
-                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
-
-                auto const start = env.balance(d.depositor, d.assets).number();
-                d.peek([](SLE& vault, auto&) -> bool {
-                    vault[sfAssetsAvailable] = Number(1);
-                    return true;
-                });
-
-                // Note, this transaction fails first (because of above change
-                // in the open ledger) but then succeeds when the ledger is
-                // closed (because a modification like above is not persistent),
-                // which is why the checks below are expected to pass.
-                auto tx = d.vault.withdraw(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.share, Number(25, 0))});
-                env(tx, Ter{tecINSUFFICIENT_FUNDS});
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
-                BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) ==
-                    STAmount(d.asset, start + Number(25, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(900 - 25, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(900 - 25, 0)));
-            }
-
-            {
-                testcase("Scale redeem exact");
-                // sharesToAssetsWithdraw:
-                //  assets = assetsTotal * (shares / sharesTotal)
-                //  assets = 87.5 * 21 / 875 = 87.5 * 0.024 = 2.1
-
-                auto const start = env.balance(d.depositor, d.assets).number();
-
-                tx = d.vault.withdraw(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.share, Number(21, 0))});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 21));
-                BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) ==
-                    STAmount(d.asset, start + Number(21, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(875 - 21, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(875 - 21, 0)));
-            }
-
-            {
-                testcase("Scale redeem rest");
-                auto const rest = env.balance(d.depositor, d.shares).number();
-
-                tx = d.vault.withdraw(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.share, rest)});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
-                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
-                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
-            }
-        });
-
-        testCase(18, [&, this](Env& env, Data d) {
-            testcase("Scale withdraw overflow");
-
-            {
-                auto tx = d.vault.deposit(
-                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
-                env(tx);
-                env.close();
-            }
-
-            {
-                auto tx = d.vault.withdraw(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.asset, Number(10, 0))});
-                env(tx, Ter{tecPATH_DRY});
-                env.close();
-            }
-        });
-
-        testCase(1, [&, this](Env& env, Data d) {
-            // initial setup: deposit 100 IOU, receive 1000 shares
-            auto const start = env.balance(d.depositor, d.assets).number();
-            auto tx = d.vault.deposit(
-                {.depositor = d.depositor,
-                 .id = d.keylet.key,
-                 .amount = STAmount(d.asset, Number(100, 0))});
-            env(tx);
-            env.close();
-            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
-            BEAST_EXPECT(
-                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
-            BEAST_EXPECT(
-                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
-            BEAST_EXPECT(
-                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0)));
-
-            {
-                testcase("Scale withdraw exact");
-                // assetsToSharesWithdraw:
-                //  shares = sharesTotal * (assets / assetsTotal)
-                //  shares = 1000 * 10 / 100 = 1000 * 0.1 = 100
-                // sharesToAssetsWithdraw:
-                //  assets = assetsTotal * (shares / sharesTotal)
-                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
-
-                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(10, 0))});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
-                BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0)));
-            }
-
-            {
-                testcase("Scale withdraw insignificant amount");
-                auto tx = d.vault.withdraw(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.asset, Number(4, -2))});
-                env(tx, Ter{tecPRECISION_LOSS});
-            }
-
-            {
-                testcase("Scale withdraw with rounding assets");
-                // assetsToSharesWithdraw:
-                //  shares = sharesTotal * (assets / assetsTotal)
-                //  shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25
-                // sharesToAssetsWithdraw:
-                //  assets = assetsTotal * (shares / sharesTotal)
-                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
-
-                auto const start = env.balance(d.depositor, d.assets).number();
-                d.peek([](SLE& vault, auto&) -> bool {
-                    vault[sfAssetsAvailable] = Number(1);
-                    return true;
-                });
-
-                // Note, this transaction fails first (because of above change
-                // in the open ledger) but then succeeds when the ledger is
-                // closed (because a modification like above is not persistent),
-                // which is why the checks below are expected to pass.
-                auto tx = d.vault.withdraw(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.asset, Number(25, -1))});
-                env(tx, Ter{tecINSUFFICIENT_FUNDS});
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
-                BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) ==
-                    STAmount(d.asset, start + Number(25, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(900 - 25, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(900 - 25, 0)));
-            }
-
-            {
-                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
-
-                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(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.assets) ==
-                    STAmount(d.asset, start + Number(38, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(875 - 38, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(875 - 38, 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
-
-                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(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.assets) ==
-                    STAmount(d.asset, start + Number(37, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(837 - 37, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(837 - 37, 0)));
-            }
-
-            {
-                testcase("Scale withdraw tiny amount");
-
-                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.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
-                BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -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)));
-            }
-
-            {
-                testcase("Scale withdraw rest");
-                auto const rest = env.balance(d.vaultAccount, d.assets).number();
-
-                tx = d.vault.withdraw(
-                    {.depositor = d.depositor,
-                     .id = d.keylet.key,
-                     .amount = STAmount(d.asset, rest)});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
-                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
-                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
-            }
-        });
-
-        testCase(18, [&, this](Env& env, Data d) {
-            testcase("Scale clawback overflow");
-
-            {
-                auto tx = d.vault.deposit(
-                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
-                env(tx);
-                env.close();
-            }
-
-            {
-                auto tx = d.vault.clawback(
-                    {.issuer = d.issuer,
-                     .id = d.keylet.key,
-                     .holder = d.depositor,
-                     .amount = STAmount(d.asset, Number(10, 0))});
-                env(tx, Ter{tecPATH_DRY});
-                env.close();
-            }
-        });
-
-        testCase(1, [&, this](Env& env, Data d) {
-            // initial setup: deposit 100 IOU, receive 1000 shares
-            auto const start = env.balance(d.depositor, d.assets).number();
-            auto tx = d.vault.deposit(
-                {.depositor = d.depositor,
-                 .id = d.keylet.key,
-                 .amount = STAmount(d.asset, Number(100, 0))});
-            env(tx);
-            env.close();
-            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
-            BEAST_EXPECT(
-                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
-            BEAST_EXPECT(
-                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
-            BEAST_EXPECT(
-                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(1000, 0)));
-            {
-                testcase("Scale clawback exact");
-                // assetsToSharesWithdraw:
-                //  shares = sharesTotal * (assets / assetsTotal)
-                //  shares = 1000 * 10 / 100 = 1000 * 0.1 = 100
-                // sharesToAssetsWithdraw:
-                //  assets = assetsTotal * (shares / sharesTotal)
-                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
-
-                auto const start = env.balance(d.depositor, d.assets).number();
-                auto tx = d.vault.clawback(
-                    {.issuer = d.issuer,
-                     .id = d.keylet.key,
-                     .holder = d.depositor,
-                     .amount = STAmount(d.asset, Number(10, 0))});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
-                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(900, 0)));
-            }
-
-            {
-                testcase("Scale clawback insignificant amount");
-                auto tx = d.vault.clawback(
-                    {.issuer = d.issuer,
-                     .id = d.keylet.key,
-                     .holder = d.depositor,
-                     .amount = STAmount(d.asset, Number(4, -2))});
-                env(tx, Ter{tecPRECISION_LOSS});
-            }
-
-            {
-                testcase("Scale clawback with rounding assets");
-                // assetsToSharesWithdraw:
-                //  shares = sharesTotal * (assets / assetsTotal)
-                //  shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25
-                // sharesToAssetsWithdraw:
-                //  assets = assetsTotal * (shares / sharesTotal)
-                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
-
-                auto const start = env.balance(d.depositor, d.assets).number();
-                auto tx = d.vault.clawback(
-                    {.issuer = d.issuer,
-                     .id = d.keylet.key,
-                     .holder = d.depositor,
-                     .amount = STAmount(d.asset, Number(25, -1))});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
-                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(900 - 25, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(900 - 25, 0)));
-            }
-
-            {
-                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
-
-                auto const start = env.balance(d.depositor, d.assets).number();
-                auto tx = d.vault.clawback(
-                    {.issuer = d.issuer,
-                     .id = d.keylet.key,
-                     .holder = d.depositor,
-                     .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.assets) == STAmount(d.asset, start));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(875 - 38, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(875 - 38, 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
-
-                auto const start = env.balance(d.depositor, d.assets).number();
-                auto tx = d.vault.clawback(
-                    {.issuer = d.issuer,
-                     .id = d.keylet.key,
-                     .holder = d.depositor,
-                     .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.assets) == STAmount(d.asset, start));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(837 - 37, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(837 - 37, 0)));
-            }
-
-            {
-                testcase("Scale clawback tiny amount");
-
-                auto const start = env.balance(d.depositor, d.assets).number();
-                auto tx = d.vault.clawback(
-                    {.issuer = d.issuer,
-                     .id = d.keylet.key,
-                     .holder = d.depositor,
-                     .amount = STAmount(d.asset, Number(9, -2))});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
-                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)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(800 - 1, 0)));
-            }
-
-            {
-                testcase("Scale clawback rest");
-                auto const rest = env.balance(d.vaultAccount, d.assets).number();
-                d.peek([](SLE& vault, auto&) -> bool {
-                    vault[sfAssetsAvailable] = Number(5);
-                    return true;
-                });
-
-                // Note, this transaction yields two different results:
-                // * in the open ledger, with AssetsAvailable = 5
-                // * when the ledger is closed with unmodified AssetsAvailable
-                //   because a modification like above is not persistent.
-                tx = d.vault.clawback(
-                    {.issuer = d.issuer,
-                     .id = d.keylet.key,
-                     .holder = d.depositor,
-                     .amount = STAmount(d.asset, rest)});
-                env(tx);
-                env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
-                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
-                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
-            }
-        });
-
-        // Non-1:1 ratio (scale=1, 10:1 shares:assets) with an outstanding loan.
-        // Deposit 100 IOU → 1000 shares. Borrow 40 → assetsAvailable=60.
-        // Clawback 80 IOU → clamped to 60, then share math uses truncation.
-        testCase(1, [&, this](Env& env, Data d) {
-            using namespace loan_broker;
-            using namespace loan;
-
-            testcase("Scale clawback clamped with outstanding loan");
-
-            auto tx = d.vault.deposit(
-                {.depositor = d.depositor,
-                 .id = d.keylet.key,
-                 .amount = STAmount(d.asset, Number(100, 0))});
-            env(tx);
-            env.close();
-            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
-
-            // Create a loan broker backed by this vault
-            auto const brokerKeylet =
-                keylet::loanBroker(d.owner.id(), SeqProxy::rawSequence(env.seq(d.owner)));
-            env(set(d.owner, d.keylet.key));
-            env.close();
-
-            // Borrow 40: assetsAvailable=60, assetsTotal=100
-            env(set(d.depositor, brokerKeylet.key, STAmount(d.asset, Number(40, 0))),
-                loan::kInterestRate(TenthBips32(0)),
-                kGracePeriod(60),
-                kPaymentInterval(120),
-                kPaymentTotal(10),
-                Sig(sfCounterpartySignature, d.owner),
-                Fee(env.current()->fees().base * 2),
-                Ter(tesSUCCESS));
-            env.close();
-
-            {
-                auto const sle = env.le(d.keylet);
-                BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(60, 0)));
-                BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(100, 0)));
-            }
-
-            // Request 80 IOU clawback — clamped to assetsAvailable (60)
-            // With scale=1 (10:1), 60 assets = 600 shares destroyed
-            tx = d.vault.clawback(
-                {.issuer = d.issuer,
-                 .id = d.keylet.key,
-                 .holder = d.depositor,
-                 .amount = STAmount(d.asset, Number(80, 0))});
-            env(tx, Ter(tesSUCCESS));
-            env.close();
-
-            {
-                auto const sle = env.le(d.keylet);
-                BEAST_EXPECT(sle != nullptr);
-                BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(0, 0)));
-                BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(40, 0)));
-
-                // 600 of 1000 shares destroyed, 400 remain
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(400));
-            }
-        });
-    }
-
-    void
-    testRPC()
-    {
-        using namespace test::jtx;
-
-        testcase("RPC");
-        Env env{*this, testableAmendments()};
-        Account const owner{"owner"};
-        Account const issuer{"issuer"};
-        Vault const vault{env};
-        env.fund(XRP(1000), issuer, owner);
-        env.close();
-
-        PrettyAsset const asset = issuer["IOU"];
-        env.trust(asset(1000), owner);
-        env(pay(issuer, owner, asset(200)));
-        env.close();
-
-        auto const sequence = env.seq(owner);
-        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-        env(tx);
-        env.close();
-
-        // Set some fields
-        {
-            auto tx1 = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
-            env(tx1);
-
-            auto tx2 = vault.set({.owner = owner, .id = keylet.key});
-            tx2[sfAssetsMaximum] = asset(1000).number();
-            env(tx2);
-            env.close();
-        }
-
-        auto const sleVault = [&env, keylet = keylet, this]() {
-            auto const vault = env.le(keylet);
-            BEAST_EXPECT(vault != nullptr);
-            return vault;
-        }();
-
-        auto const check = [&, keylet = keylet, sle = sleVault, this](
-                               json::Value const& vault,
-                               json::Value const& issuance = json::ValueType::Null) {
-            BEAST_EXPECT(vault.isObject());
-
-            static constexpr auto kCheckString =
-                [](auto& node, SField const& field, std::string v) -> bool {
-                return node.isMember(field.fieldName) && node[field.fieldName].isString() &&
-                    node[field.fieldName] == v;
-            };
-            static constexpr auto kCheckObject =
-                [](auto& node, SField const& field, json::Value v) -> bool {
-                return node.isMember(field.fieldName) && node[field.fieldName].isObject() &&
-                    node[field.fieldName] == v;
-            };
-            static constexpr auto kCheckInt = [](auto& node, SField const& field, int v) -> bool {
-                return node.isMember(field.fieldName) &&
-                    ((node[field.fieldName].isInt() && node[field.fieldName] == json::Int(v)) ||
-                     (node[field.fieldName].isUInt() && node[field.fieldName] == json::UInt(v)));
-            };
-
-            BEAST_EXPECT(vault["LedgerEntryType"].asString() == "Vault");
-            BEAST_EXPECT(vault[jss::index].asString() == strHex(keylet.key));
-            BEAST_EXPECT(kCheckInt(vault, sfFlags, 0));
-            // Ignore all other standard fields, this test doesn't care
-
-            BEAST_EXPECT(kCheckString(vault, sfAccount, toBase58(sle->at(sfAccount))));
-            BEAST_EXPECT(kCheckObject(vault, sfAsset, toJson(sle->at(sfAsset))));
-            BEAST_EXPECT(kCheckString(vault, sfAssetsAvailable, "50"));
-            BEAST_EXPECT(kCheckString(vault, sfAssetsMaximum, "1000"));
-            BEAST_EXPECT(kCheckString(vault, sfAssetsTotal, "50"));
-            BEAST_EXPECT(!vault.isMember(sfLossUnrealized.getJsonName()));
-
-            auto const strShareID = strHex(sle->at(sfShareMPTID));
-            BEAST_EXPECT(kCheckString(vault, sfShareMPTID, strShareID));
-            BEAST_EXPECT(kCheckString(vault, sfOwner, toBase58(owner.id())));
-            BEAST_EXPECT(kCheckInt(vault, sfSequence, sequence));
-            BEAST_EXPECT(kCheckInt(vault, sfWithdrawalPolicy, kVaultStrategyFirstComeFirstServe));
-
-            if (issuance.isObject())
-            {
-                BEAST_EXPECT(issuance["LedgerEntryType"].asString() == "MPTokenIssuance");
-                BEAST_EXPECT(issuance[jss::mpt_issuance_id].asString() == strShareID);
-                BEAST_EXPECT(kCheckInt(issuance, sfSequence, 1));
-                BEAST_EXPECT(kCheckInt(
-                    issuance, sfFlags, int(lsfMPTCanEscrow | lsfMPTCanTrade | lsfMPTCanTransfer)));
-                BEAST_EXPECT(kCheckString(issuance, sfOutstandingAmount, "50000000"));
-            }
-        };
-
-        {
-            testcase("RPC ledger_entry selected by key");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault] = strHex(keylet.key);
-            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
-
-            BEAST_EXPECT(!jvVault[jss::result].isMember(jss::error));
-            BEAST_EXPECT(jvVault[jss::result].isMember(jss::node));
-            check(jvVault[jss::result][jss::node]);
-        }
-
-        {
-            testcase("RPC ledger_entry selected by owner and seq");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault][jss::owner] = owner.human();
-            jvParams[jss::vault][jss::seq] = sequence;
-            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
-
-            BEAST_EXPECT(!jvVault[jss::result].isMember(jss::error));
-            BEAST_EXPECT(jvVault[jss::result].isMember(jss::node));
-            check(jvVault[jss::result][jss::node]);
-        }
-
-        {
-            testcase("RPC ledger_entry cannot find vault by key");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault] = to_string(uint256(42));
-            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
-            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "entryNotFound");
-        }
-
-        {
-            testcase("RPC ledger_entry cannot find vault by owner and seq");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault][jss::owner] = issuer.human();
-            jvParams[jss::vault][jss::seq] = 1'000'000;
-            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
-            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "entryNotFound");
-        }
-
-        {
-            testcase("RPC ledger_entry malformed key");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault] = 42;
-            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
-            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
-        }
-
-        {
-            testcase("RPC ledger_entry malformed owner");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault][jss::owner] = 42;
-            jvParams[jss::vault][jss::seq] = sequence;
-            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
-            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedOwner");
-        }
-
-        {
-            testcase("RPC ledger_entry malformed seq");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault][jss::owner] = issuer.human();
-            jvParams[jss::vault][jss::seq] = "foo";
-            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
-            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
-        }
-
-        {
-            testcase("RPC ledger_entry negative seq");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault][jss::owner] = issuer.human();
-            jvParams[jss::vault][jss::seq] = -1;
-            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
-            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
-        }
-
-        {
-            testcase("RPC ledger_entry oversized seq");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault][jss::owner] = issuer.human();
-            jvParams[jss::vault][jss::seq] = 1e20;
-            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
-            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
-        }
-
-        {
-            testcase("RPC ledger_entry bool seq");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault][jss::owner] = issuer.human();
-            jvParams[jss::vault][jss::seq] = true;
-            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
-            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
-        }
-
-        {
-            testcase("RPC account_objects");
-
-            json::Value jvParams;
-            jvParams[jss::account] = owner.human();
-            jvParams[jss::type] = jss::vault;
-            auto jv = env.rpc("json", "account_objects", to_string(jvParams))[jss::result];
-
-            BEAST_EXPECT(jv[jss::account_objects].size() == 1);
-            check(jv[jss::account_objects][0u]);
-        }
-
-        {
-            testcase("RPC ledger_data");
-
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::binary] = false;
-            jvParams[jss::type] = jss::vault;
-            json::Value jv = env.rpc("json", "ledger_data", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::state].size() == 1);
-            check(jv[jss::result][jss::state][0u]);
-        }
-
-        {
-            testcase("RPC vault_info command line");
-            json::Value jv = env.rpc("vault_info", strHex(keylet.key), "validated");
-
-            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
-            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
-            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
-        }
-
-        {
-            testcase("RPC vault_info json");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault_id] = strHex(keylet.key);
-            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-
-            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
-            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
-            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
-        }
-
-        {
-            testcase("RPC vault_info invalid vault_id");
-            json::Value jvParams;
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json invalid index");
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json by owner and sequence");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::owner] = owner.human();
-            jvParams[jss::seq] = sequence;
-            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-
-            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
-            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
-            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
-        }
-
-        {
-            testcase("RPC vault_info json malformed sequence");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json invalid sequence");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json negative sequence");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json oversized sequence");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json bool sequence");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json malformed owner");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json invalid combination only owner");
-            json::Value jvParams;
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json invalid combination only seq");
-            json::Value jvParams;
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json invalid combination seq vault_id");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json invalid combination owner vault_id");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            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");
-        }
-
-        {
-            testcase(
-                "RPC vault_info json invalid combination owner seq "
-                "vault_id");
-            json::Value jvParams;
-            jvParams[jss::ledger_index] = jss::validated;
-            jvParams[jss::vault_id] = strHex(keylet.key);
-            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");
-        }
-
-        {
-            testcase("RPC vault_info json no input");
-            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");
-        }
-
-        {
-            testcase("RPC vault_info command line invalid index");
-            json::Value jv = env.rpc("vault_info", "foobar", "validated");
-            BEAST_EXPECT(jv[jss::error].asString() == "invalidParams");
-        }
-
-        {
-            testcase("RPC vault_info command line invalid index");
-            json::Value jv = env.rpc("vault_info", "0", "validated");
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
-        }
-
-        {
-            testcase("RPC vault_info command line invalid index");
-            json::Value jv = env.rpc("vault_info", strHex(uint256(42)), "validated");
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "entryNotFound");
-        }
-
-        {
-            testcase("RPC vault_info command line invalid ledger");
-            json::Value jv = env.rpc("vault_info", strHex(keylet.key), "0");
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "lgrNotFound");
-        }
-    }
-
-    void
-    testVaultClawbackBurnShares()
-    {
-        using namespace test::jtx;
-        using namespace loan_broker;
-        using namespace loan;
-        Env env(*this, beast::Severity::Warning);
-
-        auto const vaultAssetBalance = [&](Keylet const& vaultKeylet) {
-            auto const sleVault = env.le(vaultKeylet);
-            BEAST_EXPECT(sleVault != nullptr);
-
-            return std::make_pair(sleVault->at(sfAssetsAvailable), sleVault->at(sfAssetsTotal));
-        };
-
-        auto const vaultShareBalance = [&](Keylet const& vaultKeylet) {
-            auto const sleVault = env.le(vaultKeylet);
-            BEAST_EXPECT(sleVault != nullptr);
-
-            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
-            BEAST_EXPECT(sleIssuance != nullptr);
-
-            return sleIssuance->at(sfOutstandingAmount);
-        };
-
-        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});
-            env(tx, Ter(tesSUCCESS));
-            env.close();
-
-            auto const& vaultSle = env.le(vaultKeylet);
-            BEAST_EXPECT(vaultSle != nullptr);
-
-            Asset const share = vaultSle->at(sfShareMPTID);
-
-            env(vault.deposit(
-                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
-                Ter(tesSUCCESS));
-            env.close();
-
-            auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet);
-            BEAST_EXPECT(availablePreDefault == totalPreDefault);
-            BEAST_EXPECT(availablePreDefault == asset(100).value());
-
-            // attempt to clawback shares while there are assets fails
-            env(vault.clawback(
-                    {.issuer = owner,
-                     .id = vaultKeylet.key,
-                     .holder = depositor,
-                     .amount = share(0).value()}),
-                Ter(tecNO_PERMISSION));
-            env.close();
-
-            auto const& sharesAvailable = vaultShareBalance(vaultKeylet);
-            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));
-
-            // Create a simple Loan for the full amount of Vault assets
-            env(set(depositor, brokerKeylet.key, asset(100).value()),
-                loan::kInterestRate(TenthBips32(0)),
-                kGracePeriod(60),
-                kPaymentInterval(120),
-                kPaymentTotal(10),
-                Sig(sfCounterpartySignature, owner),
-                Fee(env.current()->fees().base * 2),
-                Ter(tesSUCCESS));
-            env.close();
-
-            // attempt to clawback shares while there assetsAvailable == 0 and
-            // assetsTotal > 0 fails
-            env(vault.clawback(
-                    {.issuer = owner,
-                     .id = vaultKeylet.key,
-                     .holder = depositor,
-                     .amount = share(0).value()}),
-                Ter(tecNO_PERMISSION));
-            env.close();
-
-            env.close(std::chrono::seconds{120 + 60});
-
-            env(manage(owner, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
-
-            auto const& [availablePostDefault, totalPostDefault] = vaultAssetBalance(vaultKeylet);
-
-            BEAST_EXPECT(availablePostDefault == totalPostDefault);
-            BEAST_EXPECT(availablePostDefault == asset(0).value());
-            BEAST_EXPECT(vaultShareBalance(vaultKeylet) == sharesAvailable);
-
-            return std::make_pair(vault, vaultKeylet);
-        };
-
-        auto const testCase = [&](PrettyAsset const& asset,
-                                  std::string const& prefix,
-                                  Account const& owner,
-                                  Account const& depositor) {
-            {
-                testcase("VaultClawback (share) - " + prefix + " owner asset clawback fails");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
-                // when asset is XRP or owner is not issuer clawback fail
-                // when owner is issuer precision loss occurs as vault is
-                // empty
-                auto const expectedTer = [&]() {
-                    if (asset.native())
-                        return Ter(temMALFORMED);
-                    if (asset.raw().getIssuer() != owner.id())
-                        return Ter(tecNO_PERMISSION);
-                    return Ter(tecPRECISION_LOSS);
-                }();
-                env(vault.clawback({
-                        .issuer = owner,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = asset(100).value(),
-                    }),
-                    expectedTer);
-                env.close();
-            }
-
-            {
-                testcase(
-                    "VaultClawback (share) - " + prefix + " owner incomplete share clawback fails");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
-                auto const& vaultSle = env.le(vaultKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-                Asset const share = vaultSle->at(sfShareMPTID);
-                env(vault.clawback({
-                        .issuer = owner,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = share(1).value(),
-                    }),
-                    Ter(tecLIMIT_EXCEEDED));
-                env.close();
-            }
-
-            {
-                testcase(
-                    "VaultClawback (share) - " + prefix +
-                    " owner implicit complete share clawback");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
-                env(vault.clawback({
-                        .issuer = owner,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                    }),
-                    // when owner is issuer implicit clawback fails
-                    asset.native() || asset.raw().getIssuer() != owner.id() ? Ter(tesSUCCESS)
-                                                                            : Ter(tecWRONG_ASSET));
-                env.close();
-            }
-
-            {
-                testcase(
-                    "VaultClawback (share) - " + prefix +
-                    " owner explicit complete share clawback succeeds");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
-                auto const& vaultSle = env.le(vaultKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-                Asset const share = vaultSle->at(sfShareMPTID);
-                env(vault.clawback({
-                        .issuer = owner,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
-                    }),
-                    Ter(tesSUCCESS));
-                env.close();
-            }
-            {
-                testcase("VaultClawback (share) - " + prefix + " owner can clawback own shares");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, owner);
-                auto const& vaultSle = env.le(vaultKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-                Asset const share = vaultSle->at(sfShareMPTID);
-                env(vault.clawback({
-                        .issuer = owner,
-                        .id = vaultKeylet.key,
-                        .holder = owner,
-                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
-                    }),
-                    Ter(tesSUCCESS));
-                env.close();
-            }
-
-            {
-                testcase("VaultClawback (share) - " + prefix + " empty vault share clawback fails");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, owner);
-                auto const& vaultSle = env.le(vaultKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-                Asset const share = vaultSle->at(sfShareMPTID);
-                env(vault.clawback({
-                        .issuer = owner,
-                        .id = vaultKeylet.key,
-                        .holder = owner,
-                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
-                    }),
-                    Ter(tesSUCCESS));
-
-                // Now the vault is empty, clawback again fails
-                env(vault.clawback({
-                        .issuer = owner,
-                        .id = vaultKeylet.key,
-                        .holder = owner,
-                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
-                    }),
-                    Ter(tecNO_PERMISSION));
-                env.close();
-            }
-        };
-
-        Account const owner{"alice"};
-        Account const depositor{"bob"};
-        Account const issuer{"issuer"};
-
-        env.fund(XRP(10000), issuer, owner, depositor);
-        env.close();
-
-        // Test XRP
-        PrettyAsset const xrp = xrpIssue();
-        testCase(xrp, "XRP", owner, depositor);
-        testCase(xrp, "XRP (depositor is owner)", owner, owner);
-
-        // Test IOU
-        PrettyAsset const iou = issuer["IOU"];
-        env(fset(issuer, asfAllowTrustLineClawback));
-        env.close();
-
-        env.trust(iou(1000), owner);
-        env.trust(iou(1000), depositor);
-        env(pay(issuer, owner, iou(100)));
-        env(pay(issuer, depositor, iou(100)));
-        env.close();
-        testCase(iou, "IOU", owner, depositor);
-        testCase(iou, "IOU (owner is issuer)", issuer, depositor);
-
-        // Test MPT
-        MPTTester mptt{env, issuer, kMptInitNoFund};
-        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
-        PrettyAsset const mpt = mptt.issuanceID();
-        mptt.authorize({.account = owner});
-        mptt.authorize({.account = depositor});
-        env(pay(issuer, owner, mpt(1000)));
-        env(pay(issuer, depositor, mpt(1000)));
-        env.close();
-        testCase(mpt, "MPT", owner, depositor);
-        testCase(mpt, "MPT (owner is issuer)", issuer, depositor);
-    }
-
-    void
-    testVaultClawbackAssets()
-    {
-        using namespace test::jtx;
-        using namespace loan_broker;
-        using namespace loan;
-        Env env(*this);
-        env.enableFeature(fixCleanup3_1_3);
-
-        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});
-            env(tx, Ter(tesSUCCESS));
-            env.close();
-
-            auto const& vaultSle = env.le(vaultKeylet);
-            BEAST_EXPECT(vaultSle != nullptr);
-            env.memoize(Account("vault", vaultSle->at(sfAccount)));
-            env(vault.deposit(
-                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
-                Ter(tesSUCCESS));
-            env.close();
-
-            return std::make_pair(vault, vaultKeylet);
-        };
-
-        auto const testCase = [&](PrettyAsset const& asset,
-                                  std::string const& prefix,
-                                  Account const& owner,
-                                  Account const& depositor,
-                                  Account const& issuer) {
-            if (asset.native())
-            {
-                testcase("VaultClawback (asset) - " + prefix + " issuer XRP clawback fails");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-                // If the asset is XRP, clawback with amount fails as malformed
-                // when asset is specified.
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = issuer,
-                        .amount = asset(1).value(),
-                    }),
-                    Ter(temMALFORMED));
-                // When asset is implicit, clawback fails as no permission.
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = issuer,
-                    }),
-                    Ter(tecNO_PERMISSION));
-                return;
-            }
-
-            {
-                testcase(
-                    "VaultClawback (asset) - " + prefix + " clawback for different asset fails");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-
-                Account const issuer2{"issuer2"};
-                PrettyAsset const asset2 = issuer2["FOO"];
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = asset2(1).value(),
-                    }),
-                    Ter(tecWRONG_ASSET));
-            }
-
-            {
-                testcase(
-                    "VaultClawback (asset) - " + prefix +
-                    " ambiguous owner/issuer asset clawback fails");
-                auto [vault, vaultKeylet] = setupVault(asset, issuer, depositor, issuer);
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = issuer,
-                    }),
-                    Ter(tecWRONG_ASSET));
-            }
-
-            {
-                testcase("VaultClawback (asset) - " + prefix + " non-issuer asset clawback fails");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-
-                env(vault.clawback({
-                        .issuer = owner,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                    }),
-                    Ter(tecNO_PERMISSION));
-
-                env(vault.clawback({
-                        .issuer = owner,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = asset(1).value(),
-                    }),
-                    Ter(tecNO_PERMISSION));
-            }
-
-            {
-                testcase("VaultClawback (asset) - " + prefix + " issuer clawback from self fails");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, issuer, issuer);
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = issuer,
-                    }),
-                    Ter(tecNO_PERMISSION));
-            }
-
-            {
-                testcase("VaultClawback (asset) - " + prefix + " issuer share clawback fails");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-                auto const& vaultSle = env.le(vaultKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-                Asset const share = vaultSle->at(sfShareMPTID);
-
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = share(1).value(),
-                    }),
-                    Ter(tecNO_PERMISSION));
-            }
-
-            {
-                testcase(
-                    "VaultClawback (asset) - " + prefix +
-                    " partial issuer asset clawback succeeds");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = asset(1).value(),
-                    }),
-                    Ter(tesSUCCESS));
-            }
-
-            {
-                testcase(
-                    "VaultClawback (asset) - " + prefix + " full issuer asset clawback succeeds");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = asset(100).value(),
-                    }),
-                    Ter(tesSUCCESS));
-            }
-
-            {
-                testcase(
-                    "VaultClawback (asset) - " + prefix +
-                    " implicit full issuer asset clawback succeeds");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                    }),
-                    Ter(tesSUCCESS));
-            }
-
-            {
-                testcase(
-                    "VaultClawback (asset) - " + prefix +
-                    " zero-amount clawback clamped with outstanding loan");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-
-                auto const vaultSle = env.le(vaultKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-
-                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
-
-                // Create a loan broker backed by this vault
-                auto const brokerKeylet =
-                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-                env(set(owner, vaultKeylet.key));
-                env.close();
-
-                // Depositor borrows 40 units, reducing assetsAvailable to 60
-                // while assetsTotal stays at 100
-                env(set(depositor, brokerKeylet.key, asset(40).value()),
-                    loan::kInterestRate(TenthBips32(0)),
-                    kGracePeriod(60),
-                    kPaymentInterval(120),
-                    kPaymentTotal(10),
-                    Sig(sfCounterpartySignature, owner),
-                    Fee(env.current()->fees().base * 2),
-                    Ter(tesSUCCESS));
-                env.close();
-
-                {
-                    auto const sle = env.le(vaultKeylet);
-                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
-                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
-                }
-
-                // Zero-amount clawback (= "clawback all") should succeed,
-                // clamped to assetsAvailable (60) rather than the full
-                // share value (100).
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                    }),
-                    Ter(tesSUCCESS));
-                env.close();
-
-                // Only 60 assets clawed back; loan's 40 still outstanding
-                {
-                    auto const sle = env.le(vaultKeylet);
-                    BEAST_EXPECT(sle != nullptr);
-                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
-                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
-
-                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
-                    auto const sharesAfter = env.balance(depositor, shares);
-                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
-                }
-            }
-
-            {
-                testcase(
-                    "VaultClawback (asset) - " + prefix +
-                    " non-zero clawback clamped with outstanding loan");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-
-                auto const vaultSle = env.le(vaultKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
-
-                // Create a loan broker backed by this vault
-                auto const brokerKeylet =
-                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-                env(set(owner, vaultKeylet.key));
-                env.close();
-
-                // Depositor borrows 40 units
-                env(set(depositor, brokerKeylet.key, asset(40).value()),
-                    loan::kInterestRate(TenthBips32(0)),
-                    kGracePeriod(60),
-                    kPaymentInterval(120),
-                    kPaymentTotal(10),
-                    Sig(sfCounterpartySignature, owner),
-                    Fee(env.current()->fees().base * 2),
-                    Ter(tesSUCCESS));
-                env.close();
-
-                {
-                    auto const sle = env.le(vaultKeylet);
-                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
-                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
-                }
-
-                // Request 100 but only 60 available — clamped to 60
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = asset(100).value(),
-                    }),
-                    Ter(tesSUCCESS));
-                env.close();
-
-                {
-                    auto const sle = env.le(vaultKeylet);
-                    BEAST_EXPECT(sle != nullptr);
-                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
-                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
-
-                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
-                    auto const sharesAfter = env.balance(depositor, shares);
-                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
-                }
-            }
-
-            {
-                testcase(
-                    "VaultClawback (asset) - " + prefix +
-                    " partial clawback below available with outstanding loan");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-
-                auto const vaultSle = env.le(vaultKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
-
-                // Create a loan broker backed by this vault
-                auto const brokerKeylet =
-                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-                env(set(owner, vaultKeylet.key));
-                env.close();
-
-                // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100
-                env(set(depositor, brokerKeylet.key, asset(40).value()),
-                    loan::kInterestRate(TenthBips32(0)),
-                    kGracePeriod(60),
-                    kPaymentInterval(120),
-                    kPaymentTotal(10),
-                    Sig(sfCounterpartySignature, owner),
-                    Fee(env.current()->fees().base * 2),
-                    Ter(tesSUCCESS));
-                env.close();
-
-                {
-                    auto const sle = env.le(vaultKeylet);
-                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
-                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
-                }
-
-                // Clawback 30 — well under available (60), no clamping needed
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = asset(30).value(),
-                    }),
-                    Ter(tesSUCCESS));
-                env.close();
-
-                {
-                    auto const sle = env.le(vaultKeylet);
-                    BEAST_EXPECT(sle != nullptr);
-                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(30).value());
-                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(70).value());
-
-                    // 30 of 100 shares destroyed (1:1 ratio), 70 remain
-                    auto const sharesAfter = env.balance(depositor, shares);
-                    BEAST_EXPECT(sharesAfter == shares(Number{7, sle->at(sfScale) + 1}));
-                }
-            }
-
-            {
-                testcase(
-                    "VaultClawback (asset) - " + prefix +
-                    " clawback exactly equal to available with outstanding loan");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-
-                auto const vaultSle = env.le(vaultKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
-
-                auto const brokerKeylet =
-                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-                env(set(owner, vaultKeylet.key));
-                env.close();
-
-                // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100
-                env(set(depositor, brokerKeylet.key, asset(40).value()),
-                    loan::kInterestRate(TenthBips32(0)),
-                    kGracePeriod(60),
-                    kPaymentInterval(120),
-                    kPaymentTotal(10),
-                    Sig(sfCounterpartySignature, owner),
-                    Fee(env.current()->fees().base * 2),
-                    Ter(tesSUCCESS));
-                env.close();
-
-                // Clawback exactly 60 — at the boundary, no clamping needed
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = asset(60).value(),
-                    }),
-                    Ter(tesSUCCESS));
-                env.close();
-
-                {
-                    auto const sle = env.le(vaultKeylet);
-                    BEAST_EXPECT(sle != nullptr);
-                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
-                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
-
-                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
-                    auto const sharesAfter = env.balance(depositor, shares);
-                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
-                }
-            }
-
-            {
-                testcase(
-                    "VaultClawback (asset) - " + prefix +
-                    " clawback with zero available (fully borrowed)");
-                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
-
-                auto const vaultSle = env.le(vaultKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
-
-                auto const brokerKeylet =
-                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-                env(set(owner, vaultKeylet.key));
-                env.close();
-
-                // Depositor borrows all 100 units: assetsAvailable=0, assetsTotal=100
-                env(set(depositor, brokerKeylet.key, asset(100).value()),
-                    loan::kInterestRate(TenthBips32(0)),
-                    kGracePeriod(60),
-                    kPaymentInterval(120),
-                    kPaymentTotal(10),
-                    Sig(sfCounterpartySignature, owner),
-                    Fee(env.current()->fees().base * 2),
-                    Ter(tesSUCCESS));
-                env.close();
-
-                {
-                    auto const sle = env.le(vaultKeylet);
-                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
-                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
-                }
-
-                auto const sharesBefore = env.balance(depositor, shares);
-
-                // Zero-amount clawback — nothing available, clamped to 0,
-                // resulting in zero shares destroyed → tecPRECISION_LOSS
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                    }),
-                    Ter(tecPRECISION_LOSS));
-                env.close();
-
-                // Explicit amount clawback — also nothing available
-                env(vault.clawback({
-                        .issuer = issuer,
-                        .id = vaultKeylet.key,
-                        .holder = depositor,
-                        .amount = asset(50).value(),
-                    }),
-                    Ter(tecPRECISION_LOSS));
-                env.close();
-
-                {
-                    // Nothing changed — vault and shares unchanged
-                    auto const sle = env.le(vaultKeylet);
-                    BEAST_EXPECT(sle != nullptr);
-                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
-                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
-                    auto const sharesAfter = env.balance(depositor, shares);
-                    BEAST_EXPECT(sharesAfter == sharesBefore);
-                }
-            }
-        };
-
-        Account const owner{"alice"};
-        Account const depositor{"bob"};
-        Account const issuer{"issuer"};
-
-        env.fund(XRP(10000), issuer, owner, depositor);
-        env.close();
-
-        // Test XRP
-        PrettyAsset const xrp = xrpIssue();
-        testCase(xrp, "XRP", owner, depositor, issuer);
-
-        // Test IOU
-        PrettyAsset const iou = issuer["IOU"];
-        env(fset(issuer, asfAllowTrustLineClawback));
-        env.close();
-        env.trust(iou(2000), owner);
-        env.trust(iou(2000), depositor);
-        env(pay(issuer, owner, iou(2000)));
-        env(pay(issuer, depositor, iou(2000)));
-        env.close();
-        testCase(iou, "IOU", owner, depositor, issuer);
-
-        // Test MPT
-        MPTTester mptt{env, issuer, kMptInitNoFund};
-        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
-
-        PrettyAsset const mpt = mptt.issuanceID();
-        mptt.authorize({.account = owner});
-        mptt.authorize({.account = depositor});
-        env(pay(issuer, depositor, mpt(2000)));
-        env.close();
-        testCase(mpt, "MPT", owner, depositor, issuer);
-
-        // Test pre-fixCleanup3_1_3 legacy path: zero-amount clawback
-        // returns early without clamping to assetsAvailable.
-        {
-            testcase(
-                "VaultClawback (asset) - IOU pre-fixCleanup3_1_3"
-                " zero-amount clawback unclamped with outstanding loan");
-
-            env.disableFeature(fixCleanup3_1_3);
-
-            auto [vault, vaultKeylet] = setupVault(iou, owner, depositor, issuer);
-
-            auto const vaultSle = env.le(vaultKeylet);
-            BEAST_EXPECT(vaultSle != nullptr);
-            if (!vaultSle)
-                return;
-
-            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
-
-            // Create a loan broker backed by this vault
-            auto const brokerKeylet =
-                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-            env(set(owner, vaultKeylet.key));
-            env.close();
-
-            // Depositor borrows 40 units, reducing assetsAvailable to 60
-            // while assetsTotal stays at 100
-            env(set(depositor, brokerKeylet.key, iou(40).value()),
-                loan::kInterestRate(TenthBips32(0)),
-                kGracePeriod(60),
-                kPaymentInterval(120),
-                kPaymentTotal(10),
-                Sig(sfCounterpartySignature, owner),
-                Fee(env.current()->fees().base * 2),
-                Ter(tesSUCCESS));
-            env.close();
-
-            {
-                auto const sle = env.le(vaultKeylet);
-                BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value());
-                BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value());
-            }
-
-            auto const sharesBefore = env.balance(depositor, shares);
-
-            // Legacy: zero-amount clawback tries to recover the full
-            // share value (100) without clamping to assetsAvailable (60).
-            // This causes the vault balance to go negative, triggering
-            // the sanity check in doApply → tefINTERNAL.
-            env(vault.clawback({
-                    .issuer = issuer,
-                    .id = vaultKeylet.key,
-                    .holder = depositor,
-                }),
-                Ter(tefINTERNAL));
-            env.close();
-
-            {
-                // Transaction rolled back — vault and shares unchanged
-                auto const sle = env.le(vaultKeylet);
-                BEAST_EXPECT(sle != nullptr);
-                BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value());
-                BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value());
-                auto const sharesAfter = env.balance(depositor, shares);
-                BEAST_EXPECT(sharesAfter == sharesBefore);
-            }
-
-            env.enableFeature(fixCleanup3_1_3);
-        }
-    }
-
-    void
-    testAssetsMaximum()
-    {
-        testcase("Assets Maximum");
-
-        using namespace test::jtx;
-
-        Env env{*this, testableAmendments()};
-        Account const owner{"owner"};
-        Account const issuer{"issuer"};
-
-        Vault const vault{env};
-        env.fund(XRP(1'000'000), issuer, owner);
-        env.close();
-
-        auto const maxInt64 = std::to_string(std::numeric_limits::max());
-        BEAST_EXPECT(maxInt64 == "9223372036854775807");
-
-        auto const maxInt64Plus1 = std::to_string(
-            static_cast(std::numeric_limits::max()) + 1);
-        BEAST_EXPECT(maxInt64Plus1 == "9223372036854775808");
-
-        // Naming things is hard
-        auto const maxInt64Plus2 = std::to_string(
-            static_cast(std::numeric_limits::max()) + 2);
-        BEAST_EXPECT(maxInt64Plus2 == "9223372036854775809");
-
-        auto const initialXRP = to_string(kInitialXrp);
-        BEAST_EXPECT(initialXRP == "100000000000000000");
-
-        auto const initialXRPPlus1 = to_string(kInitialXrp + 1);
-        BEAST_EXPECT(initialXRPPlus1 == "100000000000000001");
-
-        {
-            testcase("Assets Maximum: XRP");
-
-            PrettyAsset const xrpAsset = xrpIssue();
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
-            tx[sfData] = "4D65746144617461";
-
-            tx[sfAssetsMaximum] = maxInt64;
-            env(tx, Ter(tefEXCEPTION));
-            env.close();
-
-            tx[sfAssetsMaximum] = initialXRPPlus1;
-            env(tx, Ter(tefEXCEPTION));
-            env.close();
-
-            tx[sfAssetsMaximum] = initialXRP;
-            env(tx);
-            env.close();
-
-            // There are several parse failures expected in this function, so just disable it once.
-            env.setParseFailureExpected(true);
-            try
-            {
-                tx[sfAssetsMaximum] = maxInt64Plus1;
-                env(tx, Ter(tefEXCEPTION));
-                env.close();
-                // should throw in parser
-                fail();
-            }
-            catch (std::exception const& e)
-            {
-                BEAST_EXPECT(
-                    std::string(e.what()) ==
-                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
-            }
-
-            try
-            {
-                tx[sfAssetsMaximum] = maxInt64Plus2;
-                env(tx, Ter(tefEXCEPTION));
-                // should throw in parser
-                fail();
-            }
-            catch (std::exception const& e)
-            {
-                BEAST_EXPECT(
-                    std::string(e.what()) ==
-                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
-            }
-
-            auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-            try
-            {
-                auto const insertAt = maxInt64Plus2.size() - 3;
-                auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
-                    maxInt64Plus2.substr(insertAt);  // (max int64+2) / 1000
-                BEAST_EXPECT(decimalTest == "9223372036854775.809");
-                tx[sfAssetsMaximum] = decimalTest;
-                env(tx);
-                // should throw in parser
-                fail();
-            }
-            catch (std::exception const& e)
-            {
-                BEAST_EXPECT(
-                    std::string(e.what()) ==
-                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
-            }
-
-            auto const vaultSle = env.le(newKeylet);
-            BEAST_EXPECT(!vaultSle);
-        }
-
-        {
-            testcase("Assets Maximum: MPT");
-
-            PrettyAsset const mptAsset = [&]() {
-                MPTTester mptt{env, issuer, kMptInitNoFund};
-                mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
-                env.close();
-                PrettyAsset const mptAsset = mptt["MPT"];
-                mptt.authorize({.account = owner});
-                env.close();
-                return mptAsset;
-            }();
-
-            env(pay(issuer, owner, mptAsset(100'000)));
-            env.close();
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = mptAsset});
-            tx[sfData] = "4D65746144617461";
-
-            tx[sfAssetsMaximum] = maxInt64;
-            env(tx);
-            env.close();
-
-            tx[sfAssetsMaximum] = initialXRPPlus1;
-            env(tx);
-            env.close();
-
-            tx[sfAssetsMaximum] = initialXRP;
-            env(tx);
-            env.close();
-
-            try
-            {
-                tx[sfAssetsMaximum] = maxInt64Plus2;
-                env(tx, Ter(tefEXCEPTION));
-                // should throw in parser
-                fail();
-            }
-            catch (std::exception const& e)
-            {
-                BEAST_EXPECT(
-                    std::string(e.what()) ==
-                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
-            }
-
-            auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-            try
-            {
-                auto const insertAt = maxInt64Plus2.size() - 1;
-                auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
-                    maxInt64Plus2.substr(insertAt);  // (max int64+2) / 10
-                BEAST_EXPECT(decimalTest == "922337203685477580.9");
-                tx[sfAssetsMaximum] = decimalTest;
-                env(tx);
-                // should throw in parser
-                fail();
-            }
-            catch (std::exception const& e)
-            {
-                BEAST_EXPECT(
-                    std::string(e.what()) ==
-                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
-            }
-
-            auto const vaultSle = env.le(newKeylet);
-            BEAST_EXPECT(!vaultSle);
-        }
-
-        {
-            testcase("Assets Maximum: IOU");
-
-            // Almost anything goes with IOUs
-            PrettyAsset const iouAsset = issuer["IOU"];
-            env.trust(iouAsset(1000), owner);
-            env(pay(issuer, owner, iouAsset(200)));
-            env.close();
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = iouAsset});
-            tx[sfData] = "4D65746144617461";
-
-            tx[sfAssetsMaximum] = maxInt64;
-            env(tx);
-            env.close();
-
-            tx[sfAssetsMaximum] = initialXRPPlus1;
-            env(tx);
-            env.close();
-
-            tx[sfAssetsMaximum] = initialXRP;
-            env(tx);
-            env.close();
-
-            // Since several tests are expected to have parser failures, leave this flag set for the
-            // remainder of this function.
-            env.setParseFailureExpected(true);
-            try
-            {
-                tx[sfAssetsMaximum] = maxInt64Plus2;
-                env(tx);
-                // should throw in parser
-                fail();
-            }
-            catch (std::exception const& e)
-            {
-                BEAST_EXPECT(
-                    std::string(e.what()) ==
-                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
-            }
-
-            tx[sfAssetsMaximum] = "1000000000000000e80";
-            env.close();
-
-            tx[sfAssetsMaximum] = "1000000000000000e-96";
-            env.close();
-
-            // These values will be rounded to 15 significant digits
-            {
-                auto const newKeylet =
-                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-                try
-                {
-                    auto const insertAt = maxInt64Plus2.size() - 1;
-                    auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
-                        maxInt64Plus2.substr(insertAt);  // (max int64+2) / 10
-                    BEAST_EXPECT(decimalTest == "922337203685477580.9");
-                    tx[sfAssetsMaximum] = decimalTest;
-                    env(tx);
-                    // should throw in parser
-                    fail();
-                }
-                catch (std::exception const& e)
-                {
-                    BEAST_EXPECT(
-                        std::string(e.what()) ==
-                        "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
-                }
-
-                auto const vaultSle = env.le(newKeylet);
-                BEAST_EXPECT(!vaultSle);
-            }
-            {
-                tx[sfAssetsMaximum] = "9223372036854775807e40";  // max int64 * 10^40
-                auto const newKeylet =
-                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-                env(tx);
-                env.close();
-
-                auto const vaultSle = env.le(newKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-
-                BEAST_EXPECT(
-                    (vaultSle->at(sfAssetsMaximum) ==
-                     Number{9223372036854776, 43, Number::Normalized{}}));
-            }
-            {
-                tx[sfAssetsMaximum] = "9223372036854775807e-40";  // max int64 * 10^-40
-                auto const newKeylet =
-                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-                env(tx);
-                env.close();
-
-                auto const vaultSle = env.le(newKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-
-                BEAST_EXPECT(
-                    (vaultSle->at(sfAssetsMaximum) ==
-                     Number{9223372036854776, -37, Number::Normalized{}}));
-            }
-            {
-                tx[sfAssetsMaximum] = "9223372036854775807e-100";  // max int64 * 10^-100
-                auto const newKeylet =
-                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-                env(tx);
-                env.close();
-
-                // Field 'AssetsMaximum' may not be explicitly set to default.
-                auto const vaultSle = env.le(newKeylet);
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-
-                BEAST_EXPECT(vaultSle->at(sfAssetsMaximum) == kNumZero);
-            }
-
-            // What _can't_ IOUs do?
-            // 1. Exceed maximum exponent / offset
-            tx[sfAssetsMaximum] = "1000000000000000e81";
-            env(tx, Ter(tefEXCEPTION));
-            env.close();
-
-            // 2. Mantissa larger than uint64 max
-            try
-            {
-                auto const g = env.getParseFailureGuard(true);
-                tx[sfAssetsMaximum] = "18446744073709551617e5";  // uint64 max + 1
-                env(tx);
-                BEAST_EXPECTS(false, "Expected parse_error for mantissa larger than uint64 max");
-            }
-            catch (ParseError const& e)
-            {
-                using namespace std::string_literals;
-                BEAST_EXPECT(
-                    e.what() == "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."s);
-            }
-        }
-    }
-
-    void
-    testVaultEscrowedMPT()
-    {
-        using namespace test::jtx;
-        using namespace std::literals;
-
-        // Verify vault deposit/withdraw/clawback respect sfLockedAmount.
-        // When MPT tokens are escrowed, sfMPTAmount is reduced and
-        // sfLockedAmount is increased. Vault operations go through
-        // accountSend/accountHolds which read sfMPTAmount, so escrowed
-        // tokens are naturally excluded.
-
-        {
-            testcase("Vault deposit fails when MPT asset is escrowed");
-
-            Env env{*this, testableAmendments()};
-            auto const baseFee = env.current()->fees().base;
-            Account const owner{"owner"};
-            Account const depositor{"depositor"};
-            Account const issuer{"issuer"};
-            Account const bob{"bob"};
-
-            env.fund(XRP(10000), issuer, owner, depositor, bob);
-            env.close();
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create(
-                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
-            mptt.authorize({.account = owner});
-            mptt.authorize({.account = depositor});
-            mptt.authorize({.account = bob});
-            PrettyAsset const asset = mptt.issuanceID();
-            env(pay(issuer, depositor, asset(100)));
-            env.close();
-
-            // Escrow 60 of 100 MPT tokens: sfMPTAmount drops to 40
-            auto const escrowSeq = env.seq(depositor);
-            env(escrow::create(depositor, bob, asset(60)),
-                escrow::kCondition(escrow::kCb1),
-                escrow::kFinishTime(env.now() + 1s),
-                Fee(baseFee * 150),
-                Ter(tesSUCCESS));
-            env.close();
-
-            Vault const vault{env};
-            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx, Ter(tesSUCCESS));
-            env.close();
-
-            // Deposit 100 should fail — only 40 spendable
-            env(vault.deposit(
-                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
-                Ter(tecINSUFFICIENT_FUNDS));
-            env.close();
-
-            // Deposit 40 (the unlocked balance) should succeed
-            env(vault.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}),
-                Ter(tesSUCCESS));
-            env.close();
-
-            {
-                auto const sle = env.le(vaultKeylet);
-                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
-            }
-
-            // Clean up escrow
-            env(escrow::finish(bob, depositor, escrowSeq),
-                escrow::kCondition(escrow::kCb1),
-                escrow::kFulfillment(escrow::kFb1),
-                Fee(baseFee * 150),
-                Ter(tesSUCCESS));
-            env.close();
-        }
-
-        {
-            testcase("Vault withdraw respects escrowed shares");
-
-            Env env{*this, testableAmendments()};
-            auto const baseFee = env.current()->fees().base;
-            Account const owner{"owner"};
-            Account const depositor{"depositor"};
-            Account const issuer{"issuer"};
-            Account const bob{"bob"};
-
-            env.fund(XRP(10000), issuer, owner, depositor, bob);
-            env.close();
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create(
-                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
-            mptt.authorize({.account = owner});
-            mptt.authorize({.account = depositor});
-            PrettyAsset const asset = mptt.issuanceID();
-            env(pay(issuer, depositor, asset(100)));
-            env.close();
-
-            Vault const vault{env};
-            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx, Ter(tesSUCCESS));
-            env.close();
-
-            // Deposit 100 → get shares
-            env(vault.deposit(
-                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
-                Ter(tesSUCCESS));
-            env.close();
-
-            auto const vaultSle = env.le(vaultKeylet);
-            if (!BEAST_EXPECT(vaultSle))
-                return;
-            env.memoize(Account("vault", vaultSle->at(sfAccount)));
-            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
-
-            // Authorize bob for share MPT so he can receive escrowed shares
-            auto const shareMPTID = vaultSle->at(sfShareMPTID);
-            {
-                json::Value jv;
-                jv[jss::Account] = bob.human();
-                jv[sfMPTokenIssuanceID] = to_string(shareMPTID);
-                jv[jss::TransactionType] = jss::MPTokenAuthorize;
-                env(jv, Ter(tesSUCCESS));
-                env.close();
-            }
-
-            // Escrow 60% of shares
-            auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1});
-            env(escrow::create(depositor, bob, escrowAmount),
-                escrow::kCondition(escrow::kCb1),
-                escrow::kFinishTime(env.now() + 1s),
-                Fee(baseFee * 150),
-                Ter(tesSUCCESS));
-            env.close();
-
-            // Withdraw all 100 should fail — only 40% of shares are unlocked
-            env(vault.withdraw(
-                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
-                Ter(tecINSUFFICIENT_FUNDS));
-            env.close();
-
-            // Withdraw 40 (matching unlocked shares) should succeed
-            env(vault.withdraw(
-                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}),
-                Ter(tesSUCCESS));
-            env.close();
-
-            {
-                auto const sle = env.le(vaultKeylet);
-                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value());
-            }
-        }
-
-        {
-            testcase("Vault clawback only recovers unlocked shares");
-
-            Env env{*this, testableAmendments() | fixCleanup3_1_3};
-            auto const baseFee = env.current()->fees().base;
-            Account const owner{"owner"};
-            Account const depositor{"depositor"};
-            Account const issuer{"issuer"};
-            Account const bob{"bob"};
-
-            env.fund(XRP(10000), issuer, owner, depositor, bob);
-            env.close();
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create(
-                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
-            mptt.authorize({.account = owner});
-            mptt.authorize({.account = depositor});
-            PrettyAsset const asset = mptt.issuanceID();
-            env(pay(issuer, depositor, asset(100)));
-            env.close();
-
-            Vault const vault{env};
-            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx, Ter(tesSUCCESS));
-            env.close();
-
-            // Deposit 100 → get shares
-            env(vault.deposit(
-                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
-                Ter(tesSUCCESS));
-            env.close();
-
-            auto const vaultSle = env.le(vaultKeylet);
-            if (!BEAST_EXPECT(vaultSle))
-                return;
-            env.memoize(Account("vault", vaultSle->at(sfAccount)));
-            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
-
-            // Authorize bob for share MPT so he can receive escrowed shares
-            auto const shareMPTID = vaultSle->at(sfShareMPTID);
-            {
-                json::Value jv;
-                jv[jss::Account] = bob.human();
-                jv[sfMPTokenIssuanceID] = to_string(shareMPTID);
-                jv[jss::TransactionType] = jss::MPTokenAuthorize;
-                env(jv, Ter(tesSUCCESS));
-                env.close();
-            }
-
-            // Escrow 60% of shares
-            auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1});
-            env(escrow::create(depositor, bob, escrowAmount),
-                escrow::kCondition(escrow::kCb1),
-                escrow::kFinishTime(env.now() + 1s),
-                Fee(baseFee * 150),
-                Ter(tesSUCCESS));
-            env.close();
-
-            // Zero-amount clawback ("all") — should only recover assets
-            // corresponding to unlocked shares (40%)
-            env(vault.clawback({
-                    .issuer = issuer,
-                    .id = vaultKeylet.key,
-                    .holder = depositor,
-                }),
-                Ter(tesSUCCESS));
-            env.close();
-
-            {
-                auto const sle = env.le(vaultKeylet);
-                BEAST_EXPECT(sle != nullptr);
-                // Only 40 of 100 assets recovered (matching 40% unlocked shares)
-                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value());
-                BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
-
-                // Depositor's unlocked shares are now 0
-                auto const sharesAfter = env.balance(depositor, shares);
-                BEAST_EXPECT(sharesAfter == shares(0));
-            }
-        }
-    }
-
-    // Reproduction: canWithdraw IOU limit check bypassed when
-    // withdrawal amount is specified in shares (MPT) rather than in assets.
-    void
-    testBug6LimitBypassWithShares()
-    {
-        using namespace test::jtx;
-        testcase("Bug6 - limit bypass with share-denominated withdrawal");
-
-        auto const allAmendments = testableAmendments() | featureSingleAssetVault;
-
-        for (auto const& features : {allAmendments, allAmendments - fixCleanup3_1_3})
-        {
-            bool const withFix = features[fixCleanup3_1_3];
-
-            Env env{*this, features};
-            Account const owner{"owner"};
-            Account const issuer{"issuer"};
-            Account const depositor{"depositor"};
-            Account const charlie{"charlie"};
-            Vault const vault{env};
-
-            env.fund(XRP(1000), issuer, owner, depositor, charlie);
-            env(fset(issuer, asfAllowTrustLineClawback));
-            env.close();
-
-            PrettyAsset const asset = issuer["IOU"];
-            env.trust(asset(1000), owner);
-            env.trust(asset(1000), depositor);
-            env(pay(issuer, owner, asset(200)));
-            env(pay(issuer, depositor, asset(200)));
-            env.close();
-
-            // Charlie gets a LOW trustline limit of 5
-            env.trust(asset(5), charlie);
-            env.close();
-
-            auto const [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            auto const depositTx =
-                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-            env(depositTx);
-            env.close();
-
-            // Get the share MPT info
-            auto const vaultSle = env.le(keylet);
-            if (!BEAST_EXPECT(vaultSle))
-                return;
-            auto const mptIssuanceID = vaultSle->at(sfShareMPTID);
-            MPTIssue const shares(mptIssuanceID);
-            PrettyAsset const share(shares);
-
-            // CONTROL: Withdraw 10 IOU (asset-denominated) to charlie.
-            // Charlie's limit is 5, so this should be rejected with tecNO_LINE
-            // regardless of the amendment.
-            {
-                auto withdrawTx =
-                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
-                withdrawTx[sfDestination] = charlie.human();
-                env(withdrawTx, Ter{tecNO_LINE});
-                env.close();
-            }
-            auto const charlieBalanceBefore = env.balance(charlie, asset.raw().get());
-
-            // Withdraw the equivalent amount in shares to charlie.
-            // Post-fix: rejected (tecNO_LINE) because the share amount is
-            //   converted to assets and the trustline limit is checked.
-            // Pre-fix: succeeds (tesSUCCESS) because the limit check was
-            //   skipped for share-denominated withdrawals.
-            {
-                auto withdrawTx = vault.withdraw(
-                    {.depositor = depositor,
-                     .id = keylet.key,
-                     .amount = STAmount(share, 10'000'000)});
-                withdrawTx[sfDestination] = charlie.human();
-                env(withdrawTx, Ter{withFix ? TER{tecNO_LINE} : TER{tesSUCCESS}});
-                env.close();
-
-                auto const charlieBalanceAfter = env.balance(charlie, asset.raw().get());
-                if (withFix)
-                {
-                    // Post-fix: charlie's balance is unchanged — the withdrawal
-                    // was correctly rejected despite being share-denominated.
-                    BEAST_EXPECT(charlieBalanceAfter == charlieBalanceBefore);
-                }
-                else
-                {
-                    // Pre-fix: charlie received the assets, bypassing the
-                    // trustline limit.
-                    BEAST_EXPECT(charlieBalanceAfter > charlieBalanceBefore);
-                }
-            }
-        }
-    }
-
-    void
-    testRemoveEmptyHoldingLockedAmount()
-    {
-        testcase("removeEmptyHolding deletes MPToken with sfLockedAmount");
-        using namespace test::jtx;
-        using namespace std::literals;
-
-        auto const amendments = testableAmendments();
-        auto runTest = [&](FeatureBitset f) {
-            Env env{*this, f};
-            auto const baseFee = env.current()->fees().base;
-
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            Account const depositor{"depositor"};
-            Account const bob{"bob"};
-
-            env.fund(XRP(100000), issuer, owner, depositor, bob);
-            env.close();
-
-            Vault const vault{env};
-
-            // Create an MPT asset for the vault
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
-            PrettyAsset const asset = mptt.issuanceID();
-            mptt.authorize({.account = owner});
-            mptt.authorize({.account = depositor});
-            env(pay(issuer, depositor, asset(1000)));
-            env.close();
-
-            // Create vault
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            auto const vaultSle = env.le(keylet);
-            BEAST_EXPECT(vaultSle != nullptr);
-            auto const shareMptID = vaultSle->at(sfShareMPTID);
-            MPTIssue const shareIssue{shareMptID};
-
-            // Depositor deposits 1000 asset units into vault, receiving shares
-            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}));
-            env.close();
-
-            // Check depositor has shares
-            {
-                auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
-                BEAST_EXPECT(sleMpt != nullptr);
-                BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 1000);
-            }
-
-            // Escrow 500 of those shares
-            env(escrow::create(depositor, bob, STAmount{shareIssue, 500}),
-                escrow::kCondition(escrow::kCb1),
-                escrow::kFinishTime(env.now() + 1s),
-                Fee(baseFee * 150),
-                Ter(tesSUCCESS));
-            env.close();
-
-            // Verify: sfMPTAmount=500, sfLockedAmount=500
-            {
-                auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
-                BEAST_EXPECT(sleMpt != nullptr);
-                BEAST_EXPECT(sleMpt->at(sfLockedAmount) == 500);
-                BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 500);
-            }
-
-            // Withdraw remaining spendable shares — triggers removeEmptyHolding
-            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(500)}),
-                Ter(tesSUCCESS));
-            env.close();
-
-            auto const sleMptAfter = env.le(keylet::mptoken(shareMptID, depositor));
-            if (!f[fixCleanup3_1_3])
-            {
-                // Without the fix, removeEmptyHolding deletes the MPToken
-                // even though sfLockedAmount > 0, leaving the escrow's locked
-                // amount untracked.
-                BEAST_EXPECT(sleMptAfter == nullptr);
-            }
-            else
-            {
-                // With the fix, MPToken must still exist with sfLockedAmount > 0
-                // and sfMPTAmount == 0 (all spendable shares withdrawn).
-                BEAST_EXPECT(sleMptAfter != nullptr);
-                if (sleMptAfter)
-                {
-                    BEAST_EXPECT(sleMptAfter->at(sfLockedAmount) == 500);
-                    BEAST_EXPECT(sleMptAfter->at(sfMPTAmount) == 0);
-                }
-            }
-        };
-
-        runTest(amendments - fixCleanup3_1_3);
-        runTest(amendments);
-    }
-
-    void
-    testRemoveEmptyHoldingConfidentialBalances()
-    {
-        testcase("removeEmptyHolding keeps MPToken with confidential balances");
-        using namespace test::jtx;
-
-        Env env{*this, testableAmendments()};
-
-        Account const issuer{"issuer"};
-        Account const holder{"holder"};
-        MPTTester mpt{env, issuer, {.holders = {holder}}};
-        mpt.create({.authorize = MPTCreate::allHolders});
-
-        auto const tokenKeylet = keylet::mptoken(mpt.issuanceID(), holder.id());
-        auto const encryptedBalanceFields = {
-            &sfConfidentialBalanceInbox,
-            &sfConfidentialBalanceSpending,
-            &sfIssuerEncryptedBalance,
-            &sfAuditorEncryptedBalance};
-
-        env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) {
-            for (auto const field : encryptedBalanceFields)
-            {
-                Sandbox sb(&view, TapNone);
-                auto const token = sb.peek(tokenKeylet);
-                if (!BEAST_EXPECT(token))
-                    return false;
-
-                token->setFieldVL(*field, gMakeZeroBuffer(kEcGamalEncryptedTotalLength));
-                sb.update(token);
-
-                auto const dummyTx = *env.jt(noop(holder)).stx;
-                BEAST_EXPECT(
-                    removeEmptyHolding({sb, dummyTx}, holder.id(), MPTIssue(mpt.issuanceID()), j) ==
-                    tecHAS_OBLIGATIONS);
-                BEAST_EXPECT(sb.peek(tokenKeylet) != nullptr);
-            }
-            return true;
-        });
-    }
-
-    // -----------------------------------------------------------------------
-    // Helpers and tests: sole-shareholder / stuck-depositor (XLS-0065 +
-    // fixCleanup3_2_0). The vault-level withdraw behavior is tested here;
-    // the loan-protocol setup is incidental.
-    // -----------------------------------------------------------------------
-
-    FeatureBitset const all_{test::jtx::testableAmendments()};
-    std::string const iouCurrency_{"IOU"};
-
-    // design doc:
-    //     AssetsAvailable ≈ 3,333.50
-    //     AssetsTotal     ≈ 6,666.50  (3,333.50 cash + 3,333 receivable)
-    //     LossUnrealized  =  3,333
-    //     OutstandingShares = sharesLender   (5e9 at IOU scale 1e6)
-    struct StuckDepositorFixture
-    {
-        test::jtx::Account issuer{"issuer"};
-        test::jtx::Account lender{"lender"};
-        test::jtx::Account bob{"bob"};
-        test::jtx::Account borrower{"borrower"};
-        std::optional asset;
-        std::optional vaultKeylet;
-        uint256 brokerID;
-        std::optional loanKeylet;
-        MPTID shareAsset;
-        std::uint64_t sharesLender = 0;
-    };
-
-    static constexpr std::int64_t kStuckFunding = 1'000'000;
-    static constexpr std::int64_t kStuckDepositorIOU = 1'000'000;
-    static constexpr std::int64_t kStuckBorrowerIOU = 100'000;
-    static constexpr std::int64_t kStuckDeposit = 5'000;
-    static constexpr std::int64_t kStuckPrincipal = 3'333;
-    static constexpr std::uint32_t kStuckPayInterval = 600;
-    static constexpr std::uint32_t kStuckPayTotal = 2;
-
-    [[nodiscard]] StuckDepositorFixture
-    setupStuckDepositor(test::jtx::Env& env)
-    {
-        using namespace test::jtx;
-
-        StuckDepositorFixture f;
-        f.asset = f.issuer[iouCurrency_];
-
-        env.fund(XRP(kStuckFunding), f.issuer, f.lender, f.bob, f.borrower);
-        env.close();
-
-        env(trust(f.lender, (*f.asset)(10'000'000)));
-        env(trust(f.bob, (*f.asset)(10'000'000)));
-        env(trust(f.borrower, (*f.asset)(10'000'000)));
-        env.close();
-
-        env(pay(f.issuer, f.lender, (*f.asset)(kStuckDepositorIOU)));
-        env(pay(f.issuer, f.bob, (*f.asset)(kStuckDepositorIOU)));
-        env(pay(f.issuer, f.borrower, (*f.asset)(kStuckBorrowerIOU)));
-        env.close();
-
-        // Vault: Lender creates and seeds it; Bob matches the deposit for a
-        // clean 50/50 split.
-        Vault const v{env};
-        auto [createTx, vaultKeylet] = v.create({.owner = f.lender, .asset = *f.asset});
-        env(createTx);
-        env.close();
-        if (!BEAST_EXPECT(env.le(vaultKeylet)))
-            return f;
-        f.vaultKeylet = vaultKeylet;
-
-        env(v.deposit({
-                .depositor = f.lender,
-                .id = vaultKeylet.key,
-                .amount = (*f.asset)(kStuckDeposit),
-            }),
-            Ter(tesSUCCESS));
-        env(v.deposit({
-                .depositor = f.bob,
-                .id = vaultKeylet.key,
-                .amount = (*f.asset)(kStuckDeposit),
-            }),
-            Ter(tesSUCCESS));
-        env.close();
-
-        // Loan broker: no cover, no management fee, debt cap 10x principal.
-        f.brokerID =
-            keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender))).key;
-        {
-            using namespace loan_broker;
-            env(set(f.lender, vaultKeylet.key),
-                kDebtMaximum((*f.asset)(kStuckPrincipal * 10).value()));
-            env.close();
-        }
-
-        // Loan: 3,333 USD principal, impaired immediately.
-        auto const sleBroker = env.le(keylet::loanBroker(f.brokerID));
-        if (!BEAST_EXPECT(sleBroker))
-            return f;
-        f.loanKeylet =
-            keylet::loan(f.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
-
-        {
-            using namespace loan;
-            env(set(f.borrower, f.brokerID, kStuckPrincipal),
-                Sig(sfCounterpartySignature, f.lender),
-                kPaymentTotal(kStuckPayTotal),
-                kPaymentInterval(kStuckPayInterval),
-                Fee(env.current()->fees().base * 2),
-                Ter(tesSUCCESS));
-            env.close();
-            env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
-            env.close();
-        }
-
-        auto const vaultSle = env.le(vaultKeylet);
-        if (!BEAST_EXPECT(vaultSle))
-            return f;
-        BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
-
-        f.shareAsset = vaultSle->at(sfShareMPTID);
-
-        auto const tokenBob = env.le(keylet::mptoken(f.shareAsset, f.bob.id()));
-        if (!BEAST_EXPECT(tokenBob))
-            return f;
-        std::uint64_t const sharesBob = tokenBob->getFieldU64(sfMPTAmount);
-
-        // Bob (non-sole) exits at the discounted rate. Always succeeds.
-        STAmount const bobShareAmt{MPTIssue{f.shareAsset}, Number(sharesBob)};
-        env(v.withdraw({
-                .depositor = f.bob,
-                .id = vaultKeylet.key,
-                .amount = bobShareAmt,
-            }),
-            Ter(tesSUCCESS));
-        env.close();
-
-        auto const tokenLender = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
-        if (!BEAST_EXPECT(tokenLender))
-            return f;
-        f.sharesLender = tokenLender->getFieldU64(sfMPTAmount);
-
-        auto const sleIssuance = env.le(keylet::mptokenIssuance(f.shareAsset));
-        if (!BEAST_EXPECT(sleIssuance))
-            return f;
-        BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == f.sharesLender);
-
-        auto const vaultAfterBob = env.le(vaultKeylet);
-        if (!BEAST_EXPECT(vaultAfterBob))
-            return f;
-        // After Bob's exit: loss is unchanged (3,333 receivable), and the
-        // gap between assetsTotal and assetsAvailable equals exactly that
-        // receivable.
-        BEAST_EXPECT(vaultAfterBob->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
-        BEAST_EXPECT(
-            vaultAfterBob->at(sfAssetsTotal) - vaultAfterBob->at(sfAssetsAvailable) ==
-            vaultAfterBob->at(sfLossUnrealized));
-
-        return f;
-    }
-
-    // Reproduces the worked example from the XLS-0065 design doc. The sole
-    // remaining shareholder asks (via fixed-asset input) for the vault's
-    // entire AssetsAvailable. Pre-fix this fails with the zero-sized-vault
-    // invariant violation. Post-fix the full-price exchange rate burns
-    // only a portion of the shares, the depositor receives all of
-    // AssetsAvailable, and the residual shares remain backed by the
-    // impaired-loan receivable.
-    void
-    testWithdrawSoleShareholderFixedAssetExit(FeatureBitset features)
-    {
-        using namespace test::jtx;
-
-        bool const withFix = features[fixCleanup3_2_0];
-        testcase(
-            std::string{"Vault withdraw: sole shareholder exits via "
-                        "fixed-asset amount with impaired loan"} +
-            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
-
-        std::string logs;
-        Env env(*this, features, std::make_unique(&logs));
-        auto const f = setupStuckDepositor(env);
-        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
-        {
-            BEAST_EXPECT(false);
-            return;
-        }
-        Keylet const& vaultKey = *f.vaultKeylet;
-        PrettyAsset const& asset = *f.asset;
-
-        auto const vaultBefore = env.le(vaultKey);
-        if (!BEAST_EXPECT(vaultBefore))
-            return;
-        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
-        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
-        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
-
-        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
-
-        // The requested amount differs between feature regimes because
-        // the two regimes are testing different behaviors:
-        //
-        // - Pre-fix: request the full AssetsAvailable (3,333.50). Under
-        //   the discounted formula this would burn every outstanding
-        //   share, hitting the zero-sized-vault invariant. The
-        //   transaction is rejected with tecINVARIANT_FAILED — the
-        //   stuck-depositor bug.
-        //
-        // - Post-fix: request a strictly smaller amount (1,000 USD).
-        //   The full-price formula burns only ~30% of the outstanding
-        //   shares; the vault retains the rest, backed by the impaired
-        //   receivable. Requesting *exactly* AssetsAvailable post-fix
-        //   would currently fail with tecINSUFFICIENT_FUNDS due to the
-        //   round-to-nearest used by assetsToSharesWithdraw (the
-        //   recomputed payout can overshoot the request by a few ULPs).
-        //   The "force payout to AssetsAvailable" branch in doApply
-        //   only triggers when every share is burned, which is covered
-        //   by the loan-repayment test.
-        STAmount const requestAssets =
-            withFix ? asset(1000).value() : STAmount{asset.raw(), availableBefore};
-        Vault const v{env};
-        env(v.withdraw({
-                .depositor = f.lender,
-                .id = vaultKey.key,
-                .amount = requestAssets,
-            }),
-            Ter(withFix ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}));
-        env.close();
-
-        auto const vaultAfter = env.le(vaultKey);
-        if (!BEAST_EXPECT(vaultAfter))
-            return;
-        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
-        if (!BEAST_EXPECT(issuanceAfter))
-            return;
-
-        std::uint64_t const sharesAfter = issuanceAfter->getFieldU64(sfOutstandingAmount);
-        Number const availableAfter = vaultAfter->at(sfAssetsAvailable);
-        Number const totalAfter = vaultAfter->at(sfAssetsTotal);
-        Number const lossAfter = vaultAfter->at(sfLossUnrealized);
-
-        if (!withFix)
-        {
-            // Pre-fix: rejected — vault state unchanged.
-            BEAST_EXPECT(sharesAfter == f.sharesLender);
-            BEAST_EXPECT(availableAfter == availableBefore);
-            BEAST_EXPECT(totalAfter == totalBefore);
-            BEAST_EXPECT(lossAfter == lossBefore);
-            return;
-        }
-
-        // Post-fix exact-value derivation (fixture: sharesLender=5e9,
-        // totalBefore=6666.5, request=1000):
-        //   sharesRedeemed = round(sharesLender * request / totalBefore)
-        //                  = round(750,018,750.469) = 750,018,750
-        //   received       = totalBefore * sharesRedeemed / sharesLender
-        //                  = 999.999999375  (slightly under 1,000 due to
-        //                                    integer-share rounding)
-        constexpr std::uint64_t kExpectedSharesRedeemed = 750'018'750;
-        Number const expectedReceived =
-            totalBefore * Number(kExpectedSharesRedeemed) / Number(f.sharesLender);
-
-        BEAST_EXPECT(sharesAfter == f.sharesLender - kExpectedSharesRedeemed);
-
-        // LossUnrealized is unchanged: the loan-protocol side is untouched.
-        BEAST_EXPECT(lossAfter == lossBefore);
-
-        // The entire (total - available) gap is the impaired receivable,
-        // i.e. equal to lossUnrealized.
-        BEAST_EXPECT(totalAfter - availableAfter == lossAfter);
-
-        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
-        Number const received{lenderBalanceAfter - lenderBalanceBefore};
-        BEAST_EXPECT(received == expectedReceived);
-
-        // Conservation: assets removed from the vault equal what the
-        // depositor received.
-        BEAST_EXPECT(totalBefore - totalAfter == received);
-        BEAST_EXPECT(availableBefore - availableAfter == received);
-    }
-
-    // Sole shareholder attempts to burn ALL outstanding shares via
-    // fixed-shares input while the vault still holds an impaired
-    // receivable. Pre-fix this fails with the zero-sized-vault invariant
-    // violation. Post-fix the full-price rate causes assetsWithdrawn to
-    // equal assetsTotal, which exceeds assetsAvailable, so the transaction
-    // is rejected with tecINSUFFICIENT_FUNDS.
-    void
-    testWithdrawSoleShareholderFullSharesRejected(FeatureBitset features)
-    {
-        using namespace test::jtx;
-
-        bool const withFix = features[fixCleanup3_2_0];
-        testcase(
-            std::string{"Vault withdraw: sole shareholder full-shares "
-                        "burn is rejected while loss outstanding"} +
-            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
-
-        std::string logs;
-        Env env(*this, features, std::make_unique(&logs));
-        auto const f = setupStuckDepositor(env);
-        if (!f.vaultKeylet || f.sharesLender == 0)
-        {
-            BEAST_EXPECT(false);
-            return;
-        }
-        Keylet const& vaultKey = *f.vaultKeylet;
-
-        auto const vaultBefore = env.le(vaultKey);
-        if (!BEAST_EXPECT(vaultBefore))
-            return;
-        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
-        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
-        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
-
-        // Fixed-shares input: ask for ALL outstanding shares.
-        STAmount const shareAmt{MPTIssue{f.shareAsset}, Number(f.sharesLender)};
-        Vault const v{env};
-        env(v.withdraw({
-                .depositor = f.lender,
-                .id = vaultKey.key,
-                .amount = shareAmt,
-            }),
-            Ter(withFix ? TER{tecINSUFFICIENT_FUNDS} : TER{tecINVARIANT_FAILED}));
-        env.close();
-
-        // Either way the transaction was rejected; vault state unchanged.
-        auto const vaultAfter = env.le(vaultKey);
-        if (!BEAST_EXPECT(vaultAfter))
-            return;
-        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
-        if (!BEAST_EXPECT(issuanceAfter))
-            return;
-        BEAST_EXPECT(issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender);
-        BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == availableBefore);
-        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
-        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
-    }
-
-    // Post-fix end-to-end resolution: after the sole-shareholder partial
-    // exit, the loan is repaid in full. With unrealized loss cleared and
-    // all assets back as cash, the depositor can burn all remaining
-    // shares and fully exit the vault. The final withdrawal hits the
-    // "force payout to assetsAvailable" branch in doApply.
-    void
-    testWithdrawSoleShareholderLoanRepaymentExit()
-    {
-        using namespace test::jtx;
-        using namespace loan;
-
-        testcase(
-            "Vault withdraw: sole shareholder fully exits after impaired "
-            "loan is repaid (fixCleanup3_2_0)");
-
-        Env env(*this, all_ | fixCleanup3_2_0);
-        auto const f = setupStuckDepositor(env);
-        if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
-        {
-            BEAST_EXPECT(false);
-            return;
-        }
-        Keylet const& vaultKey = *f.vaultKeylet;
-        Keylet const& loanKey = *f.loanKeylet;
-        PrettyAsset const& asset = *f.asset;
-
-        Vault const v{env};
-
-        // Sole-shareholder partial exit (see comment in
-        // testWithdrawSoleShareholderFixedAssetExit for why we request
-        // less than full AssetsAvailable).
-        {
-            STAmount const requestAssets = asset(1000).value();
-            env(v.withdraw({
-                    .depositor = f.lender,
-                    .id = vaultKey.key,
-                    .amount = requestAssets,
-                }),
-                Ter(tesSUCCESS));
-            env.close();
-        }
-
-        // Confirm the "dormant-but-alive" state from the design doc. The
-        // partial exit burned exactly 750,018,750 shares (see derivation
-        // in testWithdrawSoleShareholderFixedAssetExit).
-        auto const tokenAfterExit = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
-        if (!BEAST_EXPECT(tokenAfterExit))
-            return;
-        std::uint64_t const retainedShares = tokenAfterExit->getFieldU64(sfMPTAmount);
-        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).
-        env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
-        env.close();
-
-        auto const vaultAfterRepay = env.le(vaultKey);
-        if (!BEAST_EXPECT(vaultAfterRepay))
-            return;
-        // Repayment converts the 3,333 receivable back to cash; assetsTotal
-        // is unchanged but assetsAvailable jumps by exactly the same amount,
-        // and lossUnrealized clears to zero.
-        BEAST_EXPECT(vaultAfterRepay->at(sfLossUnrealized) == beast::kZero);
-        BEAST_EXPECT(vaultAfterRepay->at(sfAssetsAvailable) == vaultAfterRepay->at(sfAssetsTotal));
-
-        STAmount const lenderBalanceBeforeFinal = env.balance(f.lender, asset);
-        Number const availableBeforeFinal = vaultAfterRepay->at(sfAssetsAvailable);
-
-        // Burn all remaining shares — the clean-state preconditions of
-        // the "final withdrawal" guard are now satisfied.
-        STAmount const allShares{MPTIssue{f.shareAsset}, Number(retainedShares)};
-        env(v.withdraw({
-                .depositor = f.lender,
-                .id = vaultKey.key,
-                .amount = allShares,
-            }),
-            Ter(tesSUCCESS));
-        env.close();
-
-        auto const vaultFinal = env.le(vaultKey);
-        if (!BEAST_EXPECT(vaultFinal))
-            return;
-        auto const issuanceFinal = env.le(keylet::mptokenIssuance(f.shareAsset));
-        if (!BEAST_EXPECT(issuanceFinal))
-            return;
-
-        // Zero-sized vault invariant satisfied: 0 shares, 0 assets.
-        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
-        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
-        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
-        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
-
-        // The final payout equals exactly the AssetsAvailable that
-        // existed before the call (the "force payout" branch).
-        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
-        Number const finalReceived{lenderBalanceAfter - lenderBalanceBeforeFinal};
-        BEAST_EXPECT(finalReceived == availableBeforeFinal);
-    }
-
-    // Clean-state regression: with no impaired loan, a sole shareholder
-    // burning all their shares fully empties the vault under both the
-    // pre-fix and post-fix code paths. Confirms the new logic doesn't
-    // break the existing happy-path close-out.
-    void
-    testWithdrawSoleShareholderCleanVaultUnaffected(FeatureBitset features)
-    {
-        using namespace test::jtx;
-
-        bool const withFix = features[fixCleanup3_2_0];
-        testcase(
-            std::string{"Vault withdraw: sole shareholder clean-state "
-                        "close-out unchanged"} +
-            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
-
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-
-        env.fund(XRP(kStuckFunding), issuer, lender);
-        env.close();
-
-        PrettyAsset const asset = issuer[iouCurrency_];
-        env(trust(lender, asset(10'000'000)));
-        env.close();
-        env(pay(issuer, lender, asset(kStuckDepositorIOU)));
-        env.close();
-
-        // Sole shareholder of a clean vault — no loan broker needed.
-        Vault const v{env};
-        auto [createTx, vaultKeylet] = v.create({.owner = lender, .asset = asset});
-        env(createTx);
-        env.close();
-
-        env(v.deposit({
-                .depositor = lender,
-                .id = vaultKeylet.key,
-                .amount = asset(kStuckDeposit),
-            }),
-            Ter(tesSUCCESS));
-        env.close();
-
-        auto const vaultBefore = env.le(vaultKeylet);
-        if (!BEAST_EXPECT(vaultBefore))
-            return;
-        auto const shareAsset = vaultBefore->at(sfShareMPTID);
-        auto const tokenLender = env.le(keylet::mptoken(shareAsset, lender.id()));
-        if (!BEAST_EXPECT(tokenLender))
-            return;
-        std::uint64_t const sharesLender = tokenLender->getFieldU64(sfMPTAmount);
-
-        // Sole shareholder, no loans, no loss. Burn everything.
-        STAmount const allShares{MPTIssue{shareAsset}, Number(sharesLender)};
-        env(v.withdraw({
-                .depositor = lender,
-                .id = vaultKeylet.key,
-                .amount = allShares,
-            }),
-            Ter(tesSUCCESS));
-        env.close();
-
-        auto const vaultFinal = env.le(vaultKeylet);
-        if (!BEAST_EXPECT(vaultFinal))
-            return;
-        auto const issuanceFinal = env.le(keylet::mptokenIssuance(shareAsset));
-        if (!BEAST_EXPECT(issuanceFinal))
-            return;
-        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
-        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
-        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
-        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
-
-        // (Pre-fix path takes the regular code path; post-fix path enters
-        // the new final-withdrawal guard, which forces payout to exactly
-        // assetsAvailable. Either way the result is identical for a clean
-        // vault.)
-        (void)withFix;
-    }
-
-    // Sole shareholder in an impaired vault redeems a *partial* count of
-    // shares via fixed-shares input. Pre-fix the discounted formula is
-    // used; post-fix the full-price formula is used (waiveUnrealizedLoss
-    // = Yes). The relative payout therefore differs, and post-fix the
-    // depositor recovers proportionally more of the residual cash for
-    // the shares burned. In both cases the vault is left in a valid
-    // (non-empty) state.
-    void
-    testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice()
-    {
-        using namespace test::jtx;
-
-        testcase(
-            "Vault withdraw: sole-shareholder partial fixed-shares uses "
-            "full-price rate (fixCleanup3_2_0)");
-
-        Env env(*this, all_ | fixCleanup3_2_0);
-        auto const f = setupStuckDepositor(env);
-        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
-        {
-            BEAST_EXPECT(false);
-            return;
-        }
-        Keylet const& vaultKey = *f.vaultKeylet;
-        PrettyAsset const& asset = *f.asset;
-
-        auto const vaultBefore = env.le(vaultKey);
-        if (!BEAST_EXPECT(vaultBefore))
-            return;
-        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
-        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
-        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
-
-        // Burn exactly half of the outstanding shares.
-        std::uint64_t const halfShares = f.sharesLender / 2;
-        STAmount const halfAmt{MPTIssue{f.shareAsset}, Number(halfShares)};
-
-        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
-
-        Vault const v{env};
-        env(v.withdraw({
-                .depositor = f.lender,
-                .id = vaultKey.key,
-                .amount = halfAmt,
-            }),
-            Ter(tesSUCCESS));
-        env.close();
-
-        // Expected payout under the full-price formula:
-        //   assets = totalBefore * halfShares / sharesLender
-        // which (with halfShares == sharesLender/2) is roughly
-        //   totalBefore / 2.
-        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
-        Number const received{lenderBalanceAfter - lenderBalanceBefore};
-        Number const expected = totalBefore * Number(halfShares) / Number(f.sharesLender);
-        BEAST_EXPECT(received == expected);
-
-        // The full-price payout exceeds the discounted formula by exactly
-        // lossBefore * halfShares / sharesLender — that's the whole point
-        // of the waive.
-        Number const discounted =
-            (totalBefore - lossBefore) * Number(halfShares) / Number(f.sharesLender);
-        Number const expectedDelta = lossBefore * Number(halfShares) / Number(f.sharesLender);
-        BEAST_EXPECT(received - discounted == expectedDelta);
-
-        auto const vaultAfter = env.le(vaultKey);
-        if (!BEAST_EXPECT(vaultAfter))
-            return;
-        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
-        if (!BEAST_EXPECT(issuanceAfter))
-            return;
-
-        // Vault remains valid: half the shares remain, lossUnrealized
-        // is untouched, and the entire (total - available) gap is still
-        // the impaired receivable.
-        BEAST_EXPECT(
-            issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender - halfShares);
-        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore - received);
-        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
-        BEAST_EXPECT(
-            vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
-            vaultAfter->at(sfLossUnrealized));
-
-        // Conservation: vault delta matches the depositor's gain.
-        BEAST_EXPECT(totalBefore - vaultAfter->at(sfAssetsTotal) == received);
-        BEAST_EXPECT(availableBefore - vaultAfter->at(sfAssetsAvailable) == received);
-    }
-
-    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for the
-    // sfAssetsTotal and sfAssetsAvailable deltas, and visitEntry applies the
-    // same max() for the vault pseudo-account RippleState.  When
-    // sfAssetsTotal sits exactly at 1e16 (IOU exponent 1, ULP = 10) and a
-    // withdrawal of 5 USD brings it to 9.999...995e15 (IOU exponent 0,
-    // ULP = 1), all three computations pick the anterior coarser scale 1.
-    // roundToAsset(-5, scale=1) collapses to 0, so the invariant check
-    // vaultPseudoDeltaAssets >= kZero fires even though the state change is
-    // valid and fully consistent at IOU precision.
-    //
-    // Fix (fixCleanup3_2_0): finalize compares the vault pseudo-account and
-    // sfAssetsTotal/Available deltas directly in Number space, bypassing
-    // scale-coarsened rounding.
-    void
-    testBugMakeDeltaAnteriorScale()
-    {
-        using namespace test::jtx;
-
-        auto runScenario = [this](FeatureBitset features, TER expected) {
-            std::string logs;
-            Env env(*this, features, std::make_unique(&logs));
-
-            Account const issuer{"issuer"};
-            Account const alice{"alice"};
-
-            env.fund(XRP(100'000), issuer, alice);
-            env.close();
-            env(fset(issuer, asfDefaultRipple));
-            env.close();
-
-            PrettyAsset const usd{issuer["USD"]};
-            // Trust limit of 2e16, fund exactly 1e16 so deposit lands at the
-            // IOU scale-1 boundary (exponent 1, ULP = 10).
-            STAmount const fundAndDeposit{usd.raw(), Number{1, 16}};
-
-            env(trust(alice, STAmount{usd.raw(), 2, 16}));
-            env.close();
-            env(pay(issuer, alice, fundAndDeposit));
-            env.close();
-
-            Vault const vault{env};
-            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
-            vaultTx[sfScale] = 0;
-            env(vaultTx);
-            env.close();
-
-            // sfAssetsTotal = sfAssetsAvailable = 1e16 (exponent 1, ULP = 10).
-            env(vault.deposit(
-                {.depositor = alice, .id = vaultKeylet.key, .amount = fundAndDeposit}));
-            env.close();
-
-            // Withdraw 5 USD: -5 is sub-ULP at the anterior scale (ULP = 10)
-            // but exact at the posterior scale (ULP = 1).  The state change is
-            // consistent; only the invariant's scale selection is wrong.
-            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(5)}),
-                Ter(expected));
-            env.close();
-        };
-
-        {
-            testcase(
-                "bug: VaultWithdraw across IOU scale boundary fires invariant "
-                "(pre-fixCleanup3_2_0)");
-            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
-        }
-        {
-            testcase(
-                "bug: VaultWithdraw across IOU scale boundary succeeds "
-                "(post-fixCleanup3_2_0)");
-            runScenario(testableAmendments(), tesSUCCESS);
-        }
-    }
-
-    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for
-    // sfAssetsTotal/Available deltas.  This is symmetric to
-    // testBugMakeDeltaAnteriorScale but in the opposite direction: a deposit
-    // pushes assetsTotal from just below 1e16 (IOU exponent 0, ULP = 1) to just
-    // above it (exponent 1, ULP = 10).  makeDelta picks the coarser *posterior*
-    // scale 1.  The trust line balance rounds from atEdge + 2 = 10,000,000,000,000,001
-    // → 1e16, so the pseudo-account delta is only +1 in IOU space.
-    // roundToAsset(+1, scale=1) = 0 fires "deposit must increase vault balance"
-    // even though the state change is consistent at every precision boundary.
-    //
-    // Fix (fixCleanup3_2_0): computeVaultMinScale uses the posterior Number-space
-    // scale of sfAssetsTotal (which retains the full value 10,000,000,000,000,001,
-    // exponent 0), giving minScale = 0.  roundToAsset(+1, scale=0) = 1 > 0 and
-    // the invariant passes.  However the transactor's own precision guard fires
-    // first (bob pays 2 USD, vault receives only 1 due to IOU rounding), so the
-    // post-amendment result is tecPRECISION_LOSS rather than tesSUCCESS —
-    // the depositor is protected from silently losing 1 USD to rounding.
-    void
-    testBugMakeDeltaPosteriorScale()
-    {
-        using namespace test::jtx;
-
-        auto runScenario = [this](FeatureBitset features, TER expected) {
-            std::string logs;
-            Env env(*this, features, std::make_unique(&logs));
-
-            Account const issuer{"issuer"};
-            Account const alice{"alice"};
-            Account const bob{"bob"};
-
-            env.fund(XRP(100'000), issuer, alice, bob);
-            env.close();
-            env(fset(issuer, asfDefaultRipple));
-            env.close();
-
-            PrettyAsset const usd{issuer["USD"]};
-            // atEdge is the largest IOU value with exponent 0 (ULP = 1).
-            // A deposit of 2 USD brings assetsTotal to 10,000,000,000,000,001
-            // in Number space, crossing the 1e16 boundary in IOU space.
-            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
-
-            env(trust(alice, STAmount{usd.raw(), 2, 16}));
-            env(trust(bob, usd(100)));
-            env.close();
-            env(pay(issuer, alice, atEdge));
-            env(pay(issuer, bob, usd(2)));
-            env.close();
-
-            Vault const vault{env};
-            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
-            vaultTx[sfScale] = 0;
-            env(vaultTx);
-            env.close();
-
-            // sfAssetsTotal = sfAssetsAvailable = atEdge (exponent 0, ULP = 1)
-            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = atEdge}));
-            env.close();
-
-            // Deposit 2 USD: +2 is sub-ULP at the posterior IOU scale (ULP = 10)
-            // but exact at the Number scale retained by sfAssetsTotal.
-            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(2)}),
-                Ter(expected));
-            env.close();
-        };
-
-        {
-            testcase(
-                "bug: VaultDeposit across IOU scale boundary fires invariant "
-                "(pre-fixCleanup3_2_0)");
-            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
-        }
-        {
-            testcase(
-                "bug: VaultDeposit across IOU scale boundary succeeds "
-                "(post-fixCleanup3_2_0)");
-            runScenario(testableAmendments(), 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
-    // power-of-10 boundary (atEdge = 9'999'999'999'999'999), the post-credit
-    // STAmount rounds up one exponent (exponent 0 → 1), making
-    // destinationDelta.scale = 1.  The invariant then calls
-    // roundToAsset(+2 USD, scale=1) = 0 and incorrectly fires
-    // "withdrawal must increase destination balance".
-    //
-    // Fix (fixCleanup3_2_0): finalize compares destination delta directly in
-    // Number space, bypassing scale-coarsened rounding.  The transaction
-    // itself succeeds because the effective IOU credit is non-trivial at
-    // Number precision even though the STAmount exponent shifted.
-    void
-    testVaultWithdrawCanonicalizeToZero()
-    {
-        using namespace test::jtx;
-
-        enum class DestKind : bool { ThirdParty = false, Self = true };
-
-        auto runScenario = [this](FeatureBitset features, DestKind destKind, TER expected) {
-            std::string logs;
-            Env env(*this, features, std::make_unique(&logs));
-
-            Account const issuer{"issuer"};
-            Account const alice{"alice"};
-            Account const bob{"bob"};
-
-            env.fund(XRP(100'000), issuer, alice, bob);
-            env.close();
-            env(fset(issuer, asfDefaultRipple));
-            env.close();
-
-            PrettyAsset const usd{issuer["USD"]};
-            STAmount const aliceLimit{usd.raw(), 2, 16};
-            STAmount const bobLimit{usd.raw(), 2, 16};
-            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
-
-            env(trust(alice, aliceLimit));
-            if (destKind == DestKind::ThirdParty)
-                env(trust(bob, bobLimit));
-            env.close();
-
-            env(pay(issuer, alice, usd(1'000)));
-            if (destKind == DestKind::ThirdParty)
-                env(pay(issuer, bob, atEdge));
-            env.close();
-
-            Vault const vault{env};
-            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
-            vaultTx[sfScale] = 0;
-            env(vaultTx);
-            env.close();
-
-            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
-            env.close();
-
-            // For the self-destination case, push alice's own trust line to
-            // the IOU edge so the next withdraw inflow crosses the boundary.
-            if (destKind == DestKind::Self)
-            {
-                env(pay(issuer, alice, atEdge));
-                env.close();
-            }
-
-            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(2)});
-            if (destKind == DestKind::ThirdParty)
-                tx[sfDestination] = bob.human();
-            env(tx, Ter(expected));
-            env.close();
-        };
-
-        {
-            testcase(
-                "bug: VaultWithdraw to third-party at IOU edge fires invariant "
-                "(pre-fixCleanup3_2_0)");
-            runScenario(
-                testableAmendments() - fixCleanup3_2_0, DestKind::ThirdParty, tecINVARIANT_FAILED);
-        }
-        {
-            testcase(
-                "bug: VaultWithdraw to third-party at IOU edge succeeds "
-                "(post-fixCleanup3_2_0)");
-            runScenario(testableAmendments(), DestKind::ThirdParty, tesSUCCESS);
-        }
-        {
-            testcase(
-                "bug: VaultWithdraw to self at IOU edge fires invariant "
-                "(pre-fixCleanup3_2_0)");
-            runScenario(
-                testableAmendments() - fixCleanup3_2_0, DestKind::Self, tecINVARIANT_FAILED);
-        }
-        {
-            testcase(
-                "bug: VaultWithdraw to self at IOU edge succeeds "
-                "(post-fixCleanup3_2_0)");
-            runScenario(testableAmendments(), DestKind::Self, tesSUCCESS);
-        }
-    }
-
-    // Bug: the equality check (vault outflow == destination inflow) was
-    // skipped whenever the destination delta rounded to zero at localMinScale,
-    // including cases where the vault outflow rounded to a non-zero value and
-    // a representable amount of value was genuinely destroyed.
-    //
-    // Scenario: Bob's IOU balance sits 5 units below the 10^16 STAmount
-    // precision boundary (atEdge2 = 9,999,999,999,999,995).  A withdrawal of
-    // 6 USD shifts his balance across that boundary: the exponent increments
-    // (0 → 1), so his effective inflow in Number space is only +5 — 1 USD is
-    // consumed by the precision-boundary rounding and cannot be credited.
-    //
-    // The destroyed amount (1 USD) is sub-ULP at destinationScale=1 (step=10),
-    // so the check treats it as an unavoidable IOU-precision artefact and
-    // lets the transaction succeed.
-    //
-    // Contrast: if 15 USD were destroyed at the same scale (destroyed ≥ step),
-    // floor(15/10)=1 ≠ 0 and the invariant would fire — that discrepancy IS
-    // representable and indicates a real accounting bug.
-    //
-    // Pre-fixCleanup3_2_0: the "must increase destination balance" check fires
-    // because roundedDestinationDelta = 0 ≤ 0.
-    void
-    testVaultWithdrawEqualityEnforced()
-    {
-        using namespace test::jtx;
-
-        auto runScenario = [this](FeatureBitset features, TER expected) {
-            std::string logs;
-            Env env(*this, features, std::make_unique(&logs));
-
-            Account const issuer{"issuer"};
-            Account const alice{"alice"};
-            Account const bob{"bob"};
-
-            env.fund(XRP(100'000), issuer, alice, bob);
-            env.close();
-            env(fset(issuer, asfDefaultRipple));
-            env.close();
-
-            PrettyAsset const usd{issuer["USD"]};
-            STAmount const aliceLimit{usd.raw(), 2, 16};
-            STAmount const bobLimit{usd.raw(), 2, 16};
-            // Bob's balance sits 5 units below the 10^16 STAmount precision
-            // boundary.  Receiving 6 USD shifts his exponent 0 → 1; the
-            // STAmount records +5, not +6 (1 USD is lost to rounding).
-            STAmount const atEdge2{usd.raw(), Number{9'999'999'999'999'995LL}};
-
-            env(trust(alice, aliceLimit));
-            env(trust(bob, bobLimit));
-            env.close();
-
-            env(pay(issuer, alice, usd(1'000)));
-            env(pay(issuer, bob, atEdge2));
-            env.close();
-
-            Vault const vault{env};
-            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
-            vaultTx[sfScale] = 0;
-            env(vaultTx);
-            env.close();
-
-            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
-            env.close();
-
-            // Withdraw 6 USD to Bob: vault loses 6, Bob gains only 5.
-            // Destroyed amount = 1 USD, which is sub-ULP at destinationScale=1.
-            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(6)});
-            tx[sfDestination] = bob.human();
-            env(tx, Ter(expected));
-            env.close();
-        };
-
-        {
-            testcase(
-                "bug: VaultWithdraw to destination at IOU precision boundary fires "
-                "invariant (pre-fixCleanup3_2_0)");
-            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
-        }
-        {
-            testcase(
-                "bug: VaultWithdraw to destination at IOU precision boundary succeeds "
-                "when destroyed amount is sub-ULP (post-fixCleanup3_2_0)");
-            runScenario(testableAmendments(), tesSUCCESS);
-        }
-    }
-
-    // Bug: when a depositor's IOU trustline balance is very large (e.g.
-    // ~1e17), adding a small deposit (e.g. 1 USD) leaves sfAssetsTotal
-    // unchanged at IOU precision because the increment is sub-ULP at the
-    // vault's current asset scale.  The vault records the deposit, mints
-    // shares, and decrements the depositor's trustline, but sfAssetsTotal
-    // does not change — the conservation invariant fires because the rail
-    // delta is zero.
-    //
-    // Two sub-cases are exercised:
-    //   1. First-ever deposit into an empty vault: the depositor's own
-    //      trustline has a large balance so 1 USD canonicalizes to zero
-    //      when written back through the IOU rail.
-    //   2. Subsequent deposit after the vault already holds a large
-    //      sfAssetsTotal: a different depositor (bob, with a small balance)
-    //      sends 1 USD, which again rounds to zero at the vault's coarse
-    //      asset scale.
-    //
-    // Fix (fixCleanup3_2_0): the deposit transactor checks whether
-    // roundToAsset(amount, vault_scale) == 0 and rejects early with
-    // tecPRECISION_LOSS before any state is modified.
-    void
-    testVaultDepositCanonicalizeToZero()
-    {
-        using namespace test::jtx;
-        auto runScenario = [this](FeatureBitset features, TER expected) {
-            std::string logs;
-            Env env(*this, features, std::make_unique(&logs));
-
-            Account const issuer{"issuer"};
-            Account const alice{"alice"};
-            Account const bob{"bob"};
-
-            env.fund(XRP(100'000), issuer, alice, bob);
-            env.close();
-
-            env(fset(issuer, asfDefaultRipple));
-            env.close();
-
-            PrettyAsset const usd{issuer["USD"]};
-
-            STAmount const trustLimit{usd.raw(), Number{99'999'999'999'999'999LL}};
-            STAmount const aliceFund{usd.raw(), Number{99'999'999'999'999'999LL}};
-
-            env(trust(alice, trustLimit));
-            env(trust(bob, trustLimit));
-            env.close();
-
-            env(pay(issuer, alice, aliceFund));
-            env(pay(issuer, bob, usd(1000)));
-            env.close();
-
-            Vault const vault{env};
-
-            // Scale=0 so sfAssetsTotal stores whole USD
-            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
-            vaultTx[sfScale] = 0;
-            env(vaultTx);
-            env.close();
-
-            // Alice's deposit canonicalizes to zero at her own trustline scale
-            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1)}),
-                Ter(expected));
-
-            // Increase vault-scale
-            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = aliceFund}));
-            env.close();
-
-            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(1)}),
-                Ter(expected));
-            env.close();
-        };
-
-        {
-            testcase(
-                "bug: VaultDeposit below Vault precision canonicalized to zero "
-                "(pre-fixCleanup3_2_0)");
-            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
-        }
-        {
-            testcase(
-                "bug: VaultDeposit below Vault precision canonicalized to zero "
-                "(post-fixCleanup3_2_0)");
-            runScenario(testableAmendments(), tecPRECISION_LOSS);
-        }
-    }
-
-    // VaultDeposit by issuer with the vault parked at the IOU 16-digit
-    // edge (9.999e15). Issuer mints 2 more USD; the vault trust line
-    // goes 9.999e15 → 10^16, gaining 1 unit instead of 2 (canonicalization).
-    //
-    // Pre-fixCleanup3_2_0: the proactive check is absent; the deposit
-    // applies, then VaultInvariant's "deposit must increase vault
-    // balance" assertion fires at finalize time on the rounded vault
-    // delta of zero, returning tecINVARIANT_FAILED.
-    // Post-amendment: reject deposit that is not representable at Vault scale.
-    void
-    testBugIssuerVaultDepositAtEdge()
-    {
-        using namespace test::jtx;
-
-        auto runScenario = [this](FeatureBitset features, TER expected) {
-            std::string logs;
-            Env env(*this, features, std::make_unique(&logs));
-
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-
-            env.fund(XRP(100'000), issuer, owner);
-            env.close();
-            env(fset(issuer, asfDefaultRipple));
-            env.close();
-
-            PrettyAsset const usd{issuer["USD"]};
-            STAmount const trustLimit{usd.raw(), 2, 16};
-            STAmount const ownerFund{usd.raw(), Number{9'999'999'999'999'999LL}};
-
-            env(trust(owner, trustLimit));
-            env.close();
-            env(pay(issuer, owner, ownerFund));
-            env.close();
-
-            Vault const vault{env};
-            auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
-            vaultTx[sfScale] = 0;
-            env(vaultTx);
-            env.close();
-            env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = ownerFund}));
-            env.close();
-
-            // Vault pseudo-account is now at 9.999e15. Issuer mints 2
-            // more USD. Pre: tecINVARIANT_FAILED at finalize. Post:
-            // tecPRECISION_LOSS proactively. Either way, no value moves.
-            env(vault.deposit({.depositor = issuer, .id = vaultKeylet.key, .amount = usd(2)}),
-                Ter(expected));
-            env.close();
-        };
-
-        {
-            testcase(
-                "bug: VaultDeposit by issuer at IOU edge fires "
-                "tecINVARIANT_FAILED at finalize (pre-fixCleanup3_2_0)");
-            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
-        }
-        {
-            testcase(
-                "bug: VaultDeposit by issuer at IOU edge rejects with "
-                "tecPRECISION_LOSS proactively (post-fixCleanup3_2_0)");
-            runScenario(testableAmendments(), tecPRECISION_LOSS);
-        }
-    }
-
-    void
-    testReferenceHolding()
-    {
-        using namespace test::jtx;
-
-        auto readReferenceHolding = [&](Env const& env,
-                                        Keylet const& vaultKeylet) -> std::optional {
-            auto const sleVault = env.le(vaultKeylet);
-            if (!sleVault)
-                return std::nullopt;
-            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
-            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
-                return std::nullopt;
-            return sleIssuance->getFieldH256(sfReferenceHolding);
-        };
-
-        // Post-fixCleanup3_2_0: vault share carries sfReferenceHolding
-        // pointing to the vault pseudo's MPToken (for MPT-backed vaults)
-        // or RippleState (for IOU-backed vaults).
-        {
-            testcase("sfReferenceHolding: MPT-backed vault, post-amendment");
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            env.fund(XRP(10'000), issuer, owner);
-            env.close();
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
-            PrettyAsset const asset = mptt.issuanceID();
-            mptt.authorize({.account = owner});
-
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            auto const sleVault = env.le(keylet);
-            BEAST_EXPECT(sleVault != nullptr);
-            auto const pseudoId = sleVault->at(sfAccount);
-            auto const expected = keylet::mptoken(mptt.issuanceID(), pseudoId).key;
-
-            auto const stored = readReferenceHolding(env, keylet);
-            BEAST_EXPECT(stored.has_value());
-            BEAST_EXPECT(stored && *stored == expected);
-            // The pointed-to MPToken must actually exist.
-            BEAST_EXPECT(env.le(keylet::mptoken(mptt.issuanceID(), pseudoId)) != nullptr);
-        }
-
-        {
-            testcase("sfReferenceHolding: IOU-backed vault, post-amendment");
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            env.fund(XRP(10'000), issuer, owner);
-            env(fset(issuer, asfDefaultRipple));
-            env.close();
-
-            PrettyAsset const asset = issuer["IOU"];
-            env.trust(asset(1'000'000), owner);
-            env.close();
-
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            auto const sleVault = env.le(keylet);
-            BEAST_EXPECT(sleVault != nullptr);
-            auto const pseudoId = sleVault->at(sfAccount);
-            auto const expected = keylet::trustLine(pseudoId, asset.raw().get()).key;
-
-            auto const stored = readReferenceHolding(env, keylet);
-            BEAST_EXPECT(stored.has_value());
-            BEAST_EXPECT(stored && *stored == expected);
-            // The pointed-to RippleState must actually exist.
-            BEAST_EXPECT(env.le(keylet::trustLine(pseudoId, asset.raw().get())) != nullptr);
-        }
-
-        // XRP-backed vaults leave the field absent: XRP has no separate
-        // holding ledger entry and no transferability concept to inherit.
-        {
-            testcase("sfReferenceHolding: XRP-backed vault, field absent");
-            Env env{*this, testableAmendments()};
-            Account const owner{"owner"};
-            env.fund(XRP(10'000), owner);
-            env.close();
-
-            PrettyAsset const asset{xrpIssue(), 1'000'000};
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
-        }
-
-        // Pre-fixCleanup3_2_0: vault share has the field absent regardless
-        // of underlying type.
-        {
-            testcase("sfReferenceHolding: vault share, pre-amendment");
-            Env env{*this, testableAmendments() - fixCleanup3_2_0};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            env.fund(XRP(10'000), issuer, owner);
-            env.close();
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
-            PrettyAsset const asset = mptt.issuanceID();
-            mptt.authorize({.account = owner});
-
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
-        }
-
-        // Plain MPTokenIssuanceCreate (not a vault share) must never
-        // populate the field. Only the post-amendment case is
-        // interesting; pre-amendment nothing writes the field at all.
-        {
-            testcase("sfReferenceHolding: plain MPT issuance never set");
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            env.fund(XRP(10'000), issuer);
-            env.close();
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
-            env.close();
-
-            auto const sleIssuance = env.le(keylet::mptokenIssuance(mptt.issuanceID()));
-            if (BEAST_EXPECT(sleIssuance))
-                BEAST_EXPECT(!sleIssuance->isFieldPresent(sfReferenceHolding));
-        }
-    }
-
-    // Probe every transactor surface that might delete the vault pseudo-
-    // account's underlying holding (the MPToken or RippleState pointed to
-    // by sfReferenceHolding). Each scenario asserts either that the
-    // existing pseudo-account guards stop the deletion at preclaim, or
-    // that the ledger leaves the holding intact afterwards. This is a
-    // regression guard: if any of these guards regresses, the share's
-    // sfReferenceHolding pointer would dangle and the new ValidMPTIssuance
-    // invariant would catch it - but we want to fail much earlier, at
-    // the transactor's preclaim / doApply, not at invariant time.
-    void
-    testHoldingDeletionBlocked()
-    {
-        using namespace test::jtx;
-
-        // Helper: read the share's referenced holding and confirm the
-        // pointed-to SLE still exists after the probe.
-        auto referencedHoldingExists = [&](Env const& env, Keylet const& vaultKeylet) -> bool {
-            auto const sleVault = env.le(vaultKeylet);
-            if (!sleVault)
-                return false;
-            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
-            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
-                return false;
-            auto const holdingKey = sleIssuance->getFieldH256(sfReferenceHolding);
-            return env.le(keylet::unchecked(holdingKey)) != nullptr;
-        };
-
-        // ---- MPT-backed vault ----------------------------------------
-        {
-            testcase("vault pseudo MPToken: Clawback blocked by tecPSEUDO_ACCOUNT");
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            Account const depositor{"depositor"};
-            env.fund(XRP(10'000), issuer, owner, depositor);
-            env.close();
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback});
-            PrettyAsset const asset = mptt.issuanceID();
-            mptt.authorize({.account = owner});
-            mptt.authorize({.account = depositor});
-            env(pay(issuer, depositor, asset(1'000)));
-            env.close();
-
-            Vault const 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(500)}));
-            env.close();
-
-            BEAST_EXPECT(referencedHoldingExists(env, keylet));
-
-            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
-            // Issuer attempts to claw back the FULL underlying balance
-            // (500) directly from the vault pseudo-account. With the
-            // full amount, the doApply path would drain the pseudo's
-            // MPToken to zero and removeEmptyHolding would erase it -
-            // if doApply ever ran. SAV's pseudo-account guard at
-            // Clawback.cpp:201 refuses at preclaim with
-            // tecPSEUDO_ACCOUNT before any state change.
-            env(claw(issuer, asset(500), pseudoAccount), Ter{tecPSEUDO_ACCOUNT});
-            env.close();
-            BEAST_EXPECT(referencedHoldingExists(env, keylet));
-            // Sanity: pseudo's full balance is intact.
-            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
-        }
-
-        {
-            testcase("vault pseudo MPToken: Issuer cannot Unauthorize pseudo");
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            env.fund(XRP(10'000), issuer, owner);
-            env.close();
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
-            PrettyAsset const asset = mptt.issuanceID();
-            mptt.authorize({.account = owner});
-            mptt.authorize({.account = issuer, .holder = owner});
-            env.close();
-
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            BEAST_EXPECT(referencedHoldingExists(env, keylet));
-
-            auto const pseudoId = env.le(keylet)->at(sfAccount);
-            // Issuer attempts MPTokenAuthorize against the pseudo with
-            // tfMPTUnauthorize. MPTokenAuthorize.cpp blocks pseudo
-            // accounts via isPseudoAccount; the pseudo's MPToken is
-            // preserved. Construct the tx manually since the pseudo
-            // lacks a signing key, and the issuer-driven flavour is
-            // expressed via sfHolder.
-            json::Value jv;
-            jv[sfAccount] = issuer.human();
-            jv[sfHolder] = toBase58(pseudoId);
-            jv[sfMPTokenIssuanceID] = to_string(mptt.issuanceID());
-            jv[sfFlags] = tfMPTUnauthorize;
-            jv[sfTransactionType] = jss::MPTokenAuthorize;
-            env(jv, Ter{tecNO_PERMISSION});
-            env.close();
-            BEAST_EXPECT(referencedHoldingExists(env, keylet));
-        }
-
-        {
-            testcase("vault pseudo MPToken: MPTokenIssuanceDestroy blocked while vault holds");
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            Account const depositor{"depositor"};
-            env.fund(XRP(10'000), issuer, owner, depositor);
-            env.close();
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
-            PrettyAsset const asset = mptt.issuanceID();
-            mptt.authorize({.account = owner});
-            mptt.authorize({.account = depositor});
-            env(pay(issuer, depositor, asset(1'000)));
-            env.close();
-
-            Vault const 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(500)}));
-            env.close();
-
-            BEAST_EXPECT(referencedHoldingExists(env, keylet));
-
-            // While the vault holds outstanding underlying, the issuer
-            // cannot destroy the issuance. tecHAS_OBLIGATIONS confirms
-            // the protection - and as a side effect, the share's
-            // sfReferenceHolding pointer cannot be left pointing at a
-            // ghost issuance.
-            mptt.destroy({.id = mptt.issuanceID(), .err = tecHAS_OBLIGATIONS});
-            env.close();
-            BEAST_EXPECT(referencedHoldingExists(env, keylet));
-        }
-
-        // ---- IOU-backed vault ----------------------------------------
-        {
-            testcase("vault pseudo trust line: Clawback blocked by tecPSEUDO_ACCOUNT");
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            env.fund(XRP(10'000), issuer, owner);
-            env(fset(issuer, asfAllowTrustLineClawback));
-            env.close();
-
-            PrettyAsset const asset = issuer["IOU"];
-            env.trust(asset(1'000'000), owner);
-            env(pay(issuer, owner, asset(1'000)));
-            env.close();
-
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
-            env.close();
-
-            BEAST_EXPECT(referencedHoldingExists(env, keylet));
-
-            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
-            // Issuer attempts to claw back the FULL IOU balance (500)
-            // directly from the vault pseudo. With the full amount, the
-            // doApply path would drain the trust line to zero and (if
-            // both reserve flags clear) trustDelete would erase it - if
-            // doApply ever ran. The same SAV pseudo-account guard
-            // refuses at preclaim with tecPSEUDO_ACCOUNT. The amount's
-            // STAmount issuer field is the holder, per IOU clawback
-            // convention.
-            env(claw(issuer, pseudoAccount["IOU"](500)), Ter{tecPSEUDO_ACCOUNT});
-            env.close();
-            BEAST_EXPECT(referencedHoldingExists(env, keylet));
-            // Sanity: pseudo's full balance is intact.
-            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
-        }
-
-        {
-            testcase("vault pseudo trust line: TrustSet limit=0 from issuer preserves line");
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            env.fund(XRP(10'000), issuer, owner);
-            env(fset(issuer, asfDefaultRipple));
-            env.close();
-
-            PrettyAsset const asset = issuer["IOU"];
-            env.trust(asset(1'000'000), owner);
-            env(pay(issuer, owner, asset(1'000)));
-            env.close();
-
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
-            env.close();
-
-            BEAST_EXPECT(referencedHoldingExists(env, keylet));
-
-            // Issuer submits TrustSet with limit=0 against the vault
-            // pseudo. The pseudo's side of the line still has the
-            // original (non-zero) limit and a non-zero balance, so the
-            // line is preserved - even though the issuer cleared its
-            // own side. trustDelete only fires when both limits clear
-            // and the balance is zero.
-            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
-            env(trust(issuer, pseudoAccount["IOU"](0)));
-            env.close();
-            BEAST_EXPECT(referencedHoldingExists(env, keylet));
-        }
-
-        // ---- Positive control: VaultDelete is the only legitimate path
-        {
-            testcase("vault pseudo holding: VaultDelete is the legitimate cleanup path");
-            Env env{*this, testableAmendments()};
-            Account const issuer{"issuer"};
-            Account const owner{"owner"};
-            env.fund(XRP(10'000), issuer, owner);
-            env.close();
-
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
-            PrettyAsset const asset = mptt.issuanceID();
-            mptt.authorize({.account = owner});
-
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            BEAST_EXPECT(referencedHoldingExists(env, keylet));
-            auto const pseudoId = env.le(keylet)->at(sfAccount);
-            auto const sharedMptId = env.le(keylet)->at(sfShareMPTID);
-            auto const holdingKeylet = keylet::mptoken(mptt.issuanceID(), pseudoId);
-
-            // VaultDelete tears down the vault pseudo's holding, the
-            // share issuance, and the pseudo-account itself. Invariant
-            // permits this because the tx is ttVAULT_DELETE.
-            env(vault.del({.owner = owner, .id = keylet.key}));
-            env.close();
-
-            BEAST_EXPECT(env.le(keylet) == nullptr);
-            BEAST_EXPECT(env.le(holdingKeylet) == nullptr);
-            BEAST_EXPECT(env.le(keylet::mptokenIssuance(sharedMptId)) == nullptr);
-        }
-    }
-
-    // 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:
-    // getTrustLineBalance with includeOppositeLimit=true). When the
-    // depositor's raw balance < deposit amount but raw + opposite limit >=
-    // amount, preclaim is satisfied. doApply then calls
-    // directSendNoFeeIOU, which unconditionally subtracts saAmount from
-    // saBalance — driving the trust line negative — and returns tesSUCCESS.
-    // The post-send sanity check uses the default shSIMPLE_BALANCE (no
-    // opposite-limit add), sees a negative balance, and returns tefINTERNAL.
-    void
-    testVaultDepositNegativeBalanceFromOppositeLimit()
-    {
-        auto runTest = [&](FeatureBitset f, TER expected) {
-            using namespace test::jtx;
-            using namespace std::literals;
-
-            Env env{*this, f};
-            Account const gw{"gateway"};
-            Account const owner{"owner"};
-            Account const depositor{"depositor"};
-
-            env.fund(XRP(10000), gw, owner, depositor);
-            env.close();
-
-            // Gateway with DefaultRipple so vault creation on its IOU works.
-            env(fset(gw, asfDefaultRipple));
-            env.close();
-
-            // Depositor opens a trust line to gateway and receives a small
-            // balance.
-            PrettyAsset const usd = gw["USD"];
-            env.trust(usd(1000), depositor);
-            env(pay(gw, depositor, usd(100)));  // raw trust-line balance: 100
-            env.close();
-
-            // Key precondition: gateway sets a non-zero limit on the same
-            // RippleState — the "opposite field" from depositor's perspective.
-            // This is what inflates shFULL_BALANCE in preclaim above the raw
-            // balance.
-            env(trust(gw, depositor["USD"](1000)));
-            env.close();
-
-            // Create the IOU vault.
-            Vault const vault{env};
-            auto [vaultTx, keylet] = vault.create({.owner = owner, .asset = usd});
-            env(vaultTx);
-            env.close();
-
-            // Submit a deposit of 500 USD:
-            //   - raw balance:                100 USD
-            //   - opposite limit (gw's side): 1000 USD
-            //   - preclaim sees 100 + 1000 = 1100, passes (>= 500)
-            //   - doApply transfers 500, depositor's trust-line balance
-            //     becomes -400
-            //   - sanity check at VaultDeposit.cpp:256 fires
-            //   - tx returns tefINTERNAL (BUG — should be tesSUCCESS.
-            auto depositTx =
-                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = usd(500)});
-            env(depositTx, Ter(expected));
-            env.close();
-        };
-
-        {
-            testcase(
-                "IOU vault deposit exceeding depositor's balance but "
-                "within counterparty's trust limit, pre-fixCleanup3_2_0 "
-                "(tefINTERNAL)");
-            runTest(test::jtx::testableAmendments() - fixCleanup3_2_0, tefINTERNAL);
-        }
-        {
-            testcase(
-                "IOU vault deposit exceeding depositor's balance but "
-                "within counterparty's trust limit, post-fixCleanup3_2_0 "
-                "(tesSUCCESS)");
-            runTest(test::jtx::testableAmendments(), tesSUCCESS);
-        }
-    }
-
-    void
-    testVaultDeleteMemoData()
-    {
-        using namespace test::jtx;
-
-        Env env{*this};
-
-        Account const owner{"owner"};
-        env.fund(XRP(1'000'000), owner);
-        env.close();
-
-        Vault const vault{env};
-
-        auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(1));
-        auto delTx = vault.del({.owner = owner, .id = keylet.key});
-
-        // Test VaultDelete with featureLendingProtocolV1_1 disabled
-        // Transaction fails if the data field is provided
-        {
-            testcase("VaultDelete memo data featureLendingProtocolV1_1 disabled");
-            env.disableFeature(featureLendingProtocolV1_1);
-            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
-            env(delTx, Ter(temDISABLED));
-            env.enableFeature(featureLendingProtocolV1_1);
-            env.close();
-        }
-
-        // Transaction fails if the data field is too large
-        {
-            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data too large");
-            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength + 1, 'A'));
-            env(delTx, Ter(temMALFORMED));
-            env.close();
-        }
-
-        // Transaction fails if the data field is set, but is empty
-        {
-            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data empty");
-            delTx[sfMemoData] = strHex(std::string());
-            env(delTx, Ter(temMALFORMED));
-            env.close();
-        }
-
-        {
-            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled no vault");
-            auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
-
-            // Recreate the transaction as the vault keylet changed
-            auto delTx = vault.del({.owner = owner, .id = keylet.key});
-            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
-            env(delTx, Ter(tecNO_ENTRY));
-            env.close();
-        }
-
-        {
-            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data valid");
-            PrettyAsset const xrpAsset = xrpIssue();
-            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
-            env(tx, Ter(tesSUCCESS));
-            env.close();
-            // Recreate the transaction as the vault keylet changed
-            auto delTx = vault.del({.owner = owner, .id = keylet.key});
-            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
-            env(delTx, Ter(tesSUCCESS));
-            env.close();
-        }
-    }
-
-    void
-    testVaultCreateLEVersion()
-    {
-        using namespace test::jtx;
-
-        Account const owner{"owner"};
-        PrettyAsset const xrpAsset = xrpIssue();
-
-        {
-            testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent");
-            Env env{*this};
-            env.disableFeature(featureLendingProtocolV1_1);
-            env.fund(XRP(1'000'000), owner);
-            env.close();
-
-            Vault const vault{env};
-            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
-            env(tx, Ter(tesSUCCESS));
-            env.close();
-
-            auto const sleVault = env.le(keylet);
-            BEAST_EXPECT(sleVault);
-            BEAST_EXPECT(!sleVault->isFieldPresent(sfLEVersion));
-        }
-
-        {
-            testcase(
-                "VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == "
-                "VaultVersion::CashBasis");
-            Env env{*this};
-            env.fund(XRP(1'000'000), owner);
-            env.close();
-
-            Vault const vault{env};
-            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
-            env(tx, Ter(tesSUCCESS));
-            env.close();
-
-            auto const sleVault = env.le(keylet);
-            BEAST_EXPECT(sleVault);
-            BEAST_EXPECT(sleVault->isFieldPresent(sfLEVersion));
-            BEAST_EXPECT(sleVault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis));
-        }
-
-        {
-            testcase("VaultCreate rejects LEVersion set in the transaction");
-            Env env{*this};
-            env.fund(XRP(1'000'000), owner);
-            env.close();
-
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
-            tx[sfLEVersion] = 2;
-            env(tx, Ter(temMALFORMED));
-            env.close();
-
-            BEAST_EXPECT(!env.le(keylet));
-        }
-
-        {
-            testcase("VaultSet rejects LEVersion set in the transaction");
-            Env env{*this};
-            env.fund(XRP(1'000'000), owner);
-            env.close();
-
-            Vault const vault{env};
-            auto const [createTx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
-            env(createTx, Ter(tesSUCCESS));
-            env.close();
-
-            auto setTx = vault.set({.owner = owner, .id = keylet.key});
-            setTx[sfLEVersion] = 2;
-            env(setTx, Ter(temMALFORMED));
-            env.close();
-        }
-    }
-
-    void
-    testVaultDepositFreezeIOU()
-    {
-        using namespace test::jtx;
-        testcase("VaultDeposit IOU freeze checks");
-
-        Account const issuer{"issuer"};
-        Account const owner{"owner"};
-        Env env{*this};
-        Vault vault{env};
-
-        env.fund(XRP(100'000), issuer, owner);
-        env(fset(issuer, asfAllowTrustLineClawback));
-        env.close();
-        PrettyAsset const asset = issuer["IOU"];
-        env.trust(asset(1'000'000), owner);
-        env(pay(issuer, owner, asset(100'000)));
-        env.close();
-
-        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-        env(tx);
-        env.close();
-        auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
-
-        // Initial deposit so the vault pseudo-account has a trustline
-        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
-        env.close();
-
-        auto runTests = [&]() {
-            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
-
-            // Global freeze
-            {
-                testcase("VaultDeposit IOU global freeze");
-                env(fset(issuer, asfGlobalFreeze));
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
-                    Ter(tecFROZEN));
-                env(fclear(issuer, asfGlobalFreeze));
-            }
-
-            // Depositor freeze
-            {
-                testcase("VaultDeposit IOU depositor freeze");
-                env(trust(issuer, asset(0), owner, tfSetFreeze));
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
-                    Ter(tecFROZEN));
-                env(trust(issuer, asset(0), owner, tfClearFreeze));
-            }
-
-            // Depositor deep freeze
-            {
-                testcase("VaultDeposit IOU depositor deep freeze");
-                env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
-                    Ter(tecFROZEN));
-                env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
-            }
-
-            // Vault-account freeze
-            // Post-fix: checkDepositFreeze catches it → tecFROZEN
-            // Pre-fix: not checked directly, but the transitive share
-            //          check triggers → tecLOCKED
-            {
-                testcase("VaultDeposit IOU pseudo-account freeze");
-                auto trustSet = [&]() {
-                    json::Value jv;
-                    jv[jss::Account] = issuer.human();
-                    {
-                        auto& ja = jv[jss::LimitAmount] =
-                            asset(0).value().getJson(JsonOptions::Values::None);
-                        ja[jss::issuer] = toBase58(vaultAcct.id());
-                    }
-                    jv[jss::TransactionType] = jss::TrustSet;
-                    return jv;
-                }();
-
-                trustSet[jss::Flags] = tfSetFreeze;
-                env(trustSet);
-                env.close();
-
-                TER const expected = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
-                    Ter(expected));
-
-                trustSet[jss::Flags] = tfClearFreeze;
-                env(trustSet);
-                env.close();
-            }
-
-            // Vault-account deep freeze
-            {
-                testcase("VaultDeposit IOU pseudo-account deep freeze");
-                auto trustSet = [&]() {
-                    json::Value jv;
-                    jv[jss::Account] = issuer.human();
-                    {
-                        auto& ja = jv[jss::LimitAmount] =
-                            asset(0).value().getJson(JsonOptions::Values::None);
-                        ja[jss::issuer] = toBase58(vaultAcct.id());
-                    }
-                    jv[jss::TransactionType] = jss::TrustSet;
-                    return jv;
-                }();
-
-                trustSet[jss::Flags] = tfSetFreeze | tfSetDeepFreeze;
-                env(trustSet);
-                env.close();
-
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
-                    Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
-
-                trustSet[jss::Flags] = tfClearFreeze | tfClearDeepFreeze;
-                env(trustSet);
-                env.close();
-            }
-
-            // Clawback works while frozen
-            {
-                testcase("VaultDeposit IOU freeze clawback unaffected");
-                env(fset(issuer, asfGlobalFreeze));
-                env(vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
-                env(fclear(issuer, asfGlobalFreeze));
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
-                env.close();
-            }
-        };
-
-        runTests();
-        env.disableFeature(fixCleanup3_3_0);
-        runTests();
-        env.enableFeature(fixCleanup3_3_0);
-    }
-
-    void
-    testVaultDepositFreezeMPT()
-    {
-        using namespace test::jtx;
-        testcase("VaultDeposit MPT lock checks");
-
-        Account const issuer{"issuer"};
-        Account const owner{"owner"};
-        Env env{*this};
-        Vault vault{env};
-
-        env.fund(XRP(100'000), issuer, owner);
-        env.close();
-
-        MPTTester mptt{env, issuer, kMptInitNoFund};
-        mptt.create(
-            {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
-        PrettyAsset const mpt{mptt.issuanceID()};
-
-        mptt.authorize({.account = owner});
-        mptt.authorize({.account = issuer, .holder = owner});
-        env.close();
-        env(pay(issuer, owner, mpt(100'000)));
-        env.close();
-
-        auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
-        env(tx);
-        env.close();
-        auto const vaultAcctID = env.le(keylet)->at(sfAccount);
-        Account const vaultAcct("vault", vaultAcctID);
-
-        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
-        env.close();
-
-        // For MPT isDeepFrozen == isFrozen, so all locks block in
-        // both pre- and post-fix.
-        auto runTests = [&]() {
-            // Global lock
-            {
-                testcase("VaultDeposit MPT global lock");
-                mptt.set({.flags = tfMPTLock});
-                env.close();
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
-                    Ter(tecLOCKED));
-                mptt.set({.flags = tfMPTUnlock});
-                env.close();
-            }
-
-            // Depositor individual lock
-            {
-                testcase("VaultDeposit MPT depositor lock");
-                mptt.set({.holder = owner, .flags = tfMPTLock});
-                env.close();
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
-                    Ter(tecLOCKED));
-                mptt.set({.holder = owner, .flags = tfMPTUnlock});
-                env.close();
-            }
-
-            // Vault pseudo-account individual lock
-            {
-                testcase("VaultDeposit MPT pseudo-account lock");
-                mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
-                env.close();
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
-                    Ter(tecLOCKED));
-                mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
-                env.close();
-            }
-
-            // Clawback works while locked
-            {
-                testcase("VaultDeposit MPT lock clawback unaffected");
-                mptt.set({.flags = tfMPTLock});
-                env.close();
-                env(vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
-                mptt.set({.flags = tfMPTUnlock});
-                env.close();
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
-                env.close();
-            }
-        };
-
-        runTests();
-        env.disableFeature(fixCleanup3_3_0);
-        runTests();
-        env.enableFeature(fixCleanup3_3_0);
-    }
-
-    // Focused demonstration: a depositor under an individual IOU freeze
-    // can still withdraw to themselves (self-withdrawal), but is blocked from
-    // withdrawing to a third party.
-    //
-    // Pre-fixCleanup3_3_0: both the self-withdrawal AND the third-party
-    // withdrawal were blocked because the old code checked checkFrozen on the
-    // destination regardless of whether it was the submitter.
-    // Post-fixCleanup3_3_0: checkWithdrawFreeze skips the submitter freeze
-    // check when submitter == destination, so self-withdrawal succeeds.
-    void
-    testVaultSelfWithdrawWhileFrozen()
-    {
-        testcase("VaultWithdraw IOU self-withdrawal while individually frozen");
-
-        using namespace test::jtx;
-
-        Account const issuer{"issuer"};
-        Account const owner{"owner"};
-        Account const charlie{"charlie"};
-        Env env{*this};
-        Vault vault{env};
-
-        env.fund(XRP(100'000), issuer, owner, charlie);
-        env(fset(issuer, asfAllowTrustLineClawback));
-        env.close();
-
-        PrettyAsset const asset = issuer["IOU"];
-        env.trust(asset(1'000'000), owner);
-        env.trust(asset(1'000'000), charlie);
-        env(pay(issuer, owner, asset(100'000)));
-        env.close();
-
-        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-        env(tx);
-        env.close();
-
-        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}));
-        env.close();
-
-        auto runTests = [&]() {
-            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
-
-            // Set an individual freeze on the owner's IOU trustline.
-            env(trust(issuer, asset(0), owner, tfSetFreeze));
-            env.close();
-
-            // Self-withdrawal: submitter == destination, so the submitter
-            // freeze check is skipped.
-            // Post-fix: tesSUCCESS.  Pre-fix: tecFROZEN.
-            env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
-                Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
-
-            // Withdrawal to a third party is blocked: submitter != destination
-            // so the submitter freeze check applies.
-            {
-                auto withdrawToCharlie =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
-                withdrawToCharlie[sfDestination] = charlie.human();
-                // Post-fix: tecFROZEN (checkIndividualFrozen on submitter).
-                // Pre-fix: tecLOCKED (isFrozen on the vault share).
-                env(withdrawToCharlie, Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
-            }
-
-            env(trust(issuer, asset(0), owner, tfClearFreeze));
-            env.close();
-        };
-
-        runTests();
-        env.disableFeature(fixCleanup3_3_0);
-        runTests();
-        env.enableFeature(fixCleanup3_3_0);
-    }
-
-    void
-    testVaultWithdrawFreezeIOU()
-    {
-        using namespace test::jtx;
-        testcase("VaultWithdraw IOU freeze checks");
-
-        Account const issuer{"issuer"};
-        Account const owner{"owner"};
-        Env env{*this};
-        Vault const vault{env};
-
-        env.fund(XRP(100'000), issuer, owner);
-        env(fset(issuer, asfAllowTrustLineClawback));
-        env.close();
-        PrettyAsset const asset = issuer["IOU"];
-        env.trust(asset(1'000'000), owner);
-        env(pay(issuer, owner, asset(100'000)));
-        env.close();
-
-        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-        env(tx);
-        env.close();
-        auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
-
-        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
-        env.close();
-
-        Account const charlie{"charlie"};
-        env.fund(XRP(10'000), charlie);
-        env.trust(asset(1'000'000), charlie);
-        env.close();
-
-        auto runTests = [&]() {
-            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
-            // Global freeze → self-withdraw
-            {
-                testcase("VaultWithdraw IOU global freeze");
-                env(fset(issuer, asfGlobalFreeze));
-                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
-                    Ter(tecFROZEN));
-                // Global freeze → withdraw to 3rd party
-
-                auto withdrawToCharlie =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
-                withdrawToCharlie[sfDestination] = charlie.human();
-                env(withdrawToCharlie, Ter(tecFROZEN));
-
-                env(fclear(issuer, asfGlobalFreeze));
-            }
-
-            // Vault-account freeze
-            {
-                testcase("VaultWithdraw IOU pseudo-account freeze");
-                auto trustSet = [&]() {
-                    json::Value jv;
-                    jv[jss::Account] = issuer.human();
-                    {
-                        auto& ja = jv[jss::LimitAmount] =
-                            asset(0).value().getJson(JsonOptions::Values::None);
-                        ja[jss::issuer] = toBase58(vaultAcct.id());
-                    }
-                    jv[jss::TransactionType] = jss::TrustSet;
-                    return jv;
-                }();
-
-                trustSet[jss::Flags] = tfSetFreeze;
-                env(trustSet);
-                env.close();
-
-                TER const terExpected = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
-
-                // Self-withdraw
-                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
-                    Ter(terExpected));
-                // Withdraw to 3rd party
-
-                auto withdrawToCharlie =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
-                withdrawToCharlie[sfDestination] = charlie.human();
-                env(withdrawToCharlie, Ter(terExpected));
-
-                trustSet[jss::Flags] = tfClearFreeze;
-                env(trustSet);
-                env.close();
-            }
-
-            // Depositor freeze, self-withdraw
-            {
-                testcase("VaultWithdraw IOU self-withdraw freeze check");
-                env(trust(issuer, asset(0), owner, tfSetFreeze));
-
-                // Post-fix: self-withdraw allowed (submitter==dst skip)
-                // Pre-fix: isFrozen(depositor, iou) catches it
-                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
-                    Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
-
-                // Depositor freeze withdraw to 3rd party
-                auto withdrawTo3rd =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
-                withdrawTo3rd[sfDestination] = charlie.human();
-
-                // Post-fix: submitter freeze blocks withdraw to 3rd party
-                // Pre-fix: submitter's IOU freeze not checked, but checkFrozen(depositor,
-                // share) triggers tecLOCKED
-                env(withdrawTo3rd, Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
-
-                env(trust(issuer, asset(0), owner, tfClearFreeze));
-                // Replenish what was withdrawn
-                if (fix330Enabled)
-                {
-                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
-                }
-                env.close();
-            }
-
-            // Depositor deep freeze → self-withdraw blocked
-            {
-                testcase("VaultWithdraw IOU depositor deep freeze");
-                env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
-
-                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
-                    Ter(tecFROZEN));
-
-                env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
-            }
-
-            // Destination freeze → withdraw to 3rd party
-            {
-                testcase("VaultWithdraw IOU freeze withdraw to 3rd party");
-
-                env(trust(issuer, asset(0), charlie, tfSetFreeze));
-
-                // Self-withdraw unaffected by charlie's freeze
-                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
-
-                auto withdrawToCharlie =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
-                withdrawToCharlie[sfDestination] = charlie.human();
-
-                // Post-fix: freeze on dst allowed
-                // Pre-fix: checkFrozen(dst, iou) catches it
-                env(withdrawToCharlie, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
-
-                env(trust(issuer, asset(0), charlie, tfClearFreeze));
-
-                // Replenish: 1 for self-withdraw + 1 if charlie withdraw succeeded
-                env(vault.deposit(
-                    {.depositor = owner,
-                     .id = keylet.key,
-                     .amount = asset(fix330Enabled ? 2 : 1)}));
-                env.close();
-            }
-
-            // Destination deep freeze → withdraw to 3rd party blocked
-            {
-                testcase("VaultWithdraw IOU deep freeze withdraw to 3rd party");
-
-                env(trust(issuer, asset(0), charlie, tfSetFreeze | tfSetDeepFreeze));
-
-                auto withdrawToCharlie =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
-                withdrawToCharlie[sfDestination] = charlie.human();
-                env(withdrawToCharlie, Ter(tecFROZEN));
-
-                // Destination deep freeze → self-withdraw unaffected
-                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
-
-                env(trust(issuer, asset(0), charlie, tfClearFreeze | tfClearDeepFreeze));
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
-                env.close();
-            }
-
-            // Clawback works while frozen
-            {
-                testcase("VaultWithdraw IOU freeze clawback unaffected");
-                env(fset(issuer, asfGlobalFreeze));
-
-                env(vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
-
-                env(fclear(issuer, asfGlobalFreeze));
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
-                env.close();
-            }
-        };
-
-        runTests();
-        env.disableFeature(fixCleanup3_3_0);
-        runTests();
-        env.enableFeature(fixCleanup3_3_0);
-    }
-
-    void
-    testVaultWithdrawFreezeMPT()
-    {
-        using namespace test::jtx;
-        testcase("VaultWithdraw MPT lock checks");
-
-        Account const issuer{"issuer"};
-        Account const owner{"owner"};
-        Env env{*this};
-        Vault vault{env};
-
-        env.fund(XRP(100'000), issuer, owner);
-        env.close();
-
-        MPTTester mptt{env, issuer, kMptInitNoFund};
-        mptt.create(
-            {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
-        PrettyAsset const mpt{mptt.issuanceID()};
-
-        mptt.authorize({.account = owner});
-        mptt.authorize({.account = issuer, .holder = owner});
-        env.close();
-        env(pay(issuer, owner, mpt(100'000)));
-        env.close();
-
-        auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
-        env(tx);
-        env.close();
-        Account const vaultAcct("vault", env.le(keylet)->at(sfAccount));
-
-        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
-        env.close();
-
-        Account const charlie{"charlie"};
-        env.fund(XRP(10'000), charlie);
-        env.close();
-        mptt.authorize({.account = charlie});
-        mptt.authorize({.account = issuer, .holder = charlie});
-        env.close();
-
-        auto runTests = [&]() {
-            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
-
-            // Global lock
-            {
-                testcase("VaultWithdraw MPT global lock");
-                mptt.set({.flags = tfMPTLock});
-                env.close();
-                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
-                    Ter(tecLOCKED));
-
-                // Global lock → withdraw to issuer
-                // Post-fix: bypasses freeze checks, but accountHolds
-                //           on the pseudo returns 0 under global lock
-                // Pre-fix: checkFrozen(dst=issuer) catches global lock
-                {
-                    auto withdrawToIssuer =
-                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
-                    withdrawToIssuer[sfDestination] = issuer.human();
-                    env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
-                }
-                mptt.set({.flags = tfMPTUnlock});
-                env.close();
-                if (fix330Enabled)
-                {
-                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
-                }
-                env.close();
-            }
-
-            // Vault pseudo-account individual lock
-            {
-                testcase("VaultWithdraw MPT pseudo-account lock");
-                mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
-                env.close();
-                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
-                    Ter(tecLOCKED));
-                mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
-                env.close();
-            }
-
-            // Depositor individual lock → self-withdraw blocked
-            // (isDeepFrozen == isFrozen for MPT)
-            {
-                testcase("VaultWithdraw MPT depositor lock");
-                mptt.set({.holder = owner, .flags = tfMPTLock});
-                env.close();
-                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
-                    Ter(tecLOCKED));
-                // Depositor lock → withdraw to 3rd party also blocked
-                {
-                    auto withdrawToCharlie =
-                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
-                    withdrawToCharlie[sfDestination] = charlie.human();
-                    env(withdrawToCharlie, Ter(tecLOCKED));
-                }
-
-                // Depositor lock → withdraw to issuer
-                // Post-fix: issuer bypass in checkWithdrawFreezes
-                // Pre-fix: checkFrozen(depositor, share) blocks transitively
-                {
-                    auto withdrawToIssuer =
-                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
-                    withdrawToIssuer[sfDestination] = issuer.human();
-                    env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
-                }
-                mptt.set({.holder = owner, .flags = tfMPTUnlock});
-                env.close();
-                if (fix330Enabled)
-                {
-                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
-                }
-                env.close();
-            }
-
-            // 3rd party destination lock → withdraw to 3rd party blocked
-            {
-                testcase("VaultWithdraw MPT 3rd party destination lock");
-                mptt.set({.holder = charlie, .flags = tfMPTLock});
-                env.close();
-                {
-                    auto withdrawToCharlie =
-                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
-                    withdrawToCharlie[sfDestination] = charlie.human();
-                    env(withdrawToCharlie, Ter{tecLOCKED});
-                }
-                // 3rd party lock → self-withdraw unaffected
-                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
-                mptt.set({.holder = charlie, .flags = tfMPTUnlock});
-                env.close();
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
-                env.close();
-            }
-
-            // Clawback works while locked
-            {
-                testcase("VaultWithdraw MPT lock clawback unaffected");
-                mptt.set({.flags = tfMPTLock});
-                env.close();
-                env(vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
-                mptt.set({.flags = tfMPTUnlock});
-                env.close();
-                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
-                env.close();
-            }
-        };
-
-        runTests();
-        env.disableFeature(fixCleanup3_3_0);
-        runTests();
-        env.enableFeature(fixCleanup3_3_0);
-    }
-
-public:
-    void
-    run() override
-    {
-        testVaultWithdrawEqualityEnforced();
-        testBugIssuerVaultDepositAtEdge();
-        testBugMakeDeltaPosteriorScale();
-        testBugMakeDeltaAnteriorScale();
-        testVaultDepositCanonicalizeToZero();
-        testVaultWithdrawCanonicalizeToZero();
-        testVaultDepositNegativeBalanceFromOppositeLimit();
-        testSequences();
-        testPreflight();
-        testCreateFailXRP();
-        testCreateFailIOU();
-        testCreateFailMPT();
-        testWithMPT();
-        testWithIOU();
-        testWithDomainCheck();
-        testWithDomainChecXRP();
-        testNonTransferableShares();
-        testFailedPseudoAccount();
-        testScaleIOU();
-        testRPC();
-        testVaultClawbackBurnShares();
-        testVaultClawbackAssets();
-        testVaultEscrowedMPT();
-        testAssetsMaximum();
-        testVaultDeleteMemoData();
-        testVaultCreateLEVersion();
-        testBug6LimitBypassWithShares();
-        testRemoveEmptyHoldingLockedAmount();
-        testRemoveEmptyHoldingConfidentialBalances();
-
-        testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderFixedAssetExit(all_);
-        testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderFullSharesRejected(all_);
-        testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderCleanVaultUnaffected(all_);
-        testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
-        testWithdrawSoleShareholderLoanRepaymentExit();
-
-        testVaultDepositFreezeIOU();
-        testVaultDepositFreezeMPT();
-        testVaultWithdrawFreezeIOU();
-        testVaultWithdrawFreezeMPT();
-        testVaultSelfWithdrawWhileFrozen();
-
-        testReferenceHolding();
-        testHoldingDeletionBlocked();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE_PRIO(Vault, app, xrpl, 1);
-
-}  // namespace xrpl
diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp
index 5d67cdc3c5..32c49feb02 100644
--- a/src/test/app/lending/LendingHelpers_test.cpp
+++ b/src/test/app/lending/LendingHelpers_test.cpp
@@ -2,18 +2,27 @@
 // DO NOT REMOVE
 #include 
 #include 
+#include 
 #include 
+#include 
+#include 
+#include 
+#include 
 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 
 #include 
@@ -409,7 +418,7 @@ class LendingHelpers_test : public beast::unit_test::Suite
         Env const env{*this};
         auto const& rules = env.current()->rules();
 
-        // Inputs from the bug reproduction in Loan_test.cpp:
+        // Inputs from the near-zero-rate LoanPay bug reproduction:
         //   InterestRate = 1 TenthBips32 (0.001 % per year),
         //   PaymentInterval = 600 s, principal = 100, 3 payments.
         // periodicRate is ~1.9e-10.
@@ -1871,6 +1880,93 @@ public:
         }
     }
 
+    // Targeted unit test for getLoanDefaultFreezeExemptAccounts(): builds a real
+    // (XRP, so no trust lines needed) Vault/LoanBroker/Loan chain, then calls
+    // the function directly against hand-picked, unsubmitted transactions
+    // (via env.jt(), which never touches the ledger) to exercise every early
+    // return and the success path precisely.
+    void
+    testLoanDefaultFreezeExemptAccounts()
+    {
+        using namespace jtx;
+        using namespace loan;
+
+        testcase("getLoanDefaultFreezeExemptAccounts");
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        Env env{*this};
+        Vault const vault{env};
+        env.fund(XRP(10'000), lender, borrower);
+        env.close();
+
+        auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()});
+        env(vaultTx);
+        env.close();
+        env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = XRP(1'000)}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
+        env(loan_broker::set(lender, vaultKeylet.key));
+        env.close();
+
+        env(set(borrower, brokerKeylet.key, Number{200'000}),
+            Sig(sfCounterpartySignature, lender),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+
+        // Not a LoanManage transaction at all.
+        {
+            auto const jt = env.jt(jtx::pay(lender, borrower, XRP(1)));
+            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
+        }
+
+        // LoanManage, but not the tfLoanDefault flag.
+        {
+            auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanImpair));
+            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
+        }
+
+        // tfLoanDefault, but fixCleanup3_4_0 is disabled.
+        {
+            env.disableFeature(fixCleanup3_4_0);
+            auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanDefault));
+            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
+            env.enableFeature(fixCleanup3_4_0);
+        }
+
+        // tfLoanDefault, amendment enabled, but the referenced Loan doesn't
+        // exist (reusing the broker's own ID as a bogus LoanID, same trick
+        // testInvalidLoanManage-style tests use elsewhere in this suite).
+        {
+            auto const jt = env.jt(manage(lender, brokerKeylet.key, tfLoanDefault));
+            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
+        }
+
+        // tfLoanDefault, amendment enabled, Loan/LoanBroker/Vault all exist:
+        // resolves the issuer, broker, vault accounts, and the vault's asset.
+        {
+            auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanDefault));
+            auto const result = getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx);
+            auto const brokerSle = env.le(brokerKeylet);
+            auto const vaultSle = env.le(vaultKeylet);
+            BEAST_EXPECT(result);
+            BEAST_EXPECT(brokerSle);
+            BEAST_EXPECT(vaultSle);
+            if (result && brokerSle && vaultSle)
+            {
+                BEAST_EXPECT(result->issuer == vaultSle->at(sfAsset).getIssuer());
+                BEAST_EXPECT(result->broker == brokerSle->at(sfAccount));
+                BEAST_EXPECT(result->vault == vaultSle->at(sfAccount));
+                BEAST_EXPECT(result->asset == vaultSle->at(sfAsset));
+            }
+        }
+    }
+
     void
     run() override
     {
@@ -1906,6 +2002,8 @@ public:
         testLoanOriginationExceedsVaultMaximumDispatcher();
         testLoanVaultExposureDispatcher();
         testLoanPaymentDeltasDispatcher();
+
+        testLoanDefaultFreezeExemptAccounts();
     }
 };
 
diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp
index c5efb59194..5efa65d506 100644
--- a/src/test/app/lending/LoanBroker_test.cpp
+++ b/src/test/app/lending/LoanBroker_test.cpp
@@ -1114,7 +1114,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             // holder == account
             env(jtx, Ter(temINVALID));
 
-            // holder == beast::zero
+            // holder == beast::kZero
             STAmount const bad(Issue{usd.currency, beast::kZero}, 100);
             jtx.jv[sfAmount] = bad.getJson();
             jtx.stx = env.ust(jtx);
diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
index a9b3542c4e..b0c43190c5 100644
--- a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
+++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
@@ -5,6 +5,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -13,6 +14,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -368,6 +370,186 @@ private:
         };
     }
 
+    void
+    testLoanDefaultBypassesFreeze()
+    {
+        testcase("LoanManage: default bypasses asset freeze");
+        using namespace jtx;
+        using namespace loan;
+        Account const lender{"lender"};
+        Account const issuer{"issuer"};
+        Account const borrower{"borrower"};
+        auto const iou = issuer["IOU"];
+
+        Env env(*this);
+        env.fund(XRP(1'000), lender, issuer, borrower);
+        env(trust(lender, iou(10'000'000)));
+        env(pay(issuer, lender, iou(5'000'000)));
+        BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)};
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value();
+
+        env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+            Sig(sfCounterpartySignature, lender),
+            loanSetFee);
+        env.close();
+
+        auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
+
+        using tp = NetClock::time_point;
+        using d = NetClock::duration;
+
+        // Get past the grace period so the loan is defaultable.
+        if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
+        {
+            env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
+        }
+
+        // Global freeze trips the post-apply TransfersNotFrozen invariant.
+        env(fset(issuer, asfGlobalFreeze));
+        env.close();
+
+        // Pre-fixCleanup3_4_0, the invariant blocks the default.
+        env.disableFeature(fixCleanup3_4_0);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED));
+        env.close();
+
+        // Per XLS-0066, a default must succeed despite the freeze.
+        env.enableFeature(fixCleanup3_4_0);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+    }
+
+    // A default must bypass an MPT global lock the same way it bypasses IOU
+    // freeze, including when the loan was already impaired beforehand
+    // (a different defaultLoan() accounting branch than the un-impaired
+    // path exercised above) and after an ordinary LoanPay was correctly
+    // blocked by the same lock.
+    void
+    testLoanDefaultBypassesMptLockAfterImpair()
+    {
+        testcase("LoanManage: default bypasses MPT lock after impairment");
+        using namespace jtx;
+        using namespace loan;
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        Env env(*this);
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
+        env.close();
+
+        MPTTester mptt(
+            {.env = env,
+             .issuer = issuer,
+             .holders = {lender, borrower},
+             .flags = tfMPTCanTransfer | tfMPTCanLock});
+        PrettyAsset const asset = mptt.issuanceID();
+        env(pay(issuer, lender, asset(10'000'000)));
+        env.close();
+
+        BrokerInfo const brokerInfo{createVaultAndBroker(env, asset, lender)};
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value();
+        env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+            Sig(sfCounterpartySignature, lender),
+            loanSetFee);
+        env.close();
+
+        auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
+
+        // Realize a loss via impairment before locking.
+        env(manage(lender, loanKeylet.key, tfLoanImpair));
+        env.close();
+
+        // Issuer applies a global lock.
+        mptt.set({.account = issuer, .flags = tfMPTLock});
+        env.close();
+
+        // An ordinary payment is correctly blocked by the lock.
+        env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecLOCKED));
+        env.close();
+
+        using tp = NetClock::time_point;
+        using d = NetClock::duration;
+        if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
+        {
+            env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
+        }
+
+        // Pre-fixCleanup3_4_0 the ValidMPTTransfer invariant blocks the
+        // default, mirroring the IOU path above.
+        env.disableFeature(fixCleanup3_4_0);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED));
+        env.close();
+
+        // The default itself must succeed despite the lock.
+        env.enableFeature(fixCleanup3_4_0);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+    }
+
+    // The exemption must hold for an individually deep-frozen trust line, not
+    // just a global freeze: deep freeze is what the original report ran into,
+    // and it takes a different path through validateFrozenState (the frozen
+    // flag comes off the line rather than off the issuer).
+    void
+    testLoanDefaultBypassesDeepFreeze()
+    {
+        testcase("LoanManage: default bypasses asset deep freeze");
+        using namespace jtx;
+        using namespace loan;
+        Account const lender{"lender"};
+        Account const issuer{"issuer"};
+        Account const borrower{"borrower"};
+        auto const iou = issuer["IOU"];
+
+        Env env(*this);
+        env.fund(XRP(1'000), lender, issuer, borrower);
+        env(trust(lender, iou(10'000'000)));
+        env(pay(issuer, lender, iou(5'000'000)));
+        BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)};
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value();
+
+        env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+            Sig(sfCounterpartySignature, lender),
+            loanSetFee);
+        env.close();
+
+        auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
+
+        using tp = NetClock::time_point;
+        using d = NetClock::duration;
+
+        // Get past the grace period so the loan is defaultable.
+        if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
+        {
+            env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
+        }
+
+        // The default moves First-Loss Capital off the broker pseudo-account,
+        // so that is the line to freeze.
+        auto const brokerSle = env.le(brokerInfo.brokerKeylet());
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        Account const brokerPseudo{"brokerPseudo", brokerSle->at(sfAccount)};
+
+        env(trust(issuer, brokerPseudo["IOU"](0), tfSetFreeze | tfSetDeepFreeze));
+        env.close();
+
+        // Pre-fixCleanup3_4_0, the invariant blocks the default.
+        env.disableFeature(fixCleanup3_4_0);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED));
+        env.close();
+
+        // Per XLS-0066, a default must succeed despite the deep freeze.
+        env.enableFeature(fixCleanup3_4_0);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+    }
+
     void
     testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features)
     {
@@ -694,6 +876,9 @@ private:
     runAmendmentIndependent()
     {
         testServiceFeeOnBrokerDeepFreeze();
+        testLoanDefaultBypassesFreeze();
+        testLoanDefaultBypassesDeepFreeze();
+        testLoanDefaultBypassesMptLockAfterImpair();
     }
 
     // Tests run under each entry in amendmentCombinations().
diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp
index 2cb4f38ecf..c5a7d54311 100644
--- a/src/test/app/lending/LoanMisc_test.cpp
+++ b/src/test/app/lending/LoanMisc_test.cpp
@@ -473,14 +473,21 @@ protected:
         TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)};
         auto const serviceFee = serviceFeeDist_(engine_);
         TenthBips32 interest{interestRateDist_(engine_)};
-        auto const payTotal = paymentTotalDist_(engine_);
+        auto payTotal = paymentTotalDist_(engine_);
         auto const payInterval = paymentIntervalDist_(engine_);
+        // The end of the last payment's grace period must fit in a 32-bit
+        // ripple-epoch timestamp, or LoanSet fails with tecKILLED. Cap the
+        // schedule well below that horizon (2e9 seconds is roughly 63 years,
+        // leaving ample headroom over the ledger start date).
+        constexpr std::uint32_t kMaxScheduleSeconds = 2'000'000'000;
+        payTotal = std::min(payTotal, static_cast(kMaxScheduleSeconds / payInterval));
 
         BrokerParameters const brokerParams{
             .vaultDeposit = principalRequest * 10,
             .debtMax = 0,
             .coverRateMin = TenthBips32{0},
-            .managementFeeRate = managementFeeRate};
+            .managementFeeRate = managementFeeRate,
+            .coverRateLiquidation = TenthBips32{0}};
         LoanParameters const loanParams{
             .account = lender,
             .counter = borrower,
diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp
index 9d840fe1bf..93d1671feb 100644
--- a/src/test/app/lending/LoanPay_test.cpp
+++ b/src/test/app/lending/LoanPay_test.cpp
@@ -5,6 +5,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -13,6 +14,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -728,6 +730,110 @@ private:
         }
     }
 
+    void
+    testLoanPayFundsConservedPayeeBelowReserve(FeatureBitset features)
+    {
+        // Regression test: LoanPay::doApply's fund-conservation check used to
+        // read XRP balances via accountHolds(..., SpendableHandling::
+        // FullBalance), which for XRP always defers to xrpLiquid (balance
+        // minus reserve, clamped at zero). When the broker fee landed on a
+        // payee sitting below its own reserve, that payee's clamped balance
+        // stayed zero and the fee vanished from the conservation sum,
+        // tripping "funds are conserved (with rounding)".
+        testcase("LoanPay funds conserved: broker fee payee below reserve");
+
+        using namespace jtx;
+
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        // Broker defaults match the fuzz workload: ManagementFeeRate = 100
+        // tenth-bips. The service fee guarantees feePaid > 0 on the first
+        // regular payment.
+        BrokerParameters const brokerParams;
+        Number const serviceFeeValue{2};
+        LoanParameters const loanParams{
+            .account = borrower,
+            .counter = lender,
+            .principalRequest = 1000,
+            .serviceFee = serviceFeeValue,
+            .interest = TenthBips32{percentageToTenthBips(12)},
+            .payTotal = 12,
+            .payInterval = 3600};
+
+        auto const loanOpt =
+            createLoan(env, AssetType::XRP, brokerParams, loanParams, issuer, lender, borrower);
+        if (BEAST_EXPECT(loanOpt); !loanOpt.has_value())
+            return;
+        auto const& [broker, loanKeylet, brokerPseudo] = *loanOpt;
+
+        auto const vaultPseudo = [&]() {
+            auto const vaultSle = env.le(keylet::vault(broker.vaultID));
+            if (!BEAST_EXPECT(vaultSle))
+                return AccountID{};
+            return vaultSle->at(sfAccount);
+        }();
+
+        // Raw AccountRoot balance, matching LoanPay::doApply's conservation
+        // check (not the reserve-clamped accountHolds()/xrpLiquid() value).
+        auto rawBalance = [&](AccountID const& id) -> STAmount {
+            auto const sle = env.le(keylet::account(id));
+            if (!BEAST_EXPECT(sle))
+                return STAmount{};
+            return sle->getFieldAmount(sfBalance);
+        };
+        auto lenderReserve = [&] {
+            return env.current()->fees().accountReserve(ownerCount(env, lender), 1);
+        };
+
+        STAmount const baseFee{env.current()->fees().base};
+
+        // Park the lender (broker owner, fee payee) exactly at its reserve,
+        // then burn part of the reserve with an oversized transaction fee.
+        // Fees are exempt from the reserve check, so the balance ends up
+        // below the reserve.
+        env(pay(lender, issuer, rawBalance(lender.id()) - lenderReserve() - baseFee));
+        env(noop(lender), Fee(XRP(100)));
+        env.close();
+        BEAST_EXPECT(env.balance(lender) < lenderReserve());
+
+        // First regular payment, exactly the amount due.
+        auto const state = getCurrentState(env, broker, loanKeylet);
+        STAmount const serviceFee = broker.asset(serviceFeeValue);
+        STAmount const roundedPeriodicPayment{
+            broker.asset,
+            roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)};
+        STAmount const totalDue = roundToScale(
+            roundedPeriodicPayment + serviceFee, state.loanScale, Number::RoundingMode::Upward);
+
+        auto const borrowerBefore = rawBalance(borrower.id());
+        auto const vaultBefore = rawBalance(vaultPseudo);
+        auto const lenderBefore = rawBalance(lender.id());
+
+        // Before the fix, this aborted inside LoanPay::doApply on
+        // XRPL_ASSERT_PARTS(goodRounding, "xrpl::LoanPay::doApply", "funds
+        // are conserved (with rounding)").
+        env(loan::pay(borrower, loanKeylet.key, totalDue));
+        env.close();
+
+        auto const borrowerAfter = rawBalance(borrower.id());
+        auto const vaultAfter = rawBalance(vaultPseudo);
+        auto const lenderAfter = rawBalance(lender.id());
+
+        // The broker fee reached the lender's AccountRoot, even though the
+        // lender's balance remains below its reserve.
+        BEAST_EXPECT(lenderAfter > lenderBefore);
+        BEAST_EXPECT(lenderAfter < lenderReserve());
+
+        // Total funds conserved across the payer, vault, and fee payee.
+        BEAST_EXPECT(
+            borrowerBefore - baseFee + vaultBefore + lenderBefore ==
+            borrowerAfter + vaultAfter + lenderAfter);
+    }
+
     void
     runAmendmentIndependent()
     {
@@ -741,6 +847,7 @@ private:
 #if LOAN_TODO
         testLoanPayLateFullPaymentBypassesPenalties(features);
 #endif
+        testLoanPayFundsConservedPayeeBelowReserve(features);
         testOverpaymentManagementFee(features);
         testDosLoanPay(features);
         testLoanNextPaymentDueDateOverflow(features);
diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp
index 5e69c9f79e..b666281fee 100644
--- a/src/test/app/lending/LoanRounding_test.cpp
+++ b/src/test/app/lending/LoanRounding_test.cpp
@@ -889,6 +889,259 @@ private:
         env.close();
     }
 
+    // Pre-fixCleanup3_4_0 bug: VaultWithdraw for a fixed *share* amount that
+    // rounds to zero assets trips tecINVARIANT_FAILED instead of failing
+    // cleanly or succeeding, depending on why it's zero. The fixed-shares
+    // branch had no zero guard, unlike the fixed-assets branch.
+    // XRP case: pool value is nonzero (2,000,000) but 1 share's worth (0.5
+    // drops) truncates to zero drops -> real precision loss -> tecPRECISION_LOSS.
+    // IOU case: loan drew 100% of the vault and is fully impaired, so
+    // AssetsTotal == LossUnrealized exactly -> pool value is genuinely zero
+    // -> legitimate zero-value withdrawal -> tesSUCCESS.
+    void
+    testBugVaultWithdrawFixedSharesRoundsToZero(FeatureBitset features)
+    {
+        testcase("bug: VaultWithdraw fixed shares round down to zero assets");
+
+        using namespace jtx;
+        using namespace loan;
+
+        bool const fixed = features[fixCleanup3_4_0];
+
+        Env env(*this, features);
+
+        Account const lender{"lender"};
+        Account const depositorB{"depositorB"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, depositorB, borrower);
+        env.close();
+
+        // asset(n) == n drops.
+        PrettyAsset const xrpAsset{xrpIssue(), 1};
+
+        auto const broker = createVaultAndBroker(
+            env,
+            xrpAsset,
+            lender,
+            {.vaultDeposit = 1'000'000, .debtMax = 3'000'000, .coverDeposit = 1'000'000});
+
+        Vault const v{env};
+        env(v.deposit(
+            {.depositor = depositorB,
+             .id = broker.vaultKeylet().key,
+             .amount = xrpAsset(3'000'000)}));
+        env.close();
+
+        auto const brokerSle = env.le(broker.brokerKeylet());
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, Number{2'000'000}),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(2),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Impair the loan so LossUnrealized > 0.
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultSle = env.le(broker.vaultKeylet());
+        if (!BEAST_EXPECT(vaultSle))
+            return;
+        BEAST_EXPECT(vaultSle->at(sfLossUnrealized) > beast::kZero);
+
+        // (AssetsTotal 4M - LossUnrealized 2M) * 1 share / 4M shares = 0.5,
+        // rounds down to zero drops.
+        auto const shareAsset = vaultSle->at(sfShareMPTID);
+        STAmount const oneShare{MPTIssue{shareAsset}, Number(1)};
+
+        env(v.withdraw({.depositor = lender, .id = broker.vaultKeylet().key, .amount = oneShare}),
+            Ter(fixed ? tecPRECISION_LOSS : tecINVARIANT_FAILED));
+        env.close();
+
+        // Same bug, IOU asset. Needs a 2nd, minimal depositor: a sole
+        // shareholder would waive the loss subtraction (fixCleanup3_2_0),
+        // returning full value instead of zero.
+        {
+            Account const issuer{"issuer"};
+            Account const iouLender{"iouLender"};
+            Account const iouDepositorB{"iouDepositorB"};
+            Account const iouBorrower{"iouBorrower"};
+
+            env.fund(XRP(10'000'000), issuer, iouLender, iouDepositorB, iouBorrower);
+            env.close();
+
+            PrettyAsset const iouAsset = issuer[iouCurrency_];
+            env(trust(iouLender, iouAsset(10'000'000)));
+            env(trust(iouDepositorB, iouAsset(10'000'000)));
+            env(trust(iouBorrower, iouAsset(10'000'000)));
+            // iouLender funds the vault deposit and the broker's cover deposit.
+            env(pay(issuer, iouLender, iouAsset(9'000'000)));
+            env(pay(issuer, iouDepositorB, iouAsset(1)));
+            env.close();
+
+            // No management fee -> LossUnrealized ends up == AssetsTotal.
+            auto const iouBroker = createVaultAndBroker(
+                env,
+                iouAsset,
+                iouLender,
+                {.vaultDeposit = 3'999'999,
+                 .debtMax = 4'000'000,
+                 .coverDeposit = 4'000'000,
+                 .managementFeeRate = TenthBips16{0}});
+
+            env(v.deposit(
+                {.depositor = iouDepositorB,
+                 .id = iouBroker.vaultKeylet().key,
+                 .amount = iouAsset(1)}));
+            env.close();
+
+            auto const iouBrokerSle = env.le(iouBroker.brokerKeylet());
+            if (!BEAST_EXPECT(iouBrokerSle))
+                return;
+            auto const iouLoanKeylet = keylet::loan(
+                iouBroker.brokerID, SeqProxy::rawSequence(iouBrokerSle->at(sfLoanSequence)));
+
+            // Draw the entire vault out as a single loan.
+            env(set(iouBorrower, iouBroker.brokerID, Number{4'000'000}),
+                Sig(sfCounterpartySignature, iouLender),
+                kPaymentTotal(2),
+                kPaymentInterval(600),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            env(manage(iouLender, iouLoanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+            env.close();
+
+            auto const iouVaultSle = env.le(iouBroker.vaultKeylet());
+            if (!BEAST_EXPECT(iouVaultSle))
+                return;
+            BEAST_EXPECT(iouVaultSle->at(sfLossUnrealized) == iouVaultSle->at(sfAssetsTotal));
+
+            auto const iouShareAsset = iouVaultSle->at(sfShareMPTID);
+            STAmount const oneIouShare{MPTIssue{iouShareAsset}, Number(1)};
+
+            auto const iouLenderBalanceBefore = env.balance(iouLender, iouAsset);
+            auto const iouVaultAvailableBefore = iouVaultSle->at(sfAssetsAvailable);
+            // Env::balance can't be used for shares: it resolves the issuer
+            // name, and the share issuer is the vault pseudo-account, which
+            // Env doesn't know.
+            auto const lenderShares = [&]() -> std::uint64_t {
+                auto const sle = env.le(keylet::mptoken(iouShareAsset, iouLender.id()));
+                return sle ? sle->at(sfMPTAmount) : 0;
+            };
+            auto const iouLenderSharesBefore = lenderShares();
+            auto const iouIssuanceBefore = env.le(keylet::mptokenIssuance(iouShareAsset));
+            if (!BEAST_EXPECT(iouIssuanceBefore))
+                return;
+            auto const iouSharesOutstandingBefore = iouIssuanceBefore->at(sfOutstandingAmount);
+            env(v.withdraw(
+                    {.depositor = iouLender,
+                     .id = iouBroker.vaultKeylet().key,
+                     .amount = oneIouShare}),
+                fixed ? Ter(tesSUCCESS) : Ter(tecINVARIANT_FAILED));
+            env.close();
+
+            if (fixed)
+            {
+                // Confirm this was a true zero-value transfer: balances
+                // unchanged even though a share was burned.
+                BEAST_EXPECT(env.balance(iouLender, iouAsset) == iouLenderBalanceBefore);
+                BEAST_EXPECT(lenderShares() == iouLenderSharesBefore - 1);
+                auto const iouIssuanceAfter = env.le(keylet::mptokenIssuance(iouShareAsset));
+                if (BEAST_EXPECT(iouIssuanceAfter))
+                {
+                    BEAST_EXPECT(
+                        iouIssuanceAfter->at(sfOutstandingAmount) ==
+                        iouSharesOutstandingBefore - 1);
+                }
+                auto const iouVaultAfter = env.le(iouBroker.vaultKeylet());
+                if (BEAST_EXPECT(iouVaultAfter))
+                {
+                    BEAST_EXPECT(iouVaultAfter->at(sfAssetsAvailable) == iouVaultAvailableBefore);
+                }
+            }
+        }
+    }
+
+    // Companion to the Vault_test dust-debit tests, which use a single
+    // depositor so AssetsTotal == AssetsAvailable and both debitIsNonZeroDust
+    // operands in VaultWithdraw::doApply trip together. Here a loan draws
+    // almost the entire vault, leaving AssetsTotal (1e7) far above
+    // AssetsAvailable (100): redeeming 1 share moves 1e-10 assets, which is
+    // dust against AssetsTotal but representable against AssetsAvailable, so
+    // the AssetsTotal operand alone carries the rejection.
+    void
+    testBugVaultWithdrawDustVsAssetsTotal(FeatureBitset features)
+    {
+        testcase("bug: VaultWithdraw dust debit vs AssetsTotal only");
+
+        using namespace jtx;
+        using namespace loan;
+
+        bool const fixed = features[fixCleanup3_4_0];
+
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), issuer, lender, borrower);
+        env.close();
+
+        PrettyAsset const iouAsset = issuer[iouCurrency_];
+        env(trust(lender, iouAsset(100'000'000)));
+        env(trust(borrower, iouAsset(100'000'000)));
+        env(pay(issuer, lender, iouAsset(20'000'000)));
+        env.close();
+
+        // Scale 10 so 1 share is worth 1e-10 assets against the 1e7 pool.
+        auto const broker = createVaultAndBroker(
+            env,
+            iouAsset,
+            lender,
+            {.vaultDeposit = 10'000'000,
+             .debtMax = 10'000'000,
+             .coverDeposit = 1'000'000,
+             .vaultScale = 10});
+
+        // Draw all but 100 units: AssetsAvailable drops to 100 while
+        // AssetsTotal stays at 1e7 (the loan is still an asset of the vault).
+        env(set(borrower, broker.brokerID, Number{9'999'900}),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(2),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultSle = env.le(broker.vaultKeylet());
+        if (!BEAST_EXPECT(vaultSle))
+            return;
+        BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == Number{10'000'000});
+        BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == Number{100});
+
+        // 1 share redeems 1e7 * 1 / 1e17 = 1e-10 assets. Subtracting that
+        // from AssetsTotal needs 18 significant digits and canonicalizes
+        // straight back to 1e7 (no-op), while AssetsAvailable would become
+        // 99.9999999999 — perfectly representable.
+        auto const shareAsset = vaultSle->at(sfShareMPTID);
+        STAmount const oneShare{MPTIssue{shareAsset}, Number(1)};
+
+        Vault const v{env};
+        env(v.withdraw({.depositor = lender, .id = broker.vaultKeylet().key, .amount = oneShare}),
+            Ter(fixed ? tecPRECISION_LOSS : tecINVARIANT_FAILED));
+        env.close();
+    }
+
     // A near-zero interest rate on a 100 USD loan
     // produces total interest of ~6 units at loanScale -9. Numerical error
     // in the amortization formula pushes the theoretical principal above
@@ -966,6 +1219,10 @@ private:
             testYieldTheftRounding(flags);
         testBugOverpaymentPrincipalChange();
         testBugOverpayUnroundedAmount();
+        testBugVaultWithdrawFixedSharesRoundsToZero(all_ - fixCleanup3_4_0);
+        testBugVaultWithdrawFixedSharesRoundsToZero(all_);
+        testBugVaultWithdrawDustVsAssetsTotal(all_ - fixCleanup3_4_0);
+        testBugVaultWithdrawDustVsAssetsTotal(all_);
         testBugInterestDueDeltaCrash();
     }
 
diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp
index 85528ee9a0..3571853b47 100644
--- a/src/test/app/lending/LoanSet_test.cpp
+++ b/src/test/app/lending/LoanSet_test.cpp
@@ -13,12 +13,14 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -26,6 +28,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -592,6 +595,127 @@ private:
             nullptr);
     }
 
+    // LoanSet in a closed-ended vault — phase gating and maturity bound.
+    void
+    testLoanSetClosedEnded()
+    {
+        testcase("LoanSet closed-ended: phase and maturity bound");
+        using namespace jtx;
+        using namespace loan;
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        // Common loan schedule used by the phase-rejection cases below.
+        constexpr std::uint32_t kInterval = 3600u * 24u;  // 1 day
+        constexpr std::uint32_t kTotal = 2u;
+
+        // featureLendingProtocolV1_1 is excluded from `all_` by convention (see the comment on
+        // `all_`), so callers must opt in. Closed-ended vaults are gated on this amendment; without
+        // it VaultCreate returns temDISABLED and every follow-on txn sees tecNO_ENTRY.
+        auto const withEnv = [&, this](auto&& body) {
+            Env env(*this, testableAmendments() | featureLendingProtocolV1_1);
+            env.fund(XRP(1'000'000'000), issuer, lender, borrower);
+            env.close();
+            PrettyAsset const asset{xrpIssue(), 1'000'000};
+            body(env, asset);
+        };
+
+        auto const setLoan = [&](Env& env, BrokerInfo const& broker, TER expected) {
+            env(set(lender, broker.brokerID, broker.asset(100).value()),
+                kCounterparty(borrower),
+                Sig(sfCounterpartySignature, borrower),
+                Fee(env.current()->fees().base * 5),
+                kPaymentTotal(kTotal),
+                kPaymentInterval(kInterval),
+                Ter(expected));
+            env.close();
+        };
+
+        // 1. Rejected during Subscription: the broker is created in Subscription (skipPhaseAdvance
+        // = true), then LoanSet is attempted before advancing past SubscriptionDate.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            auto const broker = createVaultAndBroker(
+                env,
+                asset,
+                lender,
+                BrokerParameters{.vaultKind = VaultKind::ClosedEnded, .skipPhaseAdvance = true});
+            setLoan(env, broker, tecTOO_SOON);
+        });
+
+        // 2. Rejected during Redemption: broker is set up normally (which lands the vault in
+        // Investment), then advance the clock past RedemptionDate before attempting LoanSet.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            auto const broker = createVaultAndBroker(
+                env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
+            BEAST_EXPECT(broker.redemptionDate.has_value());
+            using d = NetClock::duration;
+            using tp = NetClock::time_point;
+            env.close(tp{d{*broker.redemptionDate + 1}});
+            setLoan(env, broker, tecEXPIRED);
+        });
+
+        // 3. Accepted during Investment when the schedule comfortably fits before RedemptionDate.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            auto const broker = createVaultAndBroker(
+                env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
+            setLoan(env, broker, tesSUCCESS);
+        });
+
+        // 4. Rejected during Investment when the loan's final payment would land on or after
+        // RedemptionDate. Use a tight redemptionOffset and a schedule whose final payment is well
+        // past that boundary.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            constexpr std::uint32_t kRedemptionOffset = 3u * 24u * 3600u;
+            auto const broker = createVaultAndBroker(
+                env,
+                asset,
+                lender,
+                BrokerParameters{
+                    .vaultKind = VaultKind::ClosedEnded, .redemptionOffset = kRedemptionOffset});
+            env(set(lender, broker.brokerID, broker.asset(100).value()),
+                kCounterparty(borrower),
+                Sig(sfCounterpartySignature, borrower),
+                Fee(env.current()->fees().base * 5),
+                kPaymentTotal(10u),
+                kPaymentInterval(kInterval),
+                Ter(tecNO_PERMISSION));
+            env.close();
+        });
+
+        // 5. Boundary: schedule whose finalPayment lands exactly (RedemptionDate - 1) is accepted,
+        // and one second later (== RedemptionDate) is rejected. Uses payTotal = 1 so the arithmetic
+        // is simple: finalPayment = startDate + interval.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            auto const broker = createVaultAndBroker(
+                env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
+            BEAST_EXPECT(broker.redemptionDate.has_value());
+
+            auto const startDate = env.now().time_since_epoch().count();
+            auto const acceptInterval = *broker.redemptionDate - 1 - startDate;
+            env(set(lender, broker.brokerID, broker.asset(100).value()),
+                kCounterparty(borrower),
+                Sig(sfCounterpartySignature, borrower),
+                Fee(env.current()->fees().base * 5),
+                kPaymentTotal(1u),
+                kPaymentInterval(acceptInterval),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const rejectInterval =
+                *broker.redemptionDate - env.now().time_since_epoch().count();
+            env(set(lender, broker.brokerID, broker.asset(100).value()),
+                kCounterparty(borrower),
+                Sig(sfCounterpartySignature, borrower),
+                Fee(env.current()->fees().base * 5),
+                kPaymentTotal(1u),
+                kPaymentInterval(rejectInterval),
+                Ter(tecNO_PERMISSION));
+            env.close();
+        });
+    }
+
 public:
     void
     run() override
@@ -599,6 +723,8 @@ public:
         for (auto const& features : jtx::amendmentCombinations(
                  {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
             testLoanSet(features);
+
+        testLoanSetClosedEnded();
     }
 };
 
diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h
index dabdfc9bed..b3669742fe 100644
--- a/src/test/app/lending/LoanTestBase.h
+++ b/src/test/app/lending/LoanTestBase.h
@@ -67,6 +67,16 @@
 
 namespace xrpl::test {
 
+/**
+ * Shared base for the Loan*_test family under src/test/app/lending/.
+ *
+ * Run all suites in this family with
+ *   xrpld -u Loan,LendingHelpers
+ * The "Loan" prefix is matched against every suite name via
+ * beast::unit_test::Selector::ModeT::Automatch; LendingHelpers is listed
+ * explicitly because it does not share the "Loan" prefix (and lives in a
+ * different module: app vs tx).
+ */
 class LoanTestBase : public beast::unit_test::Suite
 {
 protected:
@@ -95,6 +105,23 @@ protected:
         // tests that need finer loanScale to exercise rounding edge cases.
         std::optional vaultScale =
             std::nullopt;  // NOLINT(readability-redundant-member-init)
+        // Vault kind axis. When ClosedEnded, createVaultAndBroker sets sfSubscriptionDate /
+        // sfRedemptionDate from env.now() using the offsets below and advances the ledger clock
+        // past SubscriptionDate so the vault is in the Investment phase by the time the broker is
+        // set up. Requires featureLendingProtocolV1_1.
+        VaultKind vaultKind = VaultKind::OpenEnded;
+        // Seconds past env.now() at which SubscriptionDate lands. Must be strictly positive
+        // (VaultCreate::preclaim rejects SubscriptionDate <= parentCloseTime).
+        std::uint32_t subscriptionOffset = 60;
+        // Seconds between SubscriptionDate and RedemptionDate. Must be >= kMinInvestmentPeriod, <
+        // kMaxInvestmentPeriod, and generous enough to fit any loan schedule the test runs
+        // (finalPayment must be strictly before RedemptionDate). Default sized to comfortably
+        // exceed any schedule realistic tests are likely to configure.
+        std::uint32_t redemptionOffset = 10u * 365u * 24u * 60u * 60u;
+        // When true, createVaultAndBroker skips its automatic clock advance past SubscriptionDate.
+        // Useful for tests that need to observe the vault while it is still in the Subscription
+        // phase. Ignored for open-ended vaults.
+        bool skipPhaseAdvance = false;
 
         [[nodiscard]] Number
         maxCoveredLoanValue(Number const& currentDebt) const
@@ -122,15 +149,23 @@ protected:
         uint256 brokerID;
         uint256 vaultID;
         BrokerParameters params;
+        // Absolute dates resolved by createVaultAndBroker when params.vaultKind
+        // is ClosedEnded; std::nullopt for open-ended vaults.
+        std::optional subscriptionDate;
+        std::optional redemptionDate;
         BrokerInfo(
             jtx::PrettyAsset const& asset,
             Keylet const& brokerKeylet,
             Keylet const& vaultKeylet,
-            BrokerParameters p)
+            BrokerParameters p,
+            std::optional subscriptionDate = std::nullopt,
+            std::optional redemptionDate = std::nullopt)
             : asset(asset)
             , brokerID(brokerKeylet.key)
             , vaultID(vaultKeylet.key)
             , params(std::move(p))
+            , subscriptionDate(subscriptionDate)
+            , redemptionDate(redemptionDate)
         {
         }
 
@@ -461,7 +496,23 @@ protected:
 
         auto const coverRateMinValue = params.coverRateMin;
 
-        auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
+        std::optional subscriptionDate;
+        std::optional redemptionDate;
+        if (params.vaultKind == VaultKind::ClosedEnded)
+        {
+            auto const nowSec = env.now().time_since_epoch().count();
+            subscriptionDate = nowSec + params.subscriptionOffset;
+            redemptionDate = *subscriptionDate + params.redemptionOffset;
+        }
+
+        auto [tx, vaultKeylet] = vault.create(
+            {.owner = lender,
+             .asset = asset,
+             .vaultKind = params.vaultKind == VaultKind::OpenEnded
+                 ? std::optional{}
+                 : std::optional{std::to_underlying(params.vaultKind)},
+             .subscriptionDate = subscriptionDate,
+             .redemptionDate = redemptionDate});
         if (params.vaultScale)
             tx[sfScale] = *params.vaultScale;
         env(tx);
@@ -475,6 +526,15 @@ protected:
             BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value());
         }
 
+        // For closed-ended vaults, advance past SubscriptionDate so subsequent LoanSet operations
+        // run in the Investment phase (unless the caller explicitly asked to stay in Subscription).
+        if (subscriptionDate && !params.skipPhaseAdvance)
+        {
+            using d = NetClock::duration;
+            using tp = NetClock::time_point;
+            env.close(tp{d{*subscriptionDate + 1}});
+        }
+
         auto const keylet = keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
 
         using namespace loan_broker;
@@ -490,7 +550,7 @@ protected:
 
         env.close();
 
-        return {asset, keylet, vaultKeylet, params};
+        return {asset, keylet, vaultKeylet, params, subscriptionDate, redemptionDate};
     }
 
     /**
diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp
index 884384db55..c6ff22bbb3 100644
--- a/src/test/app/lending/LoanValidation_test.cpp
+++ b/src/test/app/lending/LoanValidation_test.cpp
@@ -26,6 +26,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -90,9 +91,11 @@ private:
     }
 
     void
-    testInvalidLoanSet()
+    testInvalidLoanSet(VaultKind vaultKind)
     {
-        testcase("Invalid LoanSet");
+        testcase(
+            std::string("Invalid LoanSet (") +
+            (vaultKind == VaultKind::OpenEnded ? "open-ended" : "closed-ended") + " vault)");
         using namespace jtx;
         using namespace loan;
         Account const lender{"lender"};
@@ -106,7 +109,8 @@ private:
             env.fund(XRP(1'000), lender, issuer, borrower, sponsor);
             env(trust(lender, iou(10'000'000)));
             env(pay(issuer, lender, iou(5'000'000)));
-            BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)};
+            BrokerInfo const brokerInfo{
+                createVaultAndBroker(env, issuer["IOU"], lender, {.vaultKind = vaultKind})};
 
             auto const loanSetFee = Fee(env.current()->fees().base * 2);
             Number const debtMaximumRequest = brokerInfo.asset(1'000).value();
@@ -530,7 +534,8 @@ private:
     runAmendmentIndependent()
     {
         testDisabled();
-        testInvalidLoanSet();
+        for (auto const kind : {VaultKind::OpenEnded, VaultKind::ClosedEnded})
+            testInvalidLoanSet(kind);
         testInvalidLoanDelete();
         testInvalidLoanManage();
         testInvalidLoanPay();
diff --git a/src/test/app/lending/Loan_test.cpp b/src/test/app/lending/Loan_test.cpp
deleted file mode 100644
index 717387665e..0000000000
--- a/src/test/app/lending/Loan_test.cpp
+++ /dev/null
@@ -1,46 +0,0 @@
-#include 
-#include 
-
-#include 
-#include 
-#include 
-
-namespace xrpl::test {
-
-/**
- * Aggregator: running this suite ("Loan") reruns every topical Loan/Lending
- * suite in one invocation. Each member suite below remains independently
- * runnable under its own name. Declared manual so an unfiltered full test
- * run doesn't execute every case twice.
- */
-class Loan_test : public beast::unit_test::Suite
-{
-    void
-    run() override
-    {
-        static constexpr std::array kMembers{
-            "LendingHelpers",
-            "LoanBroker",
-            "LoanCashBasis",
-            "LoanCoverFreezeAuth",
-            "LoanInvariants",
-            "LoanLifecycle",
-            "LoanMisc",
-            "LoanPay",
-            "LoanRounding",
-            "LoanSecurity",
-            "LoanSet",
-            "LoanValidation",
-        };
-
-        for (auto const& info : beast::unit_test::globalSuites())
-        {
-            if (std::ranges::find(kMembers, info.name()) != kMembers.end())
-                info.run(runner());
-        }
-    }
-};
-
-BEAST_DEFINE_TESTSUITE_MANUAL(Loan, tx, xrpl);
-
-}  // namespace xrpl::test
diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp
new file mode 100644
index 0000000000..2dbd20f855
--- /dev/null
+++ b/src/test/app/vault/VaultBugs_test.cpp
@@ -0,0 +1,813 @@
+#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 {
+
+class VaultBugs_test : public VaultTestBase
+{
+private:
+    // Bug: the equality check (vault outflow == destination inflow) was
+    // skipped whenever the destination delta rounded to zero at localMinScale,
+    // including cases where the vault outflow rounded to a non-zero value and
+    // a representable amount of value was genuinely destroyed.
+    //
+    // Scenario: Bob's IOU balance sits 5 units below the 10^16 STAmount
+    // precision boundary (atEdge2 = 9,999,999,999,999,995).  A withdrawal of
+    // 6 USD shifts his balance across that boundary: the exponent increments
+    // (0 → 1), so his effective inflow in Number space is only +5 — 1 USD is
+    // consumed by the precision-boundary rounding and cannot be credited.
+    //
+    // The destroyed amount (1 USD) is sub-ULP at destinationScale=1 (step=10),
+    // so the check treats it as an unavoidable IOU-precision artefact and
+    // lets the transaction succeed.
+    //
+    // Contrast: if 15 USD were destroyed at the same scale (destroyed ≥ step),
+    // floor(15/10)=1 ≠ 0 and the invariant would fire — that discrepancy IS
+    // representable and indicates a real accounting bug.
+    //
+    // Pre-fixCleanup3_2_0: the "must increase destination balance" check fires
+    // because roundedDestinationDelta = 0 ≤ 0.
+    void
+    testVaultWithdrawEqualityEnforced()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            STAmount const aliceLimit{usd.raw(), 2, 16};
+            STAmount const bobLimit{usd.raw(), 2, 16};
+            // Bob's balance sits 5 units below the 10^16 STAmount precision
+            // boundary.  Receiving 6 USD shifts his exponent 0 → 1; the
+            // STAmount records +5, not +6 (1 USD is lost to rounding).
+            STAmount const atEdge2{usd.raw(), Number{9'999'999'999'999'995LL}};
+
+            env(trust(alice, aliceLimit));
+            env(trust(bob, bobLimit));
+            env.close();
+
+            env(pay(issuer, alice, usd(1'000)));
+            env(pay(issuer, bob, atEdge2));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
+            env.close();
+
+            // Withdraw 6 USD to Bob: vault loses 6, Bob gains only 5.
+            // Destroyed amount = 1 USD, which is sub-ULP at destinationScale=1.
+            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(6)});
+            tx[sfDestination] = bob.human();
+            env(tx, Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultWithdraw to destination at IOU precision boundary fires "
+                "invariant (pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to destination at IOU precision boundary succeeds "
+                "when destroyed amount is sub-ULP (post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tesSUCCESS);
+        }
+    }
+
+    // VaultDeposit by issuer with the vault parked at the IOU 16-digit
+    // edge (9.999e15). Issuer mints 2 more USD; the vault trust line
+    // goes 9.999e15 → 10^16, gaining 1 unit instead of 2 (canonicalization).
+    //
+    // Pre-fixCleanup3_2_0: the proactive check is absent; the deposit
+    // applies, then VaultInvariant's "deposit must increase vault
+    // balance" assertion fires at finalize time on the rounded vault
+    // delta of zero, returning tecINVARIANT_FAILED.
+    // Post-amendment: reject deposit that is not representable at Vault scale.
+    void
+    testBugIssuerVaultDepositAtEdge()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+
+            env.fund(XRP(100'000), issuer, owner);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            STAmount const trustLimit{usd.raw(), 2, 16};
+            STAmount const ownerFund{usd.raw(), Number{9'999'999'999'999'999LL}};
+
+            env(trust(owner, trustLimit));
+            env.close();
+            env(pay(issuer, owner, ownerFund));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+            env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = ownerFund}));
+            env.close();
+
+            // Vault pseudo-account is now at 9.999e15. Issuer mints 2
+            // more USD. Pre: tecINVARIANT_FAILED at finalize. Post:
+            // tecPRECISION_LOSS proactively. Either way, no value moves.
+            env(vault.deposit({.depositor = issuer, .id = vaultKeylet.key, .amount = usd(2)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultDeposit by issuer at IOU edge fires "
+                "tecINVARIANT_FAILED at finalize (pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit by issuer at IOU edge rejects with "
+                "tecPRECISION_LOSS proactively (post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tecPRECISION_LOSS);
+        }
+    }
+
+    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for
+    // sfAssetsTotal/Available deltas.  This is symmetric to
+    // testBugMakeDeltaAnteriorScale but in the opposite direction: a deposit
+    // pushes assetsTotal from just below 1e16 (IOU exponent 0, ULP = 1) to just
+    // above it (exponent 1, ULP = 10).  makeDelta picks the coarser *posterior*
+    // scale 1.  The trust line balance rounds from atEdge + 2 = 10,000,000,000,000,001
+    // → 1e16, so the pseudo-account delta is only +1 in IOU space.
+    // roundToAsset(+1, scale=1) = 0 fires "deposit must increase vault balance"
+    // even though the state change is consistent at every precision boundary.
+    //
+    // Fix (fixCleanup3_2_0): computeVaultMinScale uses the posterior Number-space
+    // scale of sfAssetsTotal (which retains the full value 10,000,000,000,000,001,
+    // exponent 0), giving minScale = 0.  roundToAsset(+1, scale=0) = 1 > 0 and
+    // the invariant passes.  However the transactor's own precision guard fires
+    // first (bob pays 2 USD, vault receives only 1 due to IOU rounding), so the
+    // post-amendment result is tecPRECISION_LOSS rather than tesSUCCESS —
+    // the depositor is protected from silently losing 1 USD to rounding.
+    void
+    testBugMakeDeltaPosteriorScale()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            // atEdge is the largest IOU value with exponent 0 (ULP = 1).
+            // A deposit of 2 USD brings assetsTotal to 10,000,000,000,000,001
+            // in Number space, crossing the 1e16 boundary in IOU space.
+            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
+
+            env(trust(alice, STAmount{usd.raw(), 2, 16}));
+            env(trust(bob, usd(100)));
+            env.close();
+            env(pay(issuer, alice, atEdge));
+            env(pay(issuer, bob, usd(2)));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            // sfAssetsTotal = sfAssetsAvailable = atEdge (exponent 0, ULP = 1)
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = atEdge}));
+            env.close();
+
+            // Deposit 2 USD: +2 is sub-ULP at the posterior IOU scale (ULP = 10)
+            // but exact at the Number scale retained by sfAssetsTotal.
+            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(2)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultDeposit across IOU scale boundary fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit across IOU scale boundary succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tecPRECISION_LOSS);
+        }
+    }
+
+    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for the
+    // sfAssetsTotal and sfAssetsAvailable deltas, and visitEntry applies the
+    // same max() for the vault pseudo-account RippleState.  When
+    // sfAssetsTotal sits exactly at 1e16 (IOU exponent 1, ULP = 10) and a
+    // withdrawal of 5 USD brings it to 9.999...995e15 (IOU exponent 0,
+    // ULP = 1), all three computations pick the anterior coarser scale 1.
+    // roundToAsset(-5, scale=1) collapses to 0, so the invariant check
+    // vaultPseudoDeltaAssets >= kZero fires even though the state change is
+    // valid and fully consistent at IOU precision.
+    //
+    // Fix (fixCleanup3_2_0): finalize compares the vault pseudo-account and
+    // sfAssetsTotal/Available deltas directly in Number space, bypassing
+    // scale-coarsened rounding.
+    void
+    testBugMakeDeltaAnteriorScale()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+
+            env.fund(XRP(100'000), issuer, alice);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            // Trust limit of 2e16, fund exactly 1e16 so deposit lands at the
+            // IOU scale-1 boundary (exponent 1, ULP = 10).
+            STAmount const fundAndDeposit{usd.raw(), Number{1, 16}};
+
+            env(trust(alice, STAmount{usd.raw(), 2, 16}));
+            env.close();
+            env(pay(issuer, alice, fundAndDeposit));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            // sfAssetsTotal = sfAssetsAvailable = 1e16 (exponent 1, ULP = 10).
+            env(vault.deposit(
+                {.depositor = alice, .id = vaultKeylet.key, .amount = fundAndDeposit}));
+            env.close();
+
+            // Withdraw 5 USD: -5 is sub-ULP at the anterior scale (ULP = 10)
+            // but exact at the posterior scale (ULP = 1).  The state change is
+            // consistent; only the invariant's scale selection is wrong.
+            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(5)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultWithdraw across IOU scale boundary fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw across IOU scale boundary succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tesSUCCESS);
+        }
+    }
+
+    // Bug: when a depositor's IOU trustline balance is very large (e.g.
+    // ~1e17), adding a small deposit (e.g. 1 USD) leaves sfAssetsTotal
+    // unchanged at IOU precision because the increment is sub-ULP at the
+    // vault's current asset scale.  The vault records the deposit, mints
+    // shares, and decrements the depositor's trustline, but sfAssetsTotal
+    // does not change — the conservation invariant fires because the rail
+    // delta is zero.
+    //
+    // Two sub-cases are exercised:
+    //   1. First-ever deposit into an empty vault: the depositor's own
+    //      trustline has a large balance so 1 USD canonicalizes to zero
+    //      when written back through the IOU rail.
+    //   2. Subsequent deposit after the vault already holds a large
+    //      sfAssetsTotal: a different depositor (bob, with a small balance)
+    //      sends 1 USD, which again rounds to zero at the vault's coarse
+    //      asset scale.
+    //
+    // Fix (fixCleanup3_2_0): the deposit transactor checks whether
+    // roundToAsset(amount, vault_scale) == 0 and rejects early with
+    // tecPRECISION_LOSS before any state is modified.
+    void
+    testVaultDepositCanonicalizeToZero()
+    {
+        using namespace test::jtx;
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+
+            STAmount const trustLimit{usd.raw(), Number{99'999'999'999'999'999LL}};
+            STAmount const aliceFund{usd.raw(), Number{99'999'999'999'999'999LL}};
+
+            env(trust(alice, trustLimit));
+            env(trust(bob, trustLimit));
+            env.close();
+
+            env(pay(issuer, alice, aliceFund));
+            env(pay(issuer, bob, usd(1000)));
+            env.close();
+
+            Vault const vault{env};
+
+            // Scale=0 so sfAssetsTotal stores whole USD
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            // Alice's deposit canonicalizes to zero at her own trustline scale
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1)}),
+                Ter(expected));
+
+            // Increase vault-scale
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = aliceFund}));
+            env.close();
+
+            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(1)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultDeposit below Vault precision canonicalized to zero "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit below Vault precision canonicalized to zero "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), 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
+    // power-of-10 boundary (atEdge = 9'999'999'999'999'999), the post-credit
+    // STAmount rounds up one exponent (exponent 0 → 1), making
+    // destinationDelta.scale = 1.  The invariant then calls
+    // roundToAsset(+2 USD, scale=1) = 0 and incorrectly fires
+    // "withdrawal must increase destination balance".
+    //
+    // Fix (fixCleanup3_2_0): finalize compares destination delta directly in
+    // Number space, bypassing scale-coarsened rounding.  The transaction
+    // itself succeeds because the effective IOU credit is non-trivial at
+    // Number precision even though the STAmount exponent shifted.
+    void
+    testVaultWithdrawCanonicalizeToZero()
+    {
+        using namespace test::jtx;
+
+        enum class DestKind : bool { ThirdParty = false, Self = true };
+
+        auto runScenario = [this](FeatureBitset features, DestKind destKind, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            STAmount const aliceLimit{usd.raw(), 2, 16};
+            STAmount const bobLimit{usd.raw(), 2, 16};
+            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
+
+            env(trust(alice, aliceLimit));
+            if (destKind == DestKind::ThirdParty)
+                env(trust(bob, bobLimit));
+            env.close();
+
+            env(pay(issuer, alice, usd(1'000)));
+            if (destKind == DestKind::ThirdParty)
+                env(pay(issuer, bob, atEdge));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
+            env.close();
+
+            // For the self-destination case, push alice's own trust line to
+            // the IOU edge so the next withdraw inflow crosses the boundary.
+            if (destKind == DestKind::Self)
+            {
+                env(pay(issuer, alice, atEdge));
+                env.close();
+            }
+
+            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(2)});
+            if (destKind == DestKind::ThirdParty)
+                tx[sfDestination] = bob.human();
+            env(tx, Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultWithdraw to third-party at IOU edge fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(
+                testableAmendments() - fixCleanup3_2_0, DestKind::ThirdParty, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to third-party at IOU edge succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), DestKind::ThirdParty, tesSUCCESS);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to self at IOU edge fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(
+                testableAmendments() - fixCleanup3_2_0, DestKind::Self, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to self at IOU edge succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), DestKind::Self, tesSUCCESS);
+        }
+    }
+
+    // Bug: a debit can be genuinely non-zero yet still be dust relative to a
+    // sfAssetsTotal/sfAssetsAvailable large enough to exceed STAmount's precision, e.g.
+    // AssetsTotal 2e12 minus a 1e-6 debit needs 19 significant digits and rounds straight
+    // back to 2e12. The shares still move, so ValidVault later fails with "must decrease
+    // vault balance" instead of a clean upfront rejection.
+    //
+    // Fix (fixCleanup3_4_0): reject upfront with tecPRECISION_LOSS if the debit would
+    // canonicalize back to the prior stored value.
+    //
+    // With a single depositor AssetsTotal == AssetsAvailable, so both
+    // debitIsNonZeroDust operands trip together here. LoanRounding_test's
+    // "dust debit vs AssetsTotal only" case isolates the AssetsTotal operand
+    // via a heavily-loaned vault.
+    void
+    testBugVaultDustDebitCanonicalizesToNoOp()
+    {
+        using namespace test::jtx;
+
+        // Fund a single depositor and have them deposit `total` USD in one shot (default
+        // scale 6, so shares mint at exactly total*1e6).
+        auto const seedVault = [](Env& env, Number const& total) {
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const holder{"holder"};
+
+            env.fund(XRP(1'000'000), issuer, owner, holder);
+            env.close();
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            env(trust(holder, usd(100'000'000'000'000LL)));
+            env.close();
+            env(pay(issuer, holder, usd(total)));
+            env.close();
+
+            Vault const vault{env};
+            auto const [tx, keylet] = vault.create({.owner = owner, .asset = usd.raw()});
+            env(tx);
+            env.close();
+            env(vault.deposit({.depositor = holder, .id = keylet.key, .amount = usd(total)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            return keylet;
+        };
+
+        {
+            auto runScenario = [&](FeatureBitset features, TER expected) {
+                Env env(*this, features);
+                Number const total{2, 12};
+                auto const keylet = seedVault(env, total);
+
+                Account const issuer{"issuer"};
+                PrettyAsset const usd{issuer["USD"]};
+
+                // 1 share's worth of assets: 1e-6, below AssetsTotal's storage precision.
+                env(Vault::clawback(
+                        {.issuer = issuer,
+                         .id = keylet.key,
+                         .holder = Account{"holder"},
+                         .amount = usd(Number{1, -6}).value()}),
+                    Ter(expected));
+                env.close();
+            };
+
+            testcase("bug: VaultClawback dust debit fires invariant (pre-fixCleanup3_4_0)");
+            runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
+            testcase("bug: VaultClawback dust debit rejected cleanly (post-fixCleanup3_4_0)");
+            runScenario(all_, tecPRECISION_LOSS);
+        }
+
+        {
+            auto runScenario = [&](FeatureBitset features, TER expected) {
+                Env env(*this, features);
+                Number const total{2, 12};
+                auto const keylet = seedVault(env, total);
+
+                MPTIssue const share{env.le(keylet)->at(sfShareMPTID)};
+
+                // Redeem 1 share, worth 1e-6 assets, below AssetsTotal's storage precision.
+                env(Vault::withdraw(
+                        {.depositor = Account{"holder"},
+                         .id = keylet.key,
+                         .amount = STAmount{share, 1}}),
+                    Ter(expected));
+                env.close();
+            };
+
+            testcase("bug: VaultWithdraw dust debit fires invariant (pre-fixCleanup3_4_0)");
+            runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
+            testcase("bug: VaultWithdraw dust debit rejected cleanly (post-fixCleanup3_4_0)");
+            runScenario(all_, tecPRECISION_LOSS);
+        }
+    }
+
+    // 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:
+    // getTrustLineBalance with includeOppositeLimit=true). When the
+    // depositor's raw balance < deposit amount but raw + opposite limit >=
+    // amount, preclaim is satisfied. doApply then calls
+    // directSendNoFeeIOU, which unconditionally subtracts saAmount from
+    // saBalance — driving the trust line negative — and returns tesSUCCESS.
+    // The post-send sanity check uses the default shSIMPLE_BALANCE (no
+    // opposite-limit add), sees a negative balance, and returns tefINTERNAL.
+    void
+    testVaultDepositNegativeBalanceFromOppositeLimit()
+    {
+        auto runTest = [&](FeatureBitset f, TER expected) {
+            using namespace test::jtx;
+            using namespace std::literals;
+
+            Env env{*this, f};
+            Account const gw{"gateway"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+
+            env.fund(XRP(10000), gw, owner, depositor);
+            env.close();
+
+            // Gateway with DefaultRipple so vault creation on its IOU works.
+            env(fset(gw, asfDefaultRipple));
+            env.close();
+
+            // Depositor opens a trust line to gateway and receives a small
+            // balance.
+            PrettyAsset const usd = gw["USD"];
+            env.trust(usd(1000), depositor);
+            env(pay(gw, depositor, usd(100)));  // raw trust-line balance: 100
+            env.close();
+
+            // Key precondition: gateway sets a non-zero limit on the same
+            // RippleState — the "opposite field" from depositor's perspective.
+            // This is what inflates shFULL_BALANCE in preclaim above the raw
+            // balance.
+            env(trust(gw, depositor["USD"](1000)));
+            env.close();
+
+            // Create the IOU vault.
+            Vault const vault{env};
+            auto [vaultTx, keylet] = vault.create({.owner = owner, .asset = usd});
+            env(vaultTx);
+            env.close();
+
+            // Submit a deposit of 500 USD:
+            //   - raw balance:                100 USD
+            //   - opposite limit (gw's side): 1000 USD
+            //   - preclaim sees 100 + 1000 = 1100, passes (>= 500)
+            //   - doApply transfers 500, depositor's trust-line balance
+            //     becomes -400
+            //   - sanity check at VaultDeposit.cpp:256 fires
+            //   - tx returns tefINTERNAL (BUG — should be tesSUCCESS.
+            auto depositTx =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = usd(500)});
+            env(depositTx, Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "IOU vault deposit exceeding depositor's balance but "
+                "within counterparty's trust limit, pre-fixCleanup3_2_0 "
+                "(tefINTERNAL)");
+            runTest(test::jtx::testableAmendments() - fixCleanup3_2_0, tefINTERNAL);
+        }
+        {
+            testcase(
+                "IOU vault deposit exceeding depositor's balance but "
+                "within counterparty's trust limit, post-fixCleanup3_2_0 "
+                "(tesSUCCESS)");
+            runTest(test::jtx::testableAmendments(), tesSUCCESS);
+        }
+    }
+
+    // Reproduction: canWithdraw IOU limit check bypassed when
+    // withdrawal amount is specified in shares (MPT) rather than in assets.
+    void
+    testBug6LimitBypassWithShares()
+    {
+        using namespace test::jtx;
+        testcase("Bug6 - limit bypass with share-denominated withdrawal");
+
+        auto const allAmendments = testableAmendments() | featureSingleAssetVault;
+
+        for (auto const& features : {allAmendments, allAmendments - fixCleanup3_1_3})
+        {
+            bool const withFix = features[fixCleanup3_1_3];
+
+            Env env{*this, features};
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            Account const depositor{"depositor"};
+            Account const charlie{"charlie"};
+            Vault const vault{env};
+
+            env.fund(XRP(1000), issuer, owner, depositor, charlie);
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1000), owner);
+            env.trust(asset(1000), depositor);
+            env(pay(issuer, owner, asset(200)));
+            env(pay(issuer, depositor, asset(200)));
+            env.close();
+
+            // Charlie gets a LOW trustline limit of 5
+            env.trust(asset(5), charlie);
+            env.close();
+
+            auto const [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const depositTx =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+            env(depositTx);
+            env.close();
+
+            // Get the share MPT info
+            auto const vaultSle = env.le(keylet);
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            auto const mptIssuanceID = vaultSle->at(sfShareMPTID);
+            MPTIssue const shares(mptIssuanceID);
+            PrettyAsset const share(shares);
+
+            // CONTROL: Withdraw 10 IOU (asset-denominated) to charlie.
+            // Charlie's limit is 5, so this should be rejected with tecNO_LINE
+            // regardless of the amendment.
+            {
+                auto withdrawTx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+                withdrawTx[sfDestination] = charlie.human();
+                env(withdrawTx, Ter{tecNO_LINE});
+                env.close();
+            }
+            auto const charlieBalanceBefore = env.balance(charlie, asset.raw().get());
+
+            // Withdraw the equivalent amount in shares to charlie.
+            // Post-fix: rejected (tecNO_LINE) because the share amount is
+            //   converted to assets and the trustline limit is checked.
+            // Pre-fix: succeeds (tesSUCCESS) because the limit check was
+            //   skipped for share-denominated withdrawals.
+            {
+                auto withdrawTx = vault.withdraw(
+                    {.depositor = depositor,
+                     .id = keylet.key,
+                     .amount = STAmount(share, 10'000'000)});
+                withdrawTx[sfDestination] = charlie.human();
+                env(withdrawTx, Ter{withFix ? TER{tecNO_LINE} : TER{tesSUCCESS}});
+                env.close();
+
+                auto const charlieBalanceAfter = env.balance(charlie, asset.raw().get());
+                if (withFix)
+                {
+                    // Post-fix: charlie's balance is unchanged — the withdrawal
+                    // was correctly rejected despite being share-denominated.
+                    BEAST_EXPECT(charlieBalanceAfter == charlieBalanceBefore);
+                }
+                else
+                {
+                    // Pre-fix: charlie received the assets, bypassing the
+                    // trustline limit.
+                    BEAST_EXPECT(charlieBalanceAfter > charlieBalanceBefore);
+                }
+            }
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testVaultWithdrawEqualityEnforced();
+        testBugIssuerVaultDepositAtEdge();
+        testBugMakeDeltaPosteriorScale();
+        testBugMakeDeltaAnteriorScale();
+        testVaultDepositCanonicalizeToZero();
+        testVaultWithdrawCanonicalizeToZero();
+        testBugVaultDustDebitCanonicalizesToNoOp();
+        testVaultDepositNegativeBalanceFromOppositeLimit();
+        testBug6LimitBypassWithShares();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultBugs, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultClawback_test.cpp b/src/test/app/vault/VaultClawback_test.cpp
new file mode 100644
index 0000000000..2a9fe42b1c
--- /dev/null
+++ b/src/test/app/vault/VaultClawback_test.cpp
@@ -0,0 +1,1122 @@
+#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 {
+
+class VaultClawback_test : public VaultTestBase
+{
+private:
+    void
+    testVaultClawbackBurnShares()
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+        Env env(*this, beast::Severity::Warning);
+
+        auto const vaultAssetBalance = [&](Keylet const& vaultKeylet) {
+            auto const sleVault = env.le(vaultKeylet);
+            BEAST_EXPECT(sleVault != nullptr);
+
+            return std::make_pair(sleVault->at(sfAssetsAvailable), sleVault->at(sfAssetsTotal));
+        };
+
+        auto const vaultShareBalance = [&](Keylet const& vaultKeylet) {
+            auto const sleVault = env.le(vaultKeylet);
+            BEAST_EXPECT(sleVault != nullptr);
+
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+            BEAST_EXPECT(sleIssuance != nullptr);
+
+            return sleIssuance->at(sfOutstandingAmount);
+        };
+
+        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});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            auto const& vaultSle = env.le(vaultKeylet);
+            BEAST_EXPECT(vaultSle != nullptr);
+
+            Asset const share = vaultSle->at(sfShareMPTID);
+
+            env(vault.deposit(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet);
+            BEAST_EXPECT(availablePreDefault == totalPreDefault);
+            BEAST_EXPECT(availablePreDefault == asset(100).value());
+
+            // attempt to clawback shares while there are assets fails
+            env(vault.clawback(
+                    {.issuer = owner,
+                     .id = vaultKeylet.key,
+                     .holder = depositor,
+                     .amount = share(0).value()}),
+                Ter(tecNO_PERMISSION));
+            env.close();
+
+            auto const& sharesAvailable = vaultShareBalance(vaultKeylet);
+            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));
+
+            // Create a simple Loan for the full amount of Vault assets
+            env(set(depositor, brokerKeylet.key, asset(100).value()),
+                loan::kInterestRate(TenthBips32(0)),
+                kGracePeriod(60),
+                kPaymentInterval(120),
+                kPaymentTotal(10),
+                Sig(sfCounterpartySignature, owner),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // attempt to clawback shares while there assetsAvailable == 0 and
+            // assetsTotal > 0 fails
+            env(vault.clawback(
+                    {.issuer = owner,
+                     .id = vaultKeylet.key,
+                     .holder = depositor,
+                     .amount = share(0).value()}),
+                Ter(tecNO_PERMISSION));
+            env.close();
+
+            env.close(std::chrono::seconds{120 + 60});
+
+            env(manage(owner, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+
+            auto const& [availablePostDefault, totalPostDefault] = vaultAssetBalance(vaultKeylet);
+
+            BEAST_EXPECT(availablePostDefault == totalPostDefault);
+            BEAST_EXPECT(availablePostDefault == asset(0).value());
+            BEAST_EXPECT(vaultShareBalance(vaultKeylet) == sharesAvailable);
+
+            return std::make_pair(vault, vaultKeylet);
+        };
+
+        auto const testCase = [&](PrettyAsset const& asset,
+                                  std::string const& prefix,
+                                  Account const& owner,
+                                  Account const& depositor) {
+            {
+                testcase("VaultClawback (share) - " + prefix + " owner asset clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
+                // when asset is XRP or owner is not issuer clawback fail
+                // when owner is issuer precision loss occurs as vault is
+                // empty
+                auto const expectedTer = [&]() {
+                    if (asset.native())
+                        return Ter(temMALFORMED);
+                    if (asset.raw().getIssuer() != owner.id())
+                        return Ter(tecNO_PERMISSION);
+                    return Ter(tecPRECISION_LOSS);
+                }();
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(100).value(),
+                    }),
+                    expectedTer);
+                env.close();
+            }
+
+            {
+                testcase(
+                    "VaultClawback (share) - " + prefix + " owner incomplete share clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
+                auto const& vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                Asset const share = vaultSle->at(sfShareMPTID);
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = share(1).value(),
+                    }),
+                    Ter(tecLIMIT_EXCEEDED));
+                env.close();
+            }
+
+            {
+                testcase(
+                    "VaultClawback (share) - " + prefix +
+                    " owner implicit complete share clawback");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                    }),
+                    // when owner is issuer implicit clawback fails
+                    asset.native() || asset.raw().getIssuer() != owner.id() ? Ter(tesSUCCESS)
+                                                                            : Ter(tecWRONG_ASSET));
+                env.close();
+            }
+
+            {
+                testcase(
+                    "VaultClawback (share) - " + prefix +
+                    " owner explicit complete share clawback succeeds");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
+                auto const& vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                Asset const share = vaultSle->at(sfShareMPTID);
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+            }
+            {
+                testcase("VaultClawback (share) - " + prefix + " owner can clawback own shares");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, owner);
+                auto const& vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                Asset const share = vaultSle->at(sfShareMPTID);
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = owner,
+                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+            }
+
+            {
+                testcase("VaultClawback (share) - " + prefix + " empty vault share clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, owner);
+                auto const& vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                Asset const share = vaultSle->at(sfShareMPTID);
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = owner,
+                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
+                    }),
+                    Ter(tesSUCCESS));
+
+                // Now the vault is empty, clawback again fails
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = owner,
+                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
+                    }),
+                    Ter(tecNO_PERMISSION));
+                env.close();
+            }
+        };
+
+        Account const owner{"alice"};
+        Account const depositor{"bob"};
+        Account const issuer{"issuer"};
+
+        env.fund(XRP(10000), issuer, owner, depositor);
+        env.close();
+
+        // Test XRP
+        PrettyAsset const xrp = xrpIssue();
+        testCase(xrp, "XRP", owner, depositor);
+        testCase(xrp, "XRP (depositor is owner)", owner, owner);
+
+        // Test IOU
+        PrettyAsset const iou = issuer["IOU"];
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+
+        env.trust(iou(1000), owner);
+        env.trust(iou(1000), depositor);
+        env(pay(issuer, owner, iou(100)));
+        env(pay(issuer, depositor, iou(100)));
+        env.close();
+        testCase(iou, "IOU", owner, depositor);
+        testCase(iou, "IOU (owner is issuer)", issuer, depositor);
+
+        // Test MPT
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+        PrettyAsset const mpt = mptt.issuanceID();
+        mptt.authorize({.account = owner});
+        mptt.authorize({.account = depositor});
+        env(pay(issuer, owner, mpt(1000)));
+        env(pay(issuer, depositor, mpt(1000)));
+        env.close();
+        testCase(mpt, "MPT", owner, depositor);
+        testCase(mpt, "MPT (owner is issuer)", issuer, depositor);
+    }
+
+    void
+    testVaultClawbackAssets()
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+        Env env(*this);
+        env.enableFeature(fixCleanup3_1_3);
+
+        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});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            auto const& vaultSle = env.le(vaultKeylet);
+            BEAST_EXPECT(vaultSle != nullptr);
+            env.memoize(Account("vault", vaultSle->at(sfAccount)));
+            env(vault.deposit(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            return std::make_pair(vault, vaultKeylet);
+        };
+
+        auto const testCase = [&](PrettyAsset const& asset,
+                                  std::string const& prefix,
+                                  Account const& owner,
+                                  Account const& depositor,
+                                  Account const& issuer) {
+            if (asset.native())
+            {
+                testcase("VaultClawback (asset) - " + prefix + " issuer XRP clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+                // If the asset is XRP, clawback with amount fails as malformed
+                // when asset is specified.
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = issuer,
+                        .amount = asset(1).value(),
+                    }),
+                    Ter(temMALFORMED));
+                // When asset is implicit, clawback fails as no permission.
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = issuer,
+                    }),
+                    Ter(tecNO_PERMISSION));
+                return;
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix + " clawback for different asset fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                Account const issuer2{"issuer2"};
+                PrettyAsset const asset2 = issuer2["FOO"];
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset2(1).value(),
+                    }),
+                    Ter(tecWRONG_ASSET));
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " ambiguous owner/issuer asset clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, issuer, depositor, issuer);
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = issuer,
+                    }),
+                    Ter(tecWRONG_ASSET));
+            }
+
+            {
+                testcase("VaultClawback (asset) - " + prefix + " non-issuer asset clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                    }),
+                    Ter(tecNO_PERMISSION));
+
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(1).value(),
+                    }),
+                    Ter(tecNO_PERMISSION));
+            }
+
+            {
+                testcase("VaultClawback (asset) - " + prefix + " issuer clawback from self fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, issuer, issuer);
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = issuer,
+                    }),
+                    Ter(tecNO_PERMISSION));
+            }
+
+            {
+                testcase("VaultClawback (asset) - " + prefix + " issuer share clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+                auto const& vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                Asset const share = vaultSle->at(sfShareMPTID);
+
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = share(1).value(),
+                    }),
+                    Ter(tecNO_PERMISSION));
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " partial issuer asset clawback succeeds");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(1).value(),
+                    }),
+                    Ter(tesSUCCESS));
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix + " full issuer asset clawback succeeds");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(100).value(),
+                    }),
+                    Ter(tesSUCCESS));
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " implicit full issuer asset clawback succeeds");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                    }),
+                    Ter(tesSUCCESS));
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " zero-amount clawback clamped with outstanding loan");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                auto const vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+
+                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+                // Create a loan broker backed by this vault
+                auto const brokerKeylet =
+                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(set(owner, vaultKeylet.key));
+                env.close();
+
+                // Depositor borrows 40 units, reducing assetsAvailable to 60
+                // while assetsTotal stays at 100
+                env(set(depositor, brokerKeylet.key, asset(40).value()),
+                    loan::kInterestRate(TenthBips32(0)),
+                    kGracePeriod(60),
+                    kPaymentInterval(120),
+                    kPaymentTotal(10),
+                    Sig(sfCounterpartySignature, owner),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
+                }
+
+                // Zero-amount clawback (= "clawback all") should succeed,
+                // clamped to assetsAvailable (60) rather than the full
+                // share value (100).
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                // Only 60 assets clawed back; loan's 40 still outstanding
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle != nullptr);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
+
+                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
+                    auto const sharesAfter = env.balance(depositor, shares);
+                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
+                }
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " non-zero clawback clamped with outstanding loan");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                auto const vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+                // Create a loan broker backed by this vault
+                auto const brokerKeylet =
+                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(set(owner, vaultKeylet.key));
+                env.close();
+
+                // Depositor borrows 40 units
+                env(set(depositor, brokerKeylet.key, asset(40).value()),
+                    loan::kInterestRate(TenthBips32(0)),
+                    kGracePeriod(60),
+                    kPaymentInterval(120),
+                    kPaymentTotal(10),
+                    Sig(sfCounterpartySignature, owner),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
+                }
+
+                // Request 100 but only 60 available — clamped to 60
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(100).value(),
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle != nullptr);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
+
+                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
+                    auto const sharesAfter = env.balance(depositor, shares);
+                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
+                }
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " partial clawback below available with outstanding loan");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                auto const vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+                // Create a loan broker backed by this vault
+                auto const brokerKeylet =
+                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(set(owner, vaultKeylet.key));
+                env.close();
+
+                // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100
+                env(set(depositor, brokerKeylet.key, asset(40).value()),
+                    loan::kInterestRate(TenthBips32(0)),
+                    kGracePeriod(60),
+                    kPaymentInterval(120),
+                    kPaymentTotal(10),
+                    Sig(sfCounterpartySignature, owner),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
+                }
+
+                // Clawback 30 — well under available (60), no clamping needed
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(30).value(),
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle != nullptr);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(30).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(70).value());
+
+                    // 30 of 100 shares destroyed (1:1 ratio), 70 remain
+                    auto const sharesAfter = env.balance(depositor, shares);
+                    BEAST_EXPECT(sharesAfter == shares(Number{7, sle->at(sfScale) + 1}));
+                }
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " clawback exactly equal to available with outstanding loan");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                auto const vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+                auto const brokerKeylet =
+                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(set(owner, vaultKeylet.key));
+                env.close();
+
+                // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100
+                env(set(depositor, brokerKeylet.key, asset(40).value()),
+                    loan::kInterestRate(TenthBips32(0)),
+                    kGracePeriod(60),
+                    kPaymentInterval(120),
+                    kPaymentTotal(10),
+                    Sig(sfCounterpartySignature, owner),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                // Clawback exactly 60 — at the boundary, no clamping needed
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(60).value(),
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle != nullptr);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
+
+                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
+                    auto const sharesAfter = env.balance(depositor, shares);
+                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
+                }
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " clawback with zero available (fully borrowed)");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                auto const vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+                auto const brokerKeylet =
+                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(set(owner, vaultKeylet.key));
+                env.close();
+
+                // Depositor borrows all 100 units: assetsAvailable=0, assetsTotal=100
+                env(set(depositor, brokerKeylet.key, asset(100).value()),
+                    loan::kInterestRate(TenthBips32(0)),
+                    kGracePeriod(60),
+                    kPaymentInterval(120),
+                    kPaymentTotal(10),
+                    Sig(sfCounterpartySignature, owner),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
+                }
+
+                auto const sharesBefore = env.balance(depositor, shares);
+
+                // Zero-amount clawback — nothing available, clamped to 0,
+                // resulting in zero shares destroyed → tecPRECISION_LOSS
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                    }),
+                    Ter(tecPRECISION_LOSS));
+                env.close();
+
+                // Explicit amount clawback — also nothing available
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(50).value(),
+                    }),
+                    Ter(tecPRECISION_LOSS));
+                env.close();
+
+                {
+                    // Nothing changed — vault and shares unchanged
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle != nullptr);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
+                    auto const sharesAfter = env.balance(depositor, shares);
+                    BEAST_EXPECT(sharesAfter == sharesBefore);
+                }
+            }
+        };
+
+        Account const owner{"alice"};
+        Account const depositor{"bob"};
+        Account const issuer{"issuer"};
+
+        env.fund(XRP(10000), issuer, owner, depositor);
+        env.close();
+
+        // Test XRP
+        PrettyAsset const xrp = xrpIssue();
+        testCase(xrp, "XRP", owner, depositor, issuer);
+
+        // Test IOU
+        PrettyAsset const iou = issuer["IOU"];
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+        env.trust(iou(2000), owner);
+        env.trust(iou(2000), depositor);
+        env(pay(issuer, owner, iou(2000)));
+        env(pay(issuer, depositor, iou(2000)));
+        env.close();
+        testCase(iou, "IOU", owner, depositor, issuer);
+
+        // Test MPT
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+
+        PrettyAsset const mpt = mptt.issuanceID();
+        mptt.authorize({.account = owner});
+        mptt.authorize({.account = depositor});
+        env(pay(issuer, depositor, mpt(2000)));
+        env.close();
+        testCase(mpt, "MPT", owner, depositor, issuer);
+
+        // Test pre-fixCleanup3_1_3 legacy path: zero-amount clawback
+        // returns early without clamping to assetsAvailable.
+        {
+            testcase(
+                "VaultClawback (asset) - IOU pre-fixCleanup3_1_3"
+                " zero-amount clawback unclamped with outstanding loan");
+
+            env.disableFeature(fixCleanup3_1_3);
+
+            auto [vault, vaultKeylet] = setupVault(iou, owner, depositor, issuer);
+
+            auto const vaultSle = env.le(vaultKeylet);
+            BEAST_EXPECT(vaultSle != nullptr);
+            if (!vaultSle)
+                return;
+
+            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+            // Create a loan broker backed by this vault
+            auto const brokerKeylet =
+                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            env(set(owner, vaultKeylet.key));
+            env.close();
+
+            // Depositor borrows 40 units, reducing assetsAvailable to 60
+            // while assetsTotal stays at 100
+            env(set(depositor, brokerKeylet.key, iou(40).value()),
+                loan::kInterestRate(TenthBips32(0)),
+                kGracePeriod(60),
+                kPaymentInterval(120),
+                kPaymentTotal(10),
+                Sig(sfCounterpartySignature, owner),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(vaultKeylet);
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value());
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value());
+            }
+
+            auto const sharesBefore = env.balance(depositor, shares);
+
+            // Legacy: zero-amount clawback tries to recover the full
+            // share value (100) without clamping to assetsAvailable (60).
+            // This causes the vault balance to go negative, triggering
+            // the sanity check in doApply → tefINTERNAL.
+            env(vault.clawback({
+                    .issuer = issuer,
+                    .id = vaultKeylet.key,
+                    .holder = depositor,
+                }),
+                Ter(tefINTERNAL));
+            env.close();
+
+            {
+                // Transaction rolled back — vault and shares unchanged
+                auto const sle = env.le(vaultKeylet);
+                BEAST_EXPECT(sle != nullptr);
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value());
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value());
+                auto const sharesAfter = env.balance(depositor, shares);
+                BEAST_EXPECT(sharesAfter == sharesBefore);
+            }
+
+            env.enableFeature(fixCleanup3_1_3);
+        }
+    }
+
+    void
+    testVaultEscrowedMPT()
+    {
+        using namespace test::jtx;
+        using namespace std::literals;
+
+        // Verify vault deposit/withdraw/clawback respect sfLockedAmount.
+        // When MPT tokens are escrowed, sfMPTAmount is reduced and
+        // sfLockedAmount is increased. Vault operations go through
+        // accountSend/accountHolds which read sfMPTAmount, so escrowed
+        // tokens are naturally excluded.
+
+        {
+            testcase("Vault deposit fails when MPT asset is escrowed");
+
+            Env env{*this, testableAmendments()};
+            auto const baseFee = env.current()->fees().base;
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            Account const issuer{"issuer"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(10000), issuer, owner, depositor, bob);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create(
+                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            mptt.authorize({.account = bob});
+            PrettyAsset const asset = mptt.issuanceID();
+            env(pay(issuer, depositor, asset(100)));
+            env.close();
+
+            // Escrow 60 of 100 MPT tokens: sfMPTAmount drops to 40
+            auto const escrowSeq = env.seq(depositor);
+            env(escrow::create(depositor, bob, asset(60)),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFinishTime(env.now() + 1s),
+                Fee(baseFee * 150),
+                Ter(tesSUCCESS));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            // Deposit 100 should fail — only 40 spendable
+            env(vault.deposit(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tecINSUFFICIENT_FUNDS));
+            env.close();
+
+            // Deposit 40 (the unlocked balance) should succeed
+            env(vault.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(vaultKeylet);
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
+            }
+
+            // Clean up escrow
+            env(escrow::finish(bob, depositor, escrowSeq),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFulfillment(escrow::kFb1),
+                Fee(baseFee * 150),
+                Ter(tesSUCCESS));
+            env.close();
+        }
+
+        {
+            testcase("Vault withdraw respects escrowed shares");
+
+            Env env{*this, testableAmendments()};
+            auto const baseFee = env.current()->fees().base;
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            Account const issuer{"issuer"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(10000), issuer, owner, depositor, bob);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create(
+                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            PrettyAsset const asset = mptt.issuanceID();
+            env(pay(issuer, depositor, asset(100)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            // Deposit 100 → get shares
+            env(vault.deposit(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const vaultSle = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            env.memoize(Account("vault", vaultSle->at(sfAccount)));
+            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+            // Authorize bob for share MPT so he can receive escrowed shares
+            auto const shareMPTID = vaultSle->at(sfShareMPTID);
+            {
+                json::Value jv;
+                jv[jss::Account] = bob.human();
+                jv[sfMPTokenIssuanceID] = to_string(shareMPTID);
+                jv[jss::TransactionType] = jss::MPTokenAuthorize;
+                env(jv, Ter(tesSUCCESS));
+                env.close();
+            }
+
+            // Escrow 60% of shares
+            auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1});
+            env(escrow::create(depositor, bob, escrowAmount),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFinishTime(env.now() + 1s),
+                Fee(baseFee * 150),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Withdraw all 100 should fail — only 40% of shares are unlocked
+            env(vault.withdraw(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tecINSUFFICIENT_FUNDS));
+            env.close();
+
+            // Withdraw 40 (matching unlocked shares) should succeed
+            env(vault.withdraw(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(vaultKeylet);
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value());
+            }
+        }
+
+        {
+            testcase("Vault clawback only recovers unlocked shares");
+
+            Env env{*this, testableAmendments() | fixCleanup3_1_3};
+            auto const baseFee = env.current()->fees().base;
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            Account const issuer{"issuer"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(10000), issuer, owner, depositor, bob);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create(
+                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            PrettyAsset const asset = mptt.issuanceID();
+            env(pay(issuer, depositor, asset(100)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            // Deposit 100 → get shares
+            env(vault.deposit(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const vaultSle = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            env.memoize(Account("vault", vaultSle->at(sfAccount)));
+            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+            // Authorize bob for share MPT so he can receive escrowed shares
+            auto const shareMPTID = vaultSle->at(sfShareMPTID);
+            {
+                json::Value jv;
+                jv[jss::Account] = bob.human();
+                jv[sfMPTokenIssuanceID] = to_string(shareMPTID);
+                jv[jss::TransactionType] = jss::MPTokenAuthorize;
+                env(jv, Ter(tesSUCCESS));
+                env.close();
+            }
+
+            // Escrow 60% of shares
+            auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1});
+            env(escrow::create(depositor, bob, escrowAmount),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFinishTime(env.now() + 1s),
+                Fee(baseFee * 150),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Zero-amount clawback ("all") — should only recover assets
+            // corresponding to unlocked shares (40%)
+            env(vault.clawback({
+                    .issuer = issuer,
+                    .id = vaultKeylet.key,
+                    .holder = depositor,
+                }),
+                Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(vaultKeylet);
+                BEAST_EXPECT(sle != nullptr);
+                // Only 40 of 100 assets recovered (matching 40% unlocked shares)
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value());
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
+
+                // Depositor's unlocked shares are now 0
+                auto const sharesAfter = env.balance(depositor, shares);
+                BEAST_EXPECT(sharesAfter == shares(0));
+            }
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testVaultClawbackBurnShares();
+        testVaultClawbackAssets();
+        testVaultEscrowedMPT();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE_PRIO(VaultClawback, app, xrpl, 1);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultClosedEnded_test.cpp b/src/test/app/vault/VaultClosedEnded_test.cpp
new file mode 100644
index 0000000000..252a7f4990
--- /dev/null
+++ b/src/test/app/vault/VaultClosedEnded_test.cpp
@@ -0,0 +1,1008 @@
+#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 {
+
+class VaultClosedEnded_test : public VaultTestBase
+{
+private:
+    // VaultCreate malformation and happy paths for closed-ended vaults, plus the
+    // featureLendingProtocolV1_1 gate.
+    void
+    testVaultCreateClosedEnded()
+    {
+        testcase("closed-ended VaultCreate");
+        using namespace test::jtx;
+
+        auto const withEnv = [this](FeatureBitset features, auto&& body) {
+            Env env{*this, features};
+            Account const owner{"owner"};
+            env.fund(XRP(1000), owner);
+            env.close();
+            Vault vault{env};
+            body(env, owner, vault);
+        };
+
+        Asset const asset = xrpIssue();
+        auto const minPeriod = kMinInvestmentPeriod;
+        auto const maxPeriod = kMaxInvestmentPeriod;
+        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
+
+        // Gate: the three new fields require featureLendingProtocolV1_1.
+        withEnv(
+            testableAmendments() - featureLendingProtocolV1_1,
+            [&](Env& env, Account const& owner, Vault& vault) {
+                auto const sub = env.now().time_since_epoch().count() + 60;
+                auto [tx, keylet] = vault.create(
+                    {.owner = owner,
+                     .asset = asset,
+                     .vaultKind = closedEnded,
+                     .subscriptionDate = sub,
+                     .redemptionDate = sub + minPeriod});
+                env(tx, Ter{temDISABLED});
+            });
+
+        /*
+         * Valid closed-ended creation with a comfortably interior gap (well above
+         * MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD).
+         */
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto const red = sub + 86400;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = red});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfVaultKind) == closedEnded);
+                BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub);
+                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
+            }
+        });
+
+        // ClosedEnded missing one of SubscriptionDate / RedemptionDate => temMALFORMED.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .redemptionDate = sub + minPeriod});
+            env(tx, Ter{temMALFORMED});
+        });
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        /*
+         * SubscriptionDate not strictly after parent close time (preclaim, state-dependent -
+         * returns tecEXPIRED). This is the only reachable path to tecEXPIRED in VaultCreate; see
+         * the note below the next case. Note: there is no separate "expired RedemptionDate" test
+         * case here. preflight enforces red >= sub + kMinInvestmentPeriod, so any past
+         * RedemptionDate implies a strictly-earlier, equally-past SubscriptionDate; the
+         * SubscriptionDate check above short-circuits first. The RedemptionDate arm of the
+         * hasExpired check in VaultCreate::preclaim is defensive and unreachable as the sole cause
+         * of tecEXPIRED.
+         */
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const nowSec = env.now().time_since_epoch().count();
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = nowSec,
+                 .redemptionDate = nowSec + minPeriod});
+            env(tx, Ter{tecEXPIRED});
+        });
+
+        /*
+         * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >=
+         * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red <
+         * sub case, the latter yielding a negative signed int64 gap that is caught by the
+         * sub-minimum branch of the gap check.
+         */
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = sub + minPeriod - 1});
+            env(tx, Ter{temMALFORMED});
+        });
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = sub});
+            env(tx, Ter{temMALFORMED});
+        });
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = sub - 1});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        // Gap equal to MAX_INVESTMENT_PERIOD => temMALFORMED (bound is half-open on the right).
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = sub + maxPeriod});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        // Gap strictly greater than MAX_INVESTMENT_PERIOD => temMALFORMED. Same code path as
+        // gap == MAX_INVESTMENT_PERIOD above, but covers the "gap >= MAX" bullet fully.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = sub + maxPeriod + 1});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is
+        // inclusive).
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto const red = sub + minPeriod;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = red});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
+            }
+        });
+
+        // Happy path: gap one second less than MAX_INVESTMENT_PERIOD is
+        // accepted (upper bound is exclusive).
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto const red = sub + maxPeriod - 1;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = red});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
+            }
+        });
+
+        // OpenEnded (absent/0) with SubscriptionDate or RedemptionDate present
+        // => temMALFORMED.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] =
+                vault.create({.owner = owner, .asset = asset, .subscriptionDate = sub});
+            env(tx, Ter{temMALFORMED});
+        });
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] =
+                vault.create({.owner = owner, .asset = asset, .redemptionDate = sub + minPeriod});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        // Unrecognised VaultKind => temMALFORMED.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = static_cast(closedEnded + 1)});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        // Happy path: open-ended vault (no new fields present) is unaffected.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind));
+                BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate));
+                BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate));
+            }
+        });
+
+        // Happy path: explicit `VaultKind = 0` (OpenEnded) behaves the same
+        // as absent. Per spec, absent and OpenEnded are equivalent.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = std::to_underlying(VaultKind::OpenEnded)});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                // OpenEnded is sfVaultKind's default; SoeDefault fields
+                // aren't serialized when they hold the default value.
+                BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind));
+                BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate));
+                BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate));
+            }
+        });
+    }
+
+    // SubscriptionDate boundary cases at the top of the UINT32 range.
+    // (1) The largest legal sub picks red = UINT32_MAX exactly, which hits
+    // the inclusive lower bound of the kMinInvestmentPeriod gap check.
+    // (2) sub = UINT32_MAX must be rejected: sub + kMinInvestmentPeriod is
+    // unrepresentable as the tx's UINT32 sfRedemptionDate, so no red value
+    // can satisfy the gap check.
+    void
+    testVaultCreateSubscriptionDateBoundary()
+    {
+        testcase("closed-ended VaultCreate SubscriptionDate near UINT32_MAX");
+        using namespace test::jtx;
+
+        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
+        Asset const asset = xrpIssue();
+
+        {
+            Env env{*this, testableAmendments()};
+            Account const owner{"owner"};
+            env.fund(XRP(1000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto const sub = std::numeric_limits::max() - kMinInvestmentPeriod;
+            auto const red = std::numeric_limits::max();
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = red});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub);
+                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
+            }
+        }
+
+        // sub = UINT32_MAX: no legal red exists because sub + kMinInvestmentPeriod
+        // wraps in a UINT32. Every candidate red must fall to temMALFORMED via
+        // the gap check in preflight.
+        auto const rejectAtMax = [&, this](std::uint32_t red) {
+            Env env{*this, testableAmendments()};
+            Account const owner{"owner"};
+            env.fund(XRP(1000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = std::numeric_limits::max(),
+                 .redemptionDate = red});
+            env(tx, Ter{temMALFORMED});
+        };
+        rejectAtMax(std::numeric_limits::max());
+        rejectAtMax(0u);
+        rejectAtMax(kMinInvestmentPeriod - 1u);
+    }
+
+    // Phase derivation across the SubscriptionDate / RedemptionDate boundaries, including the now
+    // == SubscriptionDate case (which must still resolve to Subscription).
+    void
+    testVaultPhaseDerivation()
+    {
+        testcase("closed-ended phase derivation");
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        env.fund(XRP(1000), owner, depositor);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod);
+
+        // Pre-seed shares during Subscription so the depositor has capital to
+        // withdraw at the Redemption boundary below.
+        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()}));
+        env.close();
+
+        auto const deposit =
+            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
+                env(
+                    WithSourceLocation{
+                        vault.deposit(
+                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
+                        loc},
+                    Ter{expected});
+            };
+        auto const withdraw =
+            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
+                env(
+                    WithSourceLocation{
+                        vault.withdraw(
+                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
+                        loc},
+                    Ter{expected});
+            };
+
+        auto const runTest = [&](TER expectedDeposit,
+                                 TER expectedWithdraw,
+                                 std::source_location const& loc =
+                                     std::source_location::current()) {
+            deposit(expectedDeposit, loc);
+            withdraw(expectedWithdraw, loc);
+        };
+
+        // Assert both deposit and withdraw return codes at each point so the
+        // active phase is uniquely identified:
+        //   Subscription: deposit tesSUCCESS, withdraw tesSUCCESS
+        //   Investment:   deposit tecEXPIRED, withdraw tecTOO_SOON
+        //   Redemption:   deposit tecEXPIRED, withdraw tesSUCCESS
+
+        // Ledger time comfortably before SubscriptionDate: Subscription.
+        runTest(tesSUCCESS, tesSUCCESS);
+
+        // Boundary: parent close time exactly at SubscriptionDate must still
+        // be Subscription.
+        closeToTime(env, tp{d{sub}});
+        runTest(tesSUCCESS, tesSUCCESS);
+
+        // One second past SubscriptionDate: Investment.
+        closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env));
+        runTest(tecEXPIRED, tecTOO_SOON);
+
+        // Any point strictly before RedemptionDate remains Investment.
+        closeToTime(env, tp{d{red}} - getLedgerTimeResolution(env));
+        runTest(tecEXPIRED, tecTOO_SOON);
+
+        // Boundary: parent close time == RedemptionDate is Redemption (per
+        // spec table: now >= RedemptionDate). Deposits are rejected but
+        // withdrawals succeed.
+        closeToTime(env, tp{d{red}});
+        runTest(tecEXPIRED, tesSUCCESS);
+        env.close();
+    }
+
+    // Open-ended vaults are always in VaultPhase::NoPhase, regardless of the ledger clock or any
+    // dates present on the vault.
+    void
+    testVaultPhaseDerivationOpenEnded()
+    {
+        testcase("open-ended phase derivation");
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        env.fund(XRP(1000), owner);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        Vault const vault{env};
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        env(tx);
+        env.close();
+
+        auto const checkPhaseAt = [&](NetClock::time_point at) {
+            closeToTime(env, at);
+            auto const sle = env.le(keylet);
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(getVaultPhase(*env.current(), sle) == VaultPhase::NoPhase);
+        };
+
+        // Advance the clock through a wide range of ledger times: an open-ended vault's phase
+        // must be NoPhase at every one of them, because the derivation short-circuits on
+        // VaultKind::OpenEnded before it looks at any dates.
+        auto const ledgerTime = tp{d{30}} + env.closed()->header().closeTimeResolution;
+        checkPhaseAt(ledgerTime);
+        checkPhaseAt(ledgerTime + std::chrono::seconds{kMinInvestmentPeriod});
+        checkPhaseAt(
+            ledgerTime + std::chrono::seconds{kMaxInvestmentPeriod} -
+            env.closed()->header().closeTimeResolution);
+    }
+
+    // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and
+    // Redemption.
+    void
+    testVaultDepositClosedEnded()
+    {
+        testcase("closed-ended VaultDeposit phase gating");
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        env.fund(XRP(1000), owner, depositor);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod);
+
+        auto const deposit =
+            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
+                env(
+                    WithSourceLocation{
+                        vault.deposit(
+                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
+                        loc},
+                    Ter{expected});
+                env.close();
+            };
+
+        // Subscription: allowed.
+        deposit(tesSUCCESS);
+
+        // Investment: rejected.
+        env.close(tp{d{sub + 1}});
+        deposit(tecEXPIRED);
+
+        // Redemption: rejected.
+        env.close(tp{d{red}});
+        deposit(tecEXPIRED);
+    }
+
+    // VaultWithdraw is allowed in Subscription and Redemption; rejected in Investment. The
+    // AssetsAvailable cap continues to apply and is exercised in Redemption against a vault with
+    // capital deployed as an outstanding loan.
+    void
+    testVaultWithdrawClosedEnded()
+    {
+        testcase("closed-ended VaultWithdraw phase gating");
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const borrower{"borrower"};
+        env.fund(XRP(10'000), owner, depositor, borrower);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        // Widen the Investment window so a single-payment loan (min payment
+        // interval kMinPaymentInterval = 60s) fits before RedemptionDate.
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u);
+
+        // Deposit XRP(100) in Subscription so the depositor's shares are
+        // worth XRP(100). The vault holds XRP(100) with
+        // AssetsAvailable == AssetsTotal.
+        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+
+        // Create a loan broker backed by this vault. LoanBrokerSet has no
+        // phase gate, so this is fine to do in Subscription.
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(loan_broker::set(owner, keylet.key));
+        env.close();
+
+        auto const withdraw = [&](STAmount const& amount,
+                                  TER expected,
+                                  std::source_location const& loc =
+                                      std::source_location::current()) {
+            env(
+                WithSourceLocation{
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}),
+                    loc},
+                Ter{expected});
+            env.close();
+        };
+
+        // Subscription: allowed (LP cancel).
+        withdraw(XRP(1).value(), tesSUCCESS);
+
+        // Investment: rejected.
+        closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env));
+        withdraw(XRP(1).value(), tecTOO_SOON);
+
+        // Deploy capital: borrower takes a loan of XRP(60) against the
+        // vault, dropping AssetsAvailable to ~XRP(39) while AssetsTotal
+        // remains ~XRP(99).
+        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
+            loan::kInterestRate(TenthBips32(0)),
+            kGracePeriod(60),
+            kPaymentInterval(60),
+            kPaymentTotal(1),
+            Sig(sfCounterpartySignature, owner),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        // Redemption: withdrawals are allowed but subject to the AssetsAvailable cap. A small
+        // withdrawal within AssetsAvailable succeeds. A withdrawal within the depositor's share
+        // value but exceeding the vault's liquid balance fails with tecINSUFFICIENT_FUNDS from the
+        // vault-shortage guard (not the insufficient-shares guard).
+        closeToTime(env, tp{d{red}});
+        withdraw(XRP(10).value(), tesSUCCESS);
+        withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS);
+    }
+
+    // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with
+    // multiple depositors and a real loan originated through the Investment leg. Exercises every
+    // phase transition and verifies the expected deposit, withdrawal, and lending behaviour in each
+    // phase.
+    void
+    testVaultClosedEndedLifecycle()
+    {
+        testcase("closed-ended vault lifecycle (subscribe → invest → redeem)");
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const borrower{"borrower"};
+        env.fund(XRP(10'000), owner, alice, bob, borrower);
+        env.close();
+
+        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
+        Asset const asset = xrpIssue();
+        // Widen the Investment window so a single-payment loan (min payment interval
+        // kMinPaymentInterval = 60s) fits before RedemptionDate with headroom.
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
+
+        auto const sleCreate = env.le(keylet);
+        BEAST_EXPECT(sleCreate);
+        MPTIssue const shares{sleCreate->at(sfShareMPTID)};
+
+        auto const balancesEq = [&](STAmount const& available, STAmount const& total) {
+            auto const sle = env.le(keylet);
+            BEAST_EXPECT(sle->at(sfAssetsAvailable) == available);
+            BEAST_EXPECT(sle->at(sfAssetsTotal) == total);
+        };
+        auto const availableEq = [&](STAmount const& expected) { balancesEq(expected, expected); };
+
+        // env.balance(account, mptIssue) name-resolves the issuer via Env::lookup, but the share
+        // issuer is the vault's pseudo-account and is never registered with the jtx Env. Read the
+        // MPToken SLE directly to avoid the lookup.
+        auto const sharesEq = [&](Account const& holder, std::uint64_t expected) {
+            auto const sle = env.le(keylet::mptoken(shares.getMptID(), holder.id()));
+            std::uint64_t const actual = sle ? sle->getFieldU64(sfMPTAmount) : 0u;
+            BEAST_EXPECT(actual == expected);
+        };
+
+        // ---- Subscription phase ----
+        // A legitimate VaultSet succeeds (positive control for 3.7).
+        {
+            auto tx = vault.set({.owner = owner, .id = keylet.key});
+            tx[sfData] = "AA";
+            env(tx);
+            env.close();
+        }
+
+        // alice deposits 100 XRP.
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+        sharesEq(alice, 100'000'000);
+        availableEq(XRP(100).value());
+
+        // bob deposits 200 XRP.
+        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}));
+        env.close();
+        sharesEq(bob, 200'000'000);
+        availableEq(XRP(300).value());
+
+        // alice cancels 25 XRP (LP cancel is permitted in Subscription).
+        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(25).value()}));
+        env.close();
+        sharesEq(alice, 75'000'000);
+        availableEq(XRP(275).value());
+
+        // Create a loan broker backed by this vault. LoanBrokerSet has no phase gate, so it is
+        // fine to do in Subscription.
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(loan_broker::set(owner, keylet.key));
+        env.close();
+
+        // ---- Investment phase (now == sub + 1) ----
+        env.close(tp{d{sub + 1}});
+
+        // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED.
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
+            Ter{tecEXPIRED});
+        env.close();
+        // Withdrawals from a closed-ended vault during the Investment phase return tecTOO_SOON.
+        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
+            Ter{tecTOO_SOON});
+        env.close();
+
+        // A real loan is originated during Investment (permitted only in this phase). Zero-interest
+        // one-payment schedule keeps AssetsTotal unchanged (both accrual and cash-basis
+        // accounting recognise no interest at origination); AssetsAvailable drops by the loan
+        // principal.
+        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
+            loan::kInterestRate(TenthBips32(0)),
+            kGracePeriod(60),
+            kPaymentInterval(60),
+            kPaymentTotal(1),
+            Sig(sfCounterpartySignature, owner),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+        auto const sleBroker = env.le(keylet::loanBroker(brokerKeylet.key));
+        BEAST_EXPECT(sleBroker);
+        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
+        BEAST_EXPECT(env.le(loanKeylet));
+        balancesEq(XRP(215).value(), XRP(275).value());
+
+        // Non-immutable VaultSet still works in Investment (positive control).
+        {
+            auto tx = vault.set({.owner = owner, .id = keylet.key});
+            tx[sfData] = "BB";
+            env(tx);
+            env.close();
+        }
+
+        // Depositor share balances unchanged by the loan origination; only AssetsAvailable moved.
+        sharesEq(alice, 75'000'000);
+        sharesEq(bob, 200'000'000);
+
+        // ---- Redemption phase (now == red) ----
+        env.close(tp{d{red}});
+
+        // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED, in both
+        // Investment and Redemption.
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
+            Ter{tecEXPIRED});
+        env.close();
+
+        // alice redeems her remaining 75 XRP (fits within AssetsAvailable = 215).
+        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(75).value()}));
+        env.close();
+        sharesEq(alice, 0);
+        balancesEq(XRP(140).value(), XRP(200).value());
+
+        // bob has 200 XRP-worth of shares but only 140 XRP is available (the remaining 60 XRP
+        // sits in the outstanding loan). A full 200 XRP withdrawal fails against the
+        // AssetsAvailable cap; bob redeems 140 XRP instead and is left holding 60M shares backed
+        // by the loan receivable — the realistic outcome when capital is still deployed at
+        // Redemption.
+        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}),
+            Ter{tecINSUFFICIENT_FUNDS});
+        env.close();
+        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(140).value()}));
+        env.close();
+        sharesEq(bob, 60'000'000);
+        balancesEq(XRP(0).value(), XRP(60).value());
+
+        // Defensive spot-check that the three immutable fields have not changed across the entire
+        // lifecycle. Direct immutability coverage lives with the invariant tests.
+        auto const sleFinal = env.le(keylet);
+        if (BEAST_EXPECT(sleFinal))
+        {
+            BEAST_EXPECT(sleFinal->at(sfVaultKind) == closedEnded);
+            BEAST_EXPECT(sleFinal->at(sfSubscriptionDate) == sub);
+            BEAST_EXPECT(sleFinal->at(sfRedemptionDate) == red);
+        }
+    }
+
+    // A loan whose payment is made after the Investment phase has ended
+    // (well past its next-due-date and grace period, into Redemption) must
+    // still be repayable. The vault phase must not gate LoanPay.
+    void
+    testVaultLoanLatePaymentAfterInvestment()
+    {
+        testcase("closed-ended vault: late loan payment during Redemption succeeds");
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const alice{"alice"};
+        Account const borrower{"borrower"};
+        env.fund(XRP(10'000), owner, alice, borrower);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
+
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(loan_broker::set(owner, keylet.key));
+        env.close();
+
+        // Investment phase: originate a zero-interest, single-payment loan
+        // with a 300s payment interval and 60s grace. The payment is due
+        // shortly after origination and well before RedemptionDate.
+        env.close(tp{d{sub + 1}});
+        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
+            loan::kInterestRate(TenthBips32(0)),
+            kGracePeriod(60),
+            kPaymentInterval(300),
+            kPaymentTotal(1),
+            Sig(sfCounterpartySignature, owner),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
+        BEAST_EXPECT(env.le(loanKeylet));
+
+        // Advance to Redemption. The payment is now past its due date and
+        // grace, and the vault is no longer in Investment.
+        closeToTime(env, tp{d{red}});
+
+        env(loan::pay(borrower, loanKeylet.key, XRP(60).value(), tfLoanLatePayment));
+        env.close();
+
+        // Loan principal returned to the vault; assetsAvailable == assetsTotal.
+        auto const sleAfter = env.le(keylet);
+        if (BEAST_EXPECT(sleAfter))
+        {
+            BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == sleAfter->at(sfAssetsTotal));
+            BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == XRP(100).value());
+        }
+
+        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+    }
+
+    // Two concurrent loans against the same closed-ended vault in Investment
+    // must coexist: both loan SLEs are created, AssetsAvailable reflects the
+    // sum of the two outstanding principals, and each can be repaid
+    // independently.
+    void
+    testVaultClosedEndedMultipleLoans()
+    {
+        testcase("closed-ended vault: multiple concurrent loans in Investment");
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const borrower1{"borrower1"};
+        Account const borrower2{"borrower2"};
+        env.fund(XRP(10'000), owner, alice, bob, borrower1, borrower2);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
+
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(loan_broker::set(owner, keylet.key));
+        env.close();
+
+        env.close(tp{d{sub + 1}});
+
+        auto const originate = [&](Account const& b, STAmount const& principal) {
+            env(loan::set(b, brokerKeylet.key, principal),
+                loan::kInterestRate(TenthBips32(0)),
+                kGracePeriod(60),
+                kPaymentInterval(300),
+                kPaymentTotal(1),
+                Sig(sfCounterpartySignature, owner),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+        };
+        originate(borrower1, XRP(50).value());
+        originate(borrower2, XRP(70).value());
+
+        auto const loan1 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
+        auto const loan2 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2u));
+        BEAST_EXPECT(env.le(loan1));
+        BEAST_EXPECT(env.le(loan2));
+
+        // Zero-interest at origination: AssetsTotal unchanged, AssetsAvailable
+        // drops by the sum of the two loan principals.
+        {
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value());
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(80).value());
+            }
+        }
+
+        // Repay the first loan; the second remains outstanding.
+        env(loan::pay(borrower1, loan1.key, XRP(50).value()));
+        env.close();
+        {
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value());
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(130).value());
+            }
+        }
+
+        // Repay the second loan; vault is fully liquid again.
+        env(loan::pay(borrower2, loan2.key, XRP(70).value()));
+        env.close();
+        {
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == sle->at(sfAssetsTotal));
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(200).value());
+            }
+        }
+
+        // Redemption: both depositors withdraw in full.
+        env.close(tp{d{red}});
+        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+    }
+
+    // VaultClawback has no phase gate: an issuer must be able to reclaim
+    // asset from a depositor in Subscription, Investment and Redemption
+    // alike. Uses an IOU with asfAllowTrustLineClawback so the issuer path
+    // is exercised (XRP clawback with an explicit amount is temMALFORMED).
+    void
+    testVaultClawbackClosedEndedPhases()
+    {
+        testcase("closed-ended vault: VaultClawback succeeds in each phase");
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const alice{"alice"};
+        env.fund(XRP(10'000), issuer, owner, alice);
+        env.close();
+
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+
+        PrettyAsset const iou = issuer["IOU"];
+        env.trust(iou(10'000), alice);
+        env(pay(issuer, alice, iou(1'000)));
+        env.close();
+
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, iou, 300u, kMinInvestmentPeriod + 3600u);
+
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = iou(300).value()}));
+        env.close();
+
+        auto const totalsEq = [&](STAmount const& expected) {
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == expected);
+        };
+
+        // Subscription phase clawback.
+        env(vault.clawback(
+            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
+        env.close();
+        totalsEq(iou(290).value());
+
+        // Investment phase clawback.
+        env.close(tp{d{sub + 1}});
+        env(vault.clawback(
+            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
+        env.close();
+        totalsEq(iou(280).value());
+
+        // Redemption phase clawback.
+        env.close(tp{d{red}});
+        env(vault.clawback(
+            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
+        env.close();
+        totalsEq(iou(270).value());
+    }
+
+public:
+    void
+    run() override
+    {
+        testVaultCreateClosedEnded();
+        testVaultCreateSubscriptionDateBoundary();
+        testVaultPhaseDerivation();
+        testVaultPhaseDerivationOpenEnded();
+        testVaultDepositClosedEnded();
+        testVaultWithdrawClosedEnded();
+        testVaultClosedEndedLifecycle();
+        testVaultLoanLatePaymentAfterInvestment();
+        testVaultClosedEndedMultipleLoans();
+        testVaultClawbackClosedEndedPhases();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE_PRIO(VaultClosedEnded, app, xrpl, 1);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultDomain_test.cpp b/src/test/app/vault/VaultDomain_test.cpp
new file mode 100644
index 0000000000..db8943921b
--- /dev/null
+++ b/src/test/app/vault/VaultDomain_test.cpp
@@ -0,0 +1,586 @@
+#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 {
+
+class VaultDomain_test : public VaultTestBase
+{
+private:
+    void
+    testWithDomainCheck()
+    {
+        using namespace test::jtx;
+
+        testcase("private vault");
+
+        Env env{*this, testableAmendments()};
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const charlie{"charlie"};
+        Account const pdOwner{"pdOwner"};
+        Account const credIssuer1{"credIssuer1"};
+        Account const credIssuer2{"credIssuer2"};
+        std::string const credType = "credential";
+        Vault const vault{env};
+        env.fund(XRP(1000), issuer, owner, depositor, charlie, pdOwner, credIssuer1, credIssuer2);
+        env.close();
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+        env.require(Flags(issuer, asfAllowTrustLineClawback));
+
+        PrettyAsset const asset = issuer["IOU"];
+        env.trust(asset(1000), owner);
+        env(pay(issuer, owner, asset(500)));
+        env.trust(asset(1000), depositor);
+        env(pay(issuer, depositor, asset(500)));
+        env.trust(asset(1000), charlie);
+        env(pay(issuer, charlie, asset(5)));
+        env.close();
+
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
+        env(tx);
+        env.close();
+        BEAST_EXPECT(env.le(keylet));
+
+        {
+            testcase("private vault owner can deposit");
+            auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
+            env(tx);
+        }
+
+        {
+            testcase("private vault depositor not authorized yet");
+            auto tx =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+            env(tx, Ter{tecNO_AUTH});
+        }
+
+        {
+            testcase("private vault cannot set non-existing domain");
+            auto tx = vault.set({.owner = owner, .id = keylet.key});
+            tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+            env(tx, Ter{tecOBJECT_NOT_FOUND});
+        }
+
+        {
+            testcase("private vault set domainId");
+
+            {
+                pdomain::Credentials const credentials1{
+                    {.issuer = credIssuer1, .credType = credType}};
+
+                env(pdomain::setTx(pdOwner, credentials1));
+                auto const domainId1 = [&]() {
+                    auto tx = env.tx()->getJson(JsonOptions::Values::None);
+                    return pdomain::getNewDomain(env.meta());
+                }();
+
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfDomainID] = to_string(domainId1);
+                env(tx);
+                env.close();
+
+                // Update domain second time, should be harmless
+                env(tx);
+                env.close();
+            }
+
+            {
+                pdomain::Credentials const credentials{
+                    {.issuer = credIssuer1, .credType = credType},
+                    {.issuer = credIssuer2, .credType = credType}};
+
+                env(pdomain::setTx(pdOwner, credentials));
+                auto const domainId = [&]() {
+                    auto tx = env.tx()->getJson(JsonOptions::Values::None);
+                    return pdomain::getNewDomain(env.meta());
+                }();
+
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfDomainID] = to_string(domainId);
+                env(tx);
+                env.close();
+
+                // Should be idempotent
+                tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfDomainID] = to_string(domainId);
+                env(tx);
+                env.close();
+            }
+        }
+
+        {
+            testcase("private vault depositor still not authorized");
+            auto tx =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+            env(tx, Ter{tecNO_AUTH});
+            env.close();
+        }
+
+        auto const credKeylet = credentials::keylet(depositor, credIssuer1, credType);
+        {
+            testcase("private vault depositor now authorized");
+            env(credentials::create(depositor, credIssuer1, credType));
+            env(credentials::accept(depositor, credIssuer1, credType));
+            env(credentials::create(charlie, credIssuer1, credType));
+            // charlie's credential not accepted
+            env.close();
+            auto credSle = env.le(credKeylet);
+            BEAST_EXPECT(credSle != nullptr);
+
+            auto tx =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+            env(tx);
+            env.close();
+
+            tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(50)});
+            env(tx, Ter{tecNO_AUTH});
+            env.close();
+        }
+
+        {
+            testcase("private vault depositor lost authorization");
+            env(credentials::deleteCred(credIssuer1, depositor, credIssuer1, credType));
+            env(credentials::deleteCred(credIssuer1, charlie, credIssuer1, credType));
+            env.close();
+            auto credSle = env.le(credKeylet);
+            BEAST_EXPECT(credSle == nullptr);
+
+            auto tx =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+            env(tx, Ter{tecNO_AUTH});
+            env.close();
+        }
+
+        auto const shares = [&env, keylet = keylet, this]() -> Asset {
+            auto const vault = env.le(keylet);
+            BEAST_EXPECT(vault != nullptr);
+            return MPTIssue(vault->at(sfShareMPTID));
+        }();
+
+        {
+            testcase("private vault expired authorization");
+            uint32_t const closeTime =
+                env.current()->header().parentCloseTime.time_since_epoch().count();
+            {
+                auto tx0 = credentials::create(depositor, credIssuer2, credType);
+                tx0[sfExpiration] = closeTime + 20;
+                env(tx0);
+                tx0 = credentials::create(charlie, credIssuer2, credType);
+                tx0[sfExpiration] = closeTime + 20;
+                env(tx0);
+                env.close();
+
+                env(credentials::accept(depositor, credIssuer2, credType));
+                env(credentials::accept(charlie, credIssuer2, credType));
+                env.close();
+            }
+
+            {
+                auto tx1 =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                env(tx1);
+                env.close();
+
+                auto const tokenKeylet =
+                    keylet::mptoken(shares.get().getMptID(), depositor.id());
+                BEAST_EXPECT(env.le(tokenKeylet) != nullptr);
+            }
+
+            {
+                // time advance
+                env.close();
+                env.close();
+                env.close();
+
+                auto const credsKeylet = credentials::keylet(depositor, credIssuer2, credType);
+                BEAST_EXPECT(env.le(credsKeylet) != nullptr);
+
+                auto tx2 =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
+                env(tx2, Ter{tecEXPIRED});
+                env.close();
+
+                BEAST_EXPECT(env.le(credsKeylet) == nullptr);
+            }
+
+            {
+                auto const credsKeylet = credentials::keylet(charlie, credIssuer2, credType);
+                BEAST_EXPECT(env.le(credsKeylet) != nullptr);
+                auto const tokenKeylet =
+                    keylet::mptoken(shares.get().getMptID(), charlie.id());
+                BEAST_EXPECT(env.le(tokenKeylet) == nullptr);
+
+                auto tx3 =
+                    vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(2)});
+                env(tx3, Ter{tecEXPIRED});
+
+                env.close();
+                BEAST_EXPECT(env.le(credsKeylet) == nullptr);
+                BEAST_EXPECT(env.le(tokenKeylet) == nullptr);
+            }
+        }
+
+        {
+            testcase("private vault reset domainId");
+            auto tx = vault.set({.owner = owner, .id = keylet.key});
+            tx[sfDomainID] = "0";
+            env(tx);
+            env.close();
+
+            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+            env(tx, Ter{tecNO_AUTH});
+            env.close();
+
+            tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+            env(tx);
+            env.close();
+
+            tx = vault.clawback(
+                {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
+            env(tx);
+
+            tx = vault.clawback(
+                {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)});
+            env(tx);
+            env.close();
+
+            tx = vault.del({
+                .owner = owner,
+                .id = keylet.key,
+            });
+            env(tx);
+        }
+    }
+
+    void
+    testDomainLossAfterAcquisition()
+    {
+        using namespace test::jtx;
+
+        testcase("private vault share transfer after depositor loses domain");
+
+        // The "Private Vault - Access Control Rules" spec requires that a holder who
+        // loses Layer 2 (Permissioned Domain membership) after acquiring shares be
+        // blocked from sending them onward, by P2P transfer or DEX offer, the same
+        // way a brand-new never-authorized holder is blocked. Only withdrawal to
+        // self is meant to stay open.
+        //
+        // For a domain-gated share MPToken, requireAuth()'s escape hatch for
+        // holders who already have an MPToken (MPTokenHelpers.cpp) only applies to
+        // the classic explicit-issuer-authorization flag, which
+        // enforceMPTokenAuthorization documents as "meaningless" for
+        // domain-authorized holders and never sets. So a stale MPToken does not
+        // carry authorization forward once the account's domain credential is
+        // gone, and both actions below are correctly blocked.
+
+        Env env{*this, testableAmendments()};
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const bob{"bob"};
+        Account const pdOwner{"pdOwner"};
+        Account const credIssuer{"credIssuer"};
+        std::string const credType = "credential";
+        Vault const vault{env};
+        env.fund(XRP(1000), issuer, owner, depositor, bob, pdOwner, credIssuer);
+        env.close();
+
+        PrettyAsset const asset = issuer["IOU"];
+        env.trust(asset(1000), owner);
+        env(pay(issuer, owner, asset(500)));
+        env.trust(asset(1000), depositor);
+        env(pay(issuer, depositor, asset(500)));
+        env.trust(asset(1000), bob);
+        env(pay(issuer, bob, asset(500)));
+        env.close();
+
+        // Transferable shares (no tfVaultShareNonTransferable): sections 3.3/3.4 of
+        // the spec (DEX trading / P2P transfer) only apply to transferable shares.
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
+        env(tx);
+        env.close();
+
+        pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}};
+        env(pdomain::setTx(pdOwner, credentials));
+        auto const domainId = [&]() {
+            auto tx = env.tx()->getJson(JsonOptions::Values::None);
+            return pdomain::getNewDomain(env.meta());
+        }();
+        {
+            auto domainTx = vault.set({.owner = owner, .id = keylet.key});
+            domainTx[sfDomainID] = to_string(domainId);
+            env(domainTx);
+            env.close();
+        }
+
+        // Both depositor and bob acquire domain membership and deposit, so each
+        // ends up with an authorized share MPToken.
+        env(credentials::create(depositor, credIssuer, credType));
+        env(credentials::accept(depositor, credIssuer, credType));
+        env(credentials::create(bob, credIssuer, credType));
+        env(credentials::accept(bob, credIssuer, credType));
+        env.close();
+
+        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}));
+        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)}));
+        env.close();
+
+        auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset {
+            auto const sle = env.le(keylet);
+            BEAST_EXPECT(sle != nullptr);
+            return MPTIssue(sle->at(sfShareMPTID));
+        }();
+
+        // Depositor loses Layer 2: their Permissioned Domain credential is revoked.
+        auto const credKeylet = credentials::keylet(depositor, credIssuer, credType);
+        env(credentials::deleteCred(credIssuer, depositor, credIssuer, credType));
+        env.close();
+        BEAST_EXPECT(env.le(credKeylet) == nullptr);
+
+        // Sanity check, mirrors testWithDomainCheck's "not authorized yet" case: a
+        // brand-new depositor with no MPToken yet is still correctly blocked. The
+        // gap below is specific to holders who already hold shares.
+        {
+            Account const charlie{"charlie"};
+            env.fund(XRP(1000), charlie);
+            env.close();
+            auto depTx =
+                vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(1)});
+            env(depTx, Ter{tecNO_AUTH});
+        }
+
+        // P2P transfer: spec section 3.4 requires this blocked once Layer 2 is
+        // lost, and it is.
+        env(pay(depositor, bob, shares(1)), Ter{tecNO_AUTH});
+        env.close();
+
+        // DEX/CLOB: spec section 3.3 requires the seller leg blocked the same way.
+        // The offer can't even be created: preclaim treats the seller as
+        // unfunded once their share balance reads as zero for auth purposes.
+        env(offer(depositor, XRP(1), shares(1)), Ter{tecUNFUNDED_OFFER});
+        env.close();
+        BEAST_EXPECT(expectOffers(env, depositor, 0));
+    }
+
+    void
+    testDomainCheckBuyerSideOffer()
+    {
+        using namespace test::jtx;
+
+        testcase("private vault share purchase via DEX requires buyer domain membership");
+
+        // The "Private Vault - Access Control Rules" spec requires the buyer leg
+        // of a DEX trade in private-vault shares to hold Layer 1 and Layer 2 as
+        // well, not just the seller.
+
+        Env env{*this, testableAmendments()};
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const bob{"bob"};
+        Account const charlie{"charlie"};
+        Account const pdOwner{"pdOwner"};
+        Account const credIssuer{"credIssuer"};
+        std::string const credType = "credential";
+        Vault const vault{env};
+        env.fund(XRP(1000), issuer, owner, bob, charlie, pdOwner, credIssuer);
+        env.close();
+
+        PrettyAsset const asset = issuer["IOU"];
+        env.trust(asset(1000), owner);
+        env(pay(issuer, owner, asset(500)));
+        env.trust(asset(1000), bob);
+        env(pay(issuer, bob, asset(500)));
+        env.close();
+
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
+        env(tx);
+        env.close();
+
+        pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}};
+        env(pdomain::setTx(pdOwner, credentials));
+        auto const domainId = [&]() {
+            auto tx = env.tx()->getJson(JsonOptions::Values::None);
+            return pdomain::getNewDomain(env.meta());
+        }();
+        {
+            auto domainTx = vault.set({.owner = owner, .id = keylet.key});
+            domainTx[sfDomainID] = to_string(domainId);
+            env(domainTx);
+            env.close();
+        }
+
+        // Only bob joins the domain and deposits; charlie never does.
+        env(credentials::create(bob, credIssuer, credType));
+        env(credentials::accept(bob, credIssuer, credType));
+        env.close();
+        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)}));
+        env.close();
+
+        auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset {
+            auto const sle = env.le(keylet);
+            BEAST_EXPECT(sle != nullptr);
+            return MPTIssue(sle->at(sfShareMPTID));
+        }();
+
+        // Bob (domain member, holds shares) rests a sell offer.
+        env(offer(bob, XRP(1), shares(1)));
+        env.close();
+        BEAST_EXPECT(expectOffers(env, bob, 1));
+
+        // Charlie never held the domain credential. Buying shares via a
+        // crossing offer must be blocked the same way a direct MPTokenAuthorize
+        // + pay attempt already is (see testWithDomainChecXRP's "cannot pay
+        // shares to 3rd party"): checkAcceptAsset() rejects the offer outright
+        // in preclaim, before any funding check is even reached.
+        env(offer(charlie, shares(1), XRP(1)), Ter{tecNO_AUTH});
+        env.close();
+        BEAST_EXPECT(expectOffers(env, bob, 1));
+        BEAST_EXPECT(expectOffers(env, charlie, 0));
+    }
+
+    void
+    testWithDomainChecXRP()
+    {
+        using namespace test::jtx;
+
+        testcase("private XRP vault");
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const alice{"charlie"};
+        std::string const credType = "credential";
+        Vault const vault{env};
+        env.fund(XRP(100000), owner, depositor, alice);
+        env.close();
+
+        PrettyAsset const asset = xrpIssue();
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
+        env(tx);
+        env.close();
+
+        auto const [vaultAccount, issuanceId] =
+            [&env, keylet = keylet, this]() -> std::tuple {
+            auto const vault = env.le(keylet);
+            BEAST_EXPECT(vault != nullptr);
+            return {vault->at(sfAccount), vault->at(sfShareMPTID)};
+        }();
+        BEAST_EXPECT(env.le(keylet::account(vaultAccount)));
+        BEAST_EXPECT(env.le(keylet::mptokenIssuance(issuanceId)));
+        PrettyAsset const shares{issuanceId};
+
+        {
+            testcase("private XRP vault owner can deposit");
+            auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
+            env(tx);
+            env.close();
+        }
+
+        {
+            testcase("private XRP vault cannot pay shares to depositor yet");
+            env(pay(owner, depositor, shares(1)), Ter{tecNO_AUTH});
+        }
+
+        {
+            testcase("private XRP vault depositor not authorized yet");
+            auto tx =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+            env(tx, Ter{tecNO_AUTH});
+        }
+
+        {
+            testcase("private XRP vault set DomainID");
+            pdomain::Credentials const credentials{{.issuer = owner, .credType = credType}};
+
+            env(pdomain::setTx(owner, credentials));
+            auto const domainId = [&]() {
+                auto tx = env.tx()->getJson(JsonOptions::Values::None);
+                return pdomain::getNewDomain(env.meta());
+            }();
+
+            auto tx = vault.set({.owner = owner, .id = keylet.key});
+            tx[sfDomainID] = to_string(domainId);
+            env(tx);
+            env.close();
+        }
+
+        auto const credKeylet = credentials::keylet(depositor, owner, credType);
+        {
+            testcase("private XRP vault depositor now authorized");
+            env(credentials::create(depositor, owner, credType));
+            env(credentials::accept(depositor, owner, credType));
+            env.close();
+
+            BEAST_EXPECT(env.le(credKeylet));
+            auto tx =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+            env(tx);
+            env.close();
+        }
+
+        {
+            testcase("private XRP vault can pay shares to depositor");
+            env(pay(owner, depositor, shares(1)));
+        }
+
+        {
+            testcase("private XRP vault cannot pay shares to 3rd party");
+            json::Value jv;
+            jv[sfAccount] = alice.human();
+            jv[sfTransactionType] = jss::MPTokenAuthorize;
+            jv[sfMPTokenIssuanceID] = to_string(issuanceId);
+            env(jv);
+            env.close();
+
+            env(pay(owner, alice, shares(1)), Ter{tecNO_AUTH});
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testWithDomainCheck();
+        testDomainLossAfterAcquisition();
+        testDomainCheckBuyerSideOffer();
+        testWithDomainChecXRP();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultDomain, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultFreeze_test.cpp b/src/test/app/vault/VaultFreeze_test.cpp
new file mode 100644
index 0000000000..120aabc8f6
--- /dev/null
+++ b/src/test/app/vault/VaultFreeze_test.cpp
@@ -0,0 +1,691 @@
+#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 {
+
+class VaultFreeze_test : public VaultTestBase
+{
+private:
+    void
+    testVaultDepositFreezeIOU()
+    {
+        using namespace test::jtx;
+        testcase("VaultDeposit IOU freeze checks");
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Env env{*this};
+        Vault vault{env};
+
+        env.fund(XRP(100'000), issuer, owner);
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+        PrettyAsset const asset = issuer["IOU"];
+        env.trust(asset(1'000'000), owner);
+        env(pay(issuer, owner, asset(100'000)));
+        env.close();
+
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        env(tx);
+        env.close();
+        auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
+
+        // Initial deposit so the vault pseudo-account has a trustline
+        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
+        env.close();
+
+        auto runTests = [&]() {
+            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
+
+            // Global freeze
+            {
+                testcase("VaultDeposit IOU global freeze");
+                env(fset(issuer, asfGlobalFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(tecFROZEN));
+                env(fclear(issuer, asfGlobalFreeze));
+            }
+
+            // Depositor freeze
+            {
+                testcase("VaultDeposit IOU depositor freeze");
+                env(trust(issuer, asset(0), owner, tfSetFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(tecFROZEN));
+                env(trust(issuer, asset(0), owner, tfClearFreeze));
+            }
+
+            // Depositor deep freeze
+            {
+                testcase("VaultDeposit IOU depositor deep freeze");
+                env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(tecFROZEN));
+                env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
+            }
+
+            // Vault-account freeze
+            // Post-fix: checkDepositFreeze catches it → tecFROZEN
+            // Pre-fix: not checked directly, but the transitive share
+            //          check triggers → tecLOCKED
+            {
+                testcase("VaultDeposit IOU pseudo-account freeze");
+                auto trustSet = [&]() {
+                    json::Value jv;
+                    jv[jss::Account] = issuer.human();
+                    {
+                        auto& ja = jv[jss::LimitAmount] =
+                            asset(0).value().getJson(JsonOptions::Values::None);
+                        ja[jss::issuer] = toBase58(vaultAcct.id());
+                    }
+                    jv[jss::TransactionType] = jss::TrustSet;
+                    return jv;
+                }();
+
+                trustSet[jss::Flags] = tfSetFreeze;
+                env(trustSet);
+                env.close();
+
+                TER const expected = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(expected));
+
+                trustSet[jss::Flags] = tfClearFreeze;
+                env(trustSet);
+                env.close();
+            }
+
+            // Vault-account deep freeze
+            {
+                testcase("VaultDeposit IOU pseudo-account deep freeze");
+                auto trustSet = [&]() {
+                    json::Value jv;
+                    jv[jss::Account] = issuer.human();
+                    {
+                        auto& ja = jv[jss::LimitAmount] =
+                            asset(0).value().getJson(JsonOptions::Values::None);
+                        ja[jss::issuer] = toBase58(vaultAcct.id());
+                    }
+                    jv[jss::TransactionType] = jss::TrustSet;
+                    return jv;
+                }();
+
+                trustSet[jss::Flags] = tfSetFreeze | tfSetDeepFreeze;
+                env(trustSet);
+                env.close();
+
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
+
+                trustSet[jss::Flags] = tfClearFreeze | tfClearDeepFreeze;
+                env(trustSet);
+                env.close();
+            }
+
+            // Clawback works while frozen
+            {
+                testcase("VaultDeposit IOU freeze clawback unaffected");
+                env(fset(issuer, asfGlobalFreeze));
+                env(vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
+                env(fclear(issuer, asfGlobalFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+                env.close();
+            }
+        };
+
+        runTests();
+        env.disableFeature(fixCleanup3_3_0);
+        runTests();
+        env.enableFeature(fixCleanup3_3_0);
+    }
+
+    void
+    testVaultDepositFreezeMPT()
+    {
+        using namespace test::jtx;
+        testcase("VaultDeposit MPT lock checks");
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Env env{*this};
+        Vault vault{env};
+
+        env.fund(XRP(100'000), issuer, owner);
+        env.close();
+
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create(
+            {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
+        PrettyAsset const mpt{mptt.issuanceID()};
+
+        mptt.authorize({.account = owner});
+        mptt.authorize({.account = issuer, .holder = owner});
+        env.close();
+        env(pay(issuer, owner, mpt(100'000)));
+        env.close();
+
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
+        env(tx);
+        env.close();
+        auto const vaultAcctID = env.le(keylet)->at(sfAccount);
+        Account const vaultAcct("vault", vaultAcctID);
+
+        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
+        env.close();
+
+        // For MPT isDeepFrozen == isFrozen, so all locks block in
+        // both pre- and post-fix.
+        auto runTests = [&]() {
+            // Global lock
+            {
+                testcase("VaultDeposit MPT global lock");
+                mptt.set({.flags = tfMPTLock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+                mptt.set({.flags = tfMPTUnlock});
+                env.close();
+            }
+
+            // Depositor individual lock
+            {
+                testcase("VaultDeposit MPT depositor lock");
+                mptt.set({.holder = owner, .flags = tfMPTLock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+                mptt.set({.holder = owner, .flags = tfMPTUnlock});
+                env.close();
+            }
+
+            // Vault pseudo-account individual lock
+            {
+                testcase("VaultDeposit MPT pseudo-account lock");
+                mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+                mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
+                env.close();
+            }
+
+            // Clawback works while locked
+            {
+                testcase("VaultDeposit MPT lock clawback unaffected");
+                mptt.set({.flags = tfMPTLock});
+                env.close();
+                env(vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
+                mptt.set({.flags = tfMPTUnlock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                env.close();
+            }
+        };
+
+        runTests();
+        env.disableFeature(fixCleanup3_3_0);
+        runTests();
+        env.enableFeature(fixCleanup3_3_0);
+    }
+
+    void
+    testVaultWithdrawFreezeIOU()
+    {
+        using namespace test::jtx;
+        testcase("VaultWithdraw IOU freeze checks");
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Env env{*this};
+        Vault const vault{env};
+
+        env.fund(XRP(100'000), issuer, owner);
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+        PrettyAsset const asset = issuer["IOU"];
+        env.trust(asset(1'000'000), owner);
+        env(pay(issuer, owner, asset(100'000)));
+        env.close();
+
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        env(tx);
+        env.close();
+        auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
+
+        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
+        env.close();
+
+        Account const charlie{"charlie"};
+        env.fund(XRP(10'000), charlie);
+        env.trust(asset(1'000'000), charlie);
+        env.close();
+
+        auto runTests = [&]() {
+            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
+            // Global freeze → self-withdraw
+            {
+                testcase("VaultWithdraw IOU global freeze");
+                env(fset(issuer, asfGlobalFreeze));
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(tecFROZEN));
+                // Global freeze → withdraw to 3rd party
+
+                auto withdrawToCharlie =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                withdrawToCharlie[sfDestination] = charlie.human();
+                env(withdrawToCharlie, Ter(tecFROZEN));
+
+                env(fclear(issuer, asfGlobalFreeze));
+            }
+
+            // Vault-account freeze
+            {
+                testcase("VaultWithdraw IOU pseudo-account freeze");
+                auto trustSet = [&]() {
+                    json::Value jv;
+                    jv[jss::Account] = issuer.human();
+                    {
+                        auto& ja = jv[jss::LimitAmount] =
+                            asset(0).value().getJson(JsonOptions::Values::None);
+                        ja[jss::issuer] = toBase58(vaultAcct.id());
+                    }
+                    jv[jss::TransactionType] = jss::TrustSet;
+                    return jv;
+                }();
+
+                trustSet[jss::Flags] = tfSetFreeze;
+                env(trustSet);
+                env.close();
+
+                TER const terExpected = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
+
+                // Self-withdraw
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(terExpected));
+                // Withdraw to 3rd party
+
+                auto withdrawToCharlie =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                withdrawToCharlie[sfDestination] = charlie.human();
+                env(withdrawToCharlie, Ter(terExpected));
+
+                trustSet[jss::Flags] = tfClearFreeze;
+                env(trustSet);
+                env.close();
+            }
+
+            // Depositor freeze, self-withdraw
+            {
+                testcase("VaultWithdraw IOU self-withdraw freeze check");
+                env(trust(issuer, asset(0), owner, tfSetFreeze));
+
+                // Post-fix: self-withdraw allowed (submitter==dst skip)
+                // Pre-fix: isFrozen(depositor, iou) catches it
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
+
+                // Depositor freeze withdraw to 3rd party
+                auto withdrawTo3rd =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                withdrawTo3rd[sfDestination] = charlie.human();
+
+                // Post-fix: submitter freeze blocks withdraw to 3rd party
+                // Pre-fix: submitter's IOU freeze not checked, but checkFrozen(depositor,
+                // share) triggers tecLOCKED
+                env(withdrawTo3rd, Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
+
+                env(trust(issuer, asset(0), owner, tfClearFreeze));
+                // Replenish what was withdrawn
+                if (fix330Enabled)
+                {
+                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+                }
+                env.close();
+            }
+
+            // Depositor deep freeze → self-withdraw blocked
+            {
+                testcase("VaultWithdraw IOU depositor deep freeze");
+                env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
+
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(tecFROZEN));
+
+                env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
+            }
+
+            // Destination freeze → withdraw to 3rd party
+            {
+                testcase("VaultWithdraw IOU freeze withdraw to 3rd party");
+
+                env(trust(issuer, asset(0), charlie, tfSetFreeze));
+
+                // Self-withdraw unaffected by charlie's freeze
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+
+                auto withdrawToCharlie =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                withdrawToCharlie[sfDestination] = charlie.human();
+
+                // Post-fix: freeze on dst allowed
+                // Pre-fix: checkFrozen(dst, iou) catches it
+                env(withdrawToCharlie, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
+
+                env(trust(issuer, asset(0), charlie, tfClearFreeze));
+
+                // Replenish: 1 for self-withdraw + 1 if charlie withdraw succeeded
+                env(vault.deposit(
+                    {.depositor = owner,
+                     .id = keylet.key,
+                     .amount = asset(fix330Enabled ? 2 : 1)}));
+                env.close();
+            }
+
+            // Destination deep freeze → withdraw to 3rd party blocked
+            {
+                testcase("VaultWithdraw IOU deep freeze withdraw to 3rd party");
+
+                env(trust(issuer, asset(0), charlie, tfSetFreeze | tfSetDeepFreeze));
+
+                auto withdrawToCharlie =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                withdrawToCharlie[sfDestination] = charlie.human();
+                env(withdrawToCharlie, Ter(tecFROZEN));
+
+                // Destination deep freeze → self-withdraw unaffected
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+
+                env(trust(issuer, asset(0), charlie, tfClearFreeze | tfClearDeepFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+                env.close();
+            }
+
+            // Clawback works while frozen
+            {
+                testcase("VaultWithdraw IOU freeze clawback unaffected");
+                env(fset(issuer, asfGlobalFreeze));
+
+                env(vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
+
+                env(fclear(issuer, asfGlobalFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+                env.close();
+            }
+        };
+
+        runTests();
+        env.disableFeature(fixCleanup3_3_0);
+        runTests();
+        env.enableFeature(fixCleanup3_3_0);
+    }
+
+    void
+    testVaultWithdrawFreezeMPT()
+    {
+        using namespace test::jtx;
+        testcase("VaultWithdraw MPT lock checks");
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Env env{*this};
+        Vault vault{env};
+
+        env.fund(XRP(100'000), issuer, owner);
+        env.close();
+
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create(
+            {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
+        PrettyAsset const mpt{mptt.issuanceID()};
+
+        mptt.authorize({.account = owner});
+        mptt.authorize({.account = issuer, .holder = owner});
+        env.close();
+        env(pay(issuer, owner, mpt(100'000)));
+        env.close();
+
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
+        env(tx);
+        env.close();
+        Account const vaultAcct("vault", env.le(keylet)->at(sfAccount));
+
+        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
+        env.close();
+
+        Account const charlie{"charlie"};
+        env.fund(XRP(10'000), charlie);
+        env.close();
+        mptt.authorize({.account = charlie});
+        mptt.authorize({.account = issuer, .holder = charlie});
+        env.close();
+
+        auto runTests = [&]() {
+            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
+
+            // Global lock
+            {
+                testcase("VaultWithdraw MPT global lock");
+                mptt.set({.flags = tfMPTLock});
+                env.close();
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+
+                // Global lock → withdraw to issuer
+                // Post-fix: bypasses freeze checks, but accountHolds
+                //           on the pseudo returns 0 under global lock
+                // Pre-fix: checkFrozen(dst=issuer) catches global lock
+                {
+                    auto withdrawToIssuer =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
+                    withdrawToIssuer[sfDestination] = issuer.human();
+                    env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
+                }
+                mptt.set({.flags = tfMPTUnlock});
+                env.close();
+                if (fix330Enabled)
+                {
+                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                }
+                env.close();
+            }
+
+            // Vault pseudo-account individual lock
+            {
+                testcase("VaultWithdraw MPT pseudo-account lock");
+                mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
+                env.close();
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+                mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
+                env.close();
+            }
+
+            // Depositor individual lock → self-withdraw blocked
+            // (isDeepFrozen == isFrozen for MPT)
+            {
+                testcase("VaultWithdraw MPT depositor lock");
+                mptt.set({.holder = owner, .flags = tfMPTLock});
+                env.close();
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+                // Depositor lock → withdraw to 3rd party also blocked
+                {
+                    auto withdrawToCharlie =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
+                    withdrawToCharlie[sfDestination] = charlie.human();
+                    env(withdrawToCharlie, Ter(tecLOCKED));
+                }
+
+                // Depositor lock → withdraw to issuer
+                // Post-fix: issuer bypass in checkWithdrawFreezes
+                // Pre-fix: checkFrozen(depositor, share) blocks transitively
+                {
+                    auto withdrawToIssuer =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
+                    withdrawToIssuer[sfDestination] = issuer.human();
+                    env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
+                }
+                mptt.set({.holder = owner, .flags = tfMPTUnlock});
+                env.close();
+                if (fix330Enabled)
+                {
+                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                }
+                env.close();
+            }
+
+            // 3rd party destination lock → withdraw to 3rd party blocked
+            {
+                testcase("VaultWithdraw MPT 3rd party destination lock");
+                mptt.set({.holder = charlie, .flags = tfMPTLock});
+                env.close();
+                {
+                    auto withdrawToCharlie =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
+                    withdrawToCharlie[sfDestination] = charlie.human();
+                    env(withdrawToCharlie, Ter{tecLOCKED});
+                }
+                // 3rd party lock → self-withdraw unaffected
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                mptt.set({.holder = charlie, .flags = tfMPTUnlock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                env.close();
+            }
+
+            // Clawback works while locked
+            {
+                testcase("VaultWithdraw MPT lock clawback unaffected");
+                mptt.set({.flags = tfMPTLock});
+                env.close();
+                env(vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
+                mptt.set({.flags = tfMPTUnlock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                env.close();
+            }
+        };
+
+        runTests();
+        env.disableFeature(fixCleanup3_3_0);
+        runTests();
+        env.enableFeature(fixCleanup3_3_0);
+    }
+
+    // Focused demonstration: a depositor under an individual IOU freeze
+    // can still withdraw to themselves (self-withdrawal), but is blocked from
+    // withdrawing to a third party.
+    //
+    // Pre-fixCleanup3_3_0: both the self-withdrawal AND the third-party
+    // withdrawal were blocked because the old code checked checkFrozen on the
+    // destination regardless of whether it was the submitter.
+    // Post-fixCleanup3_3_0: checkWithdrawFreeze skips the submitter freeze
+    // check when submitter == destination, so self-withdrawal succeeds.
+    void
+    testVaultSelfWithdrawWhileFrozen()
+    {
+        testcase("VaultWithdraw IOU self-withdrawal while individually frozen");
+
+        using namespace test::jtx;
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const charlie{"charlie"};
+        Env env{*this};
+        Vault vault{env};
+
+        env.fund(XRP(100'000), issuer, owner, charlie);
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+
+        PrettyAsset const asset = issuer["IOU"];
+        env.trust(asset(1'000'000), owner);
+        env.trust(asset(1'000'000), charlie);
+        env(pay(issuer, owner, asset(100'000)));
+        env.close();
+
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        env(tx);
+        env.close();
+
+        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}));
+        env.close();
+
+        auto runTests = [&]() {
+            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
+
+            // Set an individual freeze on the owner's IOU trustline.
+            env(trust(issuer, asset(0), owner, tfSetFreeze));
+            env.close();
+
+            // Self-withdrawal: submitter == destination, so the submitter
+            // freeze check is skipped.
+            // Post-fix: tesSUCCESS.  Pre-fix: tecFROZEN.
+            env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
+
+            // Withdrawal to a third party is blocked: submitter != destination
+            // so the submitter freeze check applies.
+            {
+                auto withdrawToCharlie =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                withdrawToCharlie[sfDestination] = charlie.human();
+                // Post-fix: tecFROZEN (checkIndividualFrozen on submitter).
+                // Pre-fix: tecLOCKED (isFrozen on the vault share).
+                env(withdrawToCharlie, Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
+            }
+
+            env(trust(issuer, asset(0), owner, tfClearFreeze));
+            env.close();
+        };
+
+        runTests();
+        env.disableFeature(fixCleanup3_3_0);
+        runTests();
+        env.enableFeature(fixCleanup3_3_0);
+    }
+
+public:
+    void
+    run() override
+    {
+        testVaultDepositFreezeIOU();
+        testVaultDepositFreezeMPT();
+        testVaultWithdrawFreezeIOU();
+        testVaultWithdrawFreezeMPT();
+        testVaultSelfWithdrawWhileFrozen();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultFreeze, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultLifecycle_test.cpp b/src/test/app/vault/VaultLifecycle_test.cpp
new file mode 100644
index 0000000000..ce91ca857a
--- /dev/null
+++ b/src/test/app/vault/VaultLifecycle_test.cpp
@@ -0,0 +1,1776 @@
+#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 {
+
+class VaultLifecycle_test : public VaultTestBase
+{
+private:
+    void
+    testSequences()
+    {
+        using namespace test::jtx;
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const charlie{"charlie"};  // authorized 3rd party
+        Account const dave{"dave"};
+
+        auto const testSequence = [&, this](
+                                      std::string const& prefix,
+                                      Env& env,
+                                      Vault& vault,
+                                      PrettyAsset const& asset) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfData] = "AFEED00E";
+            tx[sfAssetsMaximum] = asset(100).number();
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.le(keylet));
+            std::uint64_t const scale = asset.raw().holds() ? 1 : 1e6;
+
+            auto const [share, vaultAccount] =
+                [&env, keylet = keylet, asset, this]() -> std::tuple {
+                auto const vault = env.le(keylet);
+                BEAST_EXPECT(vault != nullptr);
+                if (!asset.integral())
+                {
+                    BEAST_EXPECT(vault->at(sfScale) == 6);
+                }
+                else
+                {
+                    BEAST_EXPECT(vault->at(sfScale) == 0);
+                }
+                auto const shares = env.le(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
+                BEAST_EXPECT(shares != nullptr);
+                if (!asset.integral())
+                {
+                    BEAST_EXPECT(shares->at(sfAssetScale) == 6);
+                }
+                else
+                {
+                    BEAST_EXPECT(shares->at(sfAssetScale) == 0);
+                }
+                return {MPTIssue(vault->at(sfShareMPTID)), Account("vault", vault->at(sfAccount))};
+            }();
+            auto const shares = share.raw().get();
+            env.memoize(vaultAccount);
+
+            // Several 3rd party accounts which cannot receive funds
+            Account const alice{"alice"};
+            Account const erin{"erin"};  // not authorized by issuer
+            env.fund(XRP(1000), alice, erin);
+            env(fset(alice, asfDepositAuth));
+            env.close();
+
+            {
+                testcase(prefix + " fail to deposit more than assets held");
+                auto tx = vault.deposit(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(10000)});
+                env(tx, Ter(tecINSUFFICIENT_FUNDS));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " deposit non-zero amount");
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale));
+            }
+
+            {
+                testcase(prefix + " deposit non-zero amount again");
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale));
+            }
+
+            {
+                testcase(prefix + " fail to delete non-empty vault");
+                auto tx = vault.del({.owner = owner, .id = keylet.key});
+                env(tx, Ter(tecHAS_OBLIGATIONS));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to update because wrong owner");
+                auto tx = vault.set({.owner = issuer, .id = keylet.key});
+                tx[sfAssetsMaximum] = asset(50).number();
+                env(tx, Ter(tecNO_PERMISSION));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to set maximum lower than current amount");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfAssetsMaximum] = asset(50).number();
+                env(tx, Ter(tecLIMIT_EXCEEDED));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " set maximum higher than current amount");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfAssetsMaximum] = asset(150).number();
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " set maximum is idempotent, set it again");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfAssetsMaximum] = asset(150).number();
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " set data");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfData] = "0";
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to set domain on public vault");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+                env(tx, Ter{tecNO_PERMISSION});
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to deposit more than maximum");
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                env(tx, Ter(tecLIMIT_EXCEEDED));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " reset maximum to zero i.e. not enforced");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfAssetsMaximum] = asset(0).number();
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to withdraw more than assets held");
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+                env(tx, Ter(tecINSUFFICIENT_FUNDS));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " deposit some more");
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale));
+            }
+
+            {
+                testcase(prefix + " clawback some");
+                auto code = asset.raw().native() ? Ter(temMALFORMED) : Ter(tesSUCCESS);
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(10)});
+                env(tx, code);
+                env.close();
+                if (!asset.raw().native())
+                {
+                    BEAST_EXPECT(env.balance(depositor, shares) == share(190 * scale));
+                }
+            }
+
+            {
+                testcase(prefix + " clawback all");
+                auto code = asset.raw().native() ? Ter(tecNO_PERMISSION) : Ter(tesSUCCESS);
+                auto tx = vault.clawback({.issuer = issuer, .id = keylet.key, .holder = depositor});
+                env(tx, code);
+                env.close();
+                if (!asset.raw().native())
+                {
+                    BEAST_EXPECT(env.balance(depositor, shares) == share(0));
+
+                    {
+                        auto tx = vault.clawback(
+                            {.issuer = issuer,
+                             .id = keylet.key,
+                             .holder = depositor,
+                             .amount = asset(10)});
+                        env(tx, Ter{tecPRECISION_LOSS});
+                        env.close();
+                    }
+
+                    {
+                        auto tx = vault.withdraw(
+                            {.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+                        env(tx, Ter{tecPRECISION_LOSS});
+                        env.close();
+                    }
+                }
+            }
+
+            if (!asset.raw().native())
+            {
+                testcase(prefix + " deposit again");
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(200)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale));
+            }
+            else
+            {
+                testcase(prefix + " deposit/withdrawal same or less than fee");
+                auto const amount = env.current()->fees().base;
+
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount});
+                env(tx);
+                env.close();
+
+                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount});
+                env(tx);
+                env.close();
+
+                tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount});
+                env(tx);
+                env.close();
+
+                // Withdraw to 3rd party
+                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount});
+                tx[sfDestination] = charlie.human();
+                env(tx);
+                env.close();
+
+                tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount - 1});
+                env(tx);
+                env.close();
+
+                tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = amount - 1});
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to withdraw to 3rd party lsfDepositAuth");
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                tx[sfDestination] = alice.human();
+                env(tx, Ter{tecNO_PERMISSION});
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to withdraw to zero destination");
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+                tx[sfDestination] = "0";
+                env(tx, Ter(temMALFORMED));
+                env.close();
+            }
+
+            if (!asset.raw().native())
+            {
+                testcase(prefix + " fail to withdraw to 3rd party no authorization");
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                tx[sfDestination] = erin.human();
+                env(tx, Ter{asset.raw().holds() ? tecNO_LINE : tecNO_AUTH});
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to withdraw to 3rd party lsfRequireDestTag");
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                tx[sfDestination] = dave.human();
+                env(tx, Ter{tecDST_TAG_NEEDED});
+                env.close();
+            }
+
+            {
+                testcase(prefix + " withdraw to 3rd party lsfRequireDestTag");
+                auto tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                tx[sfDestination] = dave.human();
+                tx[sfDestinationTag] = "0";
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " deposit again");
+                auto tx = vault.deposit({.depositor = dave, .id = keylet.key, .amount = asset(50)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to withdraw lsfRequireDestTag");
+                auto tx =
+                    vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)});
+                env(tx, Ter{tecDST_TAG_NEEDED});
+                env.close();
+            }
+
+            {
+                testcase(prefix + " withdraw with tag");
+                auto tx =
+                    vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)});
+                tx[sfDestinationTag] = "0";
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " withdraw to authorized 3rd party");
+                auto tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                tx[sfDestination] = charlie.human();
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale));
+            }
+
+            {
+                testcase(prefix + " withdraw to issuer");
+                auto tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                tx[sfDestination] = issuer.human();
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale));
+            }
+
+            if (!asset.raw().native())
+            {
+                testcase(prefix + " issuer deposits");
+                auto tx =
+                    vault.deposit({.depositor = issuer, .id = keylet.key, .amount = asset(10)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(issuer, shares) == share(10 * scale));
+
+                testcase(prefix + " issuer withdraws");
+                tx = vault.withdraw(
+                    {.depositor = issuer, .id = keylet.key, .amount = share(10 * scale)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(issuer, shares) == share(0 * scale));
+            }
+
+            {
+                testcase(prefix + " withdraw remaining assets");
+                auto tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(0));
+
+                if (!asset.raw().native())
+                {
+                    auto tx = vault.clawback(
+                        {.issuer = issuer,
+                         .id = keylet.key,
+                         .holder = depositor,
+                         .amount = asset(0)});
+                    env(tx, Ter{tecPRECISION_LOSS});
+                    env.close();
+                }
+
+                {
+                    auto tx = vault.withdraw(
+                        {.depositor = depositor, .id = keylet.key, .amount = share(10)});
+                    env(tx, Ter{tecINSUFFICIENT_FUNDS});
+                    env.close();
+                }
+            }
+
+            if (!asset.integral())
+            {
+                testcase(prefix + " temporary authorization for 3rd party");
+                env(trust(erin, asset(1000)));
+                env(trust(issuer, asset(0), erin, tfSetfAuth));
+                env(pay(issuer, erin, asset(10)));
+
+                // Erin deposits all in vault, then sends shares to depositor
+                auto tx = vault.deposit({.depositor = erin, .id = keylet.key, .amount = asset(10)});
+                env(tx);
+                env.close();
+                {
+                    auto tx = pay(erin, depositor, share(10 * scale));
+
+                    // depositor no longer has MPToken for shares
+                    env(tx, Ter{tecNO_AUTH});
+                    env.close();
+
+                    // depositor will gain MPToken for shares again
+                    env(vault.deposit(
+                        {.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
+                    env.close();
+
+                    env(tx);
+                    env.close();
+                }
+
+                testcase(prefix + " withdraw to authorized 3rd party");
+                // Depositor withdraws assets, destined to Erin
+                tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+                tx[sfDestination] = erin.human();
+                env(tx);
+                env.close();
+
+                // Erin returns assets to issuer
+                env(pay(erin, issuer, asset(10)));
+                env.close();
+
+                testcase(prefix + " fail to pay to unauthorized 3rd party");
+                env(trust(erin, asset(0)));
+                env.close();
+
+                // Erin has MPToken but is no longer authorized to hold assets
+                env(pay(depositor, erin, share(1)), Ter{tecNO_LINE});
+                env.close();
+
+                // Depositor withdraws remaining single asset
+                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to delete because wrong owner");
+                auto tx = vault.del({.owner = issuer, .id = keylet.key});
+                env(tx, Ter(tecNO_PERMISSION));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " delete empty vault");
+                auto tx = vault.del({.owner = owner, .id = keylet.key});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(!env.le(keylet));
+            }
+        };
+
+        auto testCases = [&, this](
+                             std::string prefix, std::function setup) {
+            Env env{*this, testableAmendments()};
+
+            Vault vault{env};
+            env.fund(XRP(1000), issuer, owner, depositor, charlie, dave);
+            env.close();
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env(fset(issuer, asfRequireAuth));
+            env(fset(dave, asfRequireDest));
+            env.close();
+            env.require(Flags(issuer, asfAllowTrustLineClawback));
+            env.require(Flags(issuer, asfRequireAuth));
+
+            PrettyAsset const asset = setup(env);
+            testSequence(prefix, env, vault, asset);
+        };
+
+        testCases("XRP", [&](Env& env) -> PrettyAsset { return {xrpIssue(), 1'000'000}; });
+
+        testCases("IOU", [&](Env& env) -> Asset {
+            PrettyAsset const asset = issuer["IOU"];
+            env(trust(owner, asset(1000)));
+            env(trust(depositor, asset(1000)));
+            env(trust(charlie, asset(1000)));
+            env(trust(dave, asset(1000)));
+            env(trust(issuer, asset(0), owner, tfSetfAuth));
+            env(trust(issuer, asset(0), depositor, tfSetfAuth));
+            env(trust(issuer, asset(0), charlie, tfSetfAuth));
+            env(trust(issuer, asset(0), dave, tfSetfAuth));
+            env(pay(issuer, depositor, asset(1000)));
+            env.close();
+            return asset;
+        });
+
+        testCases("MPT", [&](Env& env) -> Asset {
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = depositor});
+            mptt.authorize({.account = charlie});
+            mptt.authorize({.account = dave});
+            env(pay(issuer, depositor, asset(1000)));
+            env.close();
+            return asset;
+        });
+    }
+
+    void
+    testWithMPT()
+    {
+        using namespace test::jtx;
+
+        struct CaseArgs
+        {
+            bool enableClawback = true;
+            bool requireAuth = true;
+            int initialXRP = 1000;
+            FeatureBitset features = testableAmendments();
+        };
+
+        auto testCase = [this](
+                            std::function test,
+                            CaseArgs args = {}) {
+            Env env{*this, args.features};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(args.initialXRP), issuer, owner, depositor);
+            env.close();
+            Vault vault{env};
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            auto const kNone = LedgerSpecificFlags(0);
+            mptt.create(
+                {.flags = tfMPTCanTransfer | tfMPTCanLock |
+                     (args.enableClawback ? tfMPTCanClawback : kNone) |
+                     (args.requireAuth ? tfMPTRequireAuth : kNone)});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            if (args.requireAuth)
+            {
+                mptt.authorize({.account = issuer, .holder = owner});
+                mptt.authorize({.account = issuer, .holder = depositor});
+            }
+
+            env(pay(issuer, depositor, asset(1000)));
+            env.close();
+
+            test(env, issuer, owner, depositor, asset, vault, mptt);
+        };
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT nothing to clawback from");
+            auto tx = vault.clawback(
+                {.issuer = issuer,
+                 .id = keylet::skip().key,
+                 .holder = depositor,
+                 .amount = asset(10)});
+            env(tx, Ter(tecNO_ENTRY));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT global lock blocks create");
+            mptt.set({.account = issuer, .flags = tfMPTLock});
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tecLOCKED));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT only issuer can clawback");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+            env(tx);
+            env.close();
+
+            {
+                auto tx = vault.clawback({
+                    .issuer = depositor,
+                    .id = keylet.key,
+                    .holder = depositor,
+                });
+                env(tx, Ter(tecNO_PERMISSION));
+            }
+
+            {
+                auto tx = vault.clawback({
+                    .issuer = owner,
+                    .id = keylet.key,
+                    .holder = depositor,
+                });
+                env(tx, Ter(tecNO_PERMISSION));
+            }
+        });
+
+        testCase(
+            [this](
+                Env& env,
+                Account const& issuer,
+                Account const& owner,
+                Account const& depositor,
+                PrettyAsset const& asset,
+                Vault& vault,
+                MPTTester& mptt) {
+                testcase("MPT depositor without MPToken, auth required");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+
+                tx = vault.deposit(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+                env(tx);
+                env.close();
+
+                {
+                    // Remove depositor MPToken and it will not be re-created
+                    mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize});
+                    env.close();
+
+                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor);
+                    auto const sleMPT1 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT1 == nullptr);
+
+                    tx = vault.withdraw(
+                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                    env(tx, Ter{tecNO_AUTH});
+                    env.close();
+
+                    auto const sleMPT2 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT2 == nullptr);
+                }
+
+                {
+                    // Set destination to 3rd party without MPToken
+                    Account const charlie{"charlie"};
+                    env.fund(XRP(1000), charlie);
+                    env.close();
+
+                    tx = vault.withdraw(
+                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                    tx[sfDestination] = charlie.human();
+                    env(tx, Ter(tecNO_AUTH));
+                }
+            },
+            {.requireAuth = true});
+
+        testCase(
+            [this](
+                Env& env,
+                Account const& issuer,
+                Account const& owner,
+                Account const& depositor,
+                PrettyAsset const& asset,
+                Vault& vault,
+                MPTTester& mptt) {
+                testcase("MPT depositor without MPToken, no auth required");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+                auto v = env.le(keylet);
+                BEAST_EXPECT(v);
+
+                tx = vault.deposit(
+                    {.depositor = depositor,
+                     .id = keylet.key,
+                     .amount = asset(1000)});  // all assets held by depositor
+                env(tx);
+                env.close();
+
+                {
+                    // Remove depositor's MPToken and it will be re-created
+                    mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize});
+                    env.close();
+
+                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor);
+                    auto const sleMPT1 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT1 == nullptr);
+
+                    tx = vault.withdraw(
+                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                    env(tx);
+                    env.close();
+
+                    auto const sleMPT2 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT2 != nullptr);
+                    BEAST_EXPECT(sleMPT2->at(sfMPTAmount) == 100);
+                }
+
+                {
+                    // Remove 3rd party MPToken and it will not be re-created
+                    mptt.authorize({.account = owner, .flags = tfMPTUnauthorize});
+                    env.close();
+
+                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner);
+                    auto const sleMPT1 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT1 == nullptr);
+
+                    tx = vault.withdraw(
+                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                    tx[sfDestination] = owner.human();
+                    env(tx, Ter(tecNO_AUTH));
+                    env.close();
+
+                    auto const sleMPT2 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT2 == nullptr);
+                }
+            },
+            {.requireAuth = false});
+
+        auto const [acctReserve, incReserve] = [this]() -> std::pair {
+            Env const env{*this, testableAmendments()};
+            return {
+                env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(),
+                env.current()->fees().increment.drops() / kDropsPerXrp.drops()};
+        }();
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& issuer,
+                Account const& owner,
+                Account const& depositor,
+                PrettyAsset const& asset,
+                Vault& vault,
+                MPTTester& mptt) {
+                testcase("MPT fail reserve to re-create MPToken");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+                auto v = env.le(keylet);
+                BEAST_EXPECT(v);
+
+                env(pay(depositor, owner, asset(1000)));
+                env.close();
+
+                tx = vault.deposit(
+                    {.depositor = owner,
+                     .id = keylet.key,
+                     .amount = asset(1000)});  // all assets held by owner
+                env(tx);
+                env.close();
+
+                {
+                    // Remove owners's MPToken and it will not be re-created
+                    mptt.authorize({.account = owner, .flags = tfMPTUnauthorize});
+                    env.close();
+
+                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner);
+                    auto const sleMPT = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT == nullptr);
+
+                    // Use one reserve so the next transaction fails
+                    env(ticket::create(owner, 1));
+                    env.close();
+
+                    // No reserve to create MPToken for asset in VaultWithdraw
+                    tx = vault.withdraw(
+                        {.depositor = owner, .id = keylet.key, .amount = asset(100)});
+                    env(tx, Ter{tecINSUFFICIENT_RESERVE});
+                    env.close();
+
+                    env(pay(depositor, owner, XRP(incReserve)));
+                    env.close();
+
+                    // Withdraw can now create asset MPToken, tx will succeed
+                    env(tx);
+                    env.close();
+                }
+            },
+            {.requireAuth = false, .initialXRP = acctReserve + (incReserve * 4) + 1});
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT issuance deleted");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+            env(tx);
+            env.close();
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
+                env(tx);
+            }
+
+            mptt.destroy({.issuer = issuer, .id = mptt.issuanceID()});
+            env.close();
+
+            {
+                auto [tx, keylet] = vault.create({.owner = depositor, .asset = asset});
+                env(tx, Ter{tecOBJECT_NOT_FOUND});
+            }
+
+            {
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+                env(tx, Ter{tecOBJECT_NOT_FOUND});
+            }
+
+            {
+                auto tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+                env(tx, Ter{tecOBJECT_NOT_FOUND});
+            }
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
+                env(tx, Ter{tecOBJECT_NOT_FOUND});
+            }
+
+            env(vault.del({.owner = owner, .id = keylet.key}));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT vault owner can receive shares unless unauthorized");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+            env(tx);
+            env.close();
+
+            auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID {
+                auto const vault = env.le(keylet);
+                return vault->at(sfShareMPTID);
+            }(keylet);
+            PrettyAsset const shares = MPTIssue(issuanceId);
+
+            {
+                // owner has MPToken for shares they did not explicitly create
+                env(pay(depositor, owner, shares(1)));
+                env.close();
+
+                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = shares(1)});
+                env(tx);
+                env.close();
+
+                // owner's MPToken for vault shares not destroyed by withdraw
+                env(pay(depositor, owner, shares(1)));
+                env.close();
+
+                tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)});
+                env(tx);
+                env.close();
+
+                // owner's MPToken for vault shares not destroyed by clawback
+                env(pay(depositor, owner, shares(1)));
+                env.close();
+
+                // pay back, so we can destroy owner's MPToken now
+                env(pay(owner, depositor, shares(1)));
+                env.close();
+
+                {
+                    // explicitly destroy vault owners MPToken with zero balance
+                    json::Value jv;
+                    jv[sfAccount] = owner.human();
+                    jv[sfMPTokenIssuanceID] = to_string(issuanceId);
+                    jv[sfFlags] = tfMPTUnauthorize;
+                    jv[sfTransactionType] = jss::MPTokenAuthorize;
+                    env(jv);
+                    env.close();
+                }
+
+                // owner no longer has MPToken for vault shares
+                tx = pay(depositor, owner, shares(1));
+                env(tx, Ter{tecNO_AUTH});
+                env.close();
+
+                // destroy all remaining shares, so we can delete vault
+                tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
+                env(tx);
+                env.close();
+
+                // will soft fail destroying MPToken for vault owner
+                env(vault.del({.owner = owner, .id = keylet.key}));
+                env.close();
+            }
+        });
+
+        testCase(
+            [this](
+                Env& env,
+                Account const& issuer,
+                Account const& owner,
+                Account const& depositor,
+                PrettyAsset const& asset,
+                Vault& vault,
+                MPTTester& mptt) {
+                testcase("MPT clawback disabled");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+
+                tx = vault.deposit(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+                env(tx);
+                env.close();
+
+                {
+                    auto tx = vault.clawback(
+                        {.issuer = issuer,
+                         .id = keylet.key,
+                         .holder = depositor,
+                         .amount = asset(0)});
+                    env(tx, Ter{tecNO_PERMISSION});
+                }
+            },
+            {.enableClawback = false});
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT un-authorization");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+            env(tx);
+            env.close();
+
+            mptt.authorize({.account = issuer, .holder = depositor, .flags = tfMPTUnauthorize});
+            env.close();
+
+            {
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                env(tx, Ter(tecNO_AUTH));
+
+                // Withdrawal to other (authorized) accounts works
+                tx[sfDestination] = issuer.human();
+                env(tx);
+                env.close();
+
+                tx[sfDestination] = owner.human();
+                env(tx);
+                env.close();
+            }
+
+            {
+                // Cannot deposit some more
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                env(tx, Ter(tecNO_AUTH));
+            }
+
+            {
+                // Cannot clawback if issuer is the holder
+                tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = issuer, .amount = asset(800)});
+                env(tx, Ter(tecNO_PERMISSION));
+            }
+            // Clawback works
+            tx = vault.clawback(
+                {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(800)});
+            env(tx);
+            env.close();
+
+            env(vault.del({.owner = owner, .id = keylet.key}));
+        });
+
+        {
+            testcase("MPT shares to a vault");
+
+            Env env{*this, testableAmendments()};
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            env.fund(XRP(1000000), owner, issuer);
+            env.close();
+            Vault const vault{env};
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create(
+                {.flags = tfMPTCanTransfer | tfMPTCanLock | lsfMPTCanClawback | tfMPTRequireAuth});
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = issuer, .holder = owner});
+            PrettyAsset const asset = mptt.issuanceID();
+            env(pay(issuer, owner, asset(100)));
+            auto [tx1, k1] = vault.create({.owner = owner, .asset = asset});
+            env(tx1);
+            env.close();
+
+            auto const shares = [&env, keylet = k1, this]() -> Asset {
+                auto const vault = env.le(keylet);
+                BEAST_EXPECT(vault != nullptr);
+                return MPTIssue(vault->at(sfShareMPTID));
+            }();
+
+            auto [tx2, k2] = vault.create({.owner = owner, .asset = shares});
+            env(tx2, Ter{tecWRONG_ASSET});
+            env.close();
+        }
+
+        {
+            testcase("MPT locked: vault shares inherit underlying lock");
+
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const carol{"carol"};
+            env.fund(XRP(10'000), issuer, owner, alice, bob, carol);
+            env.close();
+            Vault const vault{env};
+
+            MPTTester asset{
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {owner, alice, bob, carol},
+                 .flags = tfMPTCanTransfer | tfMPTCanTrade | tfMPTCanLock}};
+            env(pay(issuer, alice, asset(1'000)));
+            env(pay(issuer, bob, asset(1'000)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(500)}));
+            // Bob also deposits so he has a share MPToken to receive into.
+            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            auto const shares = [&]() -> PrettyAsset {
+                auto const sle = env.le(keylet);
+                BEAST_EXPECT(sle != nullptr);
+                return MPTIssue(sle->at(sfShareMPTID));
+            }();
+            auto const shareMptID = shares.raw().get().getMptID();
+            auto const shareBalance = [&](Account const& account) {
+                auto const sle = env.le(keylet::mptoken(shareMptID, account));
+                return sle ? sle->at(sfMPTAmount) : 0;
+            };
+
+            // Sanity: before the underlying lock, peer-to-peer share
+            // transfers are allowed.
+            env(pay(alice, bob, shares(1)));
+            env.close();
+
+            // Create the offer while shares are spendable, then lock the
+            // underlying to test whether a stale offer can still be crossed.
+            env(offer(alice, XRP(1), shares(1)));
+            env.close();
+
+            // Lock the underlying after the vault and share balances exist.
+            asset.set({.account = issuer, .flags = tfMPTLock});
+            env.close();
+
+            // Direct vault share payment inherits the underlying lock via
+            // sfReferenceHolding.
+            BEAST_EXPECT(shareBalance(alice) == 499);
+            BEAST_EXPECT(shareBalance(bob) == 501);
+            env(pay(alice, bob, shares(1)), Ter{tecLOCKED});
+            env.close();
+            BEAST_EXPECT(shareBalance(alice) == 499);
+            BEAST_EXPECT(shareBalance(bob) == 501);
+
+            // The same inherited lock must also block DEX payment paths that
+            // would consume an offer selling vault shares.
+            env(pay(carol, bob, shares(1)),
+                Sendmax(XRP(1)),
+                Path(BookSpec{shares.raw()}),
+                Ter{tecPATH_PARTIAL});
+            env.close();
+            BEAST_EXPECT(shareBalance(alice) == 499);
+            BEAST_EXPECT(shareBalance(bob) == 501);
+            BEAST_EXPECT(expectOffers(env, alice, 1));
+        }
+
+        {
+            testcase("MPT CanTrade governance: share inherits underlying on DEX and AMM");
+
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            env.fund(XRP(100'000), issuer, owner, alice, bob);
+            env.close();
+            Vault const vault{env};
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = alice});
+            mptt.authorize({.account = bob});
+            env(pay(issuer, alice, asset(10'000)));
+            env(pay(issuer, bob, asset(10'000)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            // Seed shares so we can later place them on trading venues.
+            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(5'000)}));
+            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(5'000)}));
+            env.close();
+
+            auto const shares = [&]() -> PrettyAsset {
+                auto const sle = env.le(keylet);
+                BEAST_EXPECT(sle != nullptr);
+                return MPTIssue(sle->at(sfShareMPTID));
+            }();
+
+            // CanTrade is not set on the underlying, both the asset and
+            // the vault share are blocked on the DEX.
+            env(offer(alice, XRP(1), asset(10)), Ter{tecNO_PERMISSION});
+            env(offer(alice, XRP(1), shares(1)), Ter{tecNO_PERMISSION});
+            env.close();
+
+            // Deposit still works before enabling CanTrade.
+            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
+            env.close();
+
+            // Peer-to-peer share transfers still work (CanTransfer is set on
+            // both layers).
+            env(pay(alice, bob, shares(1)));
+            env.close();
+
+            // Withdraw still works before enabling CanTrade.
+            env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
+            env.close();
+
+            // Enable CanTrade on the underlying.
+            mptt.set({.flags = tfMPTSetCanTrade});
+            env.close();
+
+            env(offer(alice, XRP(1), asset(10)));
+            env(offer(alice, XRP(1), shares(1)));
+            env.close();
+
+            AMM const ammUnderlying(env, alice, XRP(1'000), asset(1'000));
+        }
+
+        {
+            testcase("MPT OutstandingAmount > MaximumAmount");
+
+            Env env{*this, testableAmendments() | featureSingleAssetVault};
+            Account const alice{"alice"};
+            Account const issuer{"issuer"};
+            env.fund(XRP(1'000), alice, issuer);
+            env.close();
+            Vault const vault{env};
+
+            MPTTester const btc({.env = env, .issuer = issuer, .holders = {alice}, .maxAmt = 100});
+
+            auto [tx, k] = vault.create({.owner = issuer, .asset = btc});
+            env(tx);
+            env.close();
+
+            tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(110)});
+            // accountHolds is the first check and the issuer has only BTC(100)
+            // available
+            env(tx, Ter{tecINSUFFICIENT_FUNDS});
+            env.close();
+
+            // OutstandingAmount == MaximumAmount
+            env(pay(issuer, alice, btc(100)));
+            env.close();
+
+            tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(100)});
+            // the issuer has BTC(0) available
+            env(tx, Ter{tecINSUFFICIENT_FUNDS});
+            env.close();
+
+            tx = vault.deposit({.depositor = alice, .id = k.key, .amount = btc(100)});
+            // alice transfers BTC(100), OutstandingAmount is 100
+            env(tx);
+            env.close();
+        }
+    }
+
+    void
+    testWithIOU()
+    {
+        using namespace test::jtx;
+
+        struct CaseArgs
+        {
+            int initialXRP = 1000;
+            Number initialIOU = 200;
+            double transferRate = 1.0;
+            bool charlieRipple = true;
+            FeatureBitset features = testableAmendments();
+        };
+
+        auto testCase = [&, this](
+                            std::function vaultAccount,
+                                Vault& vault,
+                                PrettyAsset const& asset,
+                                std::function issuanceId)> test,
+                            CaseArgs args = {}) {
+            Env env{*this, args.features};
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            Account const charlie{"charlie"};
+            Vault vault{env};
+            env.fund(XRP(args.initialXRP), issuer, owner, charlie);
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1000), owner);
+            env(pay(issuer, owner, asset(args.initialIOU)));
+            env.close();
+            if (!args.charlieRipple)
+            {
+                env(fset(issuer, 0, asfDefaultRipple));
+                env.close();
+                env.trust(asset(1000), charlie);
+                env.close();
+                env(pay(issuer, charlie, asset(args.initialIOU)));
+                env.close();
+                env(fset(issuer, asfDefaultRipple));
+            }
+            else
+            {
+                env.trust(asset(1000), charlie);
+            }
+            env.close();
+            env(rate(issuer, args.transferRate));
+            env.close();
+
+            auto const vaultAccount = [&env](xrpl::Keylet keylet) -> Account {
+                return Account("vault", env.le(keylet)->at(sfAccount));
+            };
+            auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID {
+                return env.le(keylet)->at(sfShareMPTID);
+            };
+
+            test(env, owner, issuer, charlie, vaultAccount, vault, asset, issuanceId);
+        };
+
+        testCase([&, this](
+                     Env& env,
+                     Account const& owner,
+                     Account const& issuer,
+                     Account const&,
+                     auto vaultAccount,
+                     Vault& vault,
+                     PrettyAsset const& asset,
+                     auto&&...) {
+            testcase("IOU cannot use different asset");
+            PrettyAsset const foo = issuer["FOO"];
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            {
+                // Cannot create new trustline to a vault
+                auto tx = [&, account = vaultAccount(keylet)]() {
+                    json::Value jv;
+                    jv[jss::Account] = issuer.human();
+                    {
+                        auto& ja = jv[jss::LimitAmount] =
+                            foo(0).value().getJson(JsonOptions::Values::None);
+                        ja[jss::issuer] = toBase58(account);
+                    }
+                    jv[jss::TransactionType] = jss::TrustSet;
+                    jv[jss::Flags] = tfSetFreeze;
+                    return jv;
+                }();
+                env(tx, Ter{tecNO_PERMISSION});
+                env.close();
+            }
+
+            {
+                auto tx = vault.deposit({.depositor = issuer, .id = keylet.key, .amount = foo(20)});
+                env(tx, Ter{tecWRONG_ASSET});
+                env.close();
+            }
+
+            {
+                auto tx =
+                    vault.withdraw({.depositor = issuer, .id = keylet.key, .amount = foo(20)});
+                env(tx, Ter{tecWRONG_ASSET});
+                env.close();
+            }
+
+            env(vault.del({.owner = owner, .id = keylet.key}));
+            env.close();
+        });
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& owner,
+                Account const& issuer,
+                Account const& charlie,
+                auto vaultAccount,
+                Vault& vault,
+                PrettyAsset const& asset,
+                auto issuanceId) {
+                testcase("IOU transfer fees not applied");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
+                env.close();
+
+                auto const issue = asset.raw().get();
+                Asset const share = Asset(issuanceId(keylet));
+
+                // transfer fees ignored on deposit
+                BEAST_EXPECT(env.balance(owner, issue) == asset(100));
+                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(100));
+
+                {
+                    auto tx = vault.clawback(
+                        {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
+                    env(tx);
+                    env.close();
+                }
+
+                // transfer fees ignored on clawback
+                BEAST_EXPECT(env.balance(owner, issue) == asset(100));
+                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(50));
+
+                env(vault.withdraw(
+                    {.depositor = owner, .id = keylet.key, .amount = share(20'000'000)}));
+
+                // transfer fees ignored on withdraw
+                BEAST_EXPECT(env.balance(owner, issue) == asset(120));
+                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(30));
+
+                {
+                    auto tx = vault.withdraw(
+                        {.depositor = owner, .id = keylet.key, .amount = share(30'000'000)});
+                    tx[sfDestination] = charlie.human();
+                    env(tx);
+                }
+
+                // transfer fees ignored on withdraw to 3rd party
+                BEAST_EXPECT(env.balance(owner, issue) == asset(120));
+                BEAST_EXPECT(env.balance(charlie, issue) == asset(30));
+                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(0));
+
+                env(vault.del({.owner = owner, .id = keylet.key}));
+                env.close();
+            },
+            CaseArgs{.transferRate = 1.25});
+
+        testCase([&, this](
+                     Env& env,
+                     Account const& owner,
+                     Account const& issuer,
+                     Account const& charlie,
+                     auto,
+                     Vault& vault,
+                     PrettyAsset const& asset,
+                     auto&&...) {
+            testcase("IOU no trust line to 3rd party");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
+            env.close();
+
+            Account const erin{"erin"};
+            env.fund(XRP(1000), erin);
+            env.close();
+
+            // Withdraw to 3rd party without trust line
+            auto const tx1 = [&](xrpl::Keylet keylet) {
+                auto tx =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                tx[sfDestination] = erin.human();
+                return tx;
+            }(keylet);
+            env(tx1, Ter{tecNO_LINE});
+        });
+
+        testCase([&, this](
+                     Env& env,
+                     Account const& owner,
+                     Account const& issuer,
+                     Account const& charlie,
+                     auto,
+                     Vault& vault,
+                     PrettyAsset const& asset,
+                     auto&&...) {
+            testcase("IOU no trust line to depositor");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            // reset limit, so deposit of all funds will delete the trust line
+            env.trust(asset(0), owner);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
+            env.close();
+
+            auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
+            BEAST_EXPECT(trustline == nullptr);
+
+            // Withdraw without trust line, will succeed
+            auto const tx1 = [&](xrpl::Keylet keylet) {
+                auto tx =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                return tx;
+            }(keylet);
+            env(tx1);
+        });
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& owner,
+                Account const& issuer,
+                Account const& charlie,
+                auto vaultAccount,
+                Vault& vault,
+                PrettyAsset const& asset,
+                std::function issuanceId) {
+                testcase("IOU non-transferable");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                tx[sfScale] = 0;
+                env(tx);
+                env.close();
+
+                // Turn on noripple on the pseudo account's trust line.
+                // Charlie's is already set.
+                env(trust(issuer, vaultAccount(keylet)["IOU"], tfSetNoRipple));
+
+                {
+                    // Charlie cannot deposit
+                    auto tx = vault.deposit(
+                        {.depositor = charlie, .id = keylet.key, .amount = asset(100)});
+                    env(tx, Ter{terNO_RIPPLE});
+                    env.close();
+                }
+
+                {
+                    PrettyAsset const shares = issuanceId(keylet);
+                    auto tx1 =
+                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)});
+                    env(tx1);
+                    env.close();
+
+                    // Charlie cannot receive funds
+                    auto tx2 = vault.withdraw(
+                        {.depositor = owner, .id = keylet.key, .amount = shares(100)});
+                    tx2[sfDestination] = charlie.human();
+                    env(tx2, Ter{terNO_RIPPLE});
+                    env.close();
+
+                    {
+                        // Create MPToken for shares held by Charlie
+                        json::Value tx{json::ValueType::Object};
+                        tx[sfAccount] = charlie.human();
+                        tx[sfMPTokenIssuanceID] =
+                            to_string(shares.raw().get().getMptID());
+                        tx[sfTransactionType] = jss::MPTokenAuthorize;
+                        env(tx);
+                        env.close();
+                    }
+                    // Behavioral shift introduced by share inheritance:
+                    // before fixCleanup3_2_0 this share Payment succeeded
+                    // and the underlying IOU's NoRipple restriction surfaced
+                    // only later on Charlie's withdrawal (terNO_RIPPLE).
+                    // Post-amendment, canTransfer reads the share's
+                    // sfReferenceHolding and dispatches to the underlying IOU;
+                    // rippling is disabled between owner and charlie so the
+                    // share payment itself is now blocked. tecPATH_DRY is
+                    // the path-find layer's translation of the underlying
+                    // terNO_RIPPLE under featureMPTokensV2.
+                    env(pay(owner, charlie, shares(100)), Ter{tecPATH_DRY});
+                    env.close();
+                }
+
+                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(100)});
+                env(tx);
+                env.close();
+
+                // Delete vault with zero balance
+                env(vault.del({.owner = owner, .id = keylet.key}));
+            },
+            {.charlieRipple = false});
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& owner,
+                Account const& issuer,
+                Account const& charlie,
+                auto const& vaultAccount,
+                Vault& vault,
+                PrettyAsset const& asset,
+                auto&&...) {
+                testcase("IOU calculation rounding");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                tx[sfScale] = 1;
+                env(tx);
+                env.close();
+
+                auto const startingOwnerBalance = env.balance(owner, asset);
+                BEAST_EXPECT((startingOwnerBalance.value() == STAmount{asset, 11875, -2}));
+
+                // This operation (first deposit 100, then 3.75 x 5) is known to
+                // have triggered calculation rounding errors in Number
+                // (addition and division), causing the last deposit to be
+                // blocked by Vault invariants.
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
+
+                auto const tx1 = vault.deposit(
+                    {.depositor = owner, .id = keylet.key, .amount = asset(Number(375, -2))});
+                for (auto i = 0; i < 5; ++i)
+                {
+                    env(tx1);
+                }
+                env.close();
+
+                {
+                    STAmount const xfer{asset, 1185, -1};
+                    BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value() - xfer);
+                    BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == xfer);
+
+                    auto const vault = env.le(keylet);
+                    BEAST_EXPECT(vault->at(sfAssetsAvailable) == xfer);
+                    BEAST_EXPECT(vault->at(sfAssetsTotal) == xfer);
+                }
+
+                // Total vault balance should be 118.5 IOU. Withdraw and delete
+                // the vault to verify this exact amount was deposited and the
+                // owner has matching shares
+                env(vault.withdraw(
+                    {.depositor = owner,
+                     .id = keylet.key,
+                     .amount = asset(Number(1000 + (37 * 5), -1))}));
+
+                {
+                    BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value());
+                    BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == beast::kZero);
+                    auto const vault = env.le(keylet);
+                    BEAST_EXPECT(vault->at(sfAssetsAvailable) == beast::kZero);
+                    BEAST_EXPECT(vault->at(sfAssetsTotal) == beast::kZero);
+                }
+
+                env(vault.del({.owner = owner, .id = keylet.key}));
+                env.close();
+            },
+            {.initialIOU = Number(11875, -2)});
+
+        auto const [acctReserve, incReserve] = [this]() -> std::pair {
+            Env const env{*this, testableAmendments()};
+            return {
+                env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(),
+                env.current()->fees().increment.drops() / kDropsPerXrp.drops()};
+        }();
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& owner,
+                Account const& issuer,
+                Account const& charlie,
+                auto,
+                Vault& vault,
+                PrettyAsset const& asset,
+                auto&&...) {
+                testcase("IOU no trust line to depositor no reserve");
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+
+                // reset limit, so deposit of all funds will delete the trust
+                // line
+                env.trust(asset(0), owner);
+                env.close();
+
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
+                env.close();
+
+                auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
+                BEAST_EXPECT(trustline == nullptr);
+
+                env(ticket::create(owner, 1));
+                env.close();
+
+                // Fail because not enough reserve to create trust line
+                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                env(tx, Ter{tecNO_LINE_INSUF_RESERVE});
+                env.close();
+
+                env(pay(charlie, owner, XRP(incReserve)));
+                env.close();
+
+                // Withdraw can now create trust line, will succeed
+                env(tx);
+                env.close();
+            },
+            CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1});
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& owner,
+                Account const& issuer,
+                Account const& charlie,
+                auto,
+                Vault& vault,
+                PrettyAsset const& asset,
+                auto&&...) {
+                testcase("IOU no reserve for share MPToken");
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+
+                env(pay(owner, charlie, asset(100)));
+                env.close();
+
+                env(ticket::create(charlie, 3));
+                env.close();
+
+                // Fail because not enough reserve to create MPToken for shares
+                tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(100)});
+                env(tx, Ter{tecINSUFFICIENT_RESERVE});
+                env.close();
+
+                env(pay(issuer, charlie, XRP(incReserve)));
+                env.close();
+
+                // Deposit can now create MPToken, will succeed
+                env(tx);
+                env.close();
+            },
+            CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1});
+    }
+
+public:
+    void
+    run() override
+    {
+        testSequences();
+        testWithMPT();
+        testWithIOU();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE_PRIO(VaultLifecycle, app, xrpl, 1);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultRPC_test.cpp b/src/test/app/vault/VaultRPC_test.cpp
new file mode 100644
index 0000000000..2ac092b5a7
--- /dev/null
+++ b/src/test/app/vault/VaultRPC_test.cpp
@@ -0,0 +1,543 @@
+#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 {
+
+class VaultRPC_test : public VaultTestBase
+{
+private:
+    void
+    testRPC()
+    {
+        using namespace test::jtx;
+
+        testcase("RPC");
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const issuer{"issuer"};
+        Vault const vault{env};
+        env.fund(XRP(1000), issuer, owner);
+        env.close();
+
+        PrettyAsset const asset = issuer["IOU"];
+        env.trust(asset(1000), owner);
+        env(pay(issuer, owner, asset(200)));
+        env.close();
+
+        auto const sequence = env.seq(owner);
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        env(tx);
+        env.close();
+
+        // Set some fields
+        {
+            auto tx1 = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
+            env(tx1);
+
+            auto tx2 = vault.set({.owner = owner, .id = keylet.key});
+            tx2[sfAssetsMaximum] = asset(1000).number();
+            env(tx2);
+            env.close();
+        }
+
+        auto const sleVault = [&env, keylet = keylet, this]() {
+            auto const vault = env.le(keylet);
+            BEAST_EXPECT(vault != nullptr);
+            return vault;
+        }();
+
+        auto const check = [&, keylet = keylet, sle = sleVault, this](
+                               json::Value const& vault,
+                               json::Value const& issuance = json::ValueType::Null) {
+            BEAST_EXPECT(vault.isObject());
+
+            static constexpr auto kCheckString =
+                [](auto& node, SField const& field, std::string v) -> bool {
+                return node.isMember(field.fieldName) && node[field.fieldName].isString() &&
+                    node[field.fieldName] == v;
+            };
+            static constexpr auto kCheckObject =
+                [](auto& node, SField const& field, json::Value v) -> bool {
+                return node.isMember(field.fieldName) && node[field.fieldName].isObject() &&
+                    node[field.fieldName] == v;
+            };
+            static constexpr auto kCheckInt = [](auto& node, SField const& field, int v) -> bool {
+                return node.isMember(field.fieldName) &&
+                    ((node[field.fieldName].isInt() && node[field.fieldName] == json::Int(v)) ||
+                     (node[field.fieldName].isUInt() && node[field.fieldName] == json::UInt(v)));
+            };
+
+            BEAST_EXPECT(vault["LedgerEntryType"].asString() == "Vault");
+            BEAST_EXPECT(vault[jss::index].asString() == strHex(keylet.key));
+            BEAST_EXPECT(kCheckInt(vault, sfFlags, 0));
+            // Ignore all other standard fields, this test doesn't care
+
+            BEAST_EXPECT(kCheckString(vault, sfAccount, toBase58(sle->at(sfAccount))));
+            BEAST_EXPECT(kCheckObject(vault, sfAsset, toJson(sle->at(sfAsset))));
+            BEAST_EXPECT(kCheckString(vault, sfAssetsAvailable, "50"));
+            BEAST_EXPECT(kCheckString(vault, sfAssetsMaximum, "1000"));
+            BEAST_EXPECT(kCheckString(vault, sfAssetsTotal, "50"));
+            BEAST_EXPECT(!vault.isMember(sfLossUnrealized.getJsonName()));
+
+            auto const strShareID = strHex(sle->at(sfShareMPTID));
+            BEAST_EXPECT(kCheckString(vault, sfShareMPTID, strShareID));
+            BEAST_EXPECT(kCheckString(vault, sfOwner, toBase58(owner.id())));
+            BEAST_EXPECT(kCheckInt(vault, sfSequence, sequence));
+            BEAST_EXPECT(kCheckInt(vault, sfWithdrawalPolicy, kVaultStrategyFirstComeFirstServe));
+
+            if (issuance.isObject())
+            {
+                BEAST_EXPECT(issuance["LedgerEntryType"].asString() == "MPTokenIssuance");
+                BEAST_EXPECT(issuance[jss::mpt_issuance_id].asString() == strShareID);
+                BEAST_EXPECT(kCheckInt(issuance, sfSequence, 1));
+                BEAST_EXPECT(kCheckInt(
+                    issuance, sfFlags, int(lsfMPTCanEscrow | lsfMPTCanTrade | lsfMPTCanTransfer)));
+                BEAST_EXPECT(kCheckString(issuance, sfOutstandingAmount, "50000000"));
+            }
+        };
+
+        {
+            testcase("RPC ledger_entry selected by key");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault] = strHex(keylet.key);
+            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
+
+            BEAST_EXPECT(!jvVault[jss::result].isMember(jss::error));
+            BEAST_EXPECT(jvVault[jss::result].isMember(jss::node));
+            check(jvVault[jss::result][jss::node]);
+        }
+
+        {
+            testcase("RPC ledger_entry selected by owner and seq");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault][jss::owner] = owner.human();
+            jvParams[jss::vault][jss::seq] = sequence;
+            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
+
+            BEAST_EXPECT(!jvVault[jss::result].isMember(jss::error));
+            BEAST_EXPECT(jvVault[jss::result].isMember(jss::node));
+            check(jvVault[jss::result][jss::node]);
+        }
+
+        {
+            testcase("RPC ledger_entry cannot find vault by key");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault] = to_string(uint256(42));
+            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
+            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "entryNotFound");
+        }
+
+        {
+            testcase("RPC ledger_entry cannot find vault by owner and seq");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault][jss::owner] = issuer.human();
+            jvParams[jss::vault][jss::seq] = 1'000'000;
+            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
+            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "entryNotFound");
+        }
+
+        {
+            testcase("RPC ledger_entry malformed key");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault] = 42;
+            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
+            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
+        }
+
+        {
+            testcase("RPC ledger_entry malformed owner");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault][jss::owner] = 42;
+            jvParams[jss::vault][jss::seq] = sequence;
+            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
+            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedOwner");
+        }
+
+        {
+            testcase("RPC ledger_entry malformed seq");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault][jss::owner] = issuer.human();
+            jvParams[jss::vault][jss::seq] = "foo";
+            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
+            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
+        }
+
+        {
+            testcase("RPC ledger_entry negative seq");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault][jss::owner] = issuer.human();
+            jvParams[jss::vault][jss::seq] = -1;
+            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
+            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
+        }
+
+        {
+            testcase("RPC ledger_entry oversized seq");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault][jss::owner] = issuer.human();
+            jvParams[jss::vault][jss::seq] = 1e20;
+            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
+            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
+        }
+
+        {
+            testcase("RPC ledger_entry bool seq");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault][jss::owner] = issuer.human();
+            jvParams[jss::vault][jss::seq] = true;
+            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
+            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
+        }
+
+        {
+            testcase("RPC account_objects");
+
+            json::Value jvParams;
+            jvParams[jss::account] = owner.human();
+            jvParams[jss::type] = jss::vault;
+            auto jv = env.rpc("json", "account_objects", to_string(jvParams))[jss::result];
+
+            BEAST_EXPECT(jv[jss::account_objects].size() == 1);
+            check(jv[jss::account_objects][0u]);
+        }
+
+        {
+            testcase("RPC ledger_data");
+
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::binary] = false;
+            jvParams[jss::type] = jss::vault;
+            json::Value jv = env.rpc("json", "ledger_data", to_string(jvParams));
+            BEAST_EXPECT(jv[jss::result][jss::state].size() == 1);
+            check(jv[jss::result][jss::state][0u]);
+        }
+
+        {
+            testcase("RPC vault_info command line");
+            json::Value jv = env.rpc("vault_info", strHex(keylet.key), "validated");
+
+            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
+            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
+            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
+        }
+
+        {
+            testcase("RPC vault_info json");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault_id] = strHex(keylet.key);
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+
+            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
+            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
+            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
+        }
+
+        {
+            testcase("RPC vault_info invalid vault_id");
+            json::Value jvParams;
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json invalid index");
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json by owner and sequence");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::owner] = owner.human();
+            jvParams[jss::seq] = sequence;
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+
+            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
+            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
+            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
+        }
+
+        {
+            testcase("RPC vault_info json malformed sequence");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json invalid sequence");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json negative sequence");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json oversized sequence");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json bool sequence");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json malformed owner");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json invalid combination only owner");
+            json::Value jvParams;
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json invalid combination only seq");
+            json::Value jvParams;
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json invalid combination seq vault_id");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json invalid combination owner vault_id");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            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");
+        }
+
+        {
+            testcase(
+                "RPC vault_info json invalid combination owner seq "
+                "vault_id");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault_id] = strHex(keylet.key);
+            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");
+        }
+
+        {
+            testcase("RPC vault_info json no input");
+            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");
+        }
+
+        {
+            testcase("RPC vault_info command line invalid index");
+            json::Value jv = env.rpc("vault_info", "foobar", "validated");
+            BEAST_EXPECT(jv[jss::error].asString() == "invalidParams");
+        }
+
+        {
+            testcase("RPC vault_info command line invalid index");
+            json::Value jv = env.rpc("vault_info", "0", "validated");
+            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+        }
+
+        {
+            testcase("RPC vault_info command line invalid index");
+            json::Value jv = env.rpc("vault_info", strHex(uint256(42)), "validated");
+            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "entryNotFound");
+        }
+
+        {
+            testcase("RPC vault_info command line invalid ledger");
+            json::Value jv = env.rpc("vault_info", strHex(keylet.key), "0");
+            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "lgrNotFound");
+        }
+    }
+
+    // RPC coverage: closed-ended vaults must return VaultKind, SubscriptionDate and RedemptionDate
+    // in both vault_info and ledger_entry responses. Open-ended vaults must not.
+    void
+    testRPCClosedEnded()
+    {
+        using namespace test::jtx;
+
+        testcase("RPC closed-ended vault fields");
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const owner2{"owner2"};
+        env.fund(XRP(1000), owner, owner2);
+        env.close();
+
+        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
+        Asset const asset = xrpIssue();
+        auto const sub = env.now().time_since_epoch().count() + 60;
+        auto const red = sub + kMinInvestmentPeriod;
+
+        Vault const vault{env};
+        auto [tx, keylet] = vault.create(
+            {.owner = owner,
+             .asset = asset,
+             .vaultKind = closedEnded,
+             .subscriptionDate = sub,
+             .redemptionDate = red});
+        env(tx);
+        env.close();
+
+        auto [tx2, keylet2] = vault.create({.owner = owner2, .asset = asset});
+        env(tx2);
+        env.close();
+
+        auto const asUInt = [](json::Value const& jv) -> json::UInt {
+            return jv.isUInt() ? jv.asUInt() : json::UInt(jv.asInt());
+        };
+        auto const checkClosedEnded = [&](json::Value const& v) {
+            BEAST_EXPECT(v.isObject());
+            BEAST_EXPECT(v.isMember(sfVaultKind.fieldName));
+            BEAST_EXPECT(asUInt(v[sfVaultKind.fieldName]) == json::UInt(closedEnded));
+            BEAST_EXPECT(v.isMember(sfSubscriptionDate.fieldName));
+            BEAST_EXPECT(asUInt(v[sfSubscriptionDate.fieldName]) == json::UInt(sub));
+            BEAST_EXPECT(v.isMember(sfRedemptionDate.fieldName));
+            BEAST_EXPECT(asUInt(v[sfRedemptionDate.fieldName]) == json::UInt(red));
+        };
+        auto const checkOpenEnded = [&](json::Value const& v) {
+            BEAST_EXPECT(v.isObject());
+            BEAST_EXPECT(!v.isMember(sfVaultKind.fieldName));
+            BEAST_EXPECT(!v.isMember(sfSubscriptionDate.fieldName));
+            BEAST_EXPECT(!v.isMember(sfRedemptionDate.fieldName));
+        };
+
+        {
+            json::Value jvParams;
+            jvParams[jss::vault_id] = strHex(keylet.key);
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
+            checkClosedEnded(jv[jss::result][jss::vault]);
+        }
+        {
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault] = strHex(keylet.key);
+            auto jv = env.rpc("json", "ledger_entry", to_string(jvParams));
+            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
+            checkClosedEnded(jv[jss::result][jss::node]);
+        }
+        {
+            json::Value jvParams;
+            jvParams[jss::vault_id] = strHex(keylet2.key);
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
+            checkOpenEnded(jv[jss::result][jss::vault]);
+        }
+        {
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault] = strHex(keylet2.key);
+            auto jv = env.rpc("json", "ledger_entry", to_string(jvParams));
+            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
+            checkOpenEnded(jv[jss::result][jss::node]);
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testRPC();
+        testRPCClosedEnded();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultRPC, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp
new file mode 100644
index 0000000000..94c594f674
--- /dev/null
+++ b/src/test/app/vault/VaultScale_test.cpp
@@ -0,0 +1,1228 @@
+#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 {
+
+class VaultScale_test : public VaultTestBase
+{
+private:
+    void
+    testScaleIOU()
+    {
+        using namespace test::jtx;
+
+        struct Data
+        {
+            Account const& owner;
+            Account const& issuer;
+            Account const& depositor;
+            Account const& vaultAccount;
+            MPTIssue shares;
+            PrettyAsset const& share;
+            Vault& vault;
+            xrpl::Keylet keylet;
+            Issue assets;
+            PrettyAsset const& asset;
+            std::function)> peek;
+        };
+
+        auto testCase = [&, this](
+                            std::uint8_t scale, std::function test) {
+            Env env{*this, testableAmendments()};
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            Account const depositor{"depositor"};
+            Vault vault{env};
+            env.fund(XRP(1000), issuer, owner, depositor);
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1000), owner);
+            env.trust(asset(1000), depositor);
+            env(pay(issuer, owner, asset(200)));
+            env(pay(issuer, depositor, asset(200)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfScale] = scale;
+            env(tx);
+
+            auto const [vaultAccount, issuanceId] =
+                [&env](xrpl::Keylet keylet) -> std::tuple {
+                auto const vault = env.le(keylet);
+                return {Account("vault", vault->at(sfAccount)), vault->at(sfShareMPTID)};
+            }(keylet);
+            MPTIssue const shares(issuanceId);
+            env.memoize(vaultAccount);
+
+            auto const peek = [keylet, &env, this](std::function fn) -> bool {
+                return env.app().getOpenLedger().modify(
+                    [&](OpenView& view, beast::Journal j) -> bool {
+                        Sandbox sb(&view, TapNone);
+                        auto vault = sb.peek(keylet::vault(keylet.key));
+                        if (!BEAST_EXPECT(vault))
+                            return false;
+                        auto shares = sb.peek(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
+                        if (!BEAST_EXPECT(shares))
+                            return false;
+                        if (fn(*vault, *shares))
+                        {
+                            sb.update(vault);
+                            sb.update(shares);
+                            sb.apply(view);
+                            return true;
+                        }
+                        return false;
+                    });
+            };
+
+            test(
+                env,
+                {.owner = owner,
+                 .issuer = issuer,
+                 .depositor = depositor,
+                 .vaultAccount = vaultAccount,
+                 .shares = shares,
+                 .share = PrettyAsset(shares),
+                 .vault = vault,
+                 .keylet = keylet,
+                 .assets = asset.raw().get(),
+                 .asset = asset,
+                 .peek = peek});
+        };
+
+        testCase(18, [&, this](Env& env, Data d) {
+            testcase("Scale deposit overflow on first deposit");
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)});
+            env(tx, Ter{tecPATH_DRY});
+            env.close();
+        });
+
+        testCase(18, [&, this](Env& env, Data d) {
+            testcase("Scale deposit overflow on second deposit");
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)});
+                env(tx, Ter{tecPATH_DRY});
+                env.close();
+            }
+        });
+
+        testCase(18, [&, this](Env& env, Data d) {
+            testcase("Scale deposit overflow on total shares");
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
+                env(tx, Ter{tecPATH_DRY});
+                env.close();
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit exact");
+
+            auto const start = env.balance(d.depositor, d.assets).number();
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(1)});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(10));
+            BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start - 1));
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit insignificant amount");
+
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(9, -2))});
+            env(tx, Ter{tecPRECISION_LOSS});
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit exact, using full precision");
+
+            auto const start = env.balance(d.depositor, d.assets).number();
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(15, -1))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(15));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(15, -1)));
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit exact, truncating from .5");
+
+            auto const start = env.balance(d.depositor, d.assets).number();
+            // Each of the cases below will transfer exactly 1.2 IOU to the
+            // vault and receive 12 shares in exchange
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(125, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start - Number(12, -1)));
+            }
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(1201, -3))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(24));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start - Number(24, -1)));
+            }
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(1299, -3))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(36));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start - Number(36, -1)));
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit exact, truncating from .01");
+
+            auto const start = env.balance(d.depositor, d.assets).number();
+            // round to 12
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(1201, -3))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1)));
+
+            {
+                // round to 6
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(69, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start - Number(18, -1)));
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit exact, truncating from .99");
+
+            auto const start = env.balance(d.depositor, d.assets).number();
+            // round to 12
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(1299, -3))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1)));
+
+            {
+                // round to 6
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(62, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start - Number(18, -1)));
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            // initial setup: deposit 100 IOU, receive 1000 shares
+            auto const start = env.balance(d.depositor, d.assets).number();
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(100, 0))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0)));
+
+            {
+                testcase("Scale redeem exact");
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
+
+                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.share, Number(100, 0))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0)));
+            }
+
+            {
+                testcase("Scale redeem with rounding");
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                d.peek([](SLE& vault, auto&) -> bool {
+                    vault[sfAssetsAvailable] = Number(1);
+                    return true;
+                });
+
+                // Note, this transaction fails first (because of above change
+                // in the open ledger) but then succeeds when the ledger is
+                // closed (because a modification like above is not persistent),
+                // which is why the checks below are expected to pass.
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.share, Number(25, 0))});
+                env(tx, Ter{tecINSUFFICIENT_FUNDS});
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start + Number(25, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(900 - 25, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(900 - 25, 0)));
+            }
+
+            {
+                testcase("Scale redeem exact");
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 87.5 * 21 / 875 = 87.5 * 0.024 = 2.1
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+
+                tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.share, Number(21, 0))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 21));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start + Number(21, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(875 - 21, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(875 - 21, 0)));
+            }
+
+            {
+                testcase("Scale redeem rest");
+                auto const rest = env.balance(d.depositor, d.shares).number();
+
+                tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.share, rest)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
+            }
+        });
+
+        testCase(18, [&, this](Env& env, Data d) {
+            testcase("Scale withdraw overflow");
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(10, 0))});
+                env(tx, Ter{tecPATH_DRY});
+                env.close();
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            // initial setup: deposit 100 IOU, receive 1000 shares
+            auto const start = env.balance(d.depositor, d.assets).number();
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(100, 0))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0)));
+
+            {
+                testcase("Scale withdraw exact");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 1000 * 10 / 100 = 1000 * 0.1 = 100
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
+
+                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(10, 0))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0)));
+            }
+
+            {
+                testcase("Scale withdraw insignificant amount");
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(4, -2))});
+                env(tx, Ter{tecPRECISION_LOSS});
+            }
+
+            {
+                testcase("Scale withdraw with rounding assets");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                d.peek([](SLE& vault, auto&) -> bool {
+                    vault[sfAssetsAvailable] = Number(1);
+                    return true;
+                });
+
+                // Note, this transaction fails first (because of above change
+                // in the open ledger) but then succeeds when the ledger is
+                // closed (because a modification like above is not persistent),
+                // which is why the checks below are expected to pass.
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(25, -1))});
+                env(tx, Ter{tecINSUFFICIENT_FUNDS});
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start + Number(25, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(900 - 25, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(900 - 25, 0)));
+            }
+
+            {
+                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
+
+                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(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.assets) ==
+                    STAmount(d.asset, start + Number(38, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(875 - 38, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(875 - 38, 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
+
+                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(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.assets) ==
+                    STAmount(d.asset, start + Number(37, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(837 - 37, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(837 - 37, 0)));
+            }
+
+            {
+                testcase("Scale withdraw tiny amount");
+
+                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.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -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)));
+            }
+
+            {
+                testcase("Scale withdraw rest");
+                auto const rest = env.balance(d.vaultAccount, d.assets).number();
+
+                tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, rest)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
+            }
+        });
+
+        testCase(18, [&, this](Env& env, Data d) {
+            testcase("Scale clawback overflow");
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(10, 0))});
+                env(tx, Ter{tecPATH_DRY});
+                env.close();
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            // initial setup: deposit 100 IOU, receive 1000 shares
+            auto const start = env.balance(d.depositor, d.assets).number();
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(100, 0))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(1000, 0)));
+            {
+                testcase("Scale clawback exact");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 1000 * 10 / 100 = 1000 * 0.1 = 100
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(10, 0))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(900, 0)));
+            }
+
+            {
+                testcase("Scale clawback insignificant amount");
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(4, -2))});
+                env(tx, Ter{tecPRECISION_LOSS});
+            }
+
+            {
+                testcase("Scale clawback with rounding assets");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(25, -1))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(900 - 25, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(900 - 25, 0)));
+            }
+
+            {
+                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
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .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.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(875 - 38, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(875 - 38, 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
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .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.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(837 - 37, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(837 - 37, 0)));
+            }
+
+            {
+                testcase("Scale clawback tiny amount");
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(9, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
+                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)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(800 - 1, 0)));
+            }
+
+            {
+                testcase("Scale clawback rest");
+                auto const rest = env.balance(d.vaultAccount, d.assets).number();
+                d.peek([](SLE& vault, auto&) -> bool {
+                    vault[sfAssetsAvailable] = Number(5);
+                    return true;
+                });
+
+                // Note, this transaction yields two different results:
+                // * in the open ledger, with AssetsAvailable = 5
+                // * when the ledger is closed with unmodified AssetsAvailable
+                //   because a modification like above is not persistent.
+                tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, rest)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
+            }
+        });
+
+        // Non-1:1 ratio (scale=1, 10:1 shares:assets) with an outstanding loan.
+        // Deposit 100 IOU → 1000 shares. Borrow 40 → assetsAvailable=60.
+        // Clawback 80 IOU → clamped to 60, then share math uses truncation.
+        testCase(1, [&, this](Env& env, Data d) {
+            using namespace loan_broker;
+            using namespace loan;
+
+            testcase("Scale clawback clamped with outstanding loan");
+
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(100, 0))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
+
+            // Create a loan broker backed by this vault
+            auto const brokerKeylet =
+                keylet::loanBroker(d.owner.id(), SeqProxy::rawSequence(env.seq(d.owner)));
+            env(set(d.owner, d.keylet.key));
+            env.close();
+
+            // Borrow 40: assetsAvailable=60, assetsTotal=100
+            env(set(d.depositor, brokerKeylet.key, STAmount(d.asset, Number(40, 0))),
+                loan::kInterestRate(TenthBips32(0)),
+                kGracePeriod(60),
+                kPaymentInterval(120),
+                kPaymentTotal(10),
+                Sig(sfCounterpartySignature, d.owner),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(d.keylet);
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(60, 0)));
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(100, 0)));
+            }
+
+            // Request 80 IOU clawback — clamped to assetsAvailable (60)
+            // With scale=1 (10:1), 60 assets = 600 shares destroyed
+            tx = d.vault.clawback(
+                {.issuer = d.issuer,
+                 .id = d.keylet.key,
+                 .holder = d.depositor,
+                 .amount = STAmount(d.asset, Number(80, 0))});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(d.keylet);
+                BEAST_EXPECT(sle != nullptr);
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(0, 0)));
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(40, 0)));
+
+                // 600 of 1000 shares destroyed, 400 remain
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(400));
+            }
+        });
+    }
+
+    void
+    testAssetsMaximum()
+    {
+        testcase("Assets Maximum");
+
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const issuer{"issuer"};
+
+        Vault const vault{env};
+        env.fund(XRP(1'000'000), issuer, owner);
+        env.close();
+
+        auto const maxInt64 = std::to_string(std::numeric_limits::max());
+        BEAST_EXPECT(maxInt64 == "9223372036854775807");
+
+        auto const maxInt64Plus1 = std::to_string(
+            static_cast(std::numeric_limits::max()) + 1);
+        BEAST_EXPECT(maxInt64Plus1 == "9223372036854775808");
+
+        // Naming things is hard
+        auto const maxInt64Plus2 = std::to_string(
+            static_cast(std::numeric_limits::max()) + 2);
+        BEAST_EXPECT(maxInt64Plus2 == "9223372036854775809");
+
+        auto const initialXRP = to_string(kInitialXrp);
+        BEAST_EXPECT(initialXRP == "100000000000000000");
+
+        auto const initialXRPPlus1 = to_string(kInitialXrp + 1);
+        BEAST_EXPECT(initialXRPPlus1 == "100000000000000001");
+
+        {
+            testcase("Assets Maximum: XRP");
+
+            PrettyAsset const xrpAsset = xrpIssue();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            tx[sfData] = "4D65746144617461";
+
+            tx[sfAssetsMaximum] = maxInt64;
+            env(tx, Ter(tefEXCEPTION));
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRPPlus1;
+            env(tx, Ter(tefEXCEPTION));
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRP;
+            env(tx);
+            env.close();
+
+            // There are several parse failures expected in this function, so just disable it once.
+            env.setParseFailureExpected(true);
+            try
+            {
+                tx[sfAssetsMaximum] = maxInt64Plus1;
+                env(tx, Ter(tefEXCEPTION));
+                env.close();
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            try
+            {
+                tx[sfAssetsMaximum] = maxInt64Plus2;
+                env(tx, Ter(tefEXCEPTION));
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            try
+            {
+                auto const insertAt = maxInt64Plus2.size() - 3;
+                auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
+                    maxInt64Plus2.substr(insertAt);  // (max int64+2) / 1000
+                BEAST_EXPECT(decimalTest == "9223372036854775.809");
+                tx[sfAssetsMaximum] = decimalTest;
+                env(tx);
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            auto const vaultSle = env.le(newKeylet);
+            BEAST_EXPECT(!vaultSle);
+        }
+
+        {
+            testcase("Assets Maximum: MPT");
+
+            PrettyAsset const mptAsset = [&]() {
+                MPTTester mptt{env, issuer, kMptInitNoFund};
+                mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+                env.close();
+                PrettyAsset const mptAsset = mptt["MPT"];
+                mptt.authorize({.account = owner});
+                env.close();
+                return mptAsset;
+            }();
+
+            env(pay(issuer, owner, mptAsset(100'000)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = mptAsset});
+            tx[sfData] = "4D65746144617461";
+
+            tx[sfAssetsMaximum] = maxInt64;
+            env(tx);
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRPPlus1;
+            env(tx);
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRP;
+            env(tx);
+            env.close();
+
+            try
+            {
+                tx[sfAssetsMaximum] = maxInt64Plus2;
+                env(tx, Ter(tefEXCEPTION));
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            try
+            {
+                auto const insertAt = maxInt64Plus2.size() - 1;
+                auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
+                    maxInt64Plus2.substr(insertAt);  // (max int64+2) / 10
+                BEAST_EXPECT(decimalTest == "922337203685477580.9");
+                tx[sfAssetsMaximum] = decimalTest;
+                env(tx);
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            auto const vaultSle = env.le(newKeylet);
+            BEAST_EXPECT(!vaultSle);
+        }
+
+        {
+            testcase("Assets Maximum: IOU");
+
+            // Almost anything goes with IOUs
+            PrettyAsset const iouAsset = issuer["IOU"];
+            env.trust(iouAsset(1000), owner);
+            env(pay(issuer, owner, iouAsset(200)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = iouAsset});
+            tx[sfData] = "4D65746144617461";
+
+            tx[sfAssetsMaximum] = maxInt64;
+            env(tx);
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRPPlus1;
+            env(tx);
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRP;
+            env(tx);
+            env.close();
+
+            // Since several tests are expected to have parser failures, leave this flag set for the
+            // remainder of this function.
+            env.setParseFailureExpected(true);
+            try
+            {
+                tx[sfAssetsMaximum] = maxInt64Plus2;
+                env(tx);
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            tx[sfAssetsMaximum] = "1000000000000000e80";
+            env.close();
+
+            tx[sfAssetsMaximum] = "1000000000000000e-96";
+            env.close();
+
+            // These values will be rounded to 15 significant digits
+            {
+                auto const newKeylet =
+                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                try
+                {
+                    auto const insertAt = maxInt64Plus2.size() - 1;
+                    auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
+                        maxInt64Plus2.substr(insertAt);  // (max int64+2) / 10
+                    BEAST_EXPECT(decimalTest == "922337203685477580.9");
+                    tx[sfAssetsMaximum] = decimalTest;
+                    env(tx);
+                    // should throw in parser
+                    fail();
+                }
+                catch (std::exception const& e)
+                {
+                    BEAST_EXPECT(
+                        std::string(e.what()) ==
+                        "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+                }
+
+                auto const vaultSle = env.le(newKeylet);
+                BEAST_EXPECT(!vaultSle);
+            }
+            {
+                tx[sfAssetsMaximum] = "9223372036854775807e40";  // max int64 * 10^40
+                auto const newKeylet =
+                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(tx);
+                env.close();
+
+                auto const vaultSle = env.le(newKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+
+                BEAST_EXPECT(
+                    (vaultSle->at(sfAssetsMaximum) ==
+                     Number{9223372036854776, 43, Number::Normalized{}}));
+            }
+            {
+                tx[sfAssetsMaximum] = "9223372036854775807e-40";  // max int64 * 10^-40
+                auto const newKeylet =
+                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(tx);
+                env.close();
+
+                auto const vaultSle = env.le(newKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+
+                BEAST_EXPECT(
+                    (vaultSle->at(sfAssetsMaximum) ==
+                     Number{9223372036854776, -37, Number::Normalized{}}));
+            }
+            {
+                tx[sfAssetsMaximum] = "9223372036854775807e-100";  // max int64 * 10^-100
+                auto const newKeylet =
+                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(tx);
+                env.close();
+
+                // Field 'AssetsMaximum' may not be explicitly set to default.
+                auto const vaultSle = env.le(newKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+
+                BEAST_EXPECT(vaultSle->at(sfAssetsMaximum) == kNumZero);
+            }
+
+            // What _can't_ IOUs do?
+            // 1. Exceed maximum exponent / offset
+            tx[sfAssetsMaximum] = "1000000000000000e81";
+            env(tx, Ter(tefEXCEPTION));
+            env.close();
+
+            // 2. Mantissa larger than uint64 max
+            try
+            {
+                auto const g = env.getParseFailureGuard(true);
+                tx[sfAssetsMaximum] = "18446744073709551617e5";  // uint64 max + 1
+                env(tx);
+                BEAST_EXPECTS(false, "Expected parse_error for mantissa larger than uint64 max");
+            }
+            catch (ParseError const& e)
+            {
+                using namespace std::string_literals;
+                BEAST_EXPECT(
+                    e.what() == "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."s);
+            }
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testScaleIOU();
+        testAssetsMaximum();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE_PRIO(VaultScale, app, xrpl, 1);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultShares_test.cpp b/src/test/app/vault/VaultShares_test.cpp
new file mode 100644
index 0000000000..037ee3e057
--- /dev/null
+++ b/src/test/app/vault/VaultShares_test.cpp
@@ -0,0 +1,736 @@
+#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 {
+
+class VaultShares_test : public VaultTestBase
+{
+private:
+    void
+    testNonTransferableShares()
+    {
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        env.fund(XRP(1000), issuer, owner, depositor);
+        env.close();
+
+        Vault const vault{env};
+        PrettyAsset const asset = issuer["IOU"];
+        env.trust(asset(1000), owner);
+        env(pay(issuer, owner, asset(100)));
+        env.trust(asset(1000), depositor);
+        env(pay(issuer, depositor, asset(100)));
+        env.close();
+
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        tx[sfFlags] = tfVaultShareNonTransferable;
+        env(tx);
+        env.close();
+
+        {
+            testcase("nontransferable deposits");
+            auto tx1 =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(40)});
+            env(tx1);
+
+            auto tx2 = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(60)});
+            env(tx2);
+            env.close();
+        }
+
+        auto const vaultAccount =  //
+            [&env, key = keylet.key, this]() -> AccountID {
+            auto jvVault = env.rpc("vault_info", strHex(key));
+
+            BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "100");
+            BEAST_EXPECT(
+                jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "100000000");
+
+            // Vault pseudo-account
+            return parseBase58(jvVault[jss::result][jss::vault][jss::Account].asString())
+                .value();
+        }();
+
+        auto const mptId = makeMptID(1, vaultAccount);
+        Asset const shares = mptId;
+
+        {
+            testcase("nontransferable shares cannot be moved");
+            env(pay(owner, depositor, shares(10)), Ter{tecNO_AUTH});
+            env(pay(depositor, owner, shares(10)), Ter{tecNO_AUTH});
+        }
+
+        {
+            testcase("nontransferable shares can be used to withdraw");
+            auto tx1 =
+                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
+            env(tx1);
+
+            auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
+            env(tx2);
+            env.close();
+        }
+
+        {
+            testcase("nontransferable shares balance check");
+            auto jvVault = env.rpc("vault_info", strHex(keylet.key));
+            BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "50");
+            BEAST_EXPECT(
+                jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "50000000");
+        }
+
+        {
+            testcase("nontransferable shares withdraw rest");
+            auto tx1 =
+                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
+            env(tx1);
+
+            auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
+            env(tx2);
+            env.close();
+        }
+
+        {
+            testcase("nontransferable shares delete empty vault");
+            auto tx = vault.del({.owner = owner, .id = keylet.key});
+            env(tx);
+            BEAST_EXPECT(!env.le(keylet));
+        }
+    }
+
+    void
+    testFailedPseudoAccount()
+    {
+        using namespace test::jtx;
+
+        testcase("fail pseudo-account allocation");
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Vault const vault{env};
+        env.fund(XRP(1000), owner);
+
+        auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        for (int i = 0; i < 256; ++i)
+        {
+            AccountID const accountId = xrpl::pseudoAccountAddress(*env.current(), keylet.key);
+
+            env(pay(env.master.id(), accountId, XRP(1000)),
+                Seq(kAutofill),
+                Fee(kAutofill),
+                Sig(kAutofill));
+        }
+
+        auto [tx, keylet1] = vault.create({.owner = owner, .asset = xrpIssue()});
+        BEAST_EXPECT(keylet.key == keylet1.key);
+        env(tx, Ter{terADDRESS_COLLISION});
+    }
+
+    void
+    testRemoveEmptyHoldingLockedAmount()
+    {
+        testcase("removeEmptyHolding deletes MPToken with sfLockedAmount");
+        using namespace test::jtx;
+        using namespace std::literals;
+
+        auto const amendments = testableAmendments();
+        auto runTest = [&](FeatureBitset f) {
+            Env env{*this, f};
+            auto const baseFee = env.current()->fees().base;
+
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100000), issuer, owner, depositor, bob);
+            env.close();
+
+            Vault const vault{env};
+
+            // Create an MPT asset for the vault
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            env(pay(issuer, depositor, asset(1000)));
+            env.close();
+
+            // Create vault
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const vaultSle = env.le(keylet);
+            BEAST_EXPECT(vaultSle != nullptr);
+            auto const shareMptID = vaultSle->at(sfShareMPTID);
+            MPTIssue const shareIssue{shareMptID};
+
+            // Depositor deposits 1000 asset units into vault, receiving shares
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}));
+            env.close();
+
+            // Check depositor has shares
+            {
+                auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
+                BEAST_EXPECT(sleMpt != nullptr);
+                BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 1000);
+            }
+
+            // Escrow 500 of those shares
+            env(escrow::create(depositor, bob, STAmount{shareIssue, 500}),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFinishTime(env.now() + 1s),
+                Fee(baseFee * 150),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Verify: sfMPTAmount=500, sfLockedAmount=500
+            {
+                auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
+                BEAST_EXPECT(sleMpt != nullptr);
+                BEAST_EXPECT(sleMpt->at(sfLockedAmount) == 500);
+                BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 500);
+            }
+
+            // Withdraw remaining spendable shares — triggers removeEmptyHolding
+            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(500)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const sleMptAfter = env.le(keylet::mptoken(shareMptID, depositor));
+            if (!f[fixCleanup3_1_3])
+            {
+                // Without the fix, removeEmptyHolding deletes the MPToken
+                // even though sfLockedAmount > 0, leaving the escrow's locked
+                // amount untracked.
+                BEAST_EXPECT(sleMptAfter == nullptr);
+            }
+            else
+            {
+                // With the fix, MPToken must still exist with sfLockedAmount > 0
+                // and sfMPTAmount == 0 (all spendable shares withdrawn).
+                BEAST_EXPECT(sleMptAfter != nullptr);
+                if (sleMptAfter)
+                {
+                    BEAST_EXPECT(sleMptAfter->at(sfLockedAmount) == 500);
+                    BEAST_EXPECT(sleMptAfter->at(sfMPTAmount) == 0);
+                }
+            }
+        };
+
+        runTest(amendments - fixCleanup3_1_3);
+        runTest(amendments);
+    }
+
+    void
+    testRemoveEmptyHoldingConfidentialBalances()
+    {
+        testcase("removeEmptyHolding keeps MPToken with confidential balances");
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+
+        Account const issuer{"issuer"};
+        Account const holder{"holder"};
+        MPTTester mpt{env, issuer, {.holders = {holder}}};
+        mpt.create({.authorize = MPTCreate::allHolders});
+
+        auto const tokenKeylet = keylet::mptoken(mpt.issuanceID(), holder.id());
+        auto const encryptedBalanceFields = {
+            &sfConfidentialBalanceInbox,
+            &sfConfidentialBalanceSpending,
+            &sfIssuerEncryptedBalance,
+            &sfAuditorEncryptedBalance};
+
+        env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) {
+            for (auto const field : encryptedBalanceFields)
+            {
+                Sandbox sb(&view, TapNone);
+                auto const token = sb.peek(tokenKeylet);
+                if (!BEAST_EXPECT(token))
+                    return false;
+
+                token->setFieldVL(*field, gMakeZeroBuffer(kEcGamalEncryptedTotalLength));
+                sb.update(token);
+
+                auto const dummyTx = *env.jt(noop(holder)).stx;
+                BEAST_EXPECT(
+                    removeEmptyHolding({sb, dummyTx}, holder.id(), MPTIssue(mpt.issuanceID()), j) ==
+                    tecHAS_OBLIGATIONS);
+                BEAST_EXPECT(sb.peek(tokenKeylet) != nullptr);
+            }
+            return true;
+        });
+    }
+
+    void
+    testReferenceHolding()
+    {
+        using namespace test::jtx;
+
+        auto readReferenceHolding = [&](Env const& env,
+                                        Keylet const& vaultKeylet) -> std::optional {
+            auto const sleVault = env.le(vaultKeylet);
+            if (!sleVault)
+                return std::nullopt;
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
+                return std::nullopt;
+            return sleIssuance->getFieldH256(sfReferenceHolding);
+        };
+
+        // Post-fixCleanup3_2_0: vault share carries sfReferenceHolding
+        // pointing to the vault pseudo's MPToken (for MPT-backed vaults)
+        // or RippleState (for IOU-backed vaults).
+        {
+            testcase("sfReferenceHolding: MPT-backed vault, post-amendment");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            BEAST_EXPECT(sleVault != nullptr);
+            auto const pseudoId = sleVault->at(sfAccount);
+            auto const expected = keylet::mptoken(mptt.issuanceID(), pseudoId).key;
+
+            auto const stored = readReferenceHolding(env, keylet);
+            BEAST_EXPECT(stored.has_value());
+            BEAST_EXPECT(stored && *stored == expected);
+            // The pointed-to MPToken must actually exist.
+            BEAST_EXPECT(env.le(keylet::mptoken(mptt.issuanceID(), pseudoId)) != nullptr);
+        }
+
+        {
+            testcase("sfReferenceHolding: IOU-backed vault, post-amendment");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            BEAST_EXPECT(sleVault != nullptr);
+            auto const pseudoId = sleVault->at(sfAccount);
+            auto const expected = keylet::trustLine(pseudoId, asset.raw().get()).key;
+
+            auto const stored = readReferenceHolding(env, keylet);
+            BEAST_EXPECT(stored.has_value());
+            BEAST_EXPECT(stored && *stored == expected);
+            // The pointed-to RippleState must actually exist.
+            BEAST_EXPECT(env.le(keylet::trustLine(pseudoId, asset.raw().get())) != nullptr);
+        }
+
+        // XRP-backed vaults leave the field absent: XRP has no separate
+        // holding ledger entry and no transferability concept to inherit.
+        {
+            testcase("sfReferenceHolding: XRP-backed vault, field absent");
+            Env env{*this, testableAmendments()};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), owner);
+            env.close();
+
+            PrettyAsset const asset{xrpIssue(), 1'000'000};
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
+        }
+
+        // Pre-fixCleanup3_2_0: vault share has the field absent regardless
+        // of underlying type.
+        {
+            testcase("sfReferenceHolding: vault share, pre-amendment");
+            Env env{*this, testableAmendments() - fixCleanup3_2_0};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
+        }
+
+        // Plain MPTokenIssuanceCreate (not a vault share) must never
+        // populate the field. Only the post-amendment case is
+        // interesting; pre-amendment nothing writes the field at all.
+        {
+            testcase("sfReferenceHolding: plain MPT issuance never set");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            env.fund(XRP(10'000), issuer);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            env.close();
+
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(mptt.issuanceID()));
+            if (BEAST_EXPECT(sleIssuance))
+                BEAST_EXPECT(!sleIssuance->isFieldPresent(sfReferenceHolding));
+        }
+    }
+
+    // Probe every transactor surface that might delete the vault pseudo-
+    // account's underlying holding (the MPToken or RippleState pointed to
+    // by sfReferenceHolding). Each scenario asserts either that the
+    // existing pseudo-account guards stop the deletion at preclaim, or
+    // that the ledger leaves the holding intact afterwards. This is a
+    // regression guard: if any of these guards regresses, the share's
+    // sfReferenceHolding pointer would dangle and the new ValidMPTIssuance
+    // invariant would catch it - but we want to fail much earlier, at
+    // the transactor's preclaim / doApply, not at invariant time.
+    void
+    testHoldingDeletionBlocked()
+    {
+        using namespace test::jtx;
+
+        // Helper: read the share's referenced holding and confirm the
+        // pointed-to SLE still exists after the probe.
+        auto referencedHoldingExists = [&](Env const& env, Keylet const& vaultKeylet) -> bool {
+            auto const sleVault = env.le(vaultKeylet);
+            if (!sleVault)
+                return false;
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
+                return false;
+            auto const holdingKey = sleIssuance->getFieldH256(sfReferenceHolding);
+            return env.le(keylet::unchecked(holdingKey)) != nullptr;
+        };
+
+        // ---- MPT-backed vault ----------------------------------------
+        {
+            testcase("vault pseudo MPToken: Clawback blocked by tecPSEUDO_ACCOUNT");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(10'000), issuer, owner, depositor);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            env(pay(issuer, depositor, asset(1'000)));
+            env.close();
+
+            Vault const 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(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
+            // Issuer attempts to claw back the FULL underlying balance
+            // (500) directly from the vault pseudo-account. With the
+            // full amount, the doApply path would drain the pseudo's
+            // MPToken to zero and removeEmptyHolding would erase it -
+            // if doApply ever ran. SAV's pseudo-account guard at
+            // Clawback.cpp:201 refuses at preclaim with
+            // tecPSEUDO_ACCOUNT before any state change.
+            env(claw(issuer, asset(500), pseudoAccount), Ter{tecPSEUDO_ACCOUNT});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+            // Sanity: pseudo's full balance is intact.
+            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
+        }
+
+        {
+            testcase("vault pseudo MPToken: Issuer cannot Unauthorize pseudo");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = issuer, .holder = owner});
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            auto const pseudoId = env.le(keylet)->at(sfAccount);
+            // Issuer attempts MPTokenAuthorize against the pseudo with
+            // tfMPTUnauthorize. MPTokenAuthorize.cpp blocks pseudo
+            // accounts via isPseudoAccount; the pseudo's MPToken is
+            // preserved. Construct the tx manually since the pseudo
+            // lacks a signing key, and the issuer-driven flavour is
+            // expressed via sfHolder.
+            json::Value jv;
+            jv[sfAccount] = issuer.human();
+            jv[sfHolder] = toBase58(pseudoId);
+            jv[sfMPTokenIssuanceID] = to_string(mptt.issuanceID());
+            jv[sfFlags] = tfMPTUnauthorize;
+            jv[sfTransactionType] = jss::MPTokenAuthorize;
+            env(jv, Ter{tecNO_PERMISSION});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+        }
+
+        {
+            testcase("vault pseudo MPToken: MPTokenIssuanceDestroy blocked while vault holds");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(10'000), issuer, owner, depositor);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            env(pay(issuer, depositor, asset(1'000)));
+            env.close();
+
+            Vault const 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(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            // While the vault holds outstanding underlying, the issuer
+            // cannot destroy the issuance. tecHAS_OBLIGATIONS confirms
+            // the protection - and as a side effect, the share's
+            // sfReferenceHolding pointer cannot be left pointing at a
+            // ghost issuance.
+            mptt.destroy({.id = mptt.issuanceID(), .err = tecHAS_OBLIGATIONS});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+        }
+
+        // ---- IOU-backed vault ----------------------------------------
+        {
+            testcase("vault pseudo trust line: Clawback blocked by tecPSEUDO_ACCOUNT");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000'000), owner);
+            env(pay(issuer, owner, asset(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
+            // Issuer attempts to claw back the FULL IOU balance (500)
+            // directly from the vault pseudo. With the full amount, the
+            // doApply path would drain the trust line to zero and (if
+            // both reserve flags clear) trustDelete would erase it - if
+            // doApply ever ran. The same SAV pseudo-account guard
+            // refuses at preclaim with tecPSEUDO_ACCOUNT. The amount's
+            // STAmount issuer field is the holder, per IOU clawback
+            // convention.
+            env(claw(issuer, pseudoAccount["IOU"](500)), Ter{tecPSEUDO_ACCOUNT});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+            // Sanity: pseudo's full balance is intact.
+            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
+        }
+
+        {
+            testcase("vault pseudo trust line: TrustSet limit=0 from issuer preserves line");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000'000), owner);
+            env(pay(issuer, owner, asset(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            // Issuer submits TrustSet with limit=0 against the vault
+            // pseudo. The pseudo's side of the line still has the
+            // original (non-zero) limit and a non-zero balance, so the
+            // line is preserved - even though the issuer cleared its
+            // own side. trustDelete only fires when both limits clear
+            // and the balance is zero.
+            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
+            env(trust(issuer, pseudoAccount["IOU"](0)));
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+        }
+
+        // ---- Positive control: VaultDelete is the only legitimate path
+        {
+            testcase("vault pseudo holding: VaultDelete is the legitimate cleanup path");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+            auto const pseudoId = env.le(keylet)->at(sfAccount);
+            auto const sharedMptId = env.le(keylet)->at(sfShareMPTID);
+            auto const holdingKeylet = keylet::mptoken(mptt.issuanceID(), pseudoId);
+
+            // VaultDelete tears down the vault pseudo's holding, the
+            // share issuance, and the pseudo-account itself. Invariant
+            // permits this because the tx is ttVAULT_DELETE.
+            env(vault.del({.owner = owner, .id = keylet.key}));
+            env.close();
+
+            BEAST_EXPECT(env.le(keylet) == nullptr);
+            BEAST_EXPECT(env.le(holdingKeylet) == nullptr);
+            BEAST_EXPECT(env.le(keylet::mptokenIssuance(sharedMptId)) == nullptr);
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testNonTransferableShares();
+        testFailedPseudoAccount();
+        testRemoveEmptyHoldingLockedAmount();
+        testRemoveEmptyHoldingConfidentialBalances();
+        testReferenceHolding();
+        testHoldingDeletionBlocked();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultShares, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultSoleShareholder_test.cpp b/src/test/app/vault/VaultSoleShareholder_test.cpp
new file mode 100644
index 0000000000..ffaad07112
--- /dev/null
+++ b/src/test/app/vault/VaultSoleShareholder_test.cpp
@@ -0,0 +1,655 @@
+#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 {
+
+class VaultSoleShareholder_test : public VaultTestBase
+{
+private:
+    // design doc:
+    //     AssetsAvailable ≈ 3,333.50
+    //     AssetsTotal     ≈ 6,666.50  (3,333.50 cash + 3,333 receivable)
+    //     LossUnrealized  =  3,333
+    //     OutstandingShares = sharesLender   (5e9 at IOU scale 1e6)
+    struct StuckDepositorFixture
+    {
+        test::jtx::Account issuer{"issuer"};
+        test::jtx::Account lender{"lender"};
+        test::jtx::Account bob{"bob"};
+        test::jtx::Account borrower{"borrower"};
+        std::optional asset;
+        std::optional vaultKeylet;
+        uint256 brokerID;
+        std::optional loanKeylet;
+        MPTID shareAsset;
+        std::uint64_t sharesLender = 0;
+    };
+
+    static constexpr std::int64_t kStuckFunding = 1'000'000;
+    static constexpr std::int64_t kStuckDepositorIOU = 1'000'000;
+    static constexpr std::int64_t kStuckBorrowerIOU = 100'000;
+    static constexpr std::int64_t kStuckDeposit = 5'000;
+    static constexpr std::int64_t kStuckPrincipal = 3'333;
+    static constexpr std::uint32_t kStuckPayInterval = 600;
+    static constexpr std::uint32_t kStuckPayTotal = 2;
+
+    [[nodiscard]] StuckDepositorFixture
+    setupStuckDepositor(test::jtx::Env& env)
+    {
+        using namespace test::jtx;
+
+        StuckDepositorFixture f;
+        f.asset = f.issuer[iouCurrency_];
+
+        env.fund(XRP(kStuckFunding), f.issuer, f.lender, f.bob, f.borrower);
+        env.close();
+
+        env(trust(f.lender, (*f.asset)(10'000'000)));
+        env(trust(f.bob, (*f.asset)(10'000'000)));
+        env(trust(f.borrower, (*f.asset)(10'000'000)));
+        env.close();
+
+        env(pay(f.issuer, f.lender, (*f.asset)(kStuckDepositorIOU)));
+        env(pay(f.issuer, f.bob, (*f.asset)(kStuckDepositorIOU)));
+        env(pay(f.issuer, f.borrower, (*f.asset)(kStuckBorrowerIOU)));
+        env.close();
+
+        // Vault: Lender creates and seeds it; Bob matches the deposit for a
+        // clean 50/50 split.
+        Vault const v{env};
+        auto [createTx, vaultKeylet] = v.create({.owner = f.lender, .asset = *f.asset});
+        env(createTx);
+        env.close();
+        if (!BEAST_EXPECT(env.le(vaultKeylet)))
+            return f;
+        f.vaultKeylet = vaultKeylet;
+
+        env(v.deposit({
+                .depositor = f.lender,
+                .id = vaultKeylet.key,
+                .amount = (*f.asset)(kStuckDeposit),
+            }),
+            Ter(tesSUCCESS));
+        env(v.deposit({
+                .depositor = f.bob,
+                .id = vaultKeylet.key,
+                .amount = (*f.asset)(kStuckDeposit),
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Loan broker: no cover, no management fee, debt cap 10x principal.
+        f.brokerID =
+            keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender))).key;
+        {
+            using namespace loan_broker;
+            env(set(f.lender, vaultKeylet.key),
+                kDebtMaximum((*f.asset)(kStuckPrincipal * 10).value()));
+            env.close();
+        }
+
+        // Loan: 3,333 USD principal, impaired immediately.
+        auto const sleBroker = env.le(keylet::loanBroker(f.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return f;
+        f.loanKeylet =
+            keylet::loan(f.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        {
+            using namespace loan;
+            env(set(f.borrower, f.brokerID, kStuckPrincipal),
+                Sig(sfCounterpartySignature, f.lender),
+                kPaymentTotal(kStuckPayTotal),
+                kPaymentInterval(kStuckPayInterval),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+            env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
+            env.close();
+        }
+
+        auto const vaultSle = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultSle))
+            return f;
+        BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
+
+        f.shareAsset = vaultSle->at(sfShareMPTID);
+
+        auto const tokenBob = env.le(keylet::mptoken(f.shareAsset, f.bob.id()));
+        if (!BEAST_EXPECT(tokenBob))
+            return f;
+        std::uint64_t const sharesBob = tokenBob->getFieldU64(sfMPTAmount);
+
+        // Bob (non-sole) exits at the discounted rate. Always succeeds.
+        STAmount const bobShareAmt{MPTIssue{f.shareAsset}, Number(sharesBob)};
+        env(v.withdraw({
+                .depositor = f.bob,
+                .id = vaultKeylet.key,
+                .amount = bobShareAmt,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const tokenLender = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
+        if (!BEAST_EXPECT(tokenLender))
+            return f;
+        f.sharesLender = tokenLender->getFieldU64(sfMPTAmount);
+
+        auto const sleIssuance = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(sleIssuance))
+            return f;
+        BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == f.sharesLender);
+
+        auto const vaultAfterBob = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultAfterBob))
+            return f;
+        // After Bob's exit: loss is unchanged (3,333 receivable), and the
+        // gap between assetsTotal and assetsAvailable equals exactly that
+        // receivable.
+        BEAST_EXPECT(vaultAfterBob->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
+        BEAST_EXPECT(
+            vaultAfterBob->at(sfAssetsTotal) - vaultAfterBob->at(sfAssetsAvailable) ==
+            vaultAfterBob->at(sfLossUnrealized));
+
+        return f;
+    }
+
+    // Reproduces the worked example from the XLS-0065 design doc. The sole
+    // remaining shareholder asks (via fixed-asset input) for the vault's
+    // entire AssetsAvailable. Pre-fix this fails with the zero-sized-vault
+    // invariant violation. Post-fix the full-price exchange rate burns
+    // only a portion of the shares, the depositor receives all of
+    // AssetsAvailable, and the residual shares remain backed by the
+    // impaired-loan receivable.
+    void
+    testWithdrawSoleShareholderFixedAssetExit(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_2_0];
+        testcase(
+            std::string{"Vault withdraw: sole shareholder exits via "
+                        "fixed-asset amount with impaired loan"} +
+            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
+
+        std::string logs;
+        Env env(*this, features, std::make_unique(&logs));
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+        PrettyAsset const& asset = *f.asset;
+
+        auto const vaultBefore = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
+        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
+        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
+
+        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
+
+        // The requested amount differs between feature regimes because
+        // the two regimes are testing different behaviors:
+        //
+        // - Pre-fix: request the full AssetsAvailable (3,333.50). Under
+        //   the discounted formula this would burn every outstanding
+        //   share, hitting the zero-sized-vault invariant. The
+        //   transaction is rejected with tecINVARIANT_FAILED — the
+        //   stuck-depositor bug.
+        //
+        // - Post-fix: request a strictly smaller amount (1,000 USD).
+        //   The full-price formula burns only ~30% of the outstanding
+        //   shares; the vault retains the rest, backed by the impaired
+        //   receivable. Requesting *exactly* AssetsAvailable post-fix
+        //   would currently fail with tecINSUFFICIENT_FUNDS due to the
+        //   round-to-nearest used by assetsToSharesWithdraw (the
+        //   recomputed payout can overshoot the request by a few ULPs).
+        //   The "force payout to AssetsAvailable" branch in doApply
+        //   only triggers when every share is burned, which is covered
+        //   by the loan-repayment test.
+        STAmount const requestAssets =
+            withFix ? asset(1000).value() : STAmount{asset.raw(), availableBefore};
+        Vault const v{env};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = requestAssets,
+            }),
+            Ter(withFix ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}));
+        env.close();
+
+        auto const vaultAfter = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceAfter))
+            return;
+
+        std::uint64_t const sharesAfter = issuanceAfter->getFieldU64(sfOutstandingAmount);
+        Number const availableAfter = vaultAfter->at(sfAssetsAvailable);
+        Number const totalAfter = vaultAfter->at(sfAssetsTotal);
+        Number const lossAfter = vaultAfter->at(sfLossUnrealized);
+
+        if (!withFix)
+        {
+            // Pre-fix: rejected — vault state unchanged.
+            BEAST_EXPECT(sharesAfter == f.sharesLender);
+            BEAST_EXPECT(availableAfter == availableBefore);
+            BEAST_EXPECT(totalAfter == totalBefore);
+            BEAST_EXPECT(lossAfter == lossBefore);
+            return;
+        }
+
+        // Post-fix exact-value derivation (fixture: sharesLender=5e9,
+        // totalBefore=6666.5, request=1000):
+        //   sharesRedeemed = round(sharesLender * request / totalBefore)
+        //                  = round(750,018,750.469) = 750,018,750
+        //   received       = totalBefore * sharesRedeemed / sharesLender
+        //                  = 999.999999375  (slightly under 1,000 due to
+        //                                    integer-share rounding)
+        constexpr std::uint64_t kExpectedSharesRedeemed = 750'018'750;
+        Number const expectedReceived =
+            totalBefore * Number(kExpectedSharesRedeemed) / Number(f.sharesLender);
+
+        BEAST_EXPECT(sharesAfter == f.sharesLender - kExpectedSharesRedeemed);
+
+        // LossUnrealized is unchanged: the loan-protocol side is untouched.
+        BEAST_EXPECT(lossAfter == lossBefore);
+
+        // The entire (total - available) gap is the impaired receivable,
+        // i.e. equal to lossUnrealized.
+        BEAST_EXPECT(totalAfter - availableAfter == lossAfter);
+
+        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
+        Number const received{lenderBalanceAfter - lenderBalanceBefore};
+        BEAST_EXPECT(received == expectedReceived);
+
+        // Conservation: assets removed from the vault equal what the
+        // depositor received.
+        BEAST_EXPECT(totalBefore - totalAfter == received);
+        BEAST_EXPECT(availableBefore - availableAfter == received);
+    }
+
+    // Sole shareholder attempts to burn ALL outstanding shares via
+    // fixed-shares input while the vault still holds an impaired
+    // receivable. Pre-fix this fails with the zero-sized-vault invariant
+    // violation. Post-fix the full-price rate causes assetsWithdrawn to
+    // equal assetsTotal, which exceeds assetsAvailable, so the transaction
+    // is rejected with tecINSUFFICIENT_FUNDS.
+    void
+    testWithdrawSoleShareholderFullSharesRejected(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_2_0];
+        testcase(
+            std::string{"Vault withdraw: sole shareholder full-shares "
+                        "burn is rejected while loss outstanding"} +
+            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
+
+        std::string logs;
+        Env env(*this, features, std::make_unique(&logs));
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+
+        auto const vaultBefore = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
+        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
+        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
+
+        // Fixed-shares input: ask for ALL outstanding shares.
+        STAmount const shareAmt{MPTIssue{f.shareAsset}, Number(f.sharesLender)};
+        Vault const v{env};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = shareAmt,
+            }),
+            Ter(withFix ? TER{tecINSUFFICIENT_FUNDS} : TER{tecINVARIANT_FAILED}));
+        env.close();
+
+        // Either way the transaction was rejected; vault state unchanged.
+        auto const vaultAfter = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceAfter))
+            return;
+        BEAST_EXPECT(issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender);
+        BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == availableBefore);
+        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
+        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
+    }
+
+    // Clean-state regression: with no impaired loan, a sole shareholder
+    // burning all their shares fully empties the vault under both the
+    // pre-fix and post-fix code paths. Confirms the new logic doesn't
+    // break the existing happy-path close-out.
+    void
+    testWithdrawSoleShareholderCleanVaultUnaffected(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_2_0];
+        testcase(
+            std::string{"Vault withdraw: sole shareholder clean-state "
+                        "close-out unchanged"} +
+            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
+
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+
+        env.fund(XRP(kStuckFunding), issuer, lender);
+        env.close();
+
+        PrettyAsset const asset = issuer[iouCurrency_];
+        env(trust(lender, asset(10'000'000)));
+        env.close();
+        env(pay(issuer, lender, asset(kStuckDepositorIOU)));
+        env.close();
+
+        // Sole shareholder of a clean vault — no loan broker needed.
+        Vault const v{env};
+        auto [createTx, vaultKeylet] = v.create({.owner = lender, .asset = asset});
+        env(createTx);
+        env.close();
+
+        env(v.deposit({
+                .depositor = lender,
+                .id = vaultKeylet.key,
+                .amount = asset(kStuckDeposit),
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultBefore = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        auto const shareAsset = vaultBefore->at(sfShareMPTID);
+        auto const tokenLender = env.le(keylet::mptoken(shareAsset, lender.id()));
+        if (!BEAST_EXPECT(tokenLender))
+            return;
+        std::uint64_t const sharesLender = tokenLender->getFieldU64(sfMPTAmount);
+
+        // Sole shareholder, no loans, no loss. Burn everything.
+        STAmount const allShares{MPTIssue{shareAsset}, Number(sharesLender)};
+        env(v.withdraw({
+                .depositor = lender,
+                .id = vaultKeylet.key,
+                .amount = allShares,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultFinal = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultFinal))
+            return;
+        auto const issuanceFinal = env.le(keylet::mptokenIssuance(shareAsset));
+        if (!BEAST_EXPECT(issuanceFinal))
+            return;
+        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
+
+        // (Pre-fix path takes the regular code path; post-fix path enters
+        // the new final-withdrawal guard, which forces payout to exactly
+        // assetsAvailable. Either way the result is identical for a clean
+        // vault.)
+        (void)withFix;
+    }
+
+    // Sole shareholder in an impaired vault redeems a *partial* count of
+    // shares via fixed-shares input. Pre-fix the discounted formula is
+    // used; post-fix the full-price formula is used (waiveUnrealizedLoss
+    // = Yes). The relative payout therefore differs, and post-fix the
+    // depositor recovers proportionally more of the residual cash for
+    // the shares burned. In both cases the vault is left in a valid
+    // (non-empty) state.
+    void
+    testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice()
+    {
+        using namespace test::jtx;
+
+        testcase(
+            "Vault withdraw: sole-shareholder partial fixed-shares uses "
+            "full-price rate (fixCleanup3_2_0)");
+
+        Env env(*this, all_ | fixCleanup3_2_0);
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+        PrettyAsset const& asset = *f.asset;
+
+        auto const vaultBefore = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
+        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
+        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
+
+        // Burn exactly half of the outstanding shares.
+        std::uint64_t const halfShares = f.sharesLender / 2;
+        STAmount const halfAmt{MPTIssue{f.shareAsset}, Number(halfShares)};
+
+        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
+
+        Vault const v{env};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = halfAmt,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Expected payout under the full-price formula:
+        //   assets = totalBefore * halfShares / sharesLender
+        // which (with halfShares == sharesLender/2) is roughly
+        //   totalBefore / 2.
+        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
+        Number const received{lenderBalanceAfter - lenderBalanceBefore};
+        Number const expected = totalBefore * Number(halfShares) / Number(f.sharesLender);
+        BEAST_EXPECT(received == expected);
+
+        // The full-price payout exceeds the discounted formula by exactly
+        // lossBefore * halfShares / sharesLender — that's the whole point
+        // of the waive.
+        Number const discounted =
+            (totalBefore - lossBefore) * Number(halfShares) / Number(f.sharesLender);
+        Number const expectedDelta = lossBefore * Number(halfShares) / Number(f.sharesLender);
+        BEAST_EXPECT(received - discounted == expectedDelta);
+
+        auto const vaultAfter = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceAfter))
+            return;
+
+        // Vault remains valid: half the shares remain, lossUnrealized
+        // is untouched, and the entire (total - available) gap is still
+        // the impaired receivable.
+        BEAST_EXPECT(
+            issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender - halfShares);
+        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore - received);
+        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
+        BEAST_EXPECT(
+            vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
+            vaultAfter->at(sfLossUnrealized));
+
+        // Conservation: vault delta matches the depositor's gain.
+        BEAST_EXPECT(totalBefore - vaultAfter->at(sfAssetsTotal) == received);
+        BEAST_EXPECT(availableBefore - vaultAfter->at(sfAssetsAvailable) == received);
+    }
+
+    // Post-fix end-to-end resolution: after the sole-shareholder partial
+    // exit, the loan is repaid in full. With unrealized loss cleared and
+    // all assets back as cash, the depositor can burn all remaining
+    // shares and fully exit the vault. The final withdrawal hits the
+    // "force payout to assetsAvailable" branch in doApply.
+    void
+    testWithdrawSoleShareholderLoanRepaymentExit()
+    {
+        using namespace test::jtx;
+        using namespace loan;
+
+        testcase(
+            "Vault withdraw: sole shareholder fully exits after impaired "
+            "loan is repaid (fixCleanup3_2_0)");
+
+        Env env(*this, all_ | fixCleanup3_2_0);
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+        Keylet const& loanKey = *f.loanKeylet;
+        PrettyAsset const& asset = *f.asset;
+
+        Vault const v{env};
+
+        // Sole-shareholder partial exit (see comment in
+        // testWithdrawSoleShareholderFixedAssetExit for why we request
+        // less than full AssetsAvailable).
+        {
+            STAmount const requestAssets = asset(1000).value();
+            env(v.withdraw({
+                    .depositor = f.lender,
+                    .id = vaultKey.key,
+                    .amount = requestAssets,
+                }),
+                Ter(tesSUCCESS));
+            env.close();
+        }
+
+        // Confirm the "dormant-but-alive" state from the design doc. The
+        // partial exit burned exactly 750,018,750 shares (see derivation
+        // in testWithdrawSoleShareholderFixedAssetExit).
+        auto const tokenAfterExit = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
+        if (!BEAST_EXPECT(tokenAfterExit))
+            return;
+        std::uint64_t const retainedShares = tokenAfterExit->getFieldU64(sfMPTAmount);
+        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).
+        env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfterRepay = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfterRepay))
+            return;
+        // Repayment converts the 3,333 receivable back to cash; assetsTotal
+        // is unchanged but assetsAvailable jumps by exactly the same amount,
+        // and lossUnrealized clears to zero.
+        BEAST_EXPECT(vaultAfterRepay->at(sfLossUnrealized) == beast::kZero);
+        BEAST_EXPECT(vaultAfterRepay->at(sfAssetsAvailable) == vaultAfterRepay->at(sfAssetsTotal));
+
+        STAmount const lenderBalanceBeforeFinal = env.balance(f.lender, asset);
+        Number const availableBeforeFinal = vaultAfterRepay->at(sfAssetsAvailable);
+
+        // Burn all remaining shares — the clean-state preconditions of
+        // the "final withdrawal" guard are now satisfied.
+        STAmount const allShares{MPTIssue{f.shareAsset}, Number(retainedShares)};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = allShares,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultFinal = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultFinal))
+            return;
+        auto const issuanceFinal = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceFinal))
+            return;
+
+        // Zero-sized vault invariant satisfied: 0 shares, 0 assets.
+        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
+
+        // The final payout equals exactly the AssetsAvailable that
+        // existed before the call (the "force payout" branch).
+        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
+        Number const finalReceived{lenderBalanceAfter - lenderBalanceBeforeFinal};
+        BEAST_EXPECT(finalReceived == availableBeforeFinal);
+    }
+
+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_);
+        testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
+        testWithdrawSoleShareholderLoanRepaymentExit();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultSoleShareholder, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultTestBase.h b/src/test/app/vault/VaultTestBase.h
new file mode 100644
index 0000000000..538f3b72d8
--- /dev/null
+++ b/src/test/app/vault/VaultTestBase.h
@@ -0,0 +1,120 @@
+#pragma once
+
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+/**
+ * Shared base for the Vault*_test family under src/test/app/vault/.
+ *
+ * Owns the class-level helpers (type aliases, closed-ended vault
+ * scaffolding, standard feature bitset, IOU currency string) that every
+ * topical Vault*_test suite depends on. Mirrors
+ * src/test/app/lending/LoanTestBase.h.
+ *
+ * Run all suites in this family with `xrpld -u Vault` (the "Vault" prefix
+ * is matched against every suite name via
+ * beast::unit_test::Selector::ModeT::Automatch).
+ */
+class VaultTestBase : public beast::unit_test::Suite
+{
+protected:
+    using PrettyAsset = test::jtx::PrettyAsset;
+    using PrettyAmount = test::jtx::PrettyAmount;
+
+    static constexpr auto kNegativeAmount = [](PrettyAsset const& asset) -> PrettyAmount {
+        return {STAmount{asset.raw(), 1ul, 0, true, STAmount::Unchecked{}}, ""};
+    };
+
+    /**
+     * Get the current ledger's close time resolution.
+     * @param env The test environment.
+     */
+    static NetClock::duration
+    getLedgerTimeResolution(test::jtx::Env& env)
+    {
+        return env.current()->header().closeTimeResolution;
+    }
+
+    void
+    closeToTime(
+        test::jtx::Env& env,
+        NetClock::time_point time,
+        std::source_location const& loc = std::source_location::current())
+    {
+        using namespace std::chrono_literals;
+        env.close(time - env.closed()->header().closeTimeResolution + 1s);
+        expect(
+            env.closed()->header().closeTime == time,
+            std::format(
+                "current ledger time {} is not equal to the target ledger time {}",
+                env.closed()->header().closeTime.time_since_epoch(),
+                time.time_since_epoch()),
+            loc.file_name(),
+            loc.line());
+    }
+
+    using d = NetClock::duration;
+    using tp = NetClock::time_point;
+
+    // Vault holds an Env& so no default initializer is possible; the
+    // struct is always aggregate-initialized by makeClosedEndedVault.
+    // NOLINTBEGIN(cppcoreguidelines-pro-type-member-init)
+    struct ClosedEndedSetup
+    {
+        test::jtx::Vault vault;
+        Keylet keylet;
+        std::uint32_t sub = 0;
+        std::uint32_t red = 0;
+    };
+    // NOLINTEND(cppcoreguidelines-pro-type-member-init)
+
+    // Submit a VaultCreate for a closed-ended vault with SubscriptionDate at
+    // env.now() + subOffset and RedemptionDate at SubscriptionDate + gap, then
+    // close the ledger. Returns the Vault helper, the vault's keylet and the
+    // resolved sub/red timestamps.
+    static ClosedEndedSetup
+    makeClosedEndedVault(
+        test::jtx::Env& env,
+        test::jtx::Account const& owner,
+        Asset const& asset,
+        std::uint32_t subOffset,
+        std::uint32_t gap)
+    {
+        auto const sub = env.now().time_since_epoch().count() + subOffset;
+        auto const red = sub + gap;
+        test::jtx::Vault const vault{env};
+        auto [tx, keylet] = vault.create(
+            {.owner = owner,
+             .asset = asset,
+             .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
+             .subscriptionDate = sub,
+             .redemptionDate = red});
+        env(tx);
+        env.close();
+        return {.vault = vault, .keylet = keylet, .sub = sub, .red = red};
+    }
+
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+    std::string const iouCurrency_{"IOU"};
+};
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultValidation_test.cpp b/src/test/app/vault/VaultValidation_test.cpp
new file mode 100644
index 0000000000..4219ce4661
--- /dev/null
+++ b/src/test/app/vault/VaultValidation_test.cpp
@@ -0,0 +1,1086 @@
+#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 {
+
+class VaultValidation_test : public VaultTestBase
+{
+private:
+    void
+    testPreflight()
+    {
+        using namespace test::jtx;
+
+        struct CaseArgs
+        {
+            FeatureBitset features = testableAmendments();
+        };
+
+        auto testCase = [&, this](
+                            std::function test,
+                            CaseArgs args = {}) {
+            Env env{*this, args.features};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Vault vault{env};
+            env.fund(XRP(1000), issuer, owner);
+            env.close();
+
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env(fset(issuer, asfRequireAuth));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env(trust(owner, asset(1000)));
+            env(trust(issuer, asset(0), owner, tfSetfAuth));
+            env(pay(issuer, owner, asset(1000)));
+            env.close();
+
+            test(env, issuer, owner, asset, vault);
+        };
+
+        auto testDisabled = [&](TER resultAfterCreate = temDISABLED) {
+            return [&, resultAfterCreate](
+                       Env& env,
+                       Account const& issuer,
+                       Account const& owner,
+                       Asset const& asset,
+                       Vault& vault) {
+                testcase("disabled single asset vault");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx, Ter{temDISABLED});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    env(tx, kData("test"), Ter{resultAfterCreate});
+                }
+
+                {
+                    auto tx =
+                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                    env(tx, Ter{resultAfterCreate});
+                }
+
+                {
+                    auto tx =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                    env(tx, Ter{resultAfterCreate});
+                }
+
+                {
+                    auto tx = vault.clawback(
+                        {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
+                    env(tx, Ter{resultAfterCreate});
+                }
+
+                {
+                    auto tx = vault.del({.owner = owner, .id = keylet.key});
+                    env(tx, Ter{resultAfterCreate});
+                }
+            };
+        };
+
+        testCase(testDisabled(), {.features = testableAmendments() - featureSingleAssetVault});
+
+        testCase(testDisabled(tecNO_ENTRY), {.features = testableAmendments() - featureMPTokensV1});
+
+        testCase(
+            [&](Env& env,
+                Account const& issuer,
+                Account const& owner,
+                Asset const& asset,
+                Vault& vault) {
+                testcase("disabled permissioned domains");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+
+                tx[sfFlags] = tx[sfFlags].asUInt() | tfVaultPrivate;
+                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+                env(tx, Ter{temDISABLED});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    env(tx, kData("Test"));
+
+                    tx[sfDomainID] = to_string(BaseUInt<256>(13ul));
+                    env(tx, Ter{temDISABLED});
+                }
+            },
+            {.features = testableAmendments() - featurePermissionedDomains});
+
+        testCase([&](Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("invalid flags");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfFlags] = tfClearDeepFreeze;
+            env(tx, Ter{temINVALID_FLAG});
+
+            {
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfFlags] = tfClearDeepFreeze;
+                env(tx, Ter{temINVALID_FLAG});
+            }
+
+            {
+                auto tx =
+                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                tx[sfFlags] = tfClearDeepFreeze;
+                env(tx, Ter{temINVALID_FLAG});
+            }
+
+            {
+                auto tx =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                tx[sfFlags] = tfClearDeepFreeze;
+                env(tx, Ter{temINVALID_FLAG});
+            }
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
+                tx[sfFlags] = tfClearDeepFreeze;
+                env(tx, Ter{temINVALID_FLAG});
+            }
+
+            {
+                auto tx = vault.del({.owner = owner, .id = keylet.key});
+                tx[sfFlags] = tfClearDeepFreeze;
+                env(tx, Ter{temINVALID_FLAG});
+            }
+        });
+
+        testCase([&](Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("invalid fee");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[jss::Fee] = "-1";
+            env(tx, Ter{temBAD_FEE});
+
+            {
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[jss::Fee] = "-1";
+                env(tx, Ter{temBAD_FEE});
+            }
+
+            {
+                auto tx =
+                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                tx[jss::Fee] = "-1";
+                env(tx, Ter{temBAD_FEE});
+            }
+
+            {
+                auto tx =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                tx[jss::Fee] = "-1";
+                env(tx, Ter{temBAD_FEE});
+            }
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
+                tx[jss::Fee] = "-1";
+                env(tx, Ter{temBAD_FEE});
+            }
+
+            {
+                auto tx = vault.del({.owner = owner, .id = keylet.key});
+                tx[jss::Fee] = "-1";
+                env(tx, Ter{temBAD_FEE});
+            }
+        });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const&, Vault& vault) {
+                testcase("disabled permissioned domain");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+                env(tx, Ter{temDISABLED});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+                    env(tx, Ter{temDISABLED});
+                }
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    tx[sfDomainID] = "0";
+                    env(tx, Ter{temDISABLED});
+                }
+            },
+            {.features = (testableAmendments()) - featurePermissionedDomains});
+
+        testCase([&](Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("use zero vault");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+
+            {
+                auto tx = vault.set({
+                    .owner = owner,
+                    .id = beast::kZero,
+                });
+                env(tx, Ter{temMALFORMED});
+            }
+
+            {
+                auto tx =
+                    vault.deposit({.depositor = owner, .id = beast::kZero, .amount = asset(10)});
+                env(tx, Ter(temMALFORMED));
+            }
+
+            {
+                auto tx =
+                    vault.withdraw({.depositor = owner, .id = beast::kZero, .amount = asset(10)});
+                env(tx, Ter{temMALFORMED});
+            }
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = beast::kZero, .holder = owner, .amount = asset(10)});
+                env(tx, Ter{temMALFORMED});
+            }
+
+            {
+                auto tx = vault.del({
+                    .owner = owner,
+                    .id = beast::kZero,
+                });
+                env(tx, Ter{temMALFORMED});
+            }
+        });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("withdraw to bad destination");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                    tx[jss::Destination] = "0";
+                    env(tx, Ter{temMALFORMED});
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("create with Scale");
+
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    tx[sfScale] = 255;
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    tx[sfScale] = 19;
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                // accepted range from 0 to 18
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    tx[sfScale] = 18;
+                    env(tx);
+                    env.close();
+                    auto const sleVault = env.le(keylet);
+                    BEAST_EXPECT(sleVault);
+                    BEAST_EXPECT((*sleVault)[sfScale] == 18);
+                }
+
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    tx[sfScale] = 0;
+                    env(tx);
+                    env.close();
+                    auto const sleVault = env.le(keylet);
+                    BEAST_EXPECT(sleVault);
+                    BEAST_EXPECT((*sleVault)[sfScale] == 0);
+                }
+
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    env(tx);
+                    env.close();
+                    auto const sleVault = env.le(keylet);
+                    BEAST_EXPECT(sleVault);
+                    BEAST_EXPECT((*sleVault)[sfScale] == 6);
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("create or set invalid data");
+
+                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = tx1;
+                    tx[sfData] = "";
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                {
+                    auto tx = tx1;
+                    // A hexadecimal string of 257 bytes.
+                    tx[sfData] = std::string(514, 'A');
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    tx[sfData] = "";
+                    env(tx, Ter{temMALFORMED});
+                }
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    // A hexadecimal string of 257 bytes.
+                    tx[sfData] = std::string(514, 'A');
+                    env(tx, Ter{temMALFORMED});
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("set nothing updated");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    env(tx, Ter{temMALFORMED});
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("create with invalid metadata");
+
+                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = tx1;
+                    tx[sfMPTokenMetadata] = "";
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                {
+                    auto tx = tx1;
+                    // This metadata is for the share token.
+                    // A hexadecimal string of 1025 bytes.
+                    tx[sfMPTokenMetadata] = std::string(2050, 'B');
+                    env(tx, Ter(temMALFORMED));
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("set negative maximum");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    tx[sfAssetsMaximum] = kNegativeAmount(asset).number();
+                    env(tx, Ter{temMALFORMED});
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("invalid deposit amount");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = vault.deposit(
+                        {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)});
+                    env(tx, Ter(temBAD_AMOUNT));
+                }
+
+                {
+                    auto tx =
+                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(0)});
+                    env(tx, Ter(temBAD_AMOUNT));
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("invalid set immutable flag");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    tx[sfFlags] = tfVaultPrivate;
+                    env(tx, Ter(temINVALID_FLAG));
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("invalid withdraw amount");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = vault.withdraw(
+                        {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)});
+                    env(tx, Ter(temBAD_AMOUNT));
+                }
+
+                {
+                    auto tx =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(0)});
+                    env(tx, Ter(temBAD_AMOUNT));
+                }
+            });
+
+        testCase([&](Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("invalid clawback");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+            // Preclaim only checks for native assets.
+            if (asset.native())
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
+                env(tx, Ter(temMALFORMED));
+            }
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer,
+                     .id = keylet.key,
+                     .holder = owner,
+                     .amount = kNegativeAmount(asset)});
+                env(tx, Ter(temBAD_AMOUNT));
+            }
+        });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("invalid create");
+
+                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = tx1;
+                    tx[sfWithdrawalPolicy] = 0;
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                {
+                    auto tx = tx1;
+                    tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+                    env(tx, Ter{temMALFORMED});
+                }
+
+                {
+                    auto tx = tx1;
+                    tx[sfAssetsMaximum] = kNegativeAmount(asset).number();
+                    env(tx, Ter{temMALFORMED});
+                }
+
+                {
+                    auto tx = tx1;
+                    tx[sfFlags] = tfVaultPrivate;
+                    tx[sfDomainID] = "0";
+                    env(tx, Ter{temMALFORMED});
+                }
+            });
+    }
+
+    // Test for non-asset specific behaviors.
+    void
+    testCreateFailXRP()
+    {
+        using namespace test::jtx;
+
+        auto testCase = [this](
+                            std::function test) {
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+
+            env.fund(XRP(1000), issuer, owner, depositor);
+            env.close();
+            Vault vault{env};
+            Asset const asset = xrpIssue();
+
+            test(env, issuer, owner, depositor, asset, vault);
+        };
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault) {
+            testcase("nothing to set");
+            auto tx = vault.set({.owner = owner, .id = keylet::skip().key});
+            tx[sfAssetsMaximum] = asset(0).number();
+            env(tx, Ter(tecNO_ENTRY));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault) {
+            testcase("nothing to deposit to");
+            auto tx = vault.deposit(
+                {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)});
+            env(tx, Ter(tecNO_ENTRY));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault) {
+            testcase("nothing to withdraw from");
+            auto tx = vault.withdraw(
+                {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)});
+            env(tx, Ter(tecNO_ENTRY));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("nothing to delete");
+            auto tx = vault.del({.owner = owner, .id = keylet::skip().key});
+            env(tx, Ter(tecNO_ENTRY));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            testcase("transaction is good");
+            env(tx);
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfWithdrawalPolicy] = 1;
+            testcase("explicitly select withdrawal policy");
+            env(tx);
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            testcase("insufficient fee");
+            env(tx, Fee(env.current()->fees().base - 1), Ter(telINSUF_FEE_P));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            testcase("insufficient reserve");
+            // It is possible to construct a complicated mathematical
+            // expression for this amount, but it is sadly not easy.
+            env(pay(owner, issuer, XRP(775)));
+            env.close();
+            env(tx, Ter(tecINSUFFICIENT_RESERVE));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfFlags] = tfVaultPrivate;
+            tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+            testcase("non-existing domain");
+            env(tx, Ter{tecOBJECT_NOT_FOUND});
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("cannot set Scale=0");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfScale] = 0;
+            env(tx, Ter{temMALFORMED});
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("cannot set Scale=1");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfScale] = 1;
+            env(tx, Ter{temMALFORMED});
+        });
+    }
+
+    void
+    testCreateFailIOU()
+    {
+        using namespace test::jtx;
+        {
+            {
+                testcase("IOU fail because MPT is disabled");
+                Env env{*this, (testableAmendments() - featureMPTokensV1)};
+                Account const issuer{"issuer"};
+                Account const owner{"owner"};
+                env.fund(XRP(1000), issuer, owner);
+                env.close();
+
+                Vault const vault{env};
+                Asset const asset = issuer["IOU"].asset();
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                env(tx, Ter(temDISABLED));
+                env.close();
+            }
+
+            {
+                testcase("IOU fail create frozen");
+                Env env{*this, testableAmendments()};
+                Account const issuer{"issuer"};
+                Account const owner{"owner"};
+                env.fund(XRP(1000), issuer, owner);
+                env.close();
+                env(fset(issuer, asfGlobalFreeze));
+                env.close();
+
+                Vault const vault{env};
+                Asset const asset = issuer["IOU"].asset();
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                env(tx, Ter(tecFROZEN));
+                env.close();
+            }
+
+            {
+                testcase("IOU fail create no ripling");
+                Env env{*this, testableAmendments()};
+                Account const issuer{"issuer"};
+                Account const owner{"owner"};
+                env.fund(XRP(1000), issuer, owner);
+                env.close();
+                env(fclear(issuer, asfDefaultRipple));
+                env.close();
+
+                Vault const vault{env};
+                Asset const asset = issuer["IOU"].asset();
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx, Ter(terNO_RIPPLE));
+                env.close();
+            }
+
+            {
+                testcase("IOU no issuer");
+                Env env{*this, testableAmendments()};
+                Account const issuer{"issuer"};
+                Account const owner{"owner"};
+                env.fund(XRP(1000), owner);
+                env.close();
+
+                Vault const vault{env};
+                Asset const asset = issuer["IOU"].asset();
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    env(tx, Ter(terNO_ACCOUNT));
+                    env.close();
+                }
+            }
+        }
+
+        {
+            testcase("IOU fail create vault for AMM LPToken");
+            Env env{*this, testableAmendments()};
+            Account const gw("gateway");
+            Account const alice("alice");
+            Account const carol("carol");
+            IOU const usd = gw["USD"];
+
+            auto const [asset1, asset2] = std::pair(XRP(10000), usd(10000));
+            auto toFund = [&](STAmount const& a) -> STAmount {
+                if (a.native())
+                {
+                    auto const defXRP = XRP(30000);
+                    if (a <= defXRP)
+                        return defXRP;
+                    return a + XRP(1000);
+                }
+                auto defIOU = STAmount{a.asset(), 30000};
+                if (a <= defIOU)
+                    return defIOU;
+                return a + STAmount{a.asset(), 1000};
+            };
+            auto const toFund1 = toFund(asset1);
+            auto const toFund2 = toFund(asset2);
+            BEAST_EXPECT(asset1 <= toFund1 && asset2 <= toFund2);
+
+            if (!asset1.native() && !asset2.native())
+            {
+                fund(env, gw, {alice, carol}, {toFund1, toFund2}, Fund::All);
+            }
+            else if (asset1.native())
+            {
+                fund(env, gw, {alice, carol}, toFund1, {toFund2}, Fund::All);
+            }
+            else if (asset2.native())
+            {
+                fund(env, gw, {alice, carol}, toFund2, {toFund1}, Fund::All);
+            }
+
+            AMM const ammAlice(env, alice, asset1, asset2, CreateArg{.log = false, .tfee = 0});
+
+            Account const owner{"owner"};
+            env.fund(XRP(1000000), owner);
+
+            Vault const vault{env};
+            auto [tx, k] = vault.create({.owner = owner, .asset = ammAlice.lptIssue()});
+            env(tx, Ter{tecWRONG_ASSET});
+            env.close();
+        }
+    }
+
+    void
+    testCreateFailMPT()
+    {
+        using namespace test::jtx;
+
+        auto testCase = [this](
+                            std::function test) {
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(1000), issuer, owner, depositor);
+            env.close();
+            Vault vault{env};
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            // Locked because that is the default flag.
+            mptt.create();
+            Asset const asset = mptt.issuanceID();
+
+            test(env, issuer, owner, depositor, asset, vault);
+        };
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("MPT no authorization");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tecNO_AUTH));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("MPT cannot set Scale=0");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfScale] = 0;
+            env(tx, Ter{temMALFORMED});
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("MPT cannot set Scale=1");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfScale] = 1;
+            env(tx, Ter{temMALFORMED});
+        });
+    }
+
+    void
+    testVaultDeleteMemoData()
+    {
+        using namespace test::jtx;
+
+        Env env{*this};
+
+        Account const owner{"owner"};
+        env.fund(XRP(1'000'000), owner);
+        env.close();
+
+        Vault const vault{env};
+
+        auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(1));
+        auto delTx = vault.del({.owner = owner, .id = keylet.key});
+
+        // Test VaultDelete with featureLendingProtocolV1_1 disabled
+        // Transaction fails if the data field is provided
+        {
+            testcase("VaultDelete memo data featureLendingProtocolV1_1 disabled");
+            env.disableFeature(featureLendingProtocolV1_1);
+            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
+            env(delTx, Ter(temDISABLED));
+            env.enableFeature(featureLendingProtocolV1_1);
+            env.close();
+        }
+
+        // Transaction fails if the data field is too large
+        {
+            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data too large");
+            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength + 1, 'A'));
+            env(delTx, Ter(temMALFORMED));
+            env.close();
+        }
+
+        // Transaction fails if the data field is set, but is empty
+        {
+            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data empty");
+            delTx[sfMemoData] = strHex(std::string());
+            env(delTx, Ter(temMALFORMED));
+            env.close();
+        }
+
+        {
+            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled no vault");
+            auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+
+            // Recreate the transaction as the vault keylet changed
+            auto delTx = vault.del({.owner = owner, .id = keylet.key});
+            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
+            env(delTx, Ter(tecNO_ENTRY));
+            env.close();
+        }
+
+        {
+            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data valid");
+            PrettyAsset const xrpAsset = xrpIssue();
+            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+            // Recreate the transaction as the vault keylet changed
+            auto delTx = vault.del({.owner = owner, .id = keylet.key});
+            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
+            env(delTx, Ter(tesSUCCESS));
+            env.close();
+        }
+    }
+
+    void
+    testVaultCreateLEVersion()
+    {
+        using namespace test::jtx;
+
+        Account const owner{"owner"};
+        PrettyAsset const xrpAsset = xrpIssue();
+
+        {
+            testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent");
+            Env env{*this};
+            env.disableFeature(featureLendingProtocolV1_1);
+            env.fund(XRP(1'000'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            BEAST_EXPECT(sleVault);
+            BEAST_EXPECT(!sleVault->isFieldPresent(sfLEVersion));
+        }
+
+        {
+            testcase(
+                "VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == "
+                "VaultVersion::CashBasis");
+            Env env{*this};
+            env.fund(XRP(1'000'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            BEAST_EXPECT(sleVault);
+            BEAST_EXPECT(sleVault->isFieldPresent(sfLEVersion));
+            BEAST_EXPECT(sleVault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis));
+        }
+
+        {
+            testcase("VaultCreate rejects LEVersion set in the transaction");
+            Env env{*this};
+            env.fund(XRP(1'000'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            tx[sfLEVersion] = 2;
+            env(tx, Ter(temMALFORMED));
+            env.close();
+
+            BEAST_EXPECT(!env.le(keylet));
+        }
+
+        {
+            testcase("VaultSet rejects LEVersion set in the transaction");
+            Env env{*this};
+            env.fund(XRP(1'000'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto const [createTx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            env(createTx, Ter(tesSUCCESS));
+            env.close();
+
+            auto setTx = vault.set({.owner = owner, .id = keylet.key});
+            setTx[sfLEVersion] = 2;
+            env(setTx, Ter(temMALFORMED));
+            env.close();
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testPreflight();
+        testCreateFailXRP();
+        testCreateFailIOU();
+        testCreateFailMPT();
+        testVaultDeleteMemoData();
+        testVaultCreateLEVersion();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultValidation, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/basics/PerfLog_test.cpp b/src/test/basics/PerfLog_test.cpp
index 24ea971515..f7679dc488 100644
--- a/src/test/basics/PerfLog_test.cpp
+++ b/src/test/basics/PerfLog_test.cpp
@@ -15,14 +15,10 @@
 #include 
 #include 
 
-#include 
-#include 
-#include 
-#include 
-
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -31,6 +27,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -43,7 +40,7 @@ class PerfLog_test : public beast::unit_test::Suite
 {
     enum class WithFile : bool { No = false, Yes = true };
 
-    using path = boost::filesystem::path;
+    using path = std::filesystem::path;
 
     // We're only using Env for its Journal.  That Journal gives better
     // coverage in unit tests.
@@ -66,14 +63,14 @@ class PerfLog_test : public beast::unit_test::Suite
             // The error code is intentionally ignored: if the path doesn't
             // exist (the common case on a clean runner) remove_all returns
             // an error, and that's fine — there's nothing to clean up.
-            using namespace boost::filesystem;
-            boost::system::error_code ec;
+            using namespace std::filesystem;
+            std::error_code ec;
             remove_all(logDir(), ec);
         }
 
         ~Fixture()
         {
-            using namespace boost::filesystem;
+            using namespace std::filesystem;
 
             auto const dir{logDir()};
             auto const file{logFile()};
@@ -96,7 +93,7 @@ class PerfLog_test : public beast::unit_test::Suite
         static path
         logDir()
         {
-            using namespace boost::filesystem;
+            using namespace std::filesystem;
             return temp_directory_path() / "perf_log_test_dir";
         }
 
@@ -129,7 +126,7 @@ class PerfLog_test : public beast::unit_test::Suite
         static void
         wait()
         {
-            using namespace boost::filesystem;
+            using namespace std::filesystem;
 
             auto const path = logFile();
             if (!exists(path))
@@ -201,7 +198,7 @@ public:
     void
     testFileCreation()
     {
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
 
         {
             // Verify a PerfLog creates its file when constructed.
@@ -250,28 +247,30 @@ public:
             // Put a write protected file where PerfLog wants to write its
             // file.  Make sure that PerfLog tries to shutdown the server
             // since it can't open its file.
+            using std::filesystem::perms;
+
             Fixture fixture{env_.app(), j_};
             if (!BEAST_EXPECT(!exists(fixture.logDir())))
                 return;
 
             // Construct and write protect a file to prevent PerfLog
             // from creating its file.
-            boost::system::error_code ec;
-            boost::filesystem::create_directories(fixture.logDir(), ec);
+            std::error_code ec;
+            std::filesystem::create_directories(fixture.logDir(), ec);
             if (!BEAST_EXPECT(!ec))
                 return;
 
-            auto fileWriteable = [](boost::filesystem::path const& p) -> bool {
-                return std::ofstream{p.c_str(), std::ios::out | std::ios::app}.is_open();
+            auto fileWriteable = [](std::filesystem::path const& p) -> bool {
+                return std::ofstream{p, std::ios::out | std::ios::app}.is_open();
             };
 
             if (!BEAST_EXPECT(fileWriteable(fixture.logFile())))
                 return;
 
-            boost::filesystem::permissions(
+            std::filesystem::permissions(
                 fixture.logFile(),
-                perms::remove_perms | perms::owner_write | perms::others_write |
-                    perms::group_write);
+                perms::owner_write | perms::others_write | perms::group_write,
+                std::filesystem::perm_options::remove);
 
             // If the test is running as root, then the write protect may have
             // no effect.  Make sure write protect worked before proceeding.
@@ -295,9 +294,10 @@ public:
             perfLog->stop();
 
             // Fix file permissions so the file can be cleaned up.
-            boost::filesystem::permissions(
+            std::filesystem::permissions(
                 fixture.logFile(),
-                perms::add_perms | perms::owner_write | perms::others_write | perms::group_write);
+                perms::owner_write | perms::others_write | perms::group_write,
+                std::filesystem::perm_options::add);
         }
     }
 
@@ -962,7 +962,7 @@ public:
         // We can't fully test rotate because unit tests must run on Windows,
         // and Windows doesn't (may not?) support rotate.  But at least call
         // the interface and see that it doesn't crash.
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
 
         Fixture fixture{env_.app(), j_};
         BEAST_EXPECT(!exists(fixture.logDir()));
diff --git a/src/test/core/Config_test.cpp b/src/test/core/Config_test.cpp
index ac5471fd3c..5ed5ef4049 100644
--- a/src/test/core/Config_test.cpp
+++ b/src/test/core/Config_test.cpp
@@ -3,16 +3,13 @@
 
 #include 
 
+#include 
 #include 
-#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
 #include 
 
-#include 
-#include   // IWYU pragma: keep
-#include 
 #include 
 
 #include 
@@ -20,6 +17,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -36,7 +35,7 @@ namespace detail {
 std::string
 configContents(std::string const& dbPath, std::string const& validatorsFile)
 {
-    static boost::format kConfigContentsTemplate(R"xrpldConfig(
+    static constexpr char const* kConfigContentsTemplate = R"xrpldConfig(
 [server]
 port_rpc
 port_peer
@@ -83,9 +82,9 @@ cache_mb=256
 file_size_mb=8
 file_size_mult=2
 
-%1%
+{}
 
-%2%
+{}
 
 # This needs to be an absolute directory reference, not a relative one.
 # Modify this value as required.
@@ -106,7 +105,7 @@ r.ripple.com 51235
 # Turn down default logging to save disk space in the long run.
 # Valid values here are trace, debug, info, warning, error, and fatal
 [rpc_startup]
-{ "command": "log_level", "severity": "warning" }
+{{ "command": "log_level", "severity": "warning" }}
 
 # Defaults to 1 ("yes") so that certificates will be validated. To allow the use
 # of self-signed certificates for development or internal use, set to 0 ("no").
@@ -115,12 +114,12 @@ r.ripple.com 51235
 
 [sqdb]
 backend=sqlite
-)xrpldConfig");
+)xrpldConfig";
 
     std::string dbPathSection = dbPath.empty() ? "" : "[database_path]\n" + dbPath;
     std::string valFileSection =
         validatorsFile.empty() ? "" : "[validators_file]\n" + validatorsFile;
-    return boost::str(kConfigContentsTemplate % dbPathSection % valFileSection);
+    return std::format(kConfigContentsTemplate, dbPathSection, valFileSection);
 }
 
 /**
@@ -179,7 +178,7 @@ public:
     [[nodiscard]] bool
     dataDirExists() const
     {
-        return boost::filesystem::is_directory(dataDir_);
+        return std::filesystem::is_directory(dataDir_);
     }
 
     [[nodiscard]] bool
@@ -192,7 +191,7 @@ public:
     {
         try
         {
-            using namespace boost::filesystem;
+            using namespace std::filesystem;
             if (rmDataDir_)
                 rmDir(dataDir_);
         }
@@ -273,7 +272,7 @@ public:
 class Config_test final : public TestSuite
 {
 private:
-    using path = boost::filesystem::path;
+    using path = std::filesystem::path;
 
 public:
     void
@@ -309,7 +308,7 @@ port_wss_admin
     {
         testcase("config_file");
 
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
         auto const cwd = current_path();
 
         // Test both config file names.
@@ -319,7 +318,7 @@ port_wss_admin
         for (auto const& configFile : configFiles)
         {
             // Use a temporary directory for testing.
-            beast::TempDir const td;
+            TempDir const td;
             current_path(td.path());
             path const f = td.file(std::string{configFile});
             std::ofstream o(f.string());
@@ -341,13 +340,13 @@ port_wss_admin
         {
             // Point the current working directory to a temporary directory, so
             // we don't pick up an actual config file from the repository root.
-            beast::TempDir const td;
+            TempDir const td;
             current_path(td.path());
 
             // The XDG config directory is set: the config file must be in a
             // subdirectory named after the system.
             {
-                beast::TempDir const tc;
+                TempDir const tc;
 
                 // Set the HOME and XDG_CONFIG_HOME environment variables. The
                 // HOME variable is not used when XDG_CONFIG_HOME is set, but
@@ -381,7 +380,7 @@ port_wss_admin
             // The XDG config directory is not set: the config file must be in a
             // subdirectory named .config followed by the system name.
             {
-                beast::TempDir const tc;
+                TempDir const tc;
 
                 // Set only the HOME environment variable.
                 char const* h = getenv("HOME");
@@ -425,9 +424,9 @@ port_wss_admin
     {
         testcase("database_path");
 
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
         {
-            boost::format cc("[database_path]\n%1%\n");
+            constexpr char const* cc = "[database_path]\n{}\n";
 
             auto const cwd = current_path();
             path const dataDirRel("test_data_dir");
@@ -435,13 +434,13 @@ port_wss_admin
             {
                 // Dummy test - do we get back what we put in
                 Config c;
-                c.loadFromString(boost::str(cc % dataDirAbs.string()));
+                c.loadFromString(std::format(cc, dataDirAbs.string()));
                 BEAST_EXPECT(c.legacy(Sections::kDatabasePath) == dataDirAbs.string());
             }
             {
                 // Rel paths should convert to abs paths
                 Config c;
-                c.loadFromString(boost::str(cc % dataDirRel.string()));
+                c.loadFromString(std::format(cc, dataDirRel.string()));
                 BEAST_EXPECT(c.legacy(Sections::kDatabasePath) == dataDirAbs.string());
             }
             {
@@ -508,20 +507,20 @@ port_wss_admin
 
         {
             Config c;
-            static boost::format kConfigTemplate(R"xrpldConfig(
+            static constexpr char const* kConfigTemplate = R"xrpldConfig(
 [validation_seed]
-%1%
+{}
 
 [validator_token]
-%2%
-)xrpldConfig");
+{}
+)xrpldConfig";
             std::string error;
             auto const expectedError =
                 "Cannot have both [validation_seed] "
                 "and [validator_token] config sections";
             try
             {
-                c.loadFromString(boost::str(kConfigTemplate % validationSeed % token));
+                c.loadFromString(std::format(kConfigTemplate, validationSeed, token));
             }
             catch (std::runtime_error const& e)
             {
@@ -601,10 +600,10 @@ main
     {
         testcase("validators_file");
 
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
         {
             // load should throw for missing specified validators file
-            boost::format cc("[validators_file]\n%1%\n");
+            constexpr char const* cc = "[validators_file]\n{}\n";
             std::string error;
             std::string const missingPath = "/no/way/this/path/exists";
             auto const expectedError =
@@ -612,7 +611,7 @@ main
             try
             {
                 Config c;
-                c.loadFromString(boost::str(cc % missingPath));
+                c.loadFromString(std::format(cc, missingPath));
             }
             catch (std::runtime_error const& e)
             {
@@ -624,14 +623,14 @@ main
             // load should throw for invalid [validators_file]
             detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg");
             path const invalidFile = current_path() / vtg.subdir();
-            boost::format cc("[validators_file]\n%1%\n");
+            constexpr char const* cc = "[validators_file]\n{}\n";
             std::string error;
             auto const expectedError =
                 "Invalid file specified in [validators_file]: " + invalidFile.string();
             try
             {
                 Config c;
-                c.loadFromString(boost::str(cc % invalidFile.string()));
+                c.loadFromString(std::format(cc, invalidFile.string()));
             }
             catch (std::runtime_error const& e)
             {
@@ -829,8 +828,8 @@ trust-these-validators.gov
             detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg");
             BEAST_EXPECT(vtg.validatorsFileExists());
             Config c;
-            boost::format cc("[validators_file]\n%1%\n");
-            c.loadFromString(boost::str(cc % vtg.validatorsFile()));
+            constexpr char const* cc = "[validators_file]\n{}\n";
+            c.loadFromString(std::format(cc, vtg.validatorsFile()));
             BEAST_EXPECT(c.legacy(Sections::kValidatorsFile) == vtg.validatorsFile());
             BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 8);
             BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 2);
@@ -909,9 +908,9 @@ trust-these-validators.gov
 
         {
             // load validators from both config and validators file
-            boost::format cc(R"xrpldConfig(
+            constexpr char const* cc = R"xrpldConfig(
 [validators_file]
-%1%
+{}
 
 [validators]
 n949f75evCHwgyP4fPVgaHqNHxUVN15PsJEZ3B3HnXPcPjcZAoy7
@@ -930,11 +929,11 @@ trust-these-validators.gov
 
 [validator_list_keys]
 021A99A537FDEBC34E4FCA03B39BEADD04299BB19E85097EC92B15A3518801E566
-)xrpldConfig");
+)xrpldConfig";
             detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg");
             BEAST_EXPECT(vtg.validatorsFileExists());
             Config c;
-            c.loadFromString(boost::str(cc % vtg.validatorsFile()));
+            c.loadFromString(std::format(cc, vtg.validatorsFile()));
             BEAST_EXPECT(c.legacy(Sections::kValidatorsFile) == vtg.validatorsFile());
             BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 15);
             BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 4);
@@ -945,13 +944,13 @@ trust-these-validators.gov
         {
             // load should throw if [validator_list_threshold] is present both
             // in xrpld.cfg and validators file
-            boost::format cc(R"xrpldConfig(
+            constexpr char const* cc = R"xrpldConfig(
 [validators_file]
-%1%
+{}
 
 [validator_list_threshold]
 1
-)xrpldConfig");
+)xrpldConfig";
             std::string error;
             detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg");
             BEAST_EXPECT(vtg.validatorsFileExists());
@@ -961,7 +960,7 @@ trust-these-validators.gov
             try
             {
                 Config c;
-                c.loadFromString(boost::str(cc % vtg.validatorsFile()));
+                c.loadFromString(std::format(cc, vtg.validatorsFile()));
                 fail();
             }
             catch (std::runtime_error const& e)
@@ -975,7 +974,7 @@ trust-these-validators.gov
             // [validator_list_keys] are missing from xrpld.cfg and
             // validators file
             Config const c;
-            boost::format cc("[validators_file]\n%1%\n");
+            constexpr char const* cc = "[validators_file]\n{}\n";
             std::string error;
             detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg");
             BEAST_EXPECT(vtg.validatorsFileExists());
@@ -988,7 +987,7 @@ trust-these-validators.gov
             try
             {
                 Config c2;
-                c2.loadFromString(boost::str(cc % vtg.validatorsFile()));
+                c2.loadFromString(std::format(cc, vtg.validatorsFile()));
             }
             catch (std::runtime_error const& e)
             {
diff --git a/src/test/core/SociDB_test.cpp b/src/test/core/SociDB_test.cpp
index 373ec66cd1..a7bb8e71bc 100644
--- a/src/test/core/SociDB_test.cpp
+++ b/src/test/core/SociDB_test.cpp
@@ -6,9 +6,6 @@
 #include 
 #include 
 
-#include 
-#include 
-#include 
 #include   // IWYU pragma: keep
 
 #include   // IWYU pragma: keep
@@ -20,6 +17,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -32,7 +30,7 @@ class SociDB_test final : public TestSuite
 {
 private:
     static void
-    setupSQLiteConfig(BasicConfig& config, boost::filesystem::path const& dbPath)
+    setupSQLiteConfig(BasicConfig& config, std::filesystem::path const& dbPath)
     {
         config.overwrite(Sections::kSqdb, Keys::kBackend, "sqlite");
         auto value = dbPath.string();
@@ -41,18 +39,18 @@ private:
     }
 
     static void
-    cleanupDatabaseDir(boost::filesystem::path const& dbPath)
+    cleanupDatabaseDir(std::filesystem::path const& dbPath)
     {
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
         if (!exists(dbPath) || !is_directory(dbPath) || !is_empty(dbPath))
             return;
         remove(dbPath);
     }
 
     static void
-    setupDatabaseDir(boost::filesystem::path const& dbPath)
+    setupDatabaseDir(std::filesystem::path const& dbPath)
     {
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
         if (!exists(dbPath))
         {
             create_directory(dbPath);
@@ -65,10 +63,10 @@ private:
             Throw("Cannot create directory: " + dbPath.string());
         }
     }
-    static boost::filesystem::path
+    static std::filesystem::path
     getDatabasePath()
     {
-        return boost::filesystem::current_path() / "socidb_test_databases";
+        return std::filesystem::current_path() / "socidb_test_databases";
     }
 
 public:
@@ -108,7 +106,7 @@ public:
         for (auto const& i : d)
         {
             DBConfig const sc(c, i.first);
-            BEAST_EXPECT(boost::ends_with(sc.connectionString(), i.first + i.second));
+            BEAST_EXPECT(sc.connectionString().ends_with(i.first + i.second));
         }
     }
     void
@@ -158,7 +156,7 @@ public:
             checkValues(s);
         }
         {
-            namespace bfs = boost::filesystem;
+            namespace bfs = std::filesystem;
             // Remove the database
             bfs::path const dbPath(sc.connectionString());
             if (bfs::is_regular_file(dbPath))
@@ -232,7 +230,7 @@ public:
             // boost::tuple. DO NOT USE soci row!
         }
         {
-            namespace bfs = boost::filesystem;
+            namespace bfs = std::filesystem;
             // Remove the database
             bfs::path const dbPath(sc.connectionString());
             if (bfs::is_regular_file(dbPath))
@@ -284,7 +282,7 @@ public:
             s << "SELECT LedgerSeq FROM Ledgers;", soci::into(ledgersLS);
             BEAST_EXPECT(ledgersLS.size() == numRows);
         }
-        namespace bfs = boost::filesystem;
+        namespace bfs = std::filesystem;
         // Remove the database
         bfs::path const dbPath(sc.connectionString());
         if (bfs::is_regular_file(dbPath))
diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h
index 5c8486e6c5..801c3627b8 100644
--- a/src/test/jtx/TestHelpers.h
+++ b/src/test/jtx/TestHelpers.h
@@ -43,6 +43,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -315,19 +316,11 @@ auto const kData = JTxFieldWrapper(sfData);
 
 auto const kAmount = JTxFieldWrapper(sfAmount);
 
-// TODO We only need this long "requires" clause as polyfill, for C++20
-// implementations which are missing  header. Replace with
-// `std::ranges::range`, and accordingly use std::ranges::begin/end
-// when we have moved to better compilers.
-template 
+template 
 auto
 makeVector(Input const& input)
-    requires requires(Input& v) {
-        std::begin(v);
-        std::end(v);
-    }
 {
-    return std::vector(std::begin(input), std::end(input));
+    return std::vector(std::ranges::begin(input), std::ranges::end(input));
 }
 
 // Functions used in debugging
diff --git a/src/test/jtx/TrustedPublisherServer.h b/src/test/jtx/TrustedPublisherServer.h
index f5ee8aac3a..941af374ef 100644
--- a/src/test/jtx/TrustedPublisherServer.h
+++ b/src/test/jtx/TrustedPublisherServer.h
@@ -16,7 +16,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -549,7 +548,7 @@ private:
                 res.keep_alive(req.keep_alive());
                 bool prepare = true;
 
-                if (boost::starts_with(path, "/validators2"))
+                if (path.starts_with("/validators2"))
                 {
                     res.result(http::status::ok);
                     res.insert("Content-Type", "application/json");
@@ -565,7 +564,7 @@ private:
                     {
                         int refresh = 5;
                         static constexpr char const* kRefreshPrefix = "/validators2/refresh/";
-                        if (boost::starts_with(path, kRefreshPrefix))
+                        if (path.starts_with(kRefreshPrefix))
                         {
                             refresh = boost::lexical_cast(
                                 path.substr(strlen(kRefreshPrefix)));
@@ -573,7 +572,7 @@ private:
                         res.body() = getList2_(refresh);
                     }
                 }
-                else if (boost::starts_with(path, "/validators"))
+                else if (path.starts_with("/validators"))
                 {
                     res.result(http::status::ok);
                     res.insert("Content-Type", "application/json");
@@ -589,7 +588,7 @@ private:
                     {
                         int refresh = 5;
                         static constexpr char const* kRefreshPrefix = "/validators/refresh/";
-                        if (boost::starts_with(path, kRefreshPrefix))
+                        if (path.starts_with(kRefreshPrefix))
                         {
                             refresh = boost::lexical_cast(
                                 path.substr(strlen(kRefreshPrefix)));
@@ -597,13 +596,13 @@ private:
                         res.body() = getList_(refresh);
                     }
                 }
-                else if (boost::starts_with(path, "/textfile"))
+                else if (path.starts_with("/textfile"))
                 {
                     prepare = false;
                     res.result(http::status::ok);
                     res.insert("Content-Type", "text/example");
                     // if huge was requested, lie about content length
-                    std::uint64_t const cl = boost::starts_with(path, "/textfile/huge")
+                    std::uint64_t const cl = path.starts_with("/textfile/huge")
                         ? std::numeric_limits::max()
                         : 1024;
                     res.content_length(cl);
@@ -617,41 +616,39 @@ private:
                         }
                     }
                 }
-                else if (boost::starts_with(path, "/sleep/"))
+                else if (path.starts_with("/sleep/"))
                 {
                     auto const sleepSec = boost::lexical_cast(path.substr(7));
                     std::this_thread::sleep_for(std::chrono::seconds(sleepSec));
                 }
-                else if (boost::starts_with(path, "/redirect"))
+                else if (path.starts_with("/redirect"))
                 {
-                    if (boost::ends_with(path, "/301"))
+                    if (path.ends_with("/301"))
                     {
                         res.result(http::status::moved_permanently);
                     }
-                    else if (boost::ends_with(path, "/302"))
+                    else if (path.ends_with("/302"))
                     {
                         res.result(http::status::found);
                     }
-                    else if (boost::ends_with(path, "/307"))
+                    else if (path.ends_with("/307"))
                     {
                         res.result(http::status::temporary_redirect);
                     }
-                    else if (boost::ends_with(path, "/308"))
+                    else if (path.ends_with("/308"))
                     {
                         res.result(http::status::permanent_redirect);
                     }
 
                     std::stringstream location;
-                    if (boost::starts_with(path, "/redirect_to/"))
+                    if (path.starts_with("/redirect_to/"))
                     {
                         location << path.substr(13);
                     }
-                    else if (!boost::starts_with(path, "/redirect_nolo"))
+                    else if (!path.starts_with("/redirect_nolo"))
                     {
                         location << (ssl ? "https://" : "http://") << localEndpoint()
-                                 << (boost::starts_with(path, "/redirect_forever/")
-                                         ? path
-                                         : "/validators");
+                                 << (path.starts_with("/redirect_forever/") ? path : "/validators");
                     }
                     if (!location.str().empty())
                         res.insert("Location", location.str());
diff --git a/src/test/jtx/amount.h b/src/test/jtx/amount.h
index 57a4502db9..94dd8aef9e 100644
--- a/src/test/jtx/amount.h
+++ b/src/test/jtx/amount.h
@@ -162,12 +162,6 @@ operator==(PrettyAmount const& lhs, PrettyAmount const& rhs)
     return lhs.value() == rhs.value();
 }
 
-inline bool
-operator!=(PrettyAmount const& lhs, PrettyAmount const& rhs)
-{
-    return !operator==(lhs, rhs);
-}
-
 std::ostream&
 operator<<(std::ostream& os, PrettyAmount const& amount);
 
diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp
index baff576243..978c3864d6 100644
--- a/src/test/jtx/impl/vault.cpp
+++ b/src/test/jtx/impl/vault.cpp
@@ -28,6 +28,12 @@ Vault::create(CreateArgs const& args) const
     jv[jss::Asset] = toJson(args.asset);
     if (args.flags)
         jv[jss::Flags] = *args.flags;
+    if (args.vaultKind)
+        jv[sfVaultKind] = *args.vaultKind;
+    if (args.subscriptionDate)
+        jv[sfSubscriptionDate] = *args.subscriptionDate;
+    if (args.redemptionDate)
+        jv[sfRedemptionDate] = *args.redemptionDate;
     return {jv, keylet};
 }
 
diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h
index e72eae89b7..992051b61f 100644
--- a/src/test/jtx/vault.h
+++ b/src/test/jtx/vault.h
@@ -25,6 +25,12 @@ struct Vault
         Asset asset;
         std::optional flags =
             std::nullopt;  // NOLINT(readability-redundant-member-init)
+        std::optional vaultKind =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
+        std::optional subscriptionDate =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
+        std::optional redemptionDate =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
     };
 
     /**
diff --git a/src/test/overlay/ProtocolVersion_test.cpp b/src/test/overlay/ProtocolVersion_test.cpp
index e31a574502..e7b63a34cb 100644
--- a/src/test/overlay/ProtocolVersion_test.cpp
+++ b/src/test/overlay/ProtocolVersion_test.cpp
@@ -33,22 +33,30 @@ public:
     void
     run() override
     {
-        testcase("Convert protocol version to string");
-        BEAST_EXPECT(to_string(makeProtocol(1, 3)) == "XRPL/1.3");
-        BEAST_EXPECT(to_string(makeProtocol(2, 0)) == "XRPL/2.0");
-        BEAST_EXPECT(to_string(makeProtocol(2, 1)) == "XRPL/2.1");
-        BEAST_EXPECT(to_string(makeProtocol(10, 10)) == "XRPL/10.10");
+        {
+            testcase("Convert protocol version to string");
+
+            BEAST_EXPECT(to_string(makeProtocol(0, 0)) == "XRPL/0.0");
+            BEAST_EXPECT(to_string(makeProtocol(0, 1)) == "XRPL/0.1");
+            BEAST_EXPECT(to_string(makeProtocol(1, 3)) == "XRPL/1.3");
+            BEAST_EXPECT(to_string(makeProtocol(2, 0)) == "XRPL/2.0");
+            BEAST_EXPECT(to_string(makeProtocol(2, 1)) == "XRPL/2.1");
+            BEAST_EXPECT(to_string(makeProtocol(10, 10)) == "XRPL/10.10");
+            BEAST_EXPECT(to_string(makeProtocol(65535, 65535)) == "XRPL/65535.65535");
+        }
 
         {
             testcase("Convert strings to protocol versions");
 
-            // Empty string
+            // Invalid versions, either they do not parse as XRPL/N.M or are unsupported.
             check("", "");
+            check("RTXP/1.1,RTXP/1.2,RTXP/1.3", "");
+            check("XRPL/-2.1,XRPL/0.3,XRPL/2,XRPL/2.01,websocket", "");
 
-            check("RTXP/1.1,RTXP/1.2,RTXP/1.3,XRPL/2.1,XRPL/2.0,/XRPL/3.0", "XRPL/2.0,XRPL/2.1");
-            check("RTXP/0.9,RTXP/1.01,XRPL/0.3,XRPL/2.01,websocket", "");
+            // Mixture of valid, duplicate, and invalid versions.
+            check("RTXP/1.3,XRPL/2.1,XRPL/2.0,/XRPL/3.0", "XRPL/2.0,XRPL/2.1");
             check(
-                "XRPL/2.0,XRPL/2.0,XRPL/19.4,XRPL/7.89,XRPL/XRPL/3.0,XRPL/2.01",
+                "XRPL/2.0,XRPL/2.0,XRPL/19.4,XRPL/7.89,XRPL/XRPL/3.0,XRPL/2.01,XRPL/-65535.65535",
                 "XRPL/2.0,XRPL/7.89,XRPL/19.4");
             check(
                 "XRPL/2.0,XRPL/3.0,XRPL/4,XRPL/,XRPL,OPT XRPL/2.2,XRPL/5.67",
@@ -58,15 +66,17 @@ public:
         {
             testcase("Protocol version negotiation");
 
-            BEAST_EXPECT(negotiateProtocolVersion("RTXP/1.2") == std::nullopt);
+            // Only the highest supported protocol version, if any, is returned.
+            BEAST_EXPECT(negotiateProtocolVersion("") == std::nullopt);
+            BEAST_EXPECT(negotiateProtocolVersion("XRPL/0.0") == std::nullopt);
+            BEAST_EXPECT(negotiateProtocolVersion("RTXP/1.2,XRPL/0.1") == std::nullopt);
             BEAST_EXPECT(
-                negotiateProtocolVersion("RTXP/1.2, XRPL/2.0, XRPL/2.1") == makeProtocol(2, 1));
+                negotiateProtocolVersion("XRPL/999.999, XRPL/-2.2,WebSocket/1.0") == std::nullopt);
             BEAST_EXPECT(negotiateProtocolVersion("XRPL/2.2") == makeProtocol(2, 2));
             BEAST_EXPECT(
-                negotiateProtocolVersion("RTXP/1.2, XRPL/2.3, XRPL/2.4, XRPL/999.999") ==
+                negotiateProtocolVersion(
+                    "RTXP/1.2, XRPL/2.1, XRPL/2.2, XRPL/2.3, XRPL/2.4, XRPL/999.999") ==
                 makeProtocol(2, 3));
-            BEAST_EXPECT(negotiateProtocolVersion("XRPL/999.999, WebSocket/1.0") == std::nullopt);
-            BEAST_EXPECT(negotiateProtocolVersion("") == std::nullopt);
         }
     }
 };
diff --git a/src/test/overlay/compression_test.cpp b/src/test/overlay/compression_test.cpp
index a583a3aeab..40dee96c75 100644
--- a/src/test/overlay/compression_test.cpp
+++ b/src/test/overlay/compression_test.cpp
@@ -292,33 +292,6 @@ public:
         return getObject;
     }
 
-    static std::shared_ptr
-    buildValidatorList()
-    {
-        auto list = std::make_shared();
-
-        auto master = randomKeyPair(KeyType::Ed25519);
-        auto signing = randomKeyPair(KeyType::Ed25519);
-        STObject st(sfGeneric);
-        st[sfSequence] = 0;
-        st[sfPublicKey] = std::get<0>(master);
-        st[sfSigningPubKey] = std::get<0>(signing);
-        st[sfDomain] = makeSlice(std::string("example.com"));
-        sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(master), sfMasterSignature);
-        sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(signing));
-        Serializer s;
-        st.add(s);
-        list->set_manifest(s.data(), s.size());
-        list->set_version(3);
-        STObject const signature(sfSignature);
-        xrpl::sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(signing));
-        Serializer s1;
-        st.add(s1);
-        list->set_signature(s1.data(), s1.size());
-        list->set_blob(strHex(s.slice()));
-        return list;
-    }
-
     static std::shared_ptr
     buildValidatorListCollection()
     {
@@ -359,7 +332,6 @@ public:
         protocol::TMGetLedger const getLedger;
         protocol::TMLedgerData const ledgerData;
         protocol::TMGetObjectByHash const getObject;
-        protocol::TMValidatorList const validatorList;
         protocol::TMValidatorListCollection const validatorListCollection;
 
         // 4.5KB
@@ -386,8 +358,6 @@ public:
         doTest(buildLedgerData(500000, *logs), protocol::mtLEDGER_DATA, 100, "TMLedgerData500000");
         // 7.7KB
         doTest(buildGetObjectByHash(), protocol::mtGET_OBJECTS, 4, "TMGetObjectByHash");
-        // 895B
-        doTest(buildValidatorList(), protocol::mtVALIDATOR_LIST, 4, "TMValidatorList");
         doTest(
             buildValidatorListCollection(),
             protocol::mtVALIDATOR_LIST_COLLECTION,
diff --git a/src/test/protocol/Hooks_test.cpp b/src/test/protocol/Hooks_test.cpp
deleted file mode 100644
index 082507aca7..0000000000
--- a/src/test/protocol/Hooks_test.cpp
+++ /dev/null
@@ -1,189 +0,0 @@
-
-
-#include   // IWYU pragma: keep
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-
-namespace xrpl {
-
-class Hooks_test : public beast::unit_test::Suite
-{
-    /**
-     * This unit test was requested here:
-     * https://github.com/XRPLF/rippled/pull/4089#issuecomment-1050274539
-     * These are tests that exercise facilities that are reserved for when Hooks
-     * is merged in the future.
-     **/
-
-    void
-    testHookFields()
-    {
-        testcase("Test Hooks fields");
-
-        using namespace test::jtx;
-
-        std::vector> const fieldsToTest = {
-            sfHookResult,
-            sfHookStateChangeCount,
-            sfHookEmitCount,
-            sfHookExecutionIndex,
-            sfHookApiVersion,
-            sfHookStateCount,
-            sfEmitGeneration,
-            sfHookOn,
-            sfHookInstructionCount,
-            sfEmitBurden,
-            sfHookReturnCode,
-            sfReferenceCount,
-            sfEmitParentTxnID,
-            sfEmitNonce,
-            sfEmitHookHash,
-            sfHookStateKey,
-            sfHookHash,
-            sfHookNamespace,
-            sfHookSetTxnID,
-            sfHookStateData,
-            sfHookReturnString,
-            sfHookParameterName,
-            sfHookParameterValue,
-            sfEmitCallback,
-            sfHookAccount,
-            sfEmittedTxn,
-            sfHook,
-            sfHookDefinition,
-            sfHookParameter,
-            sfHookGrant,
-            sfEmitDetails,
-            sfHookExecutions,
-            sfHookExecution,
-            sfHookParameters,
-            sfHooks,
-            sfHookGrants};
-
-        for (auto const& rf : fieldsToTest)
-        {
-            SField const& f = rf.get();
-
-            STObject dummy{sfGeneric};
-
-            BEAST_EXPECT(!dummy.isFieldPresent(f));
-
-            switch (f.fieldType)
-            {
-                case STI_UINT8: {
-                    dummy.setFieldU8(f, 0);
-                    BEAST_EXPECT(dummy.getFieldU8(f) == 0);
-
-                    dummy.setFieldU8(f, 255);
-                    BEAST_EXPECT(dummy.getFieldU8(f) == 255);
-
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_UINT16: {
-                    dummy.setFieldU16(f, 0);
-                    BEAST_EXPECT(dummy.getFieldU16(f) == 0);
-
-                    dummy.setFieldU16(f, 0xFFFFU);
-                    BEAST_EXPECT(dummy.getFieldU16(f) == 0xFFFFU);
-
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_UINT32: {
-                    dummy.setFieldU32(f, 0);
-                    BEAST_EXPECT(dummy.getFieldU32(f) == 0);
-
-                    dummy.setFieldU32(f, 0xFFFFFFFFU);
-                    BEAST_EXPECT(dummy.getFieldU32(f) == 0xFFFFFFFFU);
-
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_UINT64: {
-                    dummy.setFieldU64(f, 0);
-                    BEAST_EXPECT(dummy.getFieldU64(f) == 0);
-
-                    dummy.setFieldU64(f, 0xFFFFFFFFFFFFFFFFU);
-                    BEAST_EXPECT(dummy.getFieldU64(f) == 0xFFFFFFFFFFFFFFFFU);
-
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_UINT256: {
-                    uint256 const u = uint256::fromVoid(
-                        "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBE"
-                        "EFDEADBEEF");
-                    dummy.setFieldH256(f, u);
-                    BEAST_EXPECT(dummy.getFieldH256(f) == u);
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_VL: {
-                    std::vector const v{1, 2, 3};
-                    dummy.setFieldVL(f, v);
-                    BEAST_EXPECT(dummy.getFieldVL(f) == v);
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_ACCOUNT: {
-                    // NOLINTBEGIN(bugprone-unchecked-optional-access)
-                    AccountID const id =
-                        *parseBase58("rwfSjJNK2YQuN64bSWn7T2eY9FJAyAPYJT");
-                    // NOLINTEND(bugprone-unchecked-optional-access)
-                    dummy.setAccountID(f, id);
-                    BEAST_EXPECT(dummy.getAccountID(f) == id);
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_OBJECT: {
-                    dummy.emplaceBack(STObject{f});
-                    BEAST_EXPECT(dummy.getField(f).getFName() == f);
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_ARRAY: {
-                    STArray dummy2{f, 2};
-                    dummy2.pushBack(STObject{sfGeneric});
-                    dummy2.pushBack(STObject{sfGeneric});
-                    dummy.setFieldArray(f, dummy2);
-                    BEAST_EXPECT(dummy.getFieldArray(f) == dummy2);
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                default:
-                    BEAST_EXPECT(false);
-            }
-        }
-    }
-
-public:
-    void
-    run() override
-    {
-        using namespace test::jtx;
-        testHookFields();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Hooks, protocol, xrpl);
-
-}  // namespace xrpl
diff --git a/src/test/protocol/STAmount_test.cpp b/src/test/protocol/STAmount_test.cpp
index f6c5a94752..c3a681cf01 100644
--- a/src/test/protocol/STAmount_test.cpp
+++ b/src/test/protocol/STAmount_test.cpp
@@ -1,16 +1,21 @@
 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -24,6 +29,7 @@
 #include 
 #include 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -990,6 +996,84 @@ public:
         }
     }
 
+    void
+    testMPTRateRounding()
+    {
+        testcase("MPT transfer rate rounding uses Number arithmetic");
+
+        MPTIssue const asset{makeMptID(1, AccountID(0x4985601))};
+        Rate const transferRate{1'500'000'000};
+        STAmount const largeAmount{asset, UINT64_C(1'230'000'000'000'000'000)};
+        STAmount const scaledAmount{asset, UINT64_C(1'845'000'000'000'000'000)};
+
+        auto rules = [](bool const mptV2) {
+            // Rules keeps a reference to the presets set, so use static
+            // storage here rather than a local temporary.
+            static std::unordered_set> const kNoFeatures;
+            static std::unordered_set> const kMptV2Features{
+                featureMPTokensV2};
+            return Rules{mptV2 ? kMptV2Features : kNoFeatures};
+        };
+
+        auto throwsOverflow = [&](auto&& f, bool expected = true) {
+            bool threw = false;
+            try
+            {
+                f();
+            }
+            catch (std::overflow_error const&)
+            {
+                threw = true;
+            }
+            BEAST_EXPECT(threw == expected);
+        };
+
+        {
+            CurrentTransactionRulesGuard const rg(rules(false));
+
+            throwsOverflow([&] { (void)multiplyRound(largeAmount, transferRate, asset, true); });
+            throwsOverflow([&] { (void)divideRound(scaledAmount, transferRate, asset, true); });
+        }
+
+        {
+            CurrentTransactionRulesGuard const rg(rules(true));
+
+            throwsOverflow(
+                [&] { (void)multiplyRound(largeAmount, transferRate, asset, true); }, false);
+            throwsOverflow(
+                [&] { (void)divideRound(scaledAmount, transferRate, asset, true); }, false);
+        }
+
+        {
+            CurrentTransactionRulesGuard const rg(rules(true));
+            STAmount const one{asset, 1};
+            STAmount const two{asset, 2};
+
+            BEAST_EXPECT(multiplyRound(one, transferRate, asset, true) == two);
+            BEAST_EXPECT(multiplyRound(one, transferRate, asset, false) == one);
+            BEAST_EXPECT(divideRound(two, transferRate, asset, true) == two);
+            BEAST_EXPECT(divideRound(two, transferRate, asset, false) == one);
+
+            BEAST_EXPECT(multiplyRound(largeAmount, transferRate, asset, true) == scaledAmount);
+            BEAST_EXPECT(divideRound(scaledAmount, transferRate, asset, true) == largeAmount);
+        }
+
+        {
+            // mulRound with an integral (XRP) operand whose mantissa is below
+            // kMinValue exercises the legacy value-scaling loop that normalizes
+            // the mantissa before multiply. The MPTokensV2 Number path is
+            // not taken here because the target asset is an IOU.
+            Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)};
+            STAmount const iouVal{usd, 5};
+            STAmount const xrpVal{XRPAmount{7}};  // integral, mantissa < kMinValue
+
+            auto const up = mulRound(iouVal, xrpVal, usd, /*roundUp*/ true);
+            auto const down = mulRound(iouVal, xrpVal, usd, /*roundUp*/ false);
+            BEAST_EXPECT(down.signum() > 0);
+            BEAST_EXPECT(up >= down);
+        }
+    }
+
     void
     testCanSubtractXRP()
     {
@@ -1267,6 +1351,7 @@ public:
         testCanAddXRP();
         testCanAddIOU();
         testCanAddMPT();
+        testMPTRateRounding();
         testCanSubtractXRP();
         testCanSubtractIOU();
         testCanSubtractMPT();
diff --git a/src/test/rpc/AccountLines_test.cpp b/src/test/rpc/AccountLines_test.cpp
index cb20de9bf5..3de2bdefa3 100644
--- a/src/test/rpc/AccountLines_test.cpp
+++ b/src/test/rpc/AccountLines_test.cpp
@@ -94,6 +94,24 @@ public:
         LedgerHeader const ledger3Info = env.closed()->header();
         BEAST_EXPECT(ledger3Info.seq == 3);
 
+        {
+            // test peer non-string
+            auto testInvalidPeerParam = [&](auto const& param) {
+                json::Value params;
+                params[jss::account] = alice.human();
+                params[jss::peer] = param;
+                auto jrr = env.rpc("json", "account_lines", to_string(params))[jss::result];
+                BEAST_EXPECT(jrr[jss::error] == "invalidParams");
+                BEAST_EXPECT(jrr[jss::error_message] == "Invalid field 'peer'.");
+            };
+
+            testInvalidPeerParam(1);
+            testInvalidPeerParam(1.1);
+            testInvalidPeerParam(true);
+            testInvalidPeerParam(json::Value(json::ValueType::Null));
+            testInvalidPeerParam(json::Value(json::ValueType::Object));
+            testInvalidPeerParam(json::Value(json::ValueType::Array));
+        }
         {
             // alice is funded but has no lines.  An empty array is returned.
             json::Value params;
@@ -775,6 +793,35 @@ public:
         LedgerHeader const ledger3Info = env.closed()->header();
         BEAST_EXPECT(ledger3Info.seq == 3);
 
+        {
+            // test peer non-string
+            auto testInvalidPeerParam = [&](auto const& param) {
+                json::Value params;
+                params[jss::account] = alice.human();
+                params[jss::peer] = param;
+
+                json::Value request;
+                request[jss::method] = "account_lines";
+                request[jss::jsonrpc] = "2.0";
+                request[jss::ripplerpc] = "2.0";
+                request[jss::id] = 5;
+                request[jss::params] = params;
+
+                auto const lines = env.rpc("json2", to_string(request));
+                BEAST_EXPECT(lines[jss::error][jss::error] == "invalidParams");
+                BEAST_EXPECT(lines[jss::error][jss::message] == "Invalid field 'peer'.");
+                BEAST_EXPECT(lines.isMember(jss::jsonrpc) && lines[jss::jsonrpc] == "2.0");
+                BEAST_EXPECT(lines.isMember(jss::ripplerpc) && lines[jss::ripplerpc] == "2.0");
+                BEAST_EXPECT(lines.isMember(jss::id) && lines[jss::id] == 5);
+            };
+
+            testInvalidPeerParam(1);
+            testInvalidPeerParam(1.1);
+            testInvalidPeerParam(true);
+            testInvalidPeerParam(json::Value(json::ValueType::Null));
+            testInvalidPeerParam(json::Value(json::ValueType::Object));
+            testInvalidPeerParam(json::Value(json::ValueType::Array));
+        }
         {
             // alice is funded but has no lines.  An empty array is returned.
             json::Value params;
diff --git a/src/test/rpc/GatewayBalances_test.cpp b/src/test/rpc/GatewayBalances_test.cpp
index 106b9b5f1a..91d9126f61 100644
--- a/src/test/rpc/GatewayBalances_test.cpp
+++ b/src/test/rpc/GatewayBalances_test.cpp
@@ -176,6 +176,45 @@ public:
         });
     }
 
+    void
+    testGWBInvalidAccount(FeatureBitset features)
+    {
+        testcase("Gateway Balances with non-string account/ident");
+        using namespace std::chrono_literals;
+        using namespace jtx;
+        Env env(*this, features);
+
+        Account const alice{"alice"};
+        env.fund(XRP(10000), alice);
+        env.close();
+
+        auto wsc = makeWSClient(env.app().config());
+
+        // A non-string "account" must be rejected cleanly with invalidParams
+        // rather than throwing a Json::LogicError that surfaces as internal.
+        json::Value qry;
+        qry[jss::account] = 42;
+        qry[jss::hotwallet] = alice.human();
+
+        forAllApiVersions([&, this](unsigned apiVersion) {
+            qry[jss::api_version] = apiVersion;
+            auto jv = wsc->invoke("gateway_balances", qry);
+            expect(jv[jss::status] == "error");
+            BEAST_EXPECT(jv[jss::result][jss::error] == "invalidParams");
+        });
+
+        // The same applies to a non-string "ident".
+        json::Value qry2;
+        qry2[jss::ident] = 42;
+
+        forAllApiVersions([&, this](unsigned apiVersion) {
+            qry2[jss::api_version] = apiVersion;
+            auto jv = wsc->invoke("gateway_balances", qry2);
+            expect(jv[jss::status] == "error");
+            BEAST_EXPECT(jv[jss::result][jss::error] == "invalidParams");
+        });
+    }
+
     void
     testGWBOverflow()
     {
@@ -280,6 +319,7 @@ public:
         {
             testGWB(feature);
             testGWBApiVersions(feature);
+            testGWBInvalidAccount(feature);
         }
         testGWBWithMPT();
         testGWBOverflow();
diff --git a/src/test/rpc/NoRippleCheck_test.cpp b/src/test/rpc/NoRippleCheck_test.cpp
index 6e30f944c7..8e719e6407 100644
--- a/src/test/rpc/NoRippleCheck_test.cpp
+++ b/src/test/rpc/NoRippleCheck_test.cpp
@@ -27,8 +27,6 @@
 #include 
 #include 
 
-#include 
-
 #include 
 #include 
 
@@ -203,13 +201,13 @@ class NoRippleCheck_test : public beast::unit_test::Suite
 
             if (user)
             {
-                BEAST_EXPECT(boost::starts_with(pa[0u].asString(), "You appear to have set"));
-                BEAST_EXPECT(boost::starts_with(pa[1u].asString(), "You should probably set"));
+                BEAST_EXPECT(pa[0u].asString().starts_with("You appear to have set"));
+                BEAST_EXPECT(pa[1u].asString().starts_with("You should probably set"));
             }
             else
             {
-                BEAST_EXPECT(boost::starts_with(pa[0u].asString(), "You should immediately set"));
-                BEAST_EXPECT(boost::starts_with(pa[1u].asString(), "You should clear"));
+                BEAST_EXPECT(pa[0u].asString().starts_with("You should immediately set"));
+                BEAST_EXPECT(pa[1u].asString().starts_with("You should clear"));
             }
         }
         else
diff --git a/src/test/rpc/ServerInfo_test.cpp b/src/test/rpc/ServerInfo_test.cpp
index 52a1e6cdb0..100ae0e49b 100644
--- a/src/test/rpc/ServerInfo_test.cpp
+++ b/src/test/rpc/ServerInfo_test.cpp
@@ -9,8 +9,7 @@
 #include 
 #include 
 
-#include 
-
+#include 
 #include 
 
 namespace xrpl::test {
@@ -36,12 +35,13 @@ public:
     makeValidatorConfig()
     {
         auto p = std::make_unique();
-        boost::format toLoad(R"xrpldConfig(
+        auto const toLoad = std::format(
+            R"xrpldConfig(
 [validator_token]
-%1%
+{}
 
 [validators]
-%2%
+{}
 
 [port_grpc]
 ip = 0.0.0.0
@@ -52,9 +52,11 @@ ip = 0.0.0.0
 port = 50052
 protocol = wss2
 admin = 127.0.0.1
-)xrpldConfig");
+)xrpldConfig",
+            validator_data::kToken,
+            validator_data::kPublicKey);
 
-        p->loadFromString(boost::str(toLoad % validator_data::kToken % validator_data::kPublicKey));
+        p->loadFromString(toLoad);
 
         setupConfigForUnitTests(*p);
 
diff --git a/src/test/server/ServerStatus_test.cpp b/src/test/server/ServerStatus_test.cpp
index 5adf6a08f5..f1989ed171 100644
--- a/src/test/server/ServerStatus_test.cpp
+++ b/src/test/server/ServerStatus_test.cpp
@@ -56,8 +56,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
     static auto
     makeConfig(std::string const& proto, bool admin = true, bool credentials = false)
     {
-        auto const sectionName =
-            boost::starts_with(proto, "h") ? Sections::kPortRpc : Sections::kPortWs;
+        auto const sectionName = proto.starts_with("h") ? Sections::kPortRpc : Sections::kPortWs;
         auto p = jtx::envconfig();
 
         p->overwrite(sectionName, Keys::kProtocol, proto);
@@ -71,9 +70,9 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         }
 
         p->overwrite(
-            boost::starts_with(proto, "h") ? Sections::kPortWs : Sections::kPortRpc,
+            proto.starts_with("h") ? Sections::kPortWs : Sections::kPortRpc,
             Keys::kProtocol,
-            boost::starts_with(proto, "h") ? "ws" : "http");
+            proto.starts_with("h") ? "ws" : "http");
 
         if (proto == "https")
         {
@@ -261,7 +260,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
             }
         }
 
-        if (boost::starts_with(proto, "h"))
+        if (proto.starts_with("h"))
         {
             auto jrc = makeJSONRPCClient(env.app().config());
             jrr = jrc->invoke("ledger_accept", jp);
@@ -289,7 +288,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         Env env{*this, makeConfig(proto, admin, credentials)};
 
         json::Value jrr;
-        auto const protoWs = boost::starts_with(proto, "w");
+        auto const protoWs = proto.starts_with("w");
 
         // the set of checks we do are different depending
         // on how the admin config options are set
@@ -485,7 +484,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
 
         boost::beast::http::response resp;
         boost::system::error_code ec;
-        if (boost::starts_with(clientProtocol, "h"))
+        if (clientProtocol.starts_with("h"))
         {
             doHTTPRequest(env, yield, clientProtocol == "https", resp, ec);
             BEAST_EXPECT(ec);
diff --git a/src/test/unit_test/FileDirGuard.h b/src/test/unit_test/FileDirGuard.h
index b583f821a4..2e6b3fd179 100644
--- a/src/test/unit_test/FileDirGuard.h
+++ b/src/test/unit_test/FileDirGuard.h
@@ -3,9 +3,8 @@
 #include 
 #include 
 
-#include 
-
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -20,7 +19,7 @@ namespace xrpl::detail {
 class DirGuard
 {
 protected:
-    using path = boost::filesystem::path;
+    using path = std::filesystem::path;
 
 private:
     path subDir_;
@@ -47,7 +46,7 @@ public:
     DirGuard(beast::unit_test::Suite& test, path subDir, bool useCounter = true)
         : subDir_(std::move(subDir)), test_(test)
     {
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
 
         static auto kSubDirCounter = 0;
         if (useCounter)
@@ -73,7 +72,7 @@ public:
     {
         try
         {
-            using namespace boost::filesystem;
+            using namespace std::filesystem;
 
             if (rmSubDir_)
                 rmDir(subDir_);
@@ -130,7 +129,7 @@ public:
     {
         try
         {
-            using namespace boost::filesystem;
+            using namespace std::filesystem;
             if (exists(file_))
             {
                 remove(file_);
@@ -160,7 +159,7 @@ public:
     [[nodiscard]] bool
     fileExists() const
     {
-        return boost::filesystem::exists(file_);
+        return std::filesystem::exists(file_);
     }
 };
 
diff --git a/src/test/unit_test/multi_runner.cpp b/src/test/unit_test/multi_runner.cpp
index 71208313a4..918fc7c89f 100644
--- a/src/test/unit_test/multi_runner.cpp
+++ b/src/test/unit_test/multi_runner.cpp
@@ -7,7 +7,6 @@
 #include 
 #include 
 #include 
-#include 
 
 #include 
 #include 
@@ -36,7 +35,7 @@ fmtdur(typename clock_type::duration const& d)
     using namespace std::chrono;
     auto const ms = duration_cast(d);
     if (ms < seconds{1})
-        return boost::lexical_cast(ms.count()) + "ms";
+        return std::to_string(ms.count()) + "ms";
     std::stringstream ss;
     ss << std::fixed << std::setprecision(1) << (ms.count() / 1000.) << "s";
     return ss.str();
diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt
index 5e4cda243a..9cbfb8ca10 100644
--- a/src/tests/libxrpl/CMakeLists.txt
+++ b/src/tests/libxrpl/CMakeLists.txt
@@ -43,6 +43,9 @@ set(test_modules
 if(NOT WIN32)
     list(APPEND test_modules net)
 endif()
+if(rust)
+    target_link_libraries(xrpl_tests PRIVATE rs_hello_world_cxxbridge)
+endif()
 
 foreach(module IN LISTS test_modules)
     # Append the module's sources (${module}/*.cpp and ${module}.cpp, if any).
@@ -52,6 +55,12 @@ foreach(module IN LISTS test_modules)
         "${CMAKE_CURRENT_SOURCE_DIR}/${module}/*.cpp"
         "${CMAKE_CURRENT_SOURCE_DIR}/${module}.cpp"
     )
+    if(NOT rust)
+        # Tests of the Rust interop include generated cxxbridge headers, which
+        # do not exist without the crates, so keep them out of the build tree
+        # entirely. They are named `Rust.cpp`.
+        list(FILTER sources EXCLUDE REGEX "/Rust[^/]*\\.cpp$")
+    endif()
     target_sources(xrpl_tests PRIVATE ${sources})
 
     # Expose the module's private headers under their canonical include path.
diff --git a/src/tests/libxrpl/basics/Buffer.cpp b/src/tests/libxrpl/basics/Buffer.cpp
index 9cdf610282..a3f78e8bcf 100644
--- a/src/tests/libxrpl/basics/Buffer.cpp
+++ b/src/tests/libxrpl/basics/Buffer.cpp
@@ -4,6 +4,7 @@
 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -12,8 +13,18 @@
 
 namespace xrpl::test {
 
+static_assert(std::is_nothrow_move_constructible_v);
+static_assert(std::is_nothrow_move_assignable_v);
+
 struct BufferTest : public ::testing::Test
 {
+    static constexpr auto kRandomData = std::to_array(
+        {0xa8, 0xa1, 0x38, 0x45, 0x23, 0xec, 0xe4, 0x23, 0x71, 0x6d, 0x2a,
+         0x18, 0xb4, 0x70, 0xcb, 0xf5, 0xac, 0x2d, 0x89, 0x4d, 0x19, 0x9c,
+         0xf0, 0x2c, 0x15, 0xd1, 0xf9, 0x9b, 0x66, 0xd2, 0x30, 0xd3});
+
+    static constexpr std::size_t kHalf = kRandomData.size() / 2;
+
     static bool
     sane(Buffer const& b)
     {
@@ -22,239 +33,321 @@ struct BufferTest : public ::testing::Test
 
         return b.data() != nullptr;
     }
+
+    /**
+     * Check the state Buffer documents for a moved-from buffer: "the other buffer is reset", i.e.
+     * empty and sane.
+     *
+     * Zeroing the size is not incidental tidiness. Moving the member unique_ptr nulls the data
+     * pointer whether Buffer wants it or not, so a moved-from buffer that kept its old size would
+     * lie about itself everywhere: alloc() would take its `n == size_` early-out and hand back a
+     * null pointer while still reporting the old size, fill() would run std::fill_n over a null
+     * pointer, and the Slice conversion would publish {nullptr, oldSize} to callers. A moved-from
+     * Buffer has to be a usable empty Buffer rather than a landmine, which is why the tests below
+     * assert this state instead of treating a moved-from buffer as untouchable.
+     */
+    static void
+    checkEmptyAfterMove(Buffer const& buf)
+    {
+        EXPECT_TRUE(sane(buf));
+        EXPECT_TRUE(buf.empty());
+    }
+
+    Buffer const emptyBuffer;
+    Buffer const firstHalf{kRandomData.data(), kHalf};
+    Buffer const secondHalf{kRandomData.data() + kHalf, kHalf};
+    Buffer const whole{kRandomData.data(), kRandomData.size()};
 };
 
-TEST_F(BufferTest, buffer)
+TEST_F(BufferTest, default_constructed_is_empty)
 {
-    std::uint8_t const data[] = {0xa8, 0xa1, 0x38, 0x45, 0x23, 0xec, 0xe4, 0x23, 0x71, 0x6d, 0x2a,
-                                 0x18, 0xb4, 0x70, 0xcb, 0xf5, 0xac, 0x2d, 0x89, 0x4d, 0x19, 0x9c,
-                                 0xf0, 0x2c, 0x15, 0xd1, 0xf9, 0x9b, 0x66, 0xd2, 0x30, 0xd3};
+    Buffer const b;
 
-    Buffer const b0;
-    EXPECT_TRUE(sane(b0));
-    EXPECT_TRUE(b0.empty());
+    EXPECT_TRUE(sane(b));
+    EXPECT_TRUE(b.empty());
+    EXPECT_EQ(b.data(), nullptr);
+}
 
-    Buffer b1{0};
-    EXPECT_TRUE(sane(b1));
-    EXPECT_TRUE(b1.empty());
-    std::memcpy(b1.alloc(16), data, 16);
-    EXPECT_TRUE(sane(b1));
-    EXPECT_FALSE(b1.empty());
-    EXPECT_EQ(b1.size(), 16);
+TEST_F(BufferTest, zero_sized_construction_is_empty)
+{
+    Buffer const b{0};
 
-    Buffer b2{b1.size()};
-    EXPECT_TRUE(sane(b2));
-    EXPECT_FALSE(b2.empty());
-    EXPECT_EQ(b2.size(), b1.size());
-    std::memcpy(b2.data(), data + 16, 16);
+    EXPECT_TRUE(sane(b));
+    EXPECT_TRUE(b.empty());
+}
 
-    Buffer b3{data, sizeof(data)};
-    EXPECT_TRUE(sane(b3));
-    EXPECT_FALSE(b3.empty());
-    EXPECT_EQ(b3.size(), sizeof(data));
-    EXPECT_EQ(std::memcmp(b3.data(), data, b3.size()), 0);
+TEST_F(BufferTest, alloc_grows_an_empty_buffer)
+{
+    Buffer b{0};
+    std::memcpy(b.alloc(kHalf), kRandomData.data(), kHalf);
 
-    // Check equality and inequality comparisons.
-    // For code readability, we want to use general
-    // EXPECT_TRUE instead of specific EXPECT_EQ etc.
-    EXPECT_TRUE(b0 == b0);
-    EXPECT_TRUE(b0 != b1);
-    EXPECT_TRUE(b1 == b1);
-    EXPECT_TRUE(b1 != b2);
-    EXPECT_TRUE(b2 != b3);
+    EXPECT_TRUE(sane(b));
+    EXPECT_FALSE(b.empty());
+    EXPECT_EQ(b.size(), kHalf);
+    EXPECT_EQ(b, firstHalf);
+}
 
-    // Check copy constructors and copy assignments:
-    {
-        Buffer x{b0};
-        EXPECT_EQ(x, b0);
-        EXPECT_TRUE(sane(x));
-        Buffer y{b1};
-        EXPECT_EQ(y, b1);
-        EXPECT_TRUE(sane(y));
-        x = b2;
-        EXPECT_EQ(x, b2);
-        EXPECT_TRUE(sane(x));
-        x = y;
-        EXPECT_EQ(x, y);
-        EXPECT_TRUE(sane(x));
-        y = b3;
-        EXPECT_EQ(y, b3);
-        EXPECT_TRUE(sane(y));
-        x = b0;
-        EXPECT_EQ(x, b0);
-        EXPECT_TRUE(sane(x));
+TEST_F(BufferTest, sized_construction_reserves_without_filling)
+{
+    Buffer b{kHalf};
+
+    EXPECT_TRUE(sane(b));
+    EXPECT_FALSE(b.empty());
+    EXPECT_EQ(b.size(), kHalf);
+
+    std::memcpy(b.data(), kRandomData.data() + kHalf, kHalf);
+    EXPECT_EQ(b, secondHalf);
+}
+
+TEST_F(BufferTest, construction_copies_raw_memory)
+{
+    Buffer const b{kRandomData.data(), kRandomData.size()};
+
+    EXPECT_TRUE(sane(b));
+    EXPECT_FALSE(b.empty());
+    EXPECT_EQ(b.size(), kRandomData.size());
+    EXPECT_EQ(std::memcmp(b.data(), kRandomData.data(), b.size()), 0);
+}
+
+TEST_F(BufferTest, equality_compares_contents)
+{
+    // Uses EXPECT_TRUE rather than EXPECT_EQ/EXPECT_NE because the operators are what is under test
+    // here.
+    EXPECT_TRUE(emptyBuffer == emptyBuffer);
+    EXPECT_TRUE(firstHalf == firstHalf);
+
+    EXPECT_TRUE(emptyBuffer != firstHalf);
+    EXPECT_TRUE(firstHalf != secondHalf);
+    EXPECT_TRUE(secondHalf != whole);
+}
+
+TEST_F(BufferTest, copy_construction)
+{
+    Buffer const fromEmpty{emptyBuffer};
+    EXPECT_TRUE(sane(fromEmpty));
+    EXPECT_EQ(fromEmpty, emptyBuffer);
+
+    Buffer const fromNonEmpty{firstHalf};
+    EXPECT_TRUE(sane(fromNonEmpty));
+    EXPECT_EQ(fromNonEmpty, firstHalf);
+}
+
+TEST_F(BufferTest, copy_assignment)
+{
+    Buffer b{emptyBuffer};
+
+    // empty <- non-empty
+    b = secondHalf;
+    EXPECT_TRUE(sane(b));
+    EXPECT_EQ(b, secondHalf);
+
+    // non-empty <- non-empty of a different size
+    b = whole;
+    EXPECT_TRUE(sane(b));
+    EXPECT_EQ(b, whole);
+
+    // non-empty <- empty
+    b = emptyBuffer;
+    EXPECT_TRUE(sane(b));
+    EXPECT_EQ(b, emptyBuffer);
+}
+
+TEST_F(BufferTest, self_assignment_preserves_contents)
+{
 #ifdef __clang__
 #pragma clang diagnostic push
 #pragma clang diagnostic ignored "-Wself-assign-overloaded"
 #endif
 
-        x = x;
-        EXPECT_EQ(x, b0);
-        EXPECT_TRUE(sane(x));
-        y = y;
-        EXPECT_EQ(y, b3);
-        EXPECT_TRUE(sane(y));
+    Buffer emptyCopy{emptyBuffer};
+    emptyCopy = emptyCopy;
+    EXPECT_TRUE(sane(emptyCopy));
+    EXPECT_EQ(emptyCopy, emptyBuffer);
+
+    Buffer wholeCopy{whole};
+    wholeCopy = wholeCopy;
+    EXPECT_TRUE(sane(wholeCopy));
+    EXPECT_EQ(wholeCopy, whole);
 
 #ifdef __clang__
 #pragma clang diagnostic pop
 #endif
-    }
+}
 
-    // Check move constructor & move assignments:
+TEST_F(BufferTest, move_construct_from_empty)
+{
+    Buffer source;
+    Buffer const moved{std::move(source)};
+
+    checkEmptyAfterMove(source);  // NOLINT(bugprone-use-after-move)
+    EXPECT_TRUE(sane(moved));
+    EXPECT_TRUE(moved.empty());
+}
+
+TEST_F(BufferTest, move_construct_from_non_empty)
+{
+    Buffer source{firstHalf};
+    Buffer const moved{std::move(source)};
+
+    checkEmptyAfterMove(source);  // NOLINT(bugprone-use-after-move)
+    EXPECT_TRUE(sane(moved));
+    EXPECT_EQ(moved, firstHalf);
+}
+
+TEST_F(BufferTest, move_assign_empty_to_empty)
+{
+    Buffer target;
+    Buffer source;
+
+    target = std::move(source);
+
+    EXPECT_TRUE(sane(target));
+    EXPECT_TRUE(target.empty());
+    checkEmptyAfterMove(source);  // NOLINT(bugprone-use-after-move)
+}
+
+TEST_F(BufferTest, move_assign_non_empty_to_empty)
+{
+    Buffer target;
+    Buffer source{firstHalf};
+
+    target = std::move(source);
+
+    EXPECT_TRUE(sane(target));
+    EXPECT_EQ(target, firstHalf);
+    checkEmptyAfterMove(source);  // NOLINT(bugprone-use-after-move)
+}
+
+TEST_F(BufferTest, move_assign_empty_to_non_empty)
+{
+    Buffer target{firstHalf};
+    Buffer source;
+
+    target = std::move(source);
+
+    EXPECT_TRUE(sane(target));
+    EXPECT_TRUE(target.empty());
+    checkEmptyAfterMove(source);  // NOLINT(bugprone-use-after-move)
+}
+
+TEST_F(BufferTest, move_assign_non_empty_to_non_empty)
+{
+    Buffer target{firstHalf};
+    Buffer sameSize{secondHalf};
+    Buffer largerSize{whole};
+
+    target = std::move(sameSize);
+    EXPECT_TRUE(sane(target));
+    EXPECT_EQ(target, secondHalf);
+    checkEmptyAfterMove(sameSize);  // NOLINT(bugprone-use-after-move)
+
+    target = std::move(largerSize);
+    EXPECT_TRUE(sane(target));
+    EXPECT_EQ(target, whole);
+    checkEmptyAfterMove(largerSize);  // NOLINT(bugprone-use-after-move)
+}
+
+TEST_F(BufferTest, construction_from_slice)
+{
+    Buffer const fromEmpty{static_cast(emptyBuffer)};
+    EXPECT_TRUE(sane(fromEmpty));
+    EXPECT_EQ(fromEmpty, emptyBuffer);
+
+    Buffer const fromNonEmpty{static_cast(whole)};
+    EXPECT_TRUE(sane(fromNonEmpty));
+    EXPECT_EQ(fromNonEmpty, whole);
+}
+
+TEST_F(BufferTest, assignment_from_slice)
+{
+    Buffer b;
+
+    // empty <- empty slice
+    b = static_cast(emptyBuffer);
+    EXPECT_TRUE(sane(b));
+    EXPECT_EQ(b, emptyBuffer);
+
+    // empty <- non-empty slice
+    b = static_cast(firstHalf);
+    EXPECT_TRUE(sane(b));
+    EXPECT_EQ(b, firstHalf);
+
+    // non-empty <- non-empty slice
+    b = static_cast(secondHalf);
+    EXPECT_TRUE(sane(b));
+    EXPECT_EQ(b, secondHalf);
+
+    // non-empty <- empty slice
+    b = static_cast(emptyBuffer);
+    EXPECT_TRUE(sane(b));
+    EXPECT_EQ(b, emptyBuffer);
+}
+
+TEST_F(BufferTest, resize_allocates_and_clear_releases)
+{
+    auto check = [](Buffer const& original, std::size_t size) {
+        SCOPED_TRACE(::testing::Message() << "size: " << size);
+
+        Buffer b{original};
+
+        // Resizing to zero is equivalent to clearing.
+        b(size);
+        EXPECT_TRUE(sane(b));
+        EXPECT_EQ(b.size(), size);
+        EXPECT_EQ(b.data() == nullptr, size == 0);
+
+        b(size + 1);
+        EXPECT_TRUE(sane(b));
+        EXPECT_EQ(b.size(), size + 1);
+        EXPECT_NE(b.data(), nullptr);
+
+        b.clear();
+        EXPECT_TRUE(sane(b));
+        EXPECT_TRUE(b.empty());
+        EXPECT_EQ(b.data(), nullptr);
+
+        // clear() is idempotent.
+        b.clear();
+        EXPECT_TRUE(sane(b));
+        EXPECT_TRUE(b.empty());
+        EXPECT_EQ(b.data(), nullptr);
+    };
+
+    for (auto size = 0uz; size < kHalf; ++size)
     {
-        static_assert(std::is_nothrow_move_constructible_v);
-        static_assert(std::is_nothrow_move_assignable_v);
-
-        {  // Move-construct from empty buf
-            Buffer x;
-            Buffer const y{std::move(x)};
-            EXPECT_TRUE(sane(x));    // NOLINT(bugprone-use-after-move)
-            EXPECT_TRUE(x.empty());  // NOLINT(bugprone-use-after-move)
-            EXPECT_TRUE(sane(y));
-            EXPECT_TRUE(y.empty());
-            EXPECT_EQ(x, y);  // NOLINT(bugprone-use-after-move)
-        }
-
-        {  // Move-construct from non-empty buf
-            Buffer x{b1};
-            Buffer const y{std::move(x)};
-            EXPECT_TRUE(sane(x));    // NOLINT(bugprone-use-after-move)
-            EXPECT_TRUE(x.empty());  // NOLINT(bugprone-use-after-move)
-            EXPECT_TRUE(sane(y));
-            EXPECT_EQ(y, b1);
-        }
-
-        {  // Move assign empty buf to empty buf
-            Buffer x;
-            Buffer y;
-
-            x = std::move(y);
-            EXPECT_TRUE(sane(x));
-            EXPECT_TRUE(x.empty());
-            EXPECT_TRUE(sane(y));    // NOLINT(bugprone-use-after-move)
-            EXPECT_TRUE(y.empty());  // NOLINT(bugprone-use-after-move)
-        }
-
-        {  // Move assign non-empty buf to empty buf
-            Buffer x;
-            Buffer y{b1};
-
-            x = std::move(y);
-            EXPECT_TRUE(sane(x));
-            EXPECT_EQ(x, b1);
-            EXPECT_TRUE(sane(y));    // NOLINT(bugprone-use-after-move)
-            EXPECT_TRUE(y.empty());  // NOLINT(bugprone-use-after-move)
-        }
-
-        {  // Move assign empty buf to non-empty buf
-            Buffer x{b1};
-            Buffer y;
-
-            x = std::move(y);
-            EXPECT_TRUE(sane(x));
-            EXPECT_TRUE(x.empty());
-            EXPECT_TRUE(sane(y));    // NOLINT(bugprone-use-after-move)
-            EXPECT_TRUE(y.empty());  // NOLINT(bugprone-use-after-move)
-        }
-
-        {  // Move assign non-empty buf to non-empty buf
-            Buffer x{b1};
-            Buffer y{b2};
-            Buffer z{b3};
-
-            x = std::move(y);
-            EXPECT_TRUE(sane(x));
-            EXPECT_FALSE(x.empty());
-            EXPECT_TRUE(sane(y));    // NOLINT(bugprone-use-after-move)
-            EXPECT_TRUE(y.empty());  // NOLINT(bugprone-use-after-move)
-
-            x = std::move(z);
-            EXPECT_TRUE(sane(x));
-            EXPECT_FALSE(x.empty());
-            EXPECT_TRUE(sane(z));    // NOLINT(bugprone-use-after-move)
-            EXPECT_TRUE(z.empty());  // NOLINT(bugprone-use-after-move)
-        }
-    }
-
-    {
-        Buffer w{static_cast(b0)};
-        EXPECT_TRUE(sane(w));
-        EXPECT_EQ(w, b0);
-
-        Buffer x{static_cast(b1)};
-        EXPECT_TRUE(sane(x));
-        EXPECT_EQ(x, b1);
-
-        Buffer y{static_cast(b2)};
-        EXPECT_TRUE(sane(y));
-        EXPECT_EQ(y, b2);
-
-        Buffer z{static_cast(b3)};
-        EXPECT_TRUE(sane(z));
-        EXPECT_EQ(z, b3);
-
-        // Assign empty slice to empty buffer
-        w = static_cast(b0);
-        EXPECT_TRUE(sane(w));
-        EXPECT_EQ(w, b0);
-
-        // Assign non-empty slice to empty buffer
-        w = static_cast(b1);
-        EXPECT_TRUE(sane(w));
-        EXPECT_EQ(w, b1);
-
-        // Assign non-empty slice to non-empty buffer
-        x = static_cast(b2);
-        EXPECT_TRUE(sane(x));
-        EXPECT_EQ(x, b2);
-
-        // Assign non-empty slice to non-empty buffer
-        y = static_cast(z);
-        EXPECT_TRUE(sane(y));
-        EXPECT_EQ(y, z);
-
-        // Assign empty slice to non-empty buffer:
-        z = static_cast(b0);
-        EXPECT_TRUE(sane(z));
-        EXPECT_EQ(z, b0);
-    }
-
-    {
-        auto test = [](Buffer const& b, std::size_t i) {
-            Buffer x{b};
-
-            // Try to allocate some number of bytes, possibly
-            // zero (which means clear) and sanity check
-            x(i);
-            EXPECT_TRUE(sane(x));
-            EXPECT_EQ(x.size(), i);
-            EXPECT_EQ((x.data() == nullptr), (i == 0));
-
-            // Try to allocate some more data (always non-zero)
-            x(i + 1);
-            EXPECT_TRUE(sane(x));
-            EXPECT_EQ(x.size(), i + 1);
-            EXPECT_NE(x.data(), nullptr);
-
-            // Try to clear:
-            x.clear();
-            EXPECT_TRUE(sane(x));
-            EXPECT_TRUE(x.empty());
-            EXPECT_EQ(x.data(), nullptr);
-
-            // Try to clear again:
-            x.clear();
-            EXPECT_TRUE(sane(x));
-            EXPECT_TRUE(x.empty());
-            EXPECT_EQ(x.data(), nullptr);
-        };
-
-        for (std::size_t i = 0; i < 16; ++i)
-        {
-            test(b0, i);
-            test(b1, i);
-        }
+        check(emptyBuffer, size);
+        check(firstHalf, size);
     }
 }
 
+TEST_F(BufferTest, fill_sets_every_byte)
+{
+    Buffer b{4};
+    b.fill(0xab);
+
+    EXPECT_EQ(b.size(), 4);
+    for (auto const byte : Slice{b})
+        EXPECT_EQ(byte, 0xab);
+}
+
+TEST_F(BufferTest, fill_overwrites_and_keeps_size)
+{
+    Buffer b{4};
+    b.fill(0xab);
+    b.fill(0x00);
+
+    EXPECT_EQ(b.size(), 4);
+    for (auto const byte : Slice{b})
+        EXPECT_EQ(byte, 0x00);
+}
+
+TEST_F(BufferTest, fill_on_empty_buffer_is_a_noop)
+{
+    Buffer empty;
+    empty.fill(0xff);
+
+    EXPECT_TRUE(empty.empty());
+    EXPECT_EQ(empty.data(), nullptr);
+}
+
 }  // namespace xrpl::test
diff --git a/src/tests/libxrpl/basics/FileUtilities.cpp b/src/tests/libxrpl/basics/FileUtilities.cpp
index cd24abd696..5cf2b72709 100644
--- a/src/tests/libxrpl/basics/FileUtilities.cpp
+++ b/src/tests/libxrpl/basics/FileUtilities.cpp
@@ -2,16 +2,14 @@
 
 #include 
 
-#include 
-#include 
-#include 
-#include 
-
 #include 
 
+#include 
 #include 
+#include 
 #include 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -20,15 +18,14 @@ namespace {
 class TempFile
 {
 public:
-    explicit TempFile(boost::filesystem::path file, std::string const& contents)
-        : dir_(
-              boost::filesystem::temp_directory_path() /
-              boost::filesystem::unique_path("xrpl-file-utilities-%%%%-%%%%-%%%%"))
-        , file_(dir_ / file)
+    explicit TempFile(std::string const& file, std::string const& contents)
+        : file_(
+              uniqueRandomPath(std::filesystem::temp_directory_path(), "xrpl-file-utilities-") /
+              file)
     {
-        boost::filesystem::create_directory(dir_);
+        std::filesystem::create_directory(file_.parent_path());
 
-        std::ofstream output(file_.string());
+        std::ofstream output(file_);
         if (!output)
             throw std::runtime_error("Unable to create temporary test file");
 
@@ -37,33 +34,36 @@ public:
 
     ~TempFile()
     {
-        boost::system::error_code ec;
-        boost::filesystem::remove(file_, ec);
-        boost::filesystem::remove(dir_, ec);
+        // use non-throwing calls in the destructor
+        std::error_code ec;
+        auto const dir = file_.parent_path();
+        std::filesystem::remove_all(dir, ec);
+        if (ec)
+        {
+            std::cerr << "Unable to remove temporary directory '" << dir.string()
+                      << "': " << ec.message() << '\n';
+        }
     }
 
-    [[nodiscard]] boost::filesystem::path const&
+    [[nodiscard]] std::filesystem::path const&
     file() const
     {
         return file_;
     }
 
 private:
-    boost::filesystem::path dir_;
-    boost::filesystem::path file_;
+    std::filesystem::path file_;
 };
 
 }  // namespace
 
 TEST(FileUtilitiesTest, get_file_contents)
 {
-    using namespace boost::system;
-
     constexpr char const* kExpectedContents = "This file is very short. That's all we need.";
 
     TempFile const file("test_file", "This is temporary text that should get overwritten");
 
-    error_code ec;
+    std::error_code ec;
     auto const path = file.file();
 
     writeFileContents(ec, path, kExpectedContents);
@@ -86,7 +86,7 @@ TEST(FileUtilitiesTest, get_file_contents)
     {
         // Test with small max
         auto const bad = getFileContents(ec, path, 16);
-        EXPECT_TRUE(ec && ec.value() == boost::system::errc::file_too_large);
+        EXPECT_TRUE(ec && ec.value() == static_cast(std::errc::file_too_large));
         EXPECT_TRUE(bad.empty());
     }
 }
diff --git a/src/tests/libxrpl/basics/IntrusiveShared.cpp b/src/tests/libxrpl/basics/IntrusiveShared.cpp
index b9f8930b7b..c6c9fcfef0 100644
--- a/src/tests/libxrpl/basics/IntrusiveShared.cpp
+++ b/src/tests/libxrpl/basics/IntrusiveShared.cpp
@@ -92,6 +92,7 @@ public:
     static constexpr std::size_t kMaxStates = 128;
     static std::array, kMaxStates> state;
     static std::atomic nextId;
+
     static TrackedState
     getState(std::size_t id)
     {
@@ -100,13 +101,12 @@ public:
 
         return state[id].load(std::memory_order_acquire);
     }
+
     static void
     resetStates(bool resetCallback)
     {
         for (std::size_t i = 0; i < kMaxStates; ++i)
-        {
             state[i].store(TrackedState::Uninitialized, std::memory_order_release);
-        }
         nextId.store(0, std::memory_order_release);
         if (resetCallback)
             TIBase::tracingCallback = [](TrackedState, std::optional) {};
@@ -120,6 +120,7 @@ public:
         {
             TIBase::resetStates(resetCallback);
         }
+
         ~ResetStatesGuard()
         {
             TIBase::resetStates(resetCallback);
@@ -130,6 +131,7 @@ public:
     {
         state[id].store(TrackedState::Alive, std::memory_order_relaxed);
     }
+
     ~TIBase() override
     {
         using enum TrackedState;
@@ -218,9 +220,7 @@ TEST(IntrusiveSharedTest, basics)
         EXPECT_EQ(TIBase::getState(id), Alive);
         EXPECT_EQ(b->useCount(), 1);
         for (auto i = 0uz; i < 10; ++i)
-        {
             strong.push_back(b);
-        }
         b.reset();
         EXPECT_EQ(TIBase::getState(id), Alive);
         strong.resize(strong.size() - 1);
@@ -244,8 +244,7 @@ TEST(IntrusiveSharedTest, basics)
         EXPECT_EQ(TIBase::getState(id), PartiallyDeleted);
         while (!weak.empty())
         {
-            weak.resize(weak.size() - 1);
-            if (!weak.empty())
+            if (weak.resize(weak.size() - 1); !weak.empty())
             {
                 EXPECT_EQ(TIBase::getState(id), PartiallyDeleted);
             }
diff --git a/src/tests/libxrpl/basics/RustInterop.cpp b/src/tests/libxrpl/basics/RustInterop.cpp
new file mode 100644
index 0000000000..8a6ad8a4ed
--- /dev/null
+++ b/src/tests/libxrpl/basics/RustInterop.cpp
@@ -0,0 +1,9 @@
+#include 
+#include 
+
+#include 
+
+TEST(RustInteropTest, hello_world)
+{
+    EXPECT_EQ(std::string(rs::hello_world::hello_world()), "hello_world");
+}
diff --git a/src/tests/libxrpl/basics/StringUtilities.cpp b/src/tests/libxrpl/basics/StringUtilities.cpp
index a10711abdb..0180e25db0 100644
--- a/src/tests/libxrpl/basics/StringUtilities.cpp
+++ b/src/tests/libxrpl/basics/StringUtilities.cpp
@@ -290,4 +290,44 @@ TEST_F(StringUtilitiesTest, to_string)
     EXPECT_EQ(result, "hello");
 }
 
+TEST_F(StringUtilitiesTest, trimWhitespace)
+{
+    EXPECT_EQ(trimWhitespace(""), "");
+    EXPECT_EQ(trimWhitespace("   "), "");
+    EXPECT_EQ(trimWhitespace("abc"), "abc");
+    EXPECT_EQ(trimWhitespace("  abc"), "abc");
+    EXPECT_EQ(trimWhitespace("abc  "), "abc");
+    EXPECT_EQ(trimWhitespace(" \t\n\v\f\r abc \t\n\v\f\r "), "abc");
+
+    // Interior whitespace is preserved.
+    EXPECT_EQ(trimWhitespace("  a b\tc  "), "a b\tc");
+}
+
+TEST_F(StringUtilitiesTest, toLower)
+{
+    EXPECT_EQ(toLower(""), "");
+    EXPECT_EQ(toLower("ABC"), "abc");
+    EXPECT_EQ(toLower("AbC123"), "abc123");
+    EXPECT_EQ(toLower("already lower"), "already lower");
+
+    // Only 'A'-'Z' are remapped. Neighbouring punctuation and digits, which a
+    // buggy range check could catch, must survive untouched.
+    EXPECT_EQ(toLower("@[`{_^"), "@[`{_^");
+}
+
+// Both helpers are documented as depending only on their input. Guard that by
+// checking the bytes just outside ASCII, which a locale-aware isspace/tolower
+// could classify differently.
+TEST_F(StringUtilitiesTest, trimAndLowerIgnoreLocale)
+{
+    // 0xA0 is NO-BREAK SPACE in Latin-1 and is whitespace to some locales.
+    std::string const nbsp("\xA0", 1);
+    EXPECT_EQ(trimWhitespace(nbsp), nbsp);
+    EXPECT_EQ(trimWhitespace(" " + nbsp + " "), nbsp);
+
+    // 0xC0 is LATIN CAPITAL LETTER A WITH GRAVE in Latin-1.
+    std::string const agrave("\xC0", 1);
+    EXPECT_EQ(toLower(agrave), agrave);
+}
+
 }  // namespace xrpl
diff --git a/src/tests/libxrpl/basics/base_uint.cpp b/src/tests/libxrpl/basics/base_uint.cpp
index 10795f4563..969705b5b7 100644
--- a/src/tests/libxrpl/basics/base_uint.cpp
+++ b/src/tests/libxrpl/basics/base_uint.cpp
@@ -6,6 +6,7 @@
 
 #include 
 
+#include 
 #include 
 
 #include 
@@ -205,125 +206,119 @@ TEST_F(BaseUintTest, base_uint)
     Blob const raw{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
     EXPECT_EQ(BaseUInt96::kBytes, raw.size());
 
-    BaseUInt96 u = BaseUInt96::fromRaw(raw);
-    uset.insert(u);
-    EXPECT_EQ(raw.size(), u.size());
-    EXPECT_EQ(to_string(u), "0102030405060708090A0B0C");
-    EXPECT_EQ(toShortString(u), "01020304...");
-    EXPECT_EQ(*u.data(), 1);
-    EXPECT_EQ(u.signum(), 1);
-    EXPECT_FALSE(!u);
-    EXPECT_FALSE(u.isZero());
-    EXPECT_TRUE(u.isNonZero());
-    unsigned char t = 0;
-    for (auto& d : u)
-    {
-        EXPECT_EQ(d, ++t);
-    }
+    BaseUInt96 ascending = BaseUInt96::fromRaw(raw);
+    uset.insert(ascending);
+    EXPECT_EQ(raw.size(), ascending.size());
+    EXPECT_EQ(to_string(ascending), "0102030405060708090A0B0C");
+    EXPECT_EQ(toShortString(ascending), "01020304...");
+    EXPECT_EQ(*ascending.data(), 1);
+    EXPECT_EQ(ascending.signum(), 1);
+    EXPECT_FALSE(!ascending);
+    EXPECT_FALSE(ascending.isZero());
+    EXPECT_TRUE(ascending.isNonZero());
+    unsigned char expectedByte = 0;
+    for (auto& byte : ascending)
+        EXPECT_EQ(byte, ++expectedByte);
 
-    // Test hash_append by "hashing" with a no-op hasher (h)
+    // Test hash_append by "hashing" with a no-op hasher (hasher)
     // and then extracting the bytes that were written during hashing
-    // back into another base_uint (w) for comparison with the original
-    Nonhash<96> h{};
-    hash_append(h, u);
-    BaseUInt96 const w =
-        BaseUInt96::fromRaw(std::vector(h.data.begin(), h.data.end()));
-    EXPECT_EQ(w, u);
+    // back into another base_uint (rehashed) for comparison with the original
+    Nonhash<96> hasher{};
+    hash_append(hasher, ascending);
+    BaseUInt96 const rehashed =
+        BaseUInt96::fromRaw(std::vector(hasher.data.begin(), hasher.data.end()));
+    EXPECT_EQ(rehashed, ascending);
 
-    BaseUInt96 v{~u};
-    uset.insert(v);
-    EXPECT_EQ(to_string(v), "FEFDFCFBFAF9F8F7F6F5F4F3");
-    EXPECT_EQ(toShortString(v), "FEFDFCFB...");
-    EXPECT_EQ(*v.data(), 0xfe);
-    EXPECT_EQ(v.signum(), 1);
-    EXPECT_FALSE(!v);
-    EXPECT_FALSE(v.isZero());
-    EXPECT_TRUE(v.isNonZero());
+    BaseUInt96 complement{~ascending};
+    uset.insert(complement);
+    EXPECT_EQ(to_string(complement), "FEFDFCFBFAF9F8F7F6F5F4F3");
+    EXPECT_EQ(toShortString(complement), "FEFDFCFB...");
+    EXPECT_EQ(*complement.data(), 0xfe);
+    EXPECT_EQ(complement.signum(), 1);
+    EXPECT_FALSE(!complement);
+    EXPECT_FALSE(complement.isZero());
+    EXPECT_TRUE(complement.isNonZero());
 
-    t = 0xff;
-    for (auto& d : v)
-    {
-        EXPECT_EQ(d, --t);
-    }
+    expectedByte = 0xff;
+    for (auto& byte : complement)
+        EXPECT_EQ(byte, --expectedByte);
 
-    EXPECT_LT(u, v);
-    EXPECT_GT(v, u);
+    EXPECT_LT(ascending, complement);
+    EXPECT_GT(complement, ascending);
 
-    v = u;
-    EXPECT_EQ(v, u);
+    complement = ascending;
+    EXPECT_EQ(complement, ascending);
 
-    BaseUInt96 z{beast::kZero};
-    uset.insert(z);
-    EXPECT_EQ(to_string(z), "000000000000000000000000");
-    EXPECT_EQ(toShortString(z), "00000000...");
-    EXPECT_EQ(*z.data(), 0);
-    EXPECT_EQ(*z.begin(), 0);
-    EXPECT_EQ(*std::prev(z.end(), 1), 0);
-    EXPECT_EQ(z.signum(), 0);
-    EXPECT_TRUE(!z);
-    EXPECT_TRUE(z.isZero());
-    EXPECT_FALSE(z.isNonZero());
-    for (auto& d : z)
-    {
-        EXPECT_EQ(d, 0);
-    }
+    BaseUInt96 zero{beast::kZero};
+    uset.insert(zero);
+    EXPECT_EQ(to_string(zero), "000000000000000000000000");
+    EXPECT_EQ(toShortString(zero), "00000000...");
+    EXPECT_EQ(*zero.data(), 0);
+    EXPECT_EQ(*zero.begin(), 0);
+    EXPECT_EQ(*std::prev(zero.end(), 1), 0);
+    EXPECT_EQ(zero.signum(), 0);
+    EXPECT_TRUE(!zero);
+    EXPECT_TRUE(zero.isZero());
+    EXPECT_FALSE(zero.isNonZero());
+    for (auto& byte : zero)
+        EXPECT_EQ(byte, 0);
 
     {
         // There are several ways to create a zero. beast::kZero is tested above. Test some
         // others.
-        BaseUInt96 const z1;
-        EXPECT_EQ(z1, z) << to_string(z1);
+        BaseUInt96 const defaultZero;
+        EXPECT_EQ(defaultZero, zero) << to_string(defaultZero);
 
-        BaseUInt96 const z2{};
-        EXPECT_EQ(z2, z) << to_string(z2);
+        BaseUInt96 const bracedZero{};
+        EXPECT_EQ(bracedZero, zero) << to_string(bracedZero);
 
-        BaseUInt96 const z3{0u};
-        EXPECT_EQ(z3, z) << to_string(z3);
+        BaseUInt96 const zeroFromUInt{0u};
+        EXPECT_EQ(zeroFromUInt, zero) << to_string(zeroFromUInt);
     }
 
-    BaseUInt96 n{z};
-    n++;
-    EXPECT_EQ(n, BaseUInt96(1));
-    n--;
-    EXPECT_EQ(n, beast::kZero);
-    EXPECT_EQ(n, z);
-    n--;
-    EXPECT_EQ(to_string(n), "FFFFFFFFFFFFFFFFFFFFFFFF");
-    EXPECT_EQ(toShortString(n), "FFFFFFFF...");
-    n = beast::kZero;
-    EXPECT_EQ(n, z);
+    BaseUInt96 counter{zero};
+    counter++;
+    EXPECT_EQ(counter, BaseUInt96(1));
+    counter--;
+    EXPECT_EQ(counter, beast::kZero);
+    EXPECT_EQ(counter, zero);
+    counter--;
+    EXPECT_EQ(to_string(counter), "FFFFFFFFFFFFFFFFFFFFFFFF");
+    EXPECT_EQ(toShortString(counter), "FFFFFFFF...");
+    counter = beast::kZero;
+    EXPECT_EQ(counter, zero);
 
-    BaseUInt96 zp1{z};
-    zp1++;
-    BaseUInt96 zm1{z};
-    zm1--;
-    BaseUInt96 const x{zm1 ^ zp1};
-    uset.insert(x);
-    EXPECT_EQ(to_string(x), "FFFFFFFFFFFFFFFFFFFFFFFE") << to_string(x);
-    EXPECT_EQ(toShortString(x), "FFFFFFFF...") << toShortString(x);
+    BaseUInt96 zeroPlusOne{zero};
+    zeroPlusOne++;
+    BaseUInt96 zeroMinusOne{zero};
+    zeroMinusOne--;
+    BaseUInt96 const xored{zeroMinusOne ^ zeroPlusOne};
+    uset.insert(xored);
+    EXPECT_EQ(to_string(xored), "FFFFFFFFFFFFFFFFFFFFFFFE") << to_string(xored);
+    EXPECT_EQ(toShortString(xored), "FFFFFFFF...") << toShortString(xored);
 
     EXPECT_EQ(uset.size(), 4);
 
-    BaseUInt96 tmp;
-    EXPECT_TRUE(tmp.parseHex(to_string(u)));
-    EXPECT_EQ(tmp, u);
-    tmp = z;
+    BaseUInt96 parsed;
+    EXPECT_TRUE(parsed.parseHex(to_string(ascending)));
+    EXPECT_EQ(parsed, ascending);
+    parsed = zero;
 
     // fails with extra char
-    EXPECT_FALSE(tmp.parseHex("A" + to_string(u)));
-    tmp = z;
+    EXPECT_FALSE(parsed.parseHex("A" + to_string(ascending)));
+    parsed = zero;
 
     // fails with extra char at end
-    EXPECT_FALSE(tmp.parseHex(to_string(u) + "A"));
+    EXPECT_FALSE(parsed.parseHex(to_string(ascending) + "A"));
 
     // fails with a non-hex character at some point in the string:
-    tmp = z;
+    parsed = zero;
 
     for (std::size_t i = 0; i != 24; ++i)
     {
-        std::string x = to_string(z);
-        x[i] = ('G' + (i % 10));
-        EXPECT_FALSE(tmp.parseHex(x));
+        std::string xored = to_string(zero);
+        xored[i] = ('G' + (i % 10));
+        EXPECT_FALSE(parsed.parseHex(xored));
     }
 
     // Walking 1s:
@@ -332,8 +327,8 @@ TEST_F(BaseUintTest, base_uint)
         std::string s1 = "000000000000000000000000";
         s1[i] = '1';
 
-        EXPECT_TRUE(tmp.parseHex(s1));
-        EXPECT_EQ(to_string(tmp), s1);
+        EXPECT_TRUE(parsed.parseHex(s1));
+        EXPECT_EQ(to_string(parsed), s1);
     }
 
     // Walking 0s:
@@ -342,8 +337,8 @@ TEST_F(BaseUintTest, base_uint)
         std::string s1 = "111111111111111111111111";
         s1[i] = '0';
 
-        EXPECT_TRUE(tmp.parseHex(s1));
-        EXPECT_EQ(to_string(tmp), s1);
+        EXPECT_TRUE(parsed.parseHex(s1));
+        EXPECT_EQ(to_string(parsed), s1);
     }
 
     // Constexpr constructors
@@ -357,39 +352,27 @@ TEST_F(BaseUintTest, base_uint)
         // Using the constexpr constructor in a non-constexpr context
         // with an error in the parsing throws an exception.
         {
-            // Invalid length for string.
-            bool caught = false;
-            try
-            {
-                // Try to prevent constant evaluation.
-                std::vector str(23, '7');
+            // Invalid length for string. The vector keeps this out of a constant
+            // expression, so the constructor throws instead of failing to compile.
+            auto tooShort = [] {
+                std::vector const str(23, '7');
                 std::string_view const sView(str.data(), str.size());
                 [[maybe_unused]] BaseUInt96 const t96(sView);
-            }
-            catch (std::invalid_argument const& e)
-            {
-                EXPECT_EQ(e.what(), std::string("invalid length for hex string"));
-                caught = true;
-            }
-            EXPECT_TRUE(caught);
+            };
+            EXPECT_THAT(
+                tooShort,
+                ::testing::ThrowsMessage("invalid length for hex string"));
         }
         {
             // Invalid character in string.
-            bool caught = false;
-            try
-            {
-                // Try to prevent constant evaluation.
+            auto badCharacter = [] {
                 std::vector str(23, '7');
                 str.push_back('G');
                 std::string_view const sView(str.data(), str.size());
                 [[maybe_unused]] BaseUInt96 const t96(sView);
-            }
-            catch (std::range_error const& e)
-            {
-                EXPECT_EQ(e.what(), std::string("invalid hex character"));
-                caught = true;
-            }
-            EXPECT_TRUE(caught);
+            };
+            EXPECT_THAT(
+                badCharacter, ::testing::ThrowsMessage("invalid hex character"));
         }
 
         // Verify that constexpr base_uints interpret a string the same
@@ -412,11 +395,11 @@ TEST_F(BaseUintTest, base_uint)
             "fFfFfFfFfFfFfFfFfFfFfFfF",
         });
 
-        for (StrBaseUInt const& t : kTestCases)
+        for (StrBaseUInt const& expectedByte : kTestCases)
         {
             BaseUInt96 t96;
-            EXPECT_TRUE(t96.parseHex(t.str));
-            EXPECT_EQ(t96, t.tst);
+            EXPECT_TRUE(t96.parseHex(expectedByte.str));
+            EXPECT_EQ(t96, expectedByte.tst);
         }
     }
 }
diff --git a/src/tests/libxrpl/nodestore/Backend.cpp b/src/tests/libxrpl/nodestore/Backend.cpp
index eb78851429..3bd36ced8d 100644
--- a/src/tests/libxrpl/nodestore/Backend.cpp
+++ b/src/tests/libxrpl/nodestore/Backend.cpp
@@ -1,8 +1,8 @@
 #include 
 
 #include 
+#include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -84,7 +84,7 @@ protected:
     }
 
     DummyScheduler scheduler_;
-    beast::TempDir const tempDir_;
+    TempDir const tempDir_;
     beast::Journal const journal_{TestSink::instance()};
     Section params_;
     Batch batch_;
diff --git a/src/tests/libxrpl/nodestore/Database.cpp b/src/tests/libxrpl/nodestore/Database.cpp
index 82012ed347..a3f7340f62 100644
--- a/src/tests/libxrpl/nodestore/Database.cpp
+++ b/src/tests/libxrpl/nodestore/Database.cpp
@@ -1,8 +1,8 @@
 #include 
 
 #include 
+#include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -81,7 +81,7 @@ protected:
     }
 
     DummyScheduler scheduler_;
-    beast::TempDir const nodeDb_;
+    TempDir const nodeDb_;
     beast::Journal const journal_{TestSink::instance()};
     Section nodeParams_;
     Batch batch_;
@@ -157,7 +157,7 @@ INSTANTIATE_TEST_SUITE_P(
 TEST(NodeStoreDatabase, memory_earliest_seq)
 {
     DummyScheduler scheduler;
-    beast::TempDir const nodeDb;
+    TempDir const nodeDb;
     Section nodeParams;
     nodeParams.set("type", "memory");
     nodeParams.set("path", nodeDb.path());
@@ -204,7 +204,7 @@ TEST_P(DatabaseImportTest, same_backend)
     DummyScheduler scheduler;
     beast::Journal const journal(TestSink::instance());
 
-    beast::TempDir const srcDir;
+    TempDir const srcDir;
     Section srcParams;
     srcParams.set("type", type);
     srcParams.set("path", srcDir.path());
@@ -222,7 +222,7 @@ TEST_P(DatabaseImportTest, same_backend)
         // re-open source and import into a fresh destination
         auto src = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, srcParams, journal);
 
-        beast::TempDir const destDir;
+        TempDir const destDir;
         Section destParams;
         destParams.set("type", type);
         destParams.set("path", destDir.path());
diff --git a/src/tests/libxrpl/nodestore/NuDBFactory.cpp b/src/tests/libxrpl/nodestore/NuDBFactory.cpp
index c126984630..7240f08256 100644
--- a/src/tests/libxrpl/nodestore/NuDBFactory.cpp
+++ b/src/tests/libxrpl/nodestore/NuDBFactory.cpp
@@ -1,6 +1,6 @@
 #include 
+#include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -58,7 +58,7 @@ runRoundTrip(Section const& params, std::size_t expectedBlocksize)
 
 TEST(NuDBFactory, default_block_size)
 {
-    beast::TempDir const tempDir;
+    TempDir const tempDir;
     auto const params = makeSection(tempDir.path());
     ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, 4096));
 }
@@ -69,14 +69,14 @@ TEST(NuDBFactory, valid_block_sizes)
     for (auto const size : kValidSizes)
     {
         SCOPED_TRACE("size=" + std::to_string(size));
-        beast::TempDir const tempDir;
+        TempDir const tempDir;
         auto const params = makeSection(tempDir.path(), std::to_string(size));
         ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, size));
     }
 
     // empty value is ignored by config parser; default (4096) is used
     {
-        beast::TempDir const tempDir;
+        TempDir const tempDir;
         auto const params = makeSection(tempDir.path(), "");
         ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, 4096));
     }
@@ -101,7 +101,7 @@ TEST(NuDBFactory, invalid_block_sizes)
     for (auto const& size : kInvalidSizes)
     {
         SCOPED_TRACE("size='" + size + "'");
-        beast::TempDir const tempDir;
+        TempDir const tempDir;
         auto const params = makeSection(tempDir.path(), size);
         EXPECT_THROW(runRoundTrip(params, 4096), std::exception);
     }
@@ -111,7 +111,7 @@ TEST(NuDBFactory, invalid_block_sizes)
     for (auto const& size : kWhitespaceSizes)
     {
         SCOPED_TRACE("size='" + size + "'");
-        beast::TempDir const tempDir;
+        TempDir const tempDir;
         auto const params = makeSection(tempDir.path(), size);
         EXPECT_THROW(runRoundTrip(params, 4096), std::exception);
     }
@@ -121,7 +121,7 @@ TEST(NuDBFactory, log_messages)
 {
     // valid custom block size emits info log
     {
-        beast::TempDir const tempDir;
+        TempDir const tempDir;
         auto const params = makeSection(tempDir.path(), "8192");
         test::CaptureSink sink(beast::Severity::Info);
         beast::Journal const journal(sink);
@@ -135,7 +135,7 @@ TEST(NuDBFactory, log_messages)
 
     // invalid block size throws with informative message
     {
-        beast::TempDir const tempDir;
+        TempDir const tempDir;
         auto const params = makeSection(tempDir.path(), "5000");
         test::CaptureSink sink(beast::Severity::Warning);
         beast::Journal const journal(sink);
@@ -156,7 +156,7 @@ TEST(NuDBFactory, log_messages)
 
     // non-numeric value throws
     {
-        beast::TempDir const tempDir;
+        TempDir const tempDir;
         auto const params = makeSection(tempDir.path(), "invalid");
         test::CaptureSink sink(beast::Severity::Warning);
         beast::Journal const journal(sink);
@@ -191,7 +191,7 @@ TEST(NuDBFactory, power_of_two_validation)
     for (auto const& [size, shouldWork] : kCASES)
     {
         SCOPED_TRACE("size=" + size + " shouldWork=" + (shouldWork ? "true" : "false"));
-        beast::TempDir const tempDir;
+        TempDir const tempDir;
         auto const params = makeSection(tempDir.path(), size);
         test::CaptureSink sink(beast::Severity::Warning);
         beast::Journal const journal(sink);
@@ -216,7 +216,7 @@ TEST(NuDBFactory, power_of_two_validation)
 
 TEST(NuDBFactory, both_constructor_variants)
 {
-    beast::TempDir const tempDir;
+    TempDir const tempDir;
     auto const params = makeSection(tempDir.path(), "16384");
     DummyScheduler scheduler;
     beast::Journal const journal(TestSink::instance());
@@ -235,7 +235,7 @@ TEST(NuDBFactory, configuration_parsing)
 {
     // basic valid format emits success log
     {
-        beast::TempDir const tempDir;
+        TempDir const tempDir;
         auto const params = makeSection(tempDir.path(), "8192");
         test::CaptureSink sink(beast::Severity::Info);
         beast::Journal const journal(sink);
@@ -250,7 +250,7 @@ TEST(NuDBFactory, configuration_parsing)
     for (auto const& format : kWhitespaceFormats)
     {
         SCOPED_TRACE("format='" + format + "'");
-        beast::TempDir const tempDir;
+        TempDir const tempDir;
         auto const params = makeSection(tempDir.path(), format);
         test::CaptureSink sink(beast::Severity::Debug);
         beast::Journal const journal(sink);
@@ -265,7 +265,7 @@ TEST(NuDBFactory, data_persistence)
     for (auto const& size : kBlockSizes)
     {
         SCOPED_TRACE("size=" + size);
-        beast::TempDir const tempDir;
+        TempDir const tempDir;
         auto const params = makeSection(tempDir.path(), size);
         DummyScheduler scheduler;
         beast::Journal const journal(TestSink::instance());
diff --git a/src/tests/libxrpl/protocol/STXChainBridge.cpp b/src/tests/libxrpl/protocol/STXChainBridge.cpp
new file mode 100644
index 0000000000..f4e6e60cc9
--- /dev/null
+++ b/src/tests/libxrpl/protocol/STXChainBridge.cpp
@@ -0,0 +1,60 @@
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+
+using namespace xrpl;
+
+namespace {
+
+// Built from raw bytes rather than base58 so the test does not depend on
+// hand-computed checksums.
+AccountID
+account(std::string_view hex)
+{
+    AccountID id;
+    EXPECT_TRUE(id.parseHex(hex));
+    return id;
+}
+
+}  // namespace
+
+// getText() builds its string from eight substitutions of the same type, so a
+// transposed pair would still compile and still type check. Pin the output so
+// the field/value pairing is actually verified.
+TEST(STXChainBridge, getTextPairsEachFieldWithItsValue)
+{
+    auto const lockingDoor = account("0102030405060708090A0B0C0D0E0F1011121314");
+    auto const issuingDoor = account("14131211100F0E0D0C0B0A090807060504030201");
+
+    auto const lockingIssue = xrpIssue();
+    Issue const issuingIssue{toCurrency("USD"), issuingDoor};
+
+    STXChainBridge const bridge{lockingDoor, lockingIssue, issuingDoor, issuingIssue};
+
+    std::string const expected = "{ LockingChainDoor = " + toBase58(lockingDoor) +
+        ", LockingChainIssue = " + lockingIssue.getText() +
+        ", IssuingChainDoor = " + toBase58(issuingDoor) +
+        ", IssuingChainIssue = " + issuingIssue.getText() + " }";
+
+    EXPECT_EQ(bridge.getText(), expected);
+}
+
+TEST(STXChainBridge, getTextOnADefaultBridge)
+{
+    STXChainBridge const bridge;
+    auto const text = bridge.getText();
+
+    // The outer braces are literal, and the four field names appear in
+    // declaration order regardless of the values.
+    EXPECT_TRUE(text.starts_with("{ LockingChainDoor = "));
+    EXPECT_TRUE(text.ends_with(" }"));
+    EXPECT_LT(text.find("LockingChainIssue"), text.find("IssuingChainDoor"));
+    EXPECT_LT(text.find("IssuingChainDoor"), text.find("IssuingChainIssue"));
+}
diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp
index f55d01f606..26dde55563 100644
--- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp
@@ -36,6 +36,9 @@ TEST(VaultTests, BuilderSettersRoundTrip)
     auto const withdrawalPolicyValue = canonical_UINT8();
     auto const scaleValue = canonical_UINT8();
     auto const lEVersionValue = canonical_UINT8();
+    auto const vaultKindValue = canonical_UINT8();
+    auto const subscriptionDateValue = canonical_UINT32();
+    auto const redemptionDateValue = canonical_UINT32();
 
     VaultBuilder builder{
         previousTxnIDValue,
@@ -56,6 +59,9 @@ TEST(VaultTests, BuilderSettersRoundTrip)
     builder.setLossUnrealized(lossUnrealizedValue);
     builder.setScale(scaleValue);
     builder.setLEVersion(lEVersionValue);
+    builder.setVaultKind(vaultKindValue);
+    builder.setSubscriptionDate(subscriptionDateValue);
+    builder.setRedemptionDate(redemptionDateValue);
 
     builder.setLedgerIndex(index);
     builder.setFlags(0x1u);
@@ -176,6 +182,30 @@ TEST(VaultTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(entry.hasLEVersion());
     }
 
+    {
+        auto const& expected = vaultKindValue;
+        auto const actualOpt = entry.getVaultKind();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfVaultKind");
+        EXPECT_TRUE(entry.hasVaultKind());
+    }
+
+    {
+        auto const& expected = subscriptionDateValue;
+        auto const actualOpt = entry.getSubscriptionDate();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfSubscriptionDate");
+        EXPECT_TRUE(entry.hasSubscriptionDate());
+    }
+
+    {
+        auto const& expected = redemptionDateValue;
+        auto const actualOpt = entry.getRedemptionDate();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfRedemptionDate");
+        EXPECT_TRUE(entry.hasRedemptionDate());
+    }
+
     EXPECT_TRUE(entry.hasLedgerIndex());
     auto const ledgerIndex = entry.getLedgerIndex();
     ASSERT_TRUE(ledgerIndex.has_value());
@@ -205,6 +235,9 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
     auto const withdrawalPolicyValue = canonical_UINT8();
     auto const scaleValue = canonical_UINT8();
     auto const lEVersionValue = canonical_UINT8();
+    auto const vaultKindValue = canonical_UINT8();
+    auto const subscriptionDateValue = canonical_UINT32();
+    auto const redemptionDateValue = canonical_UINT32();
 
     auto sle = std::make_shared(Vault::entryType, index);
 
@@ -224,6 +257,9 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
     sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue;
     sle->at(sfScale) = scaleValue;
     sle->at(sfLEVersion) = lEVersionValue;
+    sle->at(sfVaultKind) = vaultKindValue;
+    sle->at(sfSubscriptionDate) = subscriptionDateValue;
+    sle->at(sfRedemptionDate) = redemptionDateValue;
 
     VaultBuilder builderFromSle{sle};
     EXPECT_TRUE(builderFromSle.validate());
@@ -415,6 +451,45 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
         expectEqualField(expected, *fromBuilderOpt, "sfLEVersion");
     }
 
+    {
+        auto const& expected = vaultKindValue;
+
+        auto const fromSleOpt = entryFromSle.getVaultKind();
+        auto const fromBuilderOpt = entryFromBuilder.getVaultKind();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfVaultKind");
+        expectEqualField(expected, *fromBuilderOpt, "sfVaultKind");
+    }
+
+    {
+        auto const& expected = subscriptionDateValue;
+
+        auto const fromSleOpt = entryFromSle.getSubscriptionDate();
+        auto const fromBuilderOpt = entryFromBuilder.getSubscriptionDate();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfSubscriptionDate");
+        expectEqualField(expected, *fromBuilderOpt, "sfSubscriptionDate");
+    }
+
+    {
+        auto const& expected = redemptionDateValue;
+
+        auto const fromSleOpt = entryFromSle.getRedemptionDate();
+        auto const fromBuilderOpt = entryFromBuilder.getRedemptionDate();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfRedemptionDate");
+        expectEqualField(expected, *fromBuilderOpt, "sfRedemptionDate");
+    }
+
     EXPECT_EQ(entryFromSle.getKey(), index);
     EXPECT_EQ(entryFromBuilder.getKey(), index);
 }
@@ -499,5 +574,11 @@ TEST(VaultTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(entry.getScale().has_value());
     EXPECT_FALSE(entry.hasLEVersion());
     EXPECT_FALSE(entry.getLEVersion().has_value());
+    EXPECT_FALSE(entry.hasVaultKind());
+    EXPECT_FALSE(entry.getVaultKind().has_value());
+    EXPECT_FALSE(entry.hasSubscriptionDate());
+    EXPECT_FALSE(entry.getSubscriptionDate().has_value());
+    EXPECT_FALSE(entry.hasRedemptionDate());
+    EXPECT_FALSE(entry.getRedemptionDate().has_value());
 }
 }
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp
index 9c1e14f6f4..592d40a6f6 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp
@@ -36,6 +36,9 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip)
     auto const withdrawalPolicyValue = canonical_UINT8();
     auto const dataValue = canonical_VL();
     auto const scaleValue = canonical_UINT8();
+    auto const vaultKindValue = canonical_UINT8();
+    auto const subscriptionDateValue = canonical_UINT32();
+    auto const redemptionDateValue = canonical_UINT32();
 
     VaultCreateBuilder builder{
         accountValue,
@@ -51,6 +54,9 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip)
     builder.setWithdrawalPolicy(withdrawalPolicyValue);
     builder.setData(dataValue);
     builder.setScale(scaleValue);
+    builder.setVaultKind(vaultKindValue);
+    builder.setSubscriptionDate(subscriptionDateValue);
+    builder.setRedemptionDate(redemptionDateValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -122,6 +128,30 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(tx.hasScale());
     }
 
+    {
+        auto const& expected = vaultKindValue;
+        auto const actualOpt = tx.getVaultKind();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfVaultKind should be present";
+        expectEqualField(expected, *actualOpt, "sfVaultKind");
+        EXPECT_TRUE(tx.hasVaultKind());
+    }
+
+    {
+        auto const& expected = subscriptionDateValue;
+        auto const actualOpt = tx.getSubscriptionDate();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionDate should be present";
+        expectEqualField(expected, *actualOpt, "sfSubscriptionDate");
+        EXPECT_TRUE(tx.hasSubscriptionDate());
+    }
+
+    {
+        auto const& expected = redemptionDateValue;
+        auto const actualOpt = tx.getRedemptionDate();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRedemptionDate should be present";
+        expectEqualField(expected, *actualOpt, "sfRedemptionDate");
+        EXPECT_TRUE(tx.hasRedemptionDate());
+    }
+
 }
 
 // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -145,6 +175,9 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip)
     auto const withdrawalPolicyValue = canonical_UINT8();
     auto const dataValue = canonical_VL();
     auto const scaleValue = canonical_UINT8();
+    auto const vaultKindValue = canonical_UINT8();
+    auto const subscriptionDateValue = canonical_UINT32();
+    auto const redemptionDateValue = canonical_UINT32();
 
     // Build an initial transaction
     VaultCreateBuilder initialBuilder{
@@ -160,6 +193,9 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip)
     initialBuilder.setWithdrawalPolicy(withdrawalPolicyValue);
     initialBuilder.setData(dataValue);
     initialBuilder.setScale(scaleValue);
+    initialBuilder.setVaultKind(vaultKindValue);
+    initialBuilder.setSubscriptionDate(subscriptionDateValue);
+    initialBuilder.setRedemptionDate(redemptionDateValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -226,6 +262,27 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip)
         expectEqualField(expected, *actualOpt, "sfScale");
     }
 
+    {
+        auto const& expected = vaultKindValue;
+        auto const actualOpt = rebuiltTx.getVaultKind();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfVaultKind should be present";
+        expectEqualField(expected, *actualOpt, "sfVaultKind");
+    }
+
+    {
+        auto const& expected = subscriptionDateValue;
+        auto const actualOpt = rebuiltTx.getSubscriptionDate();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionDate should be present";
+        expectEqualField(expected, *actualOpt, "sfSubscriptionDate");
+    }
+
+    {
+        auto const& expected = redemptionDateValue;
+        auto const actualOpt = rebuiltTx.getRedemptionDate();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRedemptionDate should be present";
+        expectEqualField(expected, *actualOpt, "sfRedemptionDate");
+    }
+
 }
 
 // 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -295,6 +352,12 @@ TEST(TransactionsVaultCreateTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getData().has_value());
     EXPECT_FALSE(tx.hasScale());
     EXPECT_FALSE(tx.getScale().has_value());
+    EXPECT_FALSE(tx.hasVaultKind());
+    EXPECT_FALSE(tx.getVaultKind().has_value());
+    EXPECT_FALSE(tx.hasSubscriptionDate());
+    EXPECT_FALSE(tx.getSubscriptionDate().has_value());
+    EXPECT_FALSE(tx.hasRedemptionDate());
+    EXPECT_FALSE(tx.getRedemptionDate().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/shamap/SHAMap.cpp b/src/tests/libxrpl/shamap/SHAMap.cpp
index e662e16be4..c84cdf504f 100644
--- a/src/tests/libxrpl/shamap/SHAMap.cpp
+++ b/src/tests/libxrpl/shamap/SHAMap.cpp
@@ -16,7 +16,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -113,7 +112,7 @@ protected:
     intToVuc(std::uint8_t v)
     {
         Buffer vuc{32};
-        std::fill_n(vuc.data(), vuc.size(), v);
+        vuc.fill(v);
         return vuc;
     }
 };
diff --git a/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp b/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp
index 531dba59f9..230c802022 100644
--- a/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp
@@ -75,11 +75,10 @@ getSHAMapNodeID(protocol::TMLedgerNode const& ledgerNode, SHAMapTreeNode const&
     if (treeNode.isLeaf())
     {
         auto const key = leafKey(treeNode);
-        auto const expectedID = SHAMapNodeID::createID(static_cast(nodeID->getDepth()), key);
         SOMETIMES(
-            nodeID->getNodeID() != expectedID.getNodeID(),
+            !nodeID->isPrefixOf(key),
             "xrpl::getSHAMapNodeID : legacy leaf ID inconsistent with key");
-        if (nodeID->getNodeID() != expectedID.getNodeID())
+        if (!nodeID->isPrefixOf(key))
             return std::nullopt;
     }
 
diff --git a/src/xrpld/app/main/GRPCServer.cpp b/src/xrpld/app/main/GRPCServer.cpp
index 1b20ff1d49..c1ea5e874b 100644
--- a/src/xrpld/app/main/GRPCServer.cpp
+++ b/src/xrpld/app/main/GRPCServer.cpp
@@ -9,6 +9,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -24,7 +25,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -49,6 +49,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -371,7 +372,7 @@ GRPCServerImpl::GRPCServerImpl(Application& app)
                 std::string ip;
                 while (std::getline(ss, ip, ','))
                 {
-                    boost::algorithm::trim(ip);
+                    ip = trimWhitespace(ip);
                     auto const addr = boost::asio::ip::make_address(ip);
 
                     if (addr.is_unspecified())
@@ -615,7 +616,7 @@ GRPCServerImpl::createServerCredentials()
 
     try
     {
-        boost::system::error_code ec;
+        std::error_code ec;
         grpc::SslServerCredentialsOptions sslOpts;
         grpc::SslServerCredentialsOptions::PemKeyCertPair keyCertPair;
 
diff --git a/src/xrpld/app/main/Main.cpp b/src/xrpld/app/main/Main.cpp
index a23b84f2e8..ba6520db5f 100644
--- a/src/xrpld/app/main/Main.cpp
+++ b/src/xrpld/app/main/Main.cpp
@@ -6,6 +6,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -21,7 +22,6 @@
 
 #include 
 #include 
-#include 
 #include 
 #include   // IWYU pragma: keep
 #include 
@@ -211,7 +211,7 @@ public:
         boost::split(v, patterns, boost::algorithm::is_any_of(","));
         selectors_.reserve(v.size());
         std::ranges::for_each(v, [this](std::string s) {
-            boost::trim(s);
+            s = trimWhitespace(s);
             if (selectors_.empty() || !s.empty())
                 selectors_.emplace_back(beast::unit_test::Selector::ModeT::Automatch, s);
         });
@@ -614,7 +614,7 @@ run(int argc, char** argv)
                 std::vector result;
                 for (auto& s : strVec)
                 {
-                    boost::trim(s);
+                    s = trimWhitespace(s);
                     if (!s.empty())
                         result.push_back(std::stoi(s));
                 }
diff --git a/src/xrpld/app/misc/FeeVoteImpl.cpp b/src/xrpld/app/misc/FeeVoteImpl.cpp
index 76a4d8f186..f1cb944a52 100644
--- a/src/xrpld/app/misc/FeeVoteImpl.cpp
+++ b/src/xrpld/app/misc/FeeVoteImpl.cpp
@@ -260,39 +260,35 @@ FeeVoteImpl::doVoting(
     }
 
     // choose our positions
-    // TODO: Use structured binding once LLVM 16 is the minimum supported
-    // version. See also: https://github.com/llvm/llvm-project/issues/48582
-    // https://github.com/llvm/llvm-project/commit/127bf44385424891eb04cff8e52d3f157fc2cb7c
-    auto const baseFee = baseFeeVote.getVotes();
-    auto const baseReserve = baseReserveVote.getVotes();
-    auto const incReserve = incReserveVote.getVotes();
+    auto const [baseFee, baseFeeChanged] = baseFeeVote.getVotes();
+    auto const [baseReserve, baseReserveChanged] = baseReserveVote.getVotes();
+    auto const [incReserve, incReserveChanged] = incReserveVote.getVotes();
 
     auto const seq = lastClosedLedger->header().seq + 1;
 
     // add transactions to our position
-    if (baseFee.second || baseReserve.second || incReserve.second)
+    if (baseFeeChanged || baseReserveChanged || incReserveChanged)
     {
-        JLOG(journal_.warn()) << "We are voting for a fee change: " << baseFee.first << "/"
-                              << baseReserve.first << "/" << incReserve.first;
+        JLOG(journal_.warn()) << "We are voting for a fee change: " << baseFee << "/" << baseReserve
+                              << "/" << incReserve;
 
         STTx const feeTx(ttFEE, [=, &rules](auto& obj) {
             obj[sfAccount] = AccountID();
             obj[sfLedgerSequence] = seq;
             if (rules.enabled(featureXRPFees))
             {
-                obj[sfBaseFeeDrops] = baseFee.first;
-                obj[sfReserveBaseDrops] = baseReserve.first;
-                obj[sfReserveIncrementDrops] = incReserve.first;
+                obj[sfBaseFeeDrops] = baseFee;
+                obj[sfReserveBaseDrops] = baseReserve;
+                obj[sfReserveIncrementDrops] = incReserve;
             }
             else
             {
                 // Without the featureXRPFees amendment, these fields are
                 // required.
-                obj[sfBaseFee] = baseFee.first.dropsAs(baseFeeVote.current());
-                obj[sfReserveBase] =
-                    baseReserve.first.dropsAs(baseReserveVote.current());
+                obj[sfBaseFee] = baseFee.dropsAs(baseFeeVote.current());
+                obj[sfReserveBase] = baseReserve.dropsAs(baseReserveVote.current());
                 obj[sfReserveIncrement] =
-                    incReserve.first.dropsAs(incReserveVote.current());
+                    incReserve.dropsAs(incReserveVote.current());
                 obj[sfReferenceFeeUnits] = kFeeUnitsDeprecated;
             }
         });
diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp
index e41837d206..9e3f1ac52b 100644
--- a/src/xrpld/app/misc/SHAMapStoreImp.cpp
+++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp
@@ -6,6 +6,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -27,12 +28,10 @@
 #include 
 
 #include 
-#include 
-#include 
-#include 
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -426,10 +425,10 @@ SHAMapStoreImp::dbPaths()
     if (boost::iequals(get(section, Keys::kType), "memory"))
         return;
 
-    boost::filesystem::path dbPath = get(section, Keys::kPath);
-    if (boost::filesystem::exists(dbPath))
+    std::filesystem::path dbPath = get(section, Keys::kPath);
+    if (std::filesystem::exists(dbPath))
     {
-        if (!boost::filesystem::is_directory(dbPath))
+        if (!std::filesystem::is_directory(dbPath))
         {
             journal_.error() << "node db path must be a directory. " << dbPath.string();
             Throw("node db path must be a directory.");
@@ -437,7 +436,7 @@ SHAMapStoreImp::dbPaths()
     }
     else
     {
-        boost::filesystem::create_directories(dbPath);
+        std::filesystem::create_directories(dbPath);
     }
 
     SavedState state = stateDb_.getState();
@@ -448,8 +447,8 @@ SHAMapStoreImp::dbPaths()
                 return false;
 
             // Check if configured "path" matches stored directory path
-            using namespace boost::filesystem;
-            auto const stored{path(sPath)};
+            using namespace std::filesystem;
+            auto const stored{std::filesystem::path(sPath)};
             if (stored.parent_path() == dbPath)
                 return false;
 
@@ -467,9 +466,9 @@ SHAMapStoreImp::dbPaths()
     bool writableDbExists = false;
     bool archiveDbExists = false;
 
-    std::vector pathsToDelete;
-    for (boost::filesystem::directory_iterator it(dbPath);
-         it != boost::filesystem::directory_iterator();
+    std::vector pathsToDelete;
+    for (std::filesystem::directory_iterator it(dbPath);
+         it != std::filesystem::directory_iterator();
          ++it)
     {
         if (state.writableDb == it->path().string())
@@ -490,7 +489,7 @@ SHAMapStoreImp::dbPaths()
         (!archiveDbExists && !state.archiveDb.empty()) || (writableDbExists != archiveDbExists) ||
         state.writableDb.empty() != state.archiveDb.empty())
     {
-        boost::filesystem::path stateDbPathName = app_.config().legacy(Sections::kDatabasePath);
+        std::filesystem::path stateDbPathName = app_.config().legacy(Sections::kDatabasePath);
         stateDbPathName /= dbName_;
         stateDbPathName += "*";
 
@@ -512,15 +511,15 @@ SHAMapStoreImp::dbPaths()
     }
 
     // The necessary directories exist. Now, remove any others.
-    for (boost::filesystem::path const& p : pathsToDelete)
-        boost::filesystem::remove_all(p);
+    for (std::filesystem::path const& p : pathsToDelete)
+        std::filesystem::remove_all(p);
 }
 
 std::unique_ptr
 SHAMapStoreImp::makeBackendRotating(std::string path)
 {
     Section section{app_.config().section(Sections::kNodeDatabase)};
-    boost::filesystem::path newPath;
+    std::filesystem::path newPath;
 
     if (!path.empty())
     {
@@ -528,10 +527,7 @@ SHAMapStoreImp::makeBackendRotating(std::string path)
     }
     else
     {
-        boost::filesystem::path p = get(section, Keys::kPath);
-        p /= dbPrefix_;
-        p += ".%%%%";
-        newPath = boost::filesystem::unique_path(p);
+        newPath = uniqueRandomPath(get(section, Keys::kPath), dbPrefix_ + ".");
     }
     section.set(Keys::kPath, newPath.string());
 
diff --git a/src/xrpld/app/misc/Transaction.h b/src/xrpld/app/misc/Transaction.h
index b6b6d1a8d5..61951fbb59 100644
--- a/src/xrpld/app/misc/Transaction.h
+++ b/src/xrpld/app/misc/Transaction.h
@@ -15,6 +15,10 @@
 #include 
 #include 
 
+// boost::optional (not std::optional) appears in the declarations below,
+// because SOCI's into()/use() bindings only support boost::optional.
+#include 
+
 #include 
 #include 
 #include 
diff --git a/src/xrpld/app/misc/ValidatorList.h b/src/xrpld/app/misc/ValidatorList.h
index 3f9039eab8..4e001affe8 100644
--- a/src/xrpld/app/misc/ValidatorList.h
+++ b/src/xrpld/app/misc/ValidatorList.h
@@ -17,6 +17,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -29,7 +30,6 @@
 #include 
 
 namespace protocol {
-class TMValidatorList;
 class TMValidatorListCollection;
 }  // namespace protocol
 
@@ -238,7 +238,7 @@ class ValidatorList
     ManifestCache& validatorManifests_;
     ManifestCache& publisherManifests_;
     TimeKeeper& timeKeeper_;
-    boost::filesystem::path const dataPath_;
+    std::filesystem::path const dataPath_;
     beast::Journal const j_;
     std::shared_mutex mutable mutex_;
     using scoped_lock = std::scoped_lock;
@@ -370,9 +370,6 @@ public:
     static std::vector
     parseBlobs(std::uint32_t version, json::Value const& body);
 
-    static std::vector
-    parseBlobs(protocol::TMValidatorList const& body);
-
     static std::vector
     parseBlobs(protocol::TMValidatorListCollection const& body);
 
@@ -390,7 +387,6 @@ public:
 
     [[nodiscard]] static std::pair
     buildValidatorListMessages(
-        std::size_t messageVersion,
         std::uint64_t peerSequence,
         std::size_t maxSequence,
         std::uint32_t rawVersion,
@@ -866,7 +862,7 @@ private:
     /**
      * Get the filename used for caching UNLs
      */
-    boost::filesystem::path
+    std::filesystem::path
     getCacheFileName(scoped_lock const&, PublicKey const& pubKey) const;
 
     /**
@@ -986,14 +982,6 @@ hash_append(Hasher& h, std::map const& blobs)
 
 namespace protocol {
 
-template 
-void
-hash_append(Hasher& h, TMValidatorList const& msg)
-{
-    using beast::hash_append;
-    hash_append(h, msg.manifest(), msg.blob(), msg.signature(), msg.version());
-}
-
 template 
 void
 hash_append(Hasher& h, TMValidatorListCollection const& msg)
diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp
index e355cfacab..f099ebf059 100644
--- a/src/xrpld/app/misc/detail/ValidatorList.cpp
+++ b/src/xrpld/app/misc/detail/ValidatorList.cpp
@@ -29,12 +29,8 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
-#include 
-#include 
-#include 
 
 #include 
 
@@ -43,6 +39,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -54,6 +51,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -288,7 +286,7 @@ ValidatorList::load(
     return true;
 }
 
-boost::filesystem::path
+std::filesystem::path
 ValidatorList::getCacheFileName(ValidatorList::scoped_lock const&, PublicKey const& pubKey) const
 {
     return dataPath_ / (kFilePrefix + strHex(pubKey));
@@ -372,9 +370,9 @@ ValidatorList::cacheValidatorFile(ValidatorList::scoped_lock const& lock, Public
     if (dataPath_.empty())
         return;
 
-    boost::filesystem::path const filename = getCacheFileName(lock, pubKey);
+    std::filesystem::path const filename = getCacheFileName(lock, pubKey);
 
-    boost::system::error_code ec;
+    std::error_code ec;
 
     json::Value value = buildFileData(strHex(pubKey), publisherLists_.at(pubKey), j_);
     // xrpld should be the only process writing to this file, so
@@ -451,13 +449,6 @@ ValidatorList::parseBlobs(std::uint32_t version, json::Value const& body)
     }
 }
 
-// static
-std::vector
-ValidatorList::parseBlobs(protocol::TMValidatorList const& body)
-{
-    return {{.blob = body.blob(), .signature = body.signature(), .manifest = {}}};
-}
-
 // static
 std::vector
 ValidatorList::parseBlobs(protocol::TMValidatorListCollection const& body)
@@ -478,7 +469,7 @@ ValidatorList::parseBlobs(protocol::TMValidatorListCollection const& body)
     }
     XRPL_ASSERT(
         result.size() == body.blobs_size(),
-        "xrpl::ValidatorList::parseBlobs(TMValidatorList) : result size "
+        "xrpl::ValidatorList::parseBlobs(TMValidatorListCollection) : result size "
         "match");
     return result;
 }
@@ -522,29 +513,6 @@ splitMessageParts(
 {
     if (end <= begin)
         return 0;
-    if (end - begin == 1)
-    {
-        protocol::TMValidatorList smallMsg;
-        smallMsg.set_version(1);
-        smallMsg.set_manifest(largeMsg.manifest());
-
-        auto const& blob = largeMsg.blobs(begin);
-        smallMsg.set_blob(blob.blob());
-        smallMsg.set_signature(blob.signature());
-        // This is only possible if "downgrading" a v2 UNL to v1.
-        if (blob.has_manifest())
-            smallMsg.set_manifest(blob.manifest());
-
-        XRPL_ASSERT(
-            Message::totalSize(smallMsg) <= kMaximumMessageSize,
-            "xrpl::splitMessageParts : maximum message size");
-
-        messages.emplace_back(
-            std::make_shared(smallMsg, protocol::mtVALIDATOR_LIST),
-            sha512Half(smallMsg),
-            1);
-        return messages.back().numVLs;
-    }
 
     std::optional smallMsg;
     smallMsg.emplace();
@@ -556,13 +524,29 @@ splitMessageParts(
         *smallMsg->add_blobs() = largeMsg.blobs(i);
     }
 
-    if (Message::totalSize(*smallMsg) > maxSize)
+    auto const size = Message::totalSize(*smallMsg);
+
+    // Split until each message fits, but a single blob can't be split any
+    // further, so stop recursing at that point regardless of maxSize.
+    if (size > maxSize && end - begin > 1)
     {
         // free up the message space
         smallMsg.reset();
         return splitMessage(messages, largeMsg, maxSize, begin, end);
     }
 
+    // An unsplittable blob is still bounded by the protocol limit: peers drop
+    // messages exceeding it on receipt, so don't waste the bandwidth. maxSize
+    // only ever tightens this (it defaults to kMaximumMessageSize), so a blob
+    // reaching here can exceed maxSize but never the protocol limit.
+    if (size > kMaximumMessageSize)
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::splitMessageParts : maximum message size exceeded");
+        return 0;
+        // LCOV_EXCL_STOP
+    }
+
     messages.emplace_back(
         std::make_shared(*smallMsg, protocol::mtVALIDATOR_LIST_COLLECTION),
         sha512Half(*smallMsg),
@@ -570,37 +554,6 @@ splitMessageParts(
     return messages.back().numVLs;
 }
 
-// Build a v1 protocol message using only the current VL
-std::size_t
-buildValidatorListMessage(
-    std::vector& messages,
-    std::uint32_t rawVersion,
-    std::string const& rawManifest,
-    ValidatorBlobInfo const& currentBlob,
-    std::size_t maxSize)
-{
-    XRPL_ASSERT(
-        messages.empty(),
-        "xrpl::buildValidatorListMessage(ValidatorBlobInfo) : empty messages "
-        "input");
-    protocol::TMValidatorList msg;
-    auto const manifest = currentBlob.manifest ? *currentBlob.manifest : rawManifest;
-    auto const version = 1;
-    msg.set_manifest(manifest);
-    msg.set_blob(currentBlob.blob);
-    msg.set_signature(currentBlob.signature);
-    // Override the version
-    msg.set_version(version);
-
-    XRPL_ASSERT(
-        Message::totalSize(msg) <= kMaximumMessageSize,
-        "xrpl::buildValidatorListMessage(ValidatorBlobInfo) : maximum "
-        "message size");
-    messages.emplace_back(
-        std::make_shared(msg, protocol::mtVALIDATOR_LIST), sha512Half(msg), 1);
-    return 1;
-}
-
 // Build a v2 protocol message using all the VLs with sequence larger than the
 // peer's
 std::size_t
@@ -652,7 +605,6 @@ buildValidatorListMessage(
 // static
 std::pair
 ValidatorList::buildValidatorListMessages(
-    std::size_t messageVersion,
     std::uint64_t peerSequence,
     std::size_t maxSequence,
     std::uint32_t rawVersion,
@@ -665,14 +617,12 @@ ValidatorList::buildValidatorListMessages(
         !blobInfos.empty(),
         "xrpl::ValidatorList::buildValidatorListMessages : empty messages "
         "input");
-    auto const& [currentSeq, currentBlob] = *blobInfos.begin();
     auto numVLs = std::accumulate(
         messages.begin(), messages.end(), 0, [](std::size_t total, MessageWithHash const& m) {
             return total + m.numVLs;
         });
-    if (messageVersion == 2 && peerSequence < maxSequence)
+    if (peerSequence < maxSequence)
     {
-        // Version 2
         if (messages.empty())
         {
             numVLs = buildValidatorListMessage(
@@ -680,36 +630,13 @@ ValidatorList::buildValidatorListMessages(
             if (messages.empty())
             {
                 // No message was generated. Create an empty placeholder so we
-                // dont' repeat the work later.
+                // don't repeat the work later.
                 messages.emplace_back();
             }
         }
 
-        // Don't send it next time.
         return {maxSequence, numVLs};
     }
-    if (messageVersion == 1 && peerSequence < currentSeq)
-    {
-        // Version 1
-        if (messages.empty())
-        {
-            numVLs = buildValidatorListMessage(
-                messages,
-                rawVersion,
-                currentBlob.manifest ? *currentBlob.manifest : rawManifest,
-                currentBlob,
-                maxSize);
-            if (messages.empty())
-            {
-                // No message was generated. Create an empty placeholder so we
-                // dont' repeat the work later.
-                messages.emplace_back();
-            }
-        }
-
-        // Don't send it next time.
-        return {currentSeq, numVLs};
-    }
     return {0, 0};
 }
 
@@ -727,19 +654,8 @@ ValidatorList::sendValidatorList(
     HashRouter& hashRouter,
     beast::Journal j)
 {
-    std::size_t messageVersion = 0;
-    if (peer.supportsFeature(ProtocolFeature::ValidatorList2Propagation))
-    {
-        messageVersion = 2;
-    }
-    else if (peer.supportsFeature(ProtocolFeature::ValidatorListPropagation))
-    {
-        messageVersion = 1;
-    }
-    if (messageVersion == 0u)
-        return;
     auto const [newPeerSequence, numVLs] = buildValidatorListMessages(
-        messageVersion, peerSequence, maxSequence, rawVersion, rawManifest, blobInfos, messages);
+        peerSequence, maxSequence, rawVersion, rawManifest, blobInfos, messages);
     if (newPeerSequence != 0u)
     {
         XRPL_ASSERT(
@@ -766,24 +682,11 @@ ValidatorList::sendValidatorList(
             "xrpl::ValidatorList::sendValidatorList : sent or one message");
         if (sent)
         {
-            if (messageVersion > 1)
-            {
-                JLOG(j.debug()) << "Sent " << messages.size()
-                                << " validator list collection(s) containing " << numVLs
-                                << " validator list(s) for " << strHex(publisherKey)
-                                << " with sequence range " << peerSequence << ", "
-                                << newPeerSequence << " to " << peer.fingerprint();
-            }
-            else
-            {
-                XRPL_ASSERT(
-                    numVLs == 1,
-                    "xrpl::ValidatorList::sendValidatorList : one validator "
-                    "list");
-                JLOG(j.debug()) << "Sent validator list for " << strHex(publisherKey)
-                                << " with sequence " << newPeerSequence << " to "
-                                << peer.fingerprint();
-            }
+            JLOG(j.debug()) << "Sent " << messages.size()
+                            << " validator list collection(s) containing " << numVLs
+                            << " validator list(s) for " << strHex(publisherKey)
+                            << " with sequence range " << peerSequence << ", " << newPeerSequence
+                            << " to " << peer.fingerprint();
         }
     }
 }
@@ -858,16 +761,9 @@ ValidatorList::broadcastBlobs(
 
     if (toSkip)
     {
-        // We don't know what messages or message versions we're sending
-        // until we examine our peer's properties. Build the message(s) on
-        // demand, but reuse them when possible.
-
-        // This will hold a v1 message with only the current VL if we have
-        // any peers that don't support v2
-        std::vector messages1;
-        // This will hold v2 messages indexed by the peer's
-        // `publisherListSequence`. For each `publisherListSequence`, we'll
-        // only send the VLs with higher sequences.
+        // Build v2 messages on demand and reuse them when possible. Messages
+        // are indexed by the peer's `publisherListSequence`; for each sequence,
+        // we only send VLs with higher sequences.
         std::map> messages2;
         // If any peers are found that are worth considering, this list will
         // be built to hold info for all of the valid VLs.
@@ -887,8 +783,6 @@ ValidatorList::broadcastBlobs(
                 {
                     if (blobInfos.empty())
                         buildBlobInfos(blobInfos, lists);
-                    auto const v2 =
-                        peer->supportsFeature(ProtocolFeature::ValidatorList2Propagation);
                     sendValidatorList(
                         *peer,
                         peerSequence,
@@ -897,11 +791,10 @@ ValidatorList::broadcastBlobs(
                         lists.rawVersion,
                         lists.rawManifest,
                         blobInfos,
-                        v2 ? messages2[peerSequence] : messages1,
+                        messages2[peerSequence],
                         hashRouter,
                         j);
-                    // Even if the peer doesn't support the messages,
-                    // suppress it so it'll be ignored next time.
+                    // Don't send it next time.
                     hashRouter.addSuppressionPeer(hash, peer->id());
                 }
             }
@@ -1295,8 +1188,7 @@ std::vector
 ValidatorList::loadLists()
 {
     using namespace std::string_literals;
-    using namespace boost::filesystem;
-    using namespace boost::system::errc;
+    using namespace std::filesystem;
 
     std::scoped_lock const lock{mutex_};
 
@@ -1304,12 +1196,12 @@ ValidatorList::loadLists()
     sites.reserve(publisherLists_.size());
     for (auto const& [pubKey, publisherCollection] : publisherLists_)
     {
-        boost::system::error_code ec;
+        std::error_code ec;
 
         if (publisherCollection.status == PublisherStatus::Available)
             continue;
 
-        boost::filesystem::path const filename = getCacheFileName(lock, pubKey);
+        std::filesystem::path const filename = getCacheFileName(lock, pubKey);
 
         auto const fullPath{canonical(filename, ec)};
         if (ec)
@@ -1320,7 +1212,7 @@ ValidatorList::loadLists()
         {
             // Treat an empty file as a missing file, because
             // nobody else is going to write it.
-            ec = make_error_code(no_such_file_or_directory);
+            ec = make_error_code(std::errc::no_such_file_or_directory);
         }
         if (ec)
             continue;
diff --git a/src/xrpld/app/misc/detail/WorkSSL.cpp b/src/xrpld/app/misc/detail/WorkSSL.cpp
index e8d24b55d6..48231b147e 100644
--- a/src/xrpld/app/misc/detail/WorkSSL.cpp
+++ b/src/xrpld/app/misc/detail/WorkSSL.cpp
@@ -10,8 +10,8 @@
 #include 
 #include 
 #include 
-#include 
 
+#include 
 #include 
 #include 
 
@@ -38,7 +38,7 @@ WorkSSL::WorkSSL(
 {
     auto ec = context_.preConnectVerify(stream_, host_);
     if (ec)
-        Throw(boost::str(boost::format("preConnectVerify: %s") % ec.message()));
+        Throw(std::format("preConnectVerify: {}", ec.message()));
 }
 
 void
diff --git a/src/xrpld/app/misc/detail/WorkSSL.h b/src/xrpld/app/misc/detail/WorkSSL.h
index d4b3b9ff25..e4b7586054 100644
--- a/src/xrpld/app/misc/detail/WorkSSL.h
+++ b/src/xrpld/app/misc/detail/WorkSSL.h
@@ -7,7 +7,6 @@
 #include 
 
 #include 
-#include 
 
 #include 
 #include 
diff --git a/src/xrpld/app/rdb/backend/detail/Node.cpp b/src/xrpld/app/rdb/backend/detail/Node.cpp
index b2f14c71ea..be4c5d29e5 100644
--- a/src/xrpld/app/rdb/backend/detail/Node.cpp
+++ b/src/xrpld/app/rdb/backend/detail/Node.cpp
@@ -40,8 +40,6 @@
 #include 
 #include 
 
-#include 
-#include 
 #include   // IWYU pragma: keep
 #include 
 
@@ -58,6 +56,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -66,6 +66,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -108,18 +109,16 @@ makeLedgerDBs(
     // ledger database
     auto lgr{std::make_unique(
         setup, kLgrDbName, setup.lgrPragma, kLgrDbInit, checkpointerSetup, j)};
-    lgr->getSession() << boost::str(
-        boost::format("PRAGMA cache_size=-%d;") %
-        kilobytes(config.getValueFor(SizedItem::LgrDbCache)));
+    lgr->getSession() << std::format(
+        "PRAGMA cache_size=-{};", kilobytes(config.getValueFor(SizedItem::LgrDbCache)));
 
     if (config.useTxTables())
     {
         // transaction database
         auto tx{std::make_unique(
             setup, kTxDbName, setup.txPragma, kTxDbInit, checkpointerSetup, j)};
-        tx->getSession() << boost::str(
-            boost::format("PRAGMA cache_size=-%d;") %
-            kilobytes(config.getValueFor(SizedItem::TxnDbCache)));
+        tx->getSession() << std::format(
+            "PRAGMA cache_size=-{};", kilobytes(config.getValueFor(SizedItem::TxnDbCache)));
 
         if (!setup.standAlone || setup.startUp == StartUpType::Load ||
             setup.startUp == StartUpType::LoadFile || setup.startUp == StartUpType::Replay)
@@ -279,15 +278,17 @@ saveValidatedLedger(
     }
 
     {
-        static boost::format kDeleteLedger("DELETE FROM Ledgers WHERE LedgerSeq = %u;");
-        static boost::format kDeleteTranS1("DELETE FROM Transactions WHERE LedgerSeq = %u;");
-        static boost::format kDeleteTranS2("DELETE FROM AccountTransactions WHERE LedgerSeq = %u;");
-        static boost::format kDeleteAcctTrans(
-            "DELETE FROM AccountTransactions WHERE TransID = '%s';");
+        static constexpr char const* kDeleteLedger = "DELETE FROM Ledgers WHERE LedgerSeq = {};";
+        static constexpr char const* kDeleteTranS1 =
+            "DELETE FROM Transactions WHERE LedgerSeq = {};";
+        static constexpr char const* kDeleteTranS2 =
+            "DELETE FROM AccountTransactions WHERE LedgerSeq = {};";
+        static constexpr char const* kDeleteAcctTrans =
+            "DELETE FROM AccountTransactions WHERE TransID = '{}';";
 
         {
             auto db = ldgDB.checkoutDb();
-            *db << boost::str(kDeleteLedger % seq);
+            *db << std::format(kDeleteLedger, seq);
         }
 
         if (app.config().useTxTables())
@@ -304,19 +305,19 @@ saveValidatedLedger(
 
             soci::transaction tr(*db);
 
-            *db << boost::str(kDeleteTranS1 % seq);
-            *db << boost::str(kDeleteTranS2 % seq);
+            *db << std::format(kDeleteTranS1, seq);
+            *db << std::format(kDeleteTranS2, seq);
 
             std::string const ledgerSeq(std::to_string(seq));
 
             for (auto const& acceptedLedgerTx : *aLedger)
             {
-                uint256 transactionID = acceptedLedgerTx->getTransactionID();
+                uint256 const transactionID = acceptedLedgerTx->getTransactionID();
 
                 std::string const txnId(to_string(transactionID));
                 std::string const txnSeq(std::to_string(acceptedLedgerTx->getTxnSeq()));
 
-                *db << boost::str(kDeleteAcctTrans % transactionID);
+                *db << std::format(kDeleteAcctTrans, txnId);
 
                 auto const& accts = acceptedLedgerTx->getAffected();
 
@@ -628,11 +629,11 @@ getHashesByIndex(soci::session& session, LedgerIndex minSeq, LedgerIndex maxSeq,
 std::pair>, int>
 getTxHistory(soci::session& session, Application& app, LedgerIndex startIndex, int quantity)
 {
-    std::string const sql = boost::str(
-        boost::format(
-            "SELECT LedgerSeq, Status, RawTxn "
-            "FROM Transactions ORDER BY LedgerSeq DESC LIMIT %u,%u;") %
-        startIndex % quantity);
+    std::string const sql = std::format(
+        "SELECT LedgerSeq, Status, RawTxn "
+        "FROM Transactions ORDER BY LedgerSeq DESC LIMIT {},{};",
+        startIndex,
+        quantity);
 
     std::vector> txs;
     int total = 0;
@@ -729,41 +730,50 @@ transactionsSQL(
 
     if (options.ledgerRange.max != 0u)
     {
-        maxClause = boost::str(
-            boost::format("AND AccountTransactions.LedgerSeq <= '%u'") % options.ledgerRange.max);
+        maxClause =
+            std::format("AND AccountTransactions.LedgerSeq <= '{}'", options.ledgerRange.max);
     }
 
     if (options.ledgerRange.min != 0u)
     {
-        minClause = boost::str(
-            boost::format("AND AccountTransactions.LedgerSeq >= '%u'") % options.ledgerRange.min);
+        minClause =
+            std::format("AND AccountTransactions.LedgerSeq >= '{}'", options.ledgerRange.min);
     }
 
     std::string sql;
 
     if (count)
     {
-        sql = boost::str(
-            boost::format(
-                "SELECT %s FROM AccountTransactions "
-                "WHERE Account = '%s' %s %s LIMIT %u, %u;") %
-            selection % toBase58(options.account) % maxClause % minClause % options.offset %
+        sql = std::format(
+            "SELECT {} FROM AccountTransactions "
+            "WHERE Account = '{}' {} {} LIMIT {}, {};",
+            selection,
+            toBase58(options.account),
+            maxClause,
+            minClause,
+            options.offset,
             numberOfResults);
     }
     else
     {
-        sql = boost::str(
-            boost::format(
-                "SELECT %s FROM "
-                "AccountTransactions INNER JOIN Transactions "
-                "ON Transactions.TransID = AccountTransactions.TransID "
-                "WHERE Account = '%s' %s %s "
-                "ORDER BY AccountTransactions.LedgerSeq %s, "
-                "AccountTransactions.TxnSeq %s, AccountTransactions.TransID %s "
-                "LIMIT %u, %u;") %
-            selection % toBase58(options.account) % maxClause % minClause %
-            (descending ? "DESC" : "ASC") % (descending ? "DESC" : "ASC") %
-            (descending ? "DESC" : "ASC") % options.offset % numberOfResults);
+        char const* const order = descending ? "DESC" : "ASC";
+        sql = std::format(
+            "SELECT {} FROM "
+            "AccountTransactions INNER JOIN Transactions "
+            "ON Transactions.TransID = AccountTransactions.TransID "
+            "WHERE Account = '{}' {} {} "
+            "ORDER BY AccountTransactions.LedgerSeq {}, "
+            "AccountTransactions.TxnSeq {}, AccountTransactions.TransID {} "
+            "LIMIT {}, {};",
+            selection,
+            toBase58(options.account),
+            maxClause,
+            minClause,
+            order,
+            order,
+            order,
+            options.offset,
+            numberOfResults);
     }
     JLOG(j.trace()) << "txSQL query: " << sql;
     return sql;
@@ -1104,14 +1114,6 @@ accountTxPage(
 
     std::optional newmarker;
 
-    static std::string const kPrefix(
-        R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,
-          Status,RawTxn,TxnMeta
-          FROM AccountTransactions INNER JOIN Transactions
-          ON Transactions.TransID = AccountTransactions.TransID
-          AND AccountTransactions.Account = '%s' WHERE
-          )");
-
     std::string sql;
 
     // SQL's BETWEEN uses a closed interval ([a,b])
@@ -1120,13 +1122,22 @@ accountTxPage(
 
     if (findLedger == 0)
     {
-        sql = boost::str(
-            boost::format(kPrefix + R"(AccountTransactions.LedgerSeq BETWEEN %u AND %u
-             ORDER BY AccountTransactions.LedgerSeq %s,
-             AccountTransactions.TxnSeq %s
-             LIMIT %u;)") %
-            toBase58(options.account) % options.ledgerRange.min % options.ledgerRange.max % order %
-            order % queryLimit);
+        sql = std::format(
+            R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,
+          Status,RawTxn,TxnMeta
+          FROM AccountTransactions INNER JOIN Transactions
+          ON Transactions.TransID = AccountTransactions.TransID
+          AND AccountTransactions.Account = '{}' WHERE
+          AccountTransactions.LedgerSeq BETWEEN {} AND {}
+             ORDER BY AccountTransactions.LedgerSeq {},
+             AccountTransactions.TxnSeq {}
+             LIMIT {};)",
+            toBase58(options.account),
+            options.ledgerRange.min,
+            options.ledgerRange.max,
+            order,
+            order,
+            queryLimit);
     }
     else
     {
@@ -1135,27 +1146,34 @@ accountTxPage(
         std::uint32_t const maxLedger = forward ? options.ledgerRange.max : findLedger - 1;
 
         auto b58acct = toBase58(options.account);
-        sql = boost::str(
-            boost::format(
-                R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,
+        sql = std::format(
+            R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,
             Status,RawTxn,TxnMeta
             FROM AccountTransactions, Transactions WHERE
             (AccountTransactions.TransID = Transactions.TransID AND
-            AccountTransactions.Account = '%s' AND
-            AccountTransactions.LedgerSeq BETWEEN %u AND %u)
+            AccountTransactions.Account = '{}' AND
+            AccountTransactions.LedgerSeq BETWEEN {} AND {})
             UNION
             SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,Status,RawTxn,TxnMeta
             FROM AccountTransactions, Transactions WHERE
             (AccountTransactions.TransID = Transactions.TransID AND
-            AccountTransactions.Account = '%s' AND
-            AccountTransactions.LedgerSeq = %u AND
-            AccountTransactions.TxnSeq %s %u)
-            ORDER BY AccountTransactions.LedgerSeq %s,
-            AccountTransactions.TxnSeq %s
-            LIMIT %u;
-            )") %
-            b58acct % minLedger % maxLedger % b58acct % findLedger % compare % findSeq % order %
-            order % queryLimit);
+            AccountTransactions.Account = '{}' AND
+            AccountTransactions.LedgerSeq = {} AND
+            AccountTransactions.TxnSeq {} {})
+            ORDER BY AccountTransactions.LedgerSeq {},
+            AccountTransactions.TxnSeq {}
+            LIMIT {};
+            )",
+            b58acct,
+            minLedger,
+            maxLedger,
+            b58acct,
+            findLedger,
+            compare,
+            findSeq,
+            order,
+            order,
+            queryLimit);
     }
 
     {
@@ -1393,8 +1411,8 @@ getTransaction(
 bool
 dbHasSpace(soci::session& session, Config const& config, beast::Journal j)
 {
-    boost::filesystem::space_info const space =
-        boost::filesystem::space(config.legacy(Sections::kDatabasePath));
+    std::filesystem::space_info const space =
+        std::filesystem::space(config.legacy(Sections::kDatabasePath));
 
     if (space.available < megabytes(512))
     {
@@ -1405,9 +1423,9 @@ dbHasSpace(soci::session& session, Config const& config, beast::Journal j)
     if (config.useTxTables())
     {
         DatabaseCon::Setup const dbSetup = setupDatabaseCon(config);
-        boost::filesystem::path const dbPath = dbSetup.dataDir / kTxDbName;
-        boost::system::error_code ec;
-        std::optional dbSize = boost::filesystem::file_size(dbPath, ec);
+        std::filesystem::path const dbPath = dbSetup.dataDir / kTxDbName;
+        std::error_code ec;
+        std::optional dbSize = std::filesystem::file_size(dbPath, ec);
         if (ec)
         {
             JLOG(j.error()) << "Error checking transaction db file size: " << ec.message();
diff --git a/src/xrpld/core/Config.h b/src/xrpld/core/Config.h
index ac28b6e224..2dea8f3597 100644
--- a/src/xrpld/core/Config.h
+++ b/src/xrpld/core/Config.h
@@ -11,11 +11,10 @@
 #include 
 #include 
 
-#include   // VFALCO FIX: This include should not be here
-
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -97,17 +96,17 @@ public:
     /**
      * Returns the full path and filename of the debug log file.
      */
-    [[nodiscard]] boost::filesystem::path
+    [[nodiscard]] std::filesystem::path
     getDebugLogFile() const;
 
 private:
-    boost::filesystem::path configFile_;
+    std::filesystem::path configFile_;
 
 public:
-    boost::filesystem::path configDir;
+    std::filesystem::path configDir;
 
 private:
-    boost::filesystem::path debugLogfile_;
+    std::filesystem::path debugLogfile_;
 
     void
     load();
diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp
index e93ccec56e..3ff62c9b64 100644
--- a/src/xrpld/core/detail/Config.cpp
+++ b/src/xrpld/core/detail/Config.cpp
@@ -21,21 +21,19 @@
 #include 
 #include 
 #include 
-#include 
-#include 
-#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
 #include 
 #include 
-#include 
 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -45,6 +43,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -185,7 +184,7 @@ parseIniFile(std::string const& strInput, bool const bTrim)
     for (auto& strValue : vLines)
     {
         if (bTrim)
-            boost::algorithm::trim(strValue);
+            strValue = trimWhitespace(strValue);
 
         if (strValue.empty() || strValue[0] == '#')
         {
@@ -313,13 +312,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
     // directory, use the current working directory as the
     // config directory and that with "db" as the data
     // directory.
-    boost::filesystem::path dataDir;
+    std::filesystem::path dataDir;
 
     if (!strConf.empty())
     {
         // --conf= : everything is relative that file.
         configFile_ = strConf;
-        configDir = boost::filesystem::absolute(configFile_);
+        configDir = std::filesystem::absolute(configFile_);
         configDir.remove_filename();
         dataDir = configDir / kDatabaseDirName;
     }
@@ -330,13 +329,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
             // Check if either of the config files exist in the current working
             // directory, in which case the databases will be stored in a
             // subdirectory.
-            configDir = boost::filesystem::current_path();
+            configDir = std::filesystem::current_path();
             dataDir = configDir / kDatabaseDirName;
             configFile_ = configDir / kConfigFileName;
-            if (boost::filesystem::exists(configFile_))
+            if (std::filesystem::exists(configFile_))
                 break;
             configFile_ = configDir / kConfigLegacyName;
-            if (boost::filesystem::exists(configFile_))
+            if (std::filesystem::exists(configFile_))
                 break;
 
             // Check if the home directory is set, and optionally the XDG config
@@ -363,10 +362,10 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
                 dataDir = strXdgDataHome + "/" + systemName();
                 configDir = strXdgConfigHome + "/" + systemName();
                 configFile_ = configDir / kConfigFileName;
-                if (boost::filesystem::exists(configFile_))
+                if (std::filesystem::exists(configFile_))
                     break;
                 configFile_ = configDir / kConfigLegacyName;
-                if (boost::filesystem::exists(configFile_))
+                if (std::filesystem::exists(configFile_))
                     break;
             }
 
@@ -374,7 +373,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
             dataDir = "/var/lib/" + systemName();
             configDir = "/etc/" + systemName();
             configFile_ = configDir / kConfigFileName;
-            if (boost::filesystem::exists(configFile_))
+            if (std::filesystem::exists(configFile_))
                 break;
             configFile_ = configDir / kConfigLegacyName;
         } while (false);
@@ -387,7 +386,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
         std::string const dbPath(legacy(Sections::kDatabasePath));
         if (!dbPath.empty())
         {
-            dataDir = boost::filesystem::path(dbPath);
+            dataDir = std::filesystem::path(dbPath);
         }
         else if (runStandalone_)
         {
@@ -397,13 +396,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
 
     if (!dataDir.empty())
     {
-        boost::system::error_code ec;
-        boost::filesystem::create_directories(dataDir, ec);
+        std::error_code ec;
+        std::filesystem::create_directories(dataDir, ec);
 
         if (ec)
-            Throw(boost::str(boost::format("Can not create %s") % dataDir));
+            Throw(std::format("Can not create {}", dataDir.string()));
 
-        legacy(Sections::kDatabasePath, boost::filesystem::absolute(dataDir).string());
+        legacy(Sections::kDatabasePath, std::filesystem::absolute(dataDir).string());
     }
 
     HTTPClient::initializeSSLContext(this->sslVerifyDir, this->sslVerifyFile, this->sslVerify, j_);
@@ -455,7 +454,7 @@ Config::load()
     if (!quiet_)
         std::cerr << "Loading: " << configFile_ << "\n";
 
-    boost::system::error_code ec;
+    std::error_code ec;
     auto const fileContents = getFileContents(ec, configFile_);
 
     if (ec)
@@ -508,8 +507,8 @@ Config::loadFromString(std::string const& fileContents)
         std::string dbPath;
         if (getSingleSection(secConfig, Sections::kDatabasePath, dbPath, j_))
         {
-            boost::filesystem::path const p(dbPath);
-            legacy(Sections::kDatabasePath, boost::filesystem::absolute(p).string());
+            std::filesystem::path const p(dbPath);
+            legacy(Sections::kDatabasePath, std::filesystem::absolute(p).string());
         }
     }
 
@@ -1011,7 +1010,7 @@ Config::loadFromString(std::string const& fileContents)
         // If no path was specified, then look for validators.txt
         // in the same directory as the config file, but don't complain
         // if we can't find it.
-        boost::filesystem::path validatorsFile;
+        std::filesystem::path validatorsFile;
 
         if (getSingleSection(secConfig, Sections::kValidatorsFile, strTemp, j_))
         {
@@ -1026,7 +1025,7 @@ Config::loadFromString(std::string const& fileContents)
             if (!validatorsFile.is_absolute() && !configDir.empty())
                 validatorsFile = configDir / validatorsFile;
 
-            if (!boost::filesystem::exists(validatorsFile))
+            if (!std::filesystem::exists(validatorsFile))
             {
                 Throw(
                     std::string("The file specified in [") + Sections::kValidatorsFile +
@@ -1035,8 +1034,8 @@ Config::loadFromString(std::string const& fileContents)
                     validatorsFile.string());
             }
             else if (
-                !boost::filesystem::is_regular_file(validatorsFile) &&
-                !boost::filesystem::is_symlink(validatorsFile))
+                !std::filesystem::is_regular_file(validatorsFile) &&
+                !std::filesystem::is_symlink(validatorsFile))
             {
                 Throw(
                     std::string("Invalid file specified in [") + Sections::kValidatorsFile +
@@ -1049,20 +1048,20 @@ Config::loadFromString(std::string const& fileContents)
 
             if (!validatorsFile.empty())
             {
-                if (!boost::filesystem::exists(validatorsFile) ||
-                    (!boost::filesystem::is_regular_file(validatorsFile) &&
-                     !boost::filesystem::is_symlink(validatorsFile)))
+                if (!std::filesystem::exists(validatorsFile) ||
+                    (!std::filesystem::is_regular_file(validatorsFile) &&
+                     !std::filesystem::is_symlink(validatorsFile)))
                 {
                     validatorsFile.clear();
                 }
             }
         }
 
-        if (!validatorsFile.empty() && boost::filesystem::exists(validatorsFile) &&
-            (boost::filesystem::is_regular_file(validatorsFile) ||
-             boost::filesystem::is_symlink(validatorsFile)))
+        if (!validatorsFile.empty() && std::filesystem::exists(validatorsFile) &&
+            (std::filesystem::is_regular_file(validatorsFile) ||
+             std::filesystem::is_symlink(validatorsFile)))
         {
-            boost::system::error_code ec;
+            std::error_code ec;
             auto const data = getFileContents(ec, validatorsFile);
             if (ec)
             {
@@ -1195,7 +1194,7 @@ Config::loadFromString(std::string const& fileContents)
     }
 }
 
-boost::filesystem::path
+std::filesystem::path
 Config::getDebugLogFile() const
 {
     auto logFile = debugLogfile_;
@@ -1204,17 +1203,17 @@ Config::getDebugLogFile() const
     {
         // Unless an absolute path for the log file is specified, the
         // path is relative to the config file directory.
-        logFile = boost::filesystem::absolute(logFile, configDir);
+        logFile = std::filesystem::absolute(configDir / logFile);
     }
 
     if (!logFile.empty())
     {
         auto logDir = logFile.parent_path();
 
-        if (!boost::filesystem::is_directory(logDir))
+        if (!std::filesystem::is_directory(logDir))
         {
-            boost::system::error_code ec;
-            boost::filesystem::create_directories(logDir, ec);
+            std::error_code ec;
+            std::filesystem::create_directories(logDir, ec);
 
             // If we fail, we warn but continue so that the calling code can
             // decide how to handle this situation.
@@ -1316,8 +1315,7 @@ setupDatabaseCon(Config const& c, std::optional j)
                 boost::iequals(journalMode, "truncate") || boost::iequals(journalMode, "persist") ||
                 boost::iequals(journalMode, "wal"))
             {
-                result->emplace_back(
-                    boost::str(boost::format(kCommonDbPragmaJournal) % journalMode));
+                result->emplace_back(commonDbPragmaJournal(journalMode));
             }
             else
             {
@@ -1338,7 +1336,7 @@ setupDatabaseCon(Config const& c, std::optional j)
             if (higherRisk || boost::iequals(synchronous, "normal") ||
                 boost::iequals(synchronous, "full") || boost::iequals(synchronous, "extra"))
             {
-                result->emplace_back(boost::str(boost::format(kCommonDbPragmaSync) % synchronous));
+                result->emplace_back(commonDbPragmaSync(synchronous));
             }
             else
             {
@@ -1359,7 +1357,7 @@ setupDatabaseCon(Config const& c, std::optional j)
             if (higherRisk || boost::iequals(tempStore, "default") ||
                 boost::iequals(tempStore, "file"))
             {
-                result->emplace_back(boost::str(boost::format(kCommonDbPragmaTemp) % tempStore));
+                result->emplace_back(commonDbPragmaTemp(tempStore));
             }
             else
             {
diff --git a/src/xrpld/overlay/Peer.h b/src/xrpld/overlay/Peer.h
index 87750ed40e..6c4cf1dff1 100644
--- a/src/xrpld/overlay/Peer.h
+++ b/src/xrpld/overlay/Peer.h
@@ -20,8 +20,6 @@ class Charge;
 }  // namespace resource
 
 enum class ProtocolFeature {
-    ValidatorListPropagation,
-    ValidatorList2Propagation,
     LedgerReplay,
     LedgerNodeDepth,
 };
diff --git a/src/xrpld/overlay/detail/Message.cpp b/src/xrpld/overlay/detail/Message.cpp
index c6e0511515..a6af525620 100644
--- a/src/xrpld/overlay/detail/Message.cpp
+++ b/src/xrpld/overlay/detail/Message.cpp
@@ -82,7 +82,6 @@ Message::compress()
             case protocol::mtGET_LEDGER:
             case protocol::mtLEDGER_DATA:
             case protocol::mtGET_OBJECTS:
-            case protocol::mtVALIDATOR_LIST:
             case protocol::mtVALIDATOR_LIST_COLLECTION:
             case protocol::mtREPLAY_DELTA_RESPONSE:
             case protocol::mtTRANSACTIONS:
diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp
index 726002fce4..3f0b4453b8 100644
--- a/src/xrpld/overlay/detail/PeerImp.cpp
+++ b/src/xrpld/overlay/detail/PeerImp.cpp
@@ -542,10 +542,6 @@ PeerImp::supportsFeature(ProtocolFeature f) const
 {
     switch (f)
     {
-        case ProtocolFeature::ValidatorListPropagation:
-            return protocol_ >= makeProtocol(2, 1);
-        case ProtocolFeature::ValidatorList2Propagation:
-            return protocol_ >= makeProtocol(2, 2);
         case ProtocolFeature::LedgerNodeDepth:
             return protocol_ >= makeProtocol(2, 3);
         case ProtocolFeature::LedgerReplay:
@@ -885,7 +881,7 @@ PeerImp::doProtocolStart()
     onReadMessage(error_code(), 0);
 
     // Send all the validator lists that have been loaded
-    if (inbound_ && supportsFeature(ProtocolFeature::ValidatorListPropagation))
+    if (inbound_)
     {
         app_.getValidators().forEachAvailable(
             [&](std::string const& manifest,
@@ -2422,43 +2418,11 @@ PeerImp::onValidatorListMessage(
     }
 }
 
-void
-PeerImp::onMessage(std::shared_ptr const& m)
-{
-    try
-    {
-        if (!supportsFeature(ProtocolFeature::ValidatorListPropagation))
-        {
-            JLOG(pJournal_.debug()) << "ValidatorList: received validator list from peer using "
-                                    << "protocol version " << to_string(protocol_)
-                                    << " which shouldn't support this feature.";
-            fee_.update(resource::kFeeUselessData, "unsupported peer");
-            return;
-        }
-        onValidatorListMessage(
-            "ValidatorList", m->manifest(), m->version(), ValidatorList::parseBlobs(*m));
-    }
-    catch (std::exception const& e)
-    {
-        JLOG(pJournal_.warn()) << "ValidatorList: Exception, " << e.what();
-        using namespace std::string_literals;
-        fee_.update(resource::kFeeInvalidData, e.what());
-    }
-}
-
 void
 PeerImp::onMessage(std::shared_ptr const& m)
 {
     try
     {
-        if (!supportsFeature(ProtocolFeature::ValidatorList2Propagation))
-        {
-            JLOG(pJournal_.debug()) << "ValidatorListCollection: received validator list from peer "
-                                    << "using protocol version " << to_string(protocol_)
-                                    << " which shouldn't support this feature.";
-            fee_.update(resource::kFeeUselessData, "unsupported peer");
-            return;
-        }
         if (m->version() < 2)
         {
             JLOG(pJournal_.debug())
diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h
index 7078d6fb56..0f229bf9d8 100644
--- a/src/xrpld/overlay/detail/PeerImp.h
+++ b/src/xrpld/overlay/detail/PeerImp.h
@@ -623,8 +623,6 @@ public:
     void
     onMessage(std::shared_ptr const& m);
     void
-    onMessage(std::shared_ptr const& m);
-    void
     onMessage(std::shared_ptr const& m);
     void
     onMessage(std::shared_ptr const& m);
diff --git a/src/xrpld/overlay/detail/ProtocolMessage.h b/src/xrpld/overlay/detail/ProtocolMessage.h
index f7d5e26272..88f50e1e2e 100644
--- a/src/xrpld/overlay/detail/ProtocolMessage.h
+++ b/src/xrpld/overlay/detail/ProtocolMessage.h
@@ -71,8 +71,6 @@ protocolMessageName(int type)
             return "status";
         case protocol::mtHAVE_SET:
             return "have_set";
-        case protocol::mtVALIDATOR_LIST:
-            return "validator_list";
         case protocol::mtVALIDATOR_LIST_COLLECTION:
             return "validator_list_collection";
         case protocol::mtVALIDATION:
@@ -424,9 +422,6 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin
         case protocol::mtVALIDATION:
             success = detail::invoke(*header, buffers, handler);
             break;
-        case protocol::mtVALIDATOR_LIST:
-            success = detail::invoke(*header, buffers, handler);
-            break;
         case protocol::mtVALIDATOR_LIST_COLLECTION:
             success =
                 detail::invoke(*header, buffers, handler);
diff --git a/src/xrpld/overlay/detail/ProtocolVersion.cpp b/src/xrpld/overlay/detail/ProtocolVersion.cpp
index 2d5d0a56f7..74dad61828 100644
--- a/src/xrpld/overlay/detail/ProtocolVersion.cpp
+++ b/src/xrpld/overlay/detail/ProtocolVersion.cpp
@@ -14,7 +14,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -27,36 +29,21 @@ namespace xrpl {
  */
 
 constexpr ProtocolVersion const kSupportedProtocolList[]{
-    {2, 1},
     {2, 2},
     {2, 3},
 };
 
-// This ugly construct ensures that supportedProtocolList is sorted in strictly
-// ascending order and doesn't contain any duplicates.
-// FIXME: With C++20 we can use std::is_sorted with an appropriate comparator
+// There should be at least one protocol we're willing to speak.
 static_assert(
-    []() constexpr -> bool {
-        auto const len =
-            std::distance(std::begin(kSupportedProtocolList), std::end(kSupportedProtocolList));
+    !std::ranges::empty(kSupportedProtocolList),
+    "There must be at least one supported protocol.");
 
-        // There should be at least one protocol we're willing to speak.
-        if (len == 0)
-            return false;
-
-        // A list with only one entry is, by definition, sorted so we don't
-        // need to check it.
-        if (len != 1)
-        {
-            for (auto i = 0; i != len - 1; ++i)
-            {
-                if (kSupportedProtocolList[i] >= kSupportedProtocolList[i + 1])
-                    return false;
-            }
-        }
-
-        return true;
-    }(),
+// Searching for an adjacent pair where the first element is not less than the
+// second one proves the list is sorted in strictly ascending order, which in
+// turn means it holds no duplicates.
+static_assert(
+    std::ranges::adjacent_find(kSupportedProtocolList, std::ranges::greater_equal{}) ==
+        std::ranges::end(kSupportedProtocolList),
     "The list of supported protocols isn't properly sorted.");
 
 std::string
@@ -66,7 +53,7 @@ to_string(ProtocolVersion const& p)
 }
 
 std::vector
-parseProtocolVersions(boost::beast::string_view const& value)
+parseProtocolVersions(std::string_view value)
 {
     static boost::regex const kRE(
         "^"                        // start of line
@@ -133,7 +120,7 @@ negotiateProtocolVersion(std::vector const& versions)
 }
 
 std::optional
-negotiateProtocolVersion(boost::beast::string_view const& versions)
+negotiateProtocolVersion(std::string_view versions)
 {
     auto const them = parseProtocolVersions(versions);
 
diff --git a/src/xrpld/overlay/detail/ProtocolVersion.h b/src/xrpld/overlay/detail/ProtocolVersion.h
index b56871318a..5c05f63e2a 100644
--- a/src/xrpld/overlay/detail/ProtocolVersion.h
+++ b/src/xrpld/overlay/detail/ProtocolVersion.h
@@ -1,10 +1,9 @@
 #pragma once
 
-#include 
-
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -43,7 +42,7 @@ to_string(ProtocolVersion const& p);
  *       no duplicates and will be sorted in ascending protocol order.
  */
 std::vector
-parseProtocolVersions(boost::beast::string_view const& s);
+parseProtocolVersions(std::string_view s);
 
 /**
  * Given a list of supported protocol versions, choose the one we prefer.
@@ -55,7 +54,7 @@ negotiateProtocolVersion(std::vector const& versions);
  * Given a list of supported protocol versions, choose the one we prefer.
  */
 std::optional
-negotiateProtocolVersion(boost::beast::string_view const& versions);
+negotiateProtocolVersion(std::string_view versions);
 
 /**
  * The list of all the protocol versions we support.
diff --git a/src/xrpld/overlay/detail/TrafficCount.cpp b/src/xrpld/overlay/detail/TrafficCount.cpp
index bdce9e68f0..90d5c0b4ff 100644
--- a/src/xrpld/overlay/detail/TrafficCount.cpp
+++ b/src/xrpld/overlay/detail/TrafficCount.cpp
@@ -14,7 +14,6 @@ std::unordered_map const kTypeLoo
     {protocol::mtMANIFESTS, TrafficCount::Category::Manifests},
     {protocol::mtENDPOINTS, TrafficCount::Category::Overlay},
     {protocol::mtTRANSACTION, TrafficCount::Category::Transaction},
-    {protocol::mtVALIDATOR_LIST, TrafficCount::Category::Validatorlist},
     {protocol::mtVALIDATOR_LIST_COLLECTION, TrafficCount::Category::Validatorlist},
     {protocol::mtVALIDATION, TrafficCount::Category::Validation},
     {protocol::mtPROPOSE_LEDGER, TrafficCount::Category::Proposal},
diff --git a/src/xrpld/perflog/detail/PerfLogImp.cpp b/src/xrpld/perflog/detail/PerfLogImp.cpp
index 3aa7e38ea2..2777e0dcdb 100644
--- a/src/xrpld/perflog/detail/PerfLogImp.cpp
+++ b/src/xrpld/perflog/detail/PerfLogImp.cpp
@@ -17,11 +17,9 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -29,6 +27,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -220,10 +219,10 @@ PerfLogImp::openLog()
         logFile_.close();
 
     auto logDir = setup_.perfLog.parent_path();
-    if (!boost::filesystem::is_directory(logDir))
+    if (!std::filesystem::is_directory(logDir))
     {
-        boost::system::error_code ec;
-        boost::filesystem::create_directories(logDir, ec);
+        std::error_code ec;
+        std::filesystem::create_directories(logDir, ec);
         if (ec)
         {
             JLOG(j_.fatal()) << "Unable to create performance log "
@@ -478,17 +477,17 @@ PerfLogImp::stop()
 //-----------------------------------------------------------------------------
 
 PerfLog::Setup
-setupPerfLog(Section const& section, boost::filesystem::path const& configDir)
+setupPerfLog(Section const& section, std::filesystem::path const& configDir)
 {
     PerfLog::Setup setup;
     std::string perfLog;
     set(perfLog, "perf_log", section);
     if (!perfLog.empty())
     {
-        setup.perfLog = boost::filesystem::path(perfLog);
+        setup.perfLog = std::filesystem::path(perfLog);
         if (setup.perfLog.is_relative())
         {
-            setup.perfLog = boost::filesystem::absolute(setup.perfLog, configDir);
+            setup.perfLog = std::filesystem::absolute(configDir / setup.perfLog);
         }
     }
 
diff --git a/src/xrpld/rpc/detail/RPCHelpers.cpp b/src/xrpld/rpc/detail/RPCHelpers.cpp
index 4fa0fab6f7..321f8f5a3c 100644
--- a/src/xrpld/rpc/detail/RPCHelpers.cpp
+++ b/src/xrpld/rpc/detail/RPCHelpers.cpp
@@ -37,6 +37,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -424,7 +425,7 @@ parseSubUnsubJson(
     if (jv.isMember(jss::mpt_issuance_id) &&
         (jv.isMember(jss::currency) || jv.isMember(jss::issuer)))
     {
-        JLOG(j.info()) << boost::format("Bad %s currency or MPT.") % name.cStr();
+        JLOG(j.info()) << std::format("Bad {} currency or MPT.", name.cStr());
         return RpcInvalidParams;
     }
 
@@ -435,7 +436,7 @@ parseSubUnsubJson(
         if (!jv.isMember(jss::currency) ||
             !toCurrency(issue.currency, jv[jss::currency].asString()))
         {
-            JLOG(j.info()) << boost::format("Bad %s currency.") % name.cStr();
+            JLOG(j.info()) << std::format("Bad {} currency.", name.cStr());
             return assetError;
         }
 
@@ -445,7 +446,7 @@ parseSubUnsubJson(
             // Don't allow illegal issuers.
             || (!issue.currency != !issue.account) || noAccount() == issue.account)
         {
-            JLOG(j.info()) << boost::format("Bad %s issuer.") % name.cStr();
+            JLOG(j.info()) << std::format("Bad {} issuer.", name.cStr());
             return issuerError;
         }
         asset = issue;
@@ -459,7 +460,7 @@ parseSubUnsubJson(
     }
     else
     {
-        JLOG(j.info()) << boost::format("Neither %s currency or MPT is present.") % name.cStr();
+        JLOG(j.info()) << std::format("Neither {} currency or MPT is present.", name.cStr());
         return assetError;
     }
 
diff --git a/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp b/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp
index 52e68e87f1..19fe294924 100644
--- a/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp
+++ b/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp
@@ -331,6 +331,13 @@ getLedger<>(std::shared_ptr&, LedgerShortcut shortcut, Context c
 template Status
 getLedger<>(std::shared_ptr&, uint256 const&, Context const&);
 
+// explicit instantiation of ledgerFromSpecifier
+template Status
+ledgerFromSpecifier<>(
+    std::shared_ptr&,
+    org::xrpl::rpc::v1::LedgerSpecifier const&,
+    Context const&);
+
 // The previous version of the lookupLedger command would accept the
 // "ledger_index" argument as a string and silently treat it as a request to
 // return the current ledger which, while not strictly wrong, could cause a lot
diff --git a/src/xrpld/rpc/detail/ServerHandler.cpp b/src/xrpld/rpc/detail/ServerHandler.cpp
index 0181d5b10f..28e7eebd63 100644
--- a/src/xrpld/rpc/detail/ServerHandler.cpp
+++ b/src/xrpld/rpc/detail/ServerHandler.cpp
@@ -8,6 +8,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -44,7 +45,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -113,7 +113,7 @@ authorized(Port const& port, std::map const& h)
     if ((it == h.end()) || (!it->second.starts_with("Basic ")))
         return false;
     std::string strUserPass64 = it->second.substr(6);
-    boost::trim(strUserPass64);
+    strUserPass64 = trimWhitespace(strUserPass64);
     std::string const strUserPass = base64Decode(strUserPass64);
     std::string::size_type const nColon = strUserPass.find(':');
     if (nColon == std::string::npos)
@@ -264,7 +264,7 @@ ServerHandler::onHandoff(
 static inline json::Output
 makeOutput(Session& session)
 {
-    return [&](boost::beast::string_view const& b) { session.write(b.data(), b.size()); };
+    return [&](std::string_view b) { session.write(b.data(), b.size()); };
 }
 
 static std::map
@@ -564,11 +564,11 @@ ServerHandler::processSession(
         makeOutput(*session),
         coro,
         forwardedFor(session->request()),
-        [&] {
+        [&] -> std::string_view {
             auto const iter = session->request().find("X-User");
             if (iter != session->request().end())
                 return iter->value();
-            return boost::beast::string_view{};
+            return {};
         }());
 
     if (beast::rfc2616::isKeepAlive(session->request()))
diff --git a/src/xrpld/rpc/handlers/account/AccountChannels.cpp b/src/xrpld/rpc/handlers/account/AccountChannels.cpp
index d50bf1cf07..f2da1e31ee 100644
--- a/src/xrpld/rpc/handlers/account/AccountChannels.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountChannels.cpp
@@ -3,6 +3,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -22,9 +23,6 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
 #include 
@@ -129,7 +127,7 @@ doAccountChannels(rpc::JsonContext& context)
             return rpc::expectedFieldError(jss::marker, "string");
 
         // Marker is composed of a comma separated index and start hint. The
-        // former will be read as hex, and the latter using boost lexical cast.
+        // former will be read as hex, and the latter as a decimal integer.
         std::stringstream marker(params[jss::marker].asString());
         std::string value;
         if (!std::getline(marker, value, ','))
@@ -141,14 +139,10 @@ doAccountChannels(rpc::JsonContext& context)
         if (!std::getline(marker, value, ','))
             return rpcError(RpcInvalidParams);
 
-        try
-        {
-            startHint = boost::lexical_cast(value);
-        }
-        catch (boost::bad_lexical_cast&)
-        {
+        auto const hint = toUInt64(value);
+        if (!hint.has_value())
             return rpcError(RpcInvalidParams);
-        }
+        startHint = *hint;
 
         // We then must check if the object pointed to by the marker is actually
         // owned by the account in the request.
diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp
index d4232cf451..6b244af1a9 100644
--- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp
@@ -5,6 +5,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -22,11 +23,9 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -51,22 +50,16 @@ void
 injectSLE(json::Value& jv, SLE const& sle)
 {
     jv = sle.getJson(JsonOptions::Values::None);
-    if (sle.getType() == ltACCOUNT_ROOT)
+    XRPL_ASSERT(sle.getType() == ltACCOUNT_ROOT, "xrpl::injectSLE : sle is account root");
+    if (sle.isFieldPresent(sfEmailHash))
     {
-        if (sle.isFieldPresent(sfEmailHash))
-        {
-            auto const& hash = sle.getFieldH128(sfEmailHash);
-            Blob const b(hash.begin(), hash.end());
-            std::string md5 = strHex(makeSlice(b));
-            boost::to_lower(md5);
-            // VFALCO TODO Give a name to this constant and move it
-            //             to a more visible location.
-            jv[jss::urlgravatar] = str(boost::format("https://www.gravatar.com/avatar/%s") % md5);
-        }
-    }
-    else
-    {
-        jv[jss::Invalid] = true;
+        auto const& hash = sle.getFieldH128(sfEmailHash);
+        Blob const b(hash.begin(), hash.end());
+        std::string md5 = strHex(makeSlice(b));
+        md5 = toLower(md5);
+        // VFALCO TODO Give a name to this constant and move it
+        //             to a more visible location.
+        jv[jss::urlgravatar] = std::format("https://www.gravatar.com/avatar/{}", md5);
     }
 }
 
diff --git a/src/xrpld/rpc/handlers/account/AccountLines.cpp b/src/xrpld/rpc/handlers/account/AccountLines.cpp
index f134c8af92..ac98e271b6 100644
--- a/src/xrpld/rpc/handlers/account/AccountLines.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountLines.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -22,9 +23,6 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
 #include 
@@ -109,7 +107,12 @@ doAccountLines(rpc::JsonContext& context)
 
     std::string strPeer;
     if (params.isMember(jss::peer))
+    {
+        if (!params[jss::peer].isString())
+            return rpc::invalidFieldError(jss::peer);
+
         strPeer = params[jss::peer].asString();
+    }
 
     auto const raPeerAccount = [&]() -> std::optional {
         return strPeer.empty() ? std::nullopt : parseBase58(strPeer);
@@ -153,7 +156,7 @@ doAccountLines(rpc::JsonContext& context)
             return rpc::expectedFieldError(jss::marker, "string");
 
         // Marker is composed of a comma separated index and start hint. The
-        // former will be read as hex, and the latter using boost lexical cast.
+        // former will be read as hex, and the latter as a decimal integer.
         std::stringstream marker(params[jss::marker].asString());
         std::string value;
         if (!std::getline(marker, value, ','))
@@ -165,14 +168,10 @@ doAccountLines(rpc::JsonContext& context)
         if (!std::getline(marker, value, ','))
             return rpcError(RpcInvalidParams);
 
-        try
-        {
-            startHint = boost::lexical_cast(value);
-        }
-        catch (boost::bad_lexical_cast&)
-        {
+        auto const hint = toUInt64(value);
+        if (!hint.has_value())
             return rpcError(RpcInvalidParams);
-        }
+        startHint = *hint;
 
         // We then must check if the object pointed to by the marker is actually
         // owned by the account in the request.
diff --git a/src/xrpld/rpc/handlers/account/AccountOffers.cpp b/src/xrpld/rpc/handlers/account/AccountOffers.cpp
index 1467b14b48..a7933f65a7 100644
--- a/src/xrpld/rpc/handlers/account/AccountOffers.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountOffers.cpp
@@ -3,6 +3,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -20,9 +21,6 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
 #include 
@@ -97,7 +95,7 @@ doAccountOffers(rpc::JsonContext& context)
             return rpc::expectedFieldError(jss::marker, "string");
 
         // Marker is composed of a comma separated index and start hint. The
-        // former will be read as hex, and the latter using boost lexical cast.
+        // former will be read as hex, and the latter as a decimal integer.
         std::stringstream marker(params[jss::marker].asString());
         std::string value;
         if (!std::getline(marker, value, ','))
@@ -109,14 +107,10 @@ doAccountOffers(rpc::JsonContext& context)
         if (!std::getline(marker, value, ','))
             return rpc::invalidFieldError(jss::marker);
 
-        try
-        {
-            startHint = boost::lexical_cast(value);
-        }
-        catch (boost::bad_lexical_cast&)
-        {
+        auto const hint = toUInt64(value);
+        if (!hint.has_value())
             return rpc::invalidFieldError(jss::marker);
-        }
+        startHint = *hint;
 
         // We then must check if the object pointed to by the marker is actually
         // owned by the account in the request.
diff --git a/src/xrpld/rpc/handlers/account/GatewayBalances.cpp b/src/xrpld/rpc/handlers/account/GatewayBalances.cpp
index ff19d1d1e5..041e878a3f 100644
--- a/src/xrpld/rpc/handlers/account/GatewayBalances.cpp
+++ b/src/xrpld/rpc/handlers/account/GatewayBalances.cpp
@@ -63,6 +63,12 @@ doGatewayBalances(rpc::JsonContext& context)
     if (!(params.isMember(jss::account) || params.isMember(jss::ident)))
         return rpc::missingFieldError(jss::account);
 
+    if (params.isMember(jss::account) && !params[jss::account].isString())
+        return rpc::invalidFieldError(jss::account);
+
+    if (params.isMember(jss::ident) && !params[jss::ident].isString())
+        return rpc::invalidFieldError(jss::ident);
+
     std::string const strIdent(
         params.isMember(jss::account) ? params[jss::account].asString()
                                       : params[jss::ident].asString());
diff --git a/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp b/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp
index 91db16bb4f..5c96bfb215 100644
--- a/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp
+++ b/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp
@@ -3,14 +3,13 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 
-#include 
-
 #include 
 #include 
 #include 
@@ -38,7 +37,7 @@ doCanDelete(rpc::JsonContext& context)
         else
         {
             std::string canDeleteStr = canDelete.asString();
-            boost::to_lower(canDeleteStr);
+            canDeleteStr = toLower(canDeleteStr);
 
             if (canDeleteStr.find_first_not_of("0123456789") == std::string::npos)
             {
diff --git a/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp b/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp
index ae539a59f3..219c29d53a 100644
--- a/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp
@@ -22,6 +22,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 
@@ -32,7 +33,7 @@ validateTakerJSON(json::Value const& taker, json::StaticString const& name)
 {
     if (!taker.isMember(jss::currency) && !taker.isMember(jss::mpt_issuance_id))
     {
-        return rpc::missingFieldError((boost::format("%s.currency") % name.cStr()).str());
+        return rpc::missingFieldError(std::format("{}.currency", name.cStr()));
     }
 
     if (taker.isMember(jss::mpt_issuance_id) &&
@@ -44,8 +45,7 @@ validateTakerJSON(json::Value const& taker, json::StaticString const& name)
     if ((taker.isMember(jss::currency) && !taker[jss::currency].isString()) ||
         (taker.isMember(jss::mpt_issuance_id) && !taker[jss::mpt_issuance_id].isString()))
     {
-        return rpc::expectedFieldError(
-            (boost::format("%s.currency") % name.cStr()).str(), "string");
+        return rpc::expectedFieldError(std::format("{}.currency", name.cStr()), "string");
     }
 
     return std::nullopt;
@@ -70,10 +70,9 @@ parseTakerAssetJSON(
 
         if (!toCurrency(issue.currency, taker[jss::currency].asString()))
         {
-            JLOG(j.info()) << boost::format("Bad %s currency.") % name.cStr();
+            JLOG(j.info()) << std::format("Bad {} currency.", name.cStr());
             return rpc::makeError(
-                assetError,
-                (boost::format("Invalid field '%s.currency', bad currency.") % name.cStr()).str());
+                assetError, std::format("Invalid field '{}.currency', bad currency.", name.cStr()));
         }
         asset = issue;
     }
@@ -83,8 +82,7 @@ parseTakerAssetJSON(
         if (!mptid.parseHex(taker[jss::mpt_issuance_id].asString()))
         {
             return rpc::makeError(
-                assetError,
-                (boost::format("Invalid field '%s.mpt_issuance_id'") % name.cStr()).str());
+                assetError, std::format("Invalid field '{}.mpt_issuance_id'", name.cStr()));
         }
         asset = mptid;
     }
@@ -113,24 +111,21 @@ parseTakerIssuerJSON(
         {
             if (!taker[jss::issuer].isString())
             {
-                return rpc::expectedFieldError(
-                    (boost::format("%s.issuer") % name.cStr()).str(), "string");
+                return rpc::expectedFieldError(std::format("{}.issuer", name.cStr()), "string");
             }
 
             if (!toIssuer(issue.account, taker[jss::issuer].asString()))
             {
                 return rpc::makeError(
                     issuerError,
-                    (boost::format("Invalid field '%s.issuer', bad issuer.") % name.cStr()).str());
+                    std::format("Invalid field '{}.issuer', bad issuer.", name.cStr()));
             }
 
             if (issue.account == noAccount())
             {
                 return rpc::makeError(
                     issuerError,
-                    (boost::format("Invalid field '%s.issuer', bad issuer account one.") %
-                     name.cStr())
-                        .str());
+                    std::format("Invalid field '{}.issuer', bad issuer account one.", name.cStr()));
             }
         }
         else
@@ -142,19 +137,17 @@ parseTakerIssuerJSON(
         {
             return rpc::makeError(
                 issuerError,
-                (boost::format(
-                     "Unneeded field '%s.issuer' for XRP currency "
-                     "specification.") %
-                 name.cStr())
-                    .str());
+                std::format(
+                    "Unneeded field '{}.issuer' for XRP currency "
+                    "specification.",
+                    name.cStr()));
         }
 
         if (!isXRP(issue.currency) && isXRP(issue.account))
         {
             return rpc::makeError(
                 issuerError,
-                (boost::format("Invalid field '%s.issuer', expected non-XRP issuer.") % name.cStr())
-                    .str());
+                std::format("Invalid field '{}.issuer', expected non-XRP issuer.", name.cStr()));
         }
     }
 
diff --git a/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h b/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h
index e03830ae0d..21bf3f8be8 100644
--- a/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h
+++ b/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h
@@ -93,6 +93,17 @@ enumerateNFTOffers(rpc::JsonContext& context, uint256 const& nftId, Keylet const
         if (!sle || nftId != sle->getFieldH256(sfNFTokenID))
             return rpcError(RpcInvalidParams);
 
+        // Reject a marker that references an offer on the opposite side
+        // (buy vs. sell) of the directory being enumerated.  Without this
+        // check the marker's node hint points into the other directory, so
+        // forEachItemAfter never finds `startAfter` and instead scans every
+        // page of `directory` before returning invalidParams -- turning an
+        // O(1) rejection into an O(directory size) walk.
+        auto const offerDir =
+            sle->isFlag(lsfSellNFToken) ? keylet::nftSells(nftId) : keylet::nftBuys(nftId);
+        if (directory.key != offerDir.key)
+            return rpcError(RpcInvalidParams);
+
         startHint = sle->getFieldU64(sfNFTokenOfferNode);
         appendNftOfferJson(context.app, sle, jsonOffers);
         offers.reserve(reserve);
diff --git a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp
index b561ce6d38..c297c2482d 100644
--- a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp
+++ b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp
@@ -2,6 +2,7 @@
 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -14,7 +15,6 @@
 #include 
 #include 
 
-#include 
 #include 
 
 #include 
@@ -64,7 +64,6 @@ ServerDefinitions::translate(std::string const& inp)
         return out;
     };
 
-    // TODO: use string::contains with C++23
     auto contains = [&](std::string_view s) -> bool { return inp.contains(s); };
 
     if (contains("UINT"))
@@ -107,7 +106,7 @@ ServerDefinitions::translate(std::string const& inp)
         std::string token = inpToProcess.substr(0, pos);
         if (token.size() > 1)
         {
-            boost::algorithm::to_lower(token);
+            token = toLower(token);
             token[0] -= ('a' - 'A');
             out += token;
         }